From 7bf66ec39b0b287b27920e1f96b87faade534769 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:02:30 -0700 Subject: [PATCH 01/15] fix(compression): /compress refusal no longer reports a successful rewrite --- agent/context_compressor.py | 3 + agent/conversation_compression.py | 11 ++++ agent/manual_compression_feedback.py | 22 ++++++- cli.py | 6 +- ...est_manual_compression_refusal_feedback.py | 58 +++++++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/agent/test_manual_compression_refusal_feedback.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index d66bee1cad..03639cf985 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1885,6 +1885,7 @@ class ContextCompressor(ContextEngine): self._cooldown_persist_failed = False self._last_summary_error = None self._last_compress_aborted = False + self._last_compress_refused_would_grow = False self.last_real_prompt_tokens = 0 self.last_compression_rough_tokens = 0 self.last_rough_tokens_when_real_prompt_fit = 0 @@ -2167,6 +2168,7 @@ class ContextCompressor(ContextEngine): self._summary_failure_cooldown_until = 0.0 self._cooldown_persist_failed = False self._last_compress_aborted = False + self._last_compress_refused_would_grow = False self._context_probed = False self._context_probe_persistable = False self.last_real_prompt_tokens = 0 @@ -6927,6 +6929,7 @@ This compaction should PRIORITISE preserving all information related to the focu self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None self._last_compress_aborted = False + self._last_compress_refused_would_grow = False self._last_compression_made_progress = False # NOTE: do NOT reset _last_summary_auth_failure or # _last_summary_network_failure here. These flags are set by diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 97a9e3dbf2..ba142416b3 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -3395,6 +3395,17 @@ def compress_context( f"{_rough_in:,}", f"{_rough_out:,}", ) + # Flag the refusal on the compressor state so manual + # /compress feedback can report it honestly. Without this, + # the CLI compared the returned list against its pre-call + # snapshot, saw a difference (durable-snapshot adoption can + # legitimately change the count), and printed + # "✅ Compressed: 8 → 14 messages" directly under the + # refusal warning (Aug 2026 full-surface CLI QA sweep). + try: + agent.context_compressor._last_compress_refused_would_grow = True + except Exception: + pass try: agent._emit_warning( "⚠️ Compression refused: the generated summary " diff --git a/agent/manual_compression_feedback.py b/agent/manual_compression_feedback.py index b2e12d6834..b37361e6e2 100644 --- a/agent/manual_compression_feedback.py +++ b/agent/manual_compression_feedback.py @@ -53,6 +53,11 @@ def summarize_manual_compression( compression_state is not None and getattr(compression_state, "_last_compress_aborted", False) is True ) + refused_would_grow = ( + compression_state is not None + and getattr(compression_state, "_last_compress_refused_would_grow", False) + is True + ) fallback_used = ( compression_state is not None and getattr(compression_state, "_last_summary_fallback_used", False) is True @@ -65,7 +70,12 @@ def summarize_manual_compression( if not isinstance(failure_reason, str) or not failure_reason.strip(): failure_reason = None - if aborted: + if refused_would_grow: + headline = ( + f"Compression refused (summary would grow the conversation): " + f"{before_count} messages preserved" + ) + elif aborted: headline = f"Compression aborted: {before_count} messages preserved" elif fallback_used: headline = ( @@ -78,6 +88,8 @@ def summarize_manual_compression( if noop and after_tokens == before_tokens: token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)" + elif refused_would_grow: + token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)" else: token_line = ( f"Approx request size: ~{before_tokens:,} → " @@ -85,7 +97,12 @@ def summarize_manual_compression( ) note = None - if aborted: + if refused_would_grow: + note = ( + "The generated summary was larger than what it would replace; " + "no messages were removed." + ) + elif aborted: note = "Summary generation failed; no messages were removed." elif fallback_used: dropped_count = getattr( @@ -113,6 +130,7 @@ def summarize_manual_compression( return { "noop": noop, "aborted": aborted, + "refused_would_grow": refused_would_grow, "fallback_used": fallback_used, "headline": headline, "token_line": token_line, diff --git a/cli.py b/cli.py index 45b9b17ebc..97d5719fd4 100644 --- a/cli.py +++ b/cli.py @@ -12970,7 +12970,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self.agent, "context_compressor", None ), ) - if summary.get("aborted") or summary.get("fallback_used"): + if ( + summary.get("aborted") + or summary.get("fallback_used") + or summary.get("refused_would_grow") + ): icon = "⚠️" else: icon = "🗜️" if summary["noop"] else "✅" diff --git a/tests/agent/test_manual_compression_refusal_feedback.py b/tests/agent/test_manual_compression_refusal_feedback.py new file mode 100644 index 0000000000..6ed514b6b6 --- /dev/null +++ b/tests/agent/test_manual_compression_refusal_feedback.py @@ -0,0 +1,58 @@ +"""Regression tests: manual /compress feedback for a would-grow refusal. + +The anti-growth guard (#83339 / PR #86700) can refuse the commit AFTER the +compressor produced a candidate. The CLI's feedback previously compared the +returned list against its pre-call snapshot; durable-snapshot adoption can +legitimately change that list, so a REFUSED compression printed +"✅ Compressed: 8 → 14 messages" directly under the refusal warning +(Aug 2026 full-surface CLI QA sweep). The refusal must surface as its own +honest headline. +""" + +from types import SimpleNamespace + +from agent.manual_compression_feedback import summarize_manual_compression + + +def _msgs(n): + return [{"role": "user", "content": f"m{n_i}"} for n_i in range(n)] + + +def _state(**flags): + base = { + "_last_compress_aborted": False, + "_last_compress_refused_would_grow": False, + "_last_summary_fallback_used": False, + "_last_summary_error": None, + "_last_summary_dropped_count": 0, + } + base.update(flags) + return SimpleNamespace(**base) + + +class TestWouldGrowRefusalFeedback: + def test_refusal_reports_preserved_not_compressed(self): + summary = summarize_manual_compression( + _msgs(8), _msgs(14), 22025, 22453, + compression_state=_state(_last_compress_refused_would_grow=True), + ) + assert summary["refused_would_grow"] is True + assert "refused" in summary["headline"].lower() + assert "→" not in summary["headline"] # never claims a message rewrite + assert "unchanged" in summary["token_line"] + assert "no messages were removed" in summary["note"] + + def test_normal_compression_unchanged(self): + summary = summarize_manual_compression( + _msgs(10), _msgs(4), 30000, 12000, compression_state=_state(), + ) + assert summary["refused_would_grow"] is False + assert summary["headline"].startswith("Compressed: 10 → 4") + + def test_abort_still_wins_its_own_headline(self): + summary = summarize_manual_compression( + _msgs(6), _msgs(6), 9000, 9000, + compression_state=_state(_last_compress_aborted=True), + ) + assert summary["aborted"] is True + assert summary["headline"].startswith("Compression aborted") From fa89d87ea6cce17d377028401b98604e08a08e14 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:11:41 -0700 Subject: [PATCH 02/15] fix(checkpoints): bare /rollback falls back to a labeled all-directories view (#10505, reapply #10633) --- hermes_cli/cli_commands_mixin.py | 13 +++- tests/tools/test_rollback_all_directories.py | 70 ++++++++++++++++++++ tools/checkpoint_manager.py | 34 +++++++++- 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_rollback_all_directories.py diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 3b57335f74..1778c28749 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -88,8 +88,19 @@ class CLICommandsMixin: args = filtered if not args: - # List checkpoints + # List checkpoints — fall back to the cross-project view when the + # current directory has none (#10505, reapply of PR #10633 by + # @nightq). The Aug 2026 QA sweep hit this live: writes landed + # checkpoints under the session cwd (/tmp/qa-repo) while bare + # /rollback searched only TERMINAL_CWD's project and reported + # "No checkpoints found" despite fresh checkpoints existing. checkpoints = mgr.list_checkpoints(cwd) + if not checkpoints: + all_checkpoints = mgr.list_all_checkpoints() + if all_checkpoints: + print(f" No checkpoints for {cwd} — showing all directories.") + print(format_checkpoint_list(all_checkpoints, "all directories")) + return print(format_checkpoint_list(checkpoints, cwd)) return diff --git a/tests/tools/test_rollback_all_directories.py b/tests/tools/test_rollback_all_directories.py new file mode 100644 index 0000000000..66f4f8c635 --- /dev/null +++ b/tests/tools/test_rollback_all_directories.py @@ -0,0 +1,70 @@ +"""Regression tests for the /rollback all-directories fallback (#10505). + +Bare /rollback searched only TERMINAL_CWD's project; checkpoints created +under a different session cwd were invisible ("No checkpoints found for +/home/user" while checkpoints existed seconds earlier). Reapply of PR +#10633 by @nightq onto the v2 single-store layout. +""" + +import json + +import tools.checkpoint_manager as cm +from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list + + +def _make_store_with_project(tmp_path, workdir: str): + store = tmp_path / "store" + (store / cm._PROJECTS_DIRNAME).mkdir(parents=True) + (store / "HEAD").write_text("ref: refs/heads/main\n") + dir_hash = cm._project_hash(workdir) + meta = {"workdir": workdir, "created_at": 1, "last_touch": 2} + (store / cm._PROJECTS_DIRNAME / f"{dir_hash}.json").write_text(json.dumps(meta)) + return store + + +class TestListAllCheckpoints: + def test_aggregates_projects_and_labels_workdir(self, tmp_path, monkeypatch): + workdir = str(tmp_path / "proj") + store = _make_store_with_project(tmp_path, workdir) + monkeypatch.setattr(cm, "CHECKPOINT_BASE", tmp_path) + monkeypatch.setattr(cm, "_store_path", lambda base=None: store) + + mgr = CheckpointManager(enabled=True) + fake_entries = [ + {"hash": "a" * 40, "short_hash": "aaaaaaa", "timestamp": "2026-08-19T10:00:00", + "reason": "before write_file", "files_changed": 1, "insertions": 2, "deletions": 0}, + ] + monkeypatch.setattr( + CheckpointManager, "list_checkpoints", lambda self, wd: list(fake_entries) + ) + + results = mgr.list_all_checkpoints() + assert len(results) == 1 + assert results[0]["workdir"] == workdir + + def test_empty_store_returns_empty(self, tmp_path, monkeypatch): + monkeypatch.setattr(cm, "CHECKPOINT_BASE", tmp_path) + monkeypatch.setattr(cm, "_store_path", lambda base=None: tmp_path / "missing") + assert CheckpointManager(enabled=True).list_all_checkpoints() == [] + + +class TestFormatAllDirectories: + def test_workdir_label_shown_in_all_directories_view(self): + cps = [{ + "hash": "b" * 40, "short_hash": "bbbbbbb", + "timestamp": "2026-08-19T10:00:00", "reason": "before patch", + "files_changed": 0, "insertions": 0, "deletions": 0, + "workdir": "/tmp/qa-repo", + }] + out = format_checkpoint_list(cps, "all directories") + assert "[qa-repo]" in out + + def test_single_directory_view_unchanged(self): + cps = [{ + "hash": "c" * 40, "short_hash": "ccccccc", + "timestamp": "2026-08-19T10:00:00", "reason": "before patch", + "files_changed": 0, "insertions": 0, "deletions": 0, + }] + out = format_checkpoint_list(cps, "/tmp/qa-repo") + assert "[qa-repo]" not in out + assert "ccccccc" in out diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index 5448632103..7149eebc90 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -968,6 +968,29 @@ class CheckpointManager: results.append(entry) return results + def list_all_checkpoints(self) -> List[Dict]: + """List checkpoints across every registered project (most recent first). + + Surgical reapply of PR #10633 by @nightq (#10505) onto the v2 + single-store layout: iterate ``projects/.json`` metadata via + ``_list_projects`` instead of the pre-v2 per-shadow-dir scan. Each + entry carries the extra ``workdir`` key so callers can label which + project a checkpoint belongs to. + """ + store = _store_path(CHECKPOINT_BASE) + if not (store / "HEAD").exists(): + return [] + results: List[Dict] = [] + for meta in _list_projects(store): + workdir = meta.get("workdir") or "" + if not workdir: + continue + for entry in self.list_checkpoints(workdir): + entry["workdir"] = workdir + results.append(entry) + results.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + return results + @staticmethod def _parse_shortstat(stat_line: str, entry: Dict) -> None: """Parse git --shortstat output into entry dict.""" @@ -1563,7 +1586,16 @@ def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str: else: stat = "" - lines.append(f" {i}. {cp['short_hash']} {ts} {cp['reason']}{stat}") + # Label per-project entries when showing the cross-project view + # (workdir key only present on list_all_checkpoints results). + workdir = cp.get("workdir", "") + if workdir and directory == "all directories": + workdir_short = Path(workdir).name or workdir + lines.append( + f" {i}. {cp['short_hash']} {ts} [{workdir_short}] {cp['reason']}{stat}" + ) + else: + lines.append(f" {i}. {cp['short_hash']} {ts} {cp['reason']}{stat}") lines.append("\n /rollback restore to checkpoint N") lines.append(" /rollback diff preview changes since checkpoint N") From 508b3cf65d8d6b9a56a3842d232259179c7ca68c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 20 Aug 2026 00:38:09 -0500 Subject: [PATCH 03/15] feat(desktop): count unread sessions from the shared status map The titlebar badge needs the same unread answer the green dots use, without double-counting lineage aliases that are not listed rows. --- .../src/store/session-dot-state.test.ts | 19 +++++++++++++- apps/desktop/src/store/session-dot-state.ts | 26 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/store/session-dot-state.test.ts b/apps/desktop/src/store/session-dot-state.test.ts index 1661c3ba09..81f20f9b0b 100644 --- a/apps/desktop/src/store/session-dot-state.test.ts +++ b/apps/desktop/src/store/session-dot-state.test.ts @@ -4,7 +4,7 @@ import { createClientSessionState } from '@/lib/chat-runtime' import type { SessionInfo } from '@/types/hermes' import { $sessions, $unreadFinishedSessionIds, setSessions } from './session' -import { $delegatingSessionIds, $sessionDotStateById, hasLiveTurn, showsRunningArc } from './session-dot-state' +import { $delegatingSessionIds, $sessionDotStateById, hasLiveTurn, showsRunningArc, unreadSessionCount } from './session-dot-state' import { clearAllSessionStates, publishSessionState } from './session-states' import { $unreadWriteGuard } from './session-unread-remote' import { $subagentsBySession, type SubagentProgress } from './subagents' @@ -148,3 +148,20 @@ describe('persisted unread (backend watermark)', () => { expect($sessionDotStateById.get()['s1'] ?? 'idle').not.toBe('unread') }) }) + +describe('unreadSessionCount', () => { + it('counts listed unread rows and skips archived', () => { + expect( + unreadSessionCount({ a: 'unread', b: 'working', c: 'unread' }, [ + { id: 'a' }, + { id: 'b' }, + { archived: true, id: 'c' }, + { id: 'missing' } + ]) + ).toBe(1) + }) + + it('does not count alias keys that are not listed rows', () => { + expect(unreadSessionCount({ tip: 'unread', root: 'unread' }, [{ id: 'tip' }])).toBe(1) + }) +}) diff --git a/apps/desktop/src/store/session-dot-state.ts b/apps/desktop/src/store/session-dot-state.ts index f6f30169ff..58c0f8182c 100644 --- a/apps/desktop/src/store/session-dot-state.ts +++ b/apps/desktop/src/store/session-dot-state.ts @@ -28,7 +28,7 @@ import { computed } from 'nanostores' import { stableArray, stableRecord } from '@/lib/stable-array' import { $backgroundRunningSessionIds } from './composer-status' -import { $sessions, $unreadFinishedSessionIds, lineageAliases } from './session' +import { $cronSessions, $messagingSessions, $sessions, $unreadFinishedSessionIds, lineageAliases } from './session' import { $attentionSessionIds, $draftSessionIds, @@ -182,3 +182,27 @@ export const $sessionDotStateById = computed( return (dotStates = stableRecord(dotStates, next)) } ) + +/** Listed, non-archived rows whose resolved status is unread. Alias keys in + * `$sessionDotStateById` are ignored unless they are themselves a listed row. */ +export function unreadSessionCount( + byId: Readonly>, + ...lists: Array +): number { + let n = 0 + + for (const rows of lists) { + for (const row of rows) { + if (!row.archived && byId[row.id] === 'unread') { + n++ + } + } + } + + return n +} + +export const $unreadSessionCount = computed( + [$sessionDotStateById, $sessions, $cronSessions, $messagingSessions], + (byId, sessions, cron, messaging) => unreadSessionCount(byId, sessions, cron, messaging) +) From bf4f5e17f8a014d9be71c18ed3133858ee460bc2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 20 Aug 2026 00:38:09 -0500 Subject: [PATCH 04/15] feat(desktop): show unread count on the sessions sidebar toggle A small overlay on the left sidebar icon (right when panes are flipped) so a closed sessions list still reports unfinished-unread chats. --- apps/desktop/DESIGN.md | 6 +++ .../src/app/shell/titlebar-controls.tsx | 40 +++++++++++++++++-- apps/desktop/src/components/ui/badge.tsx | 7 +++- apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 9 files changed, 53 insertions(+), 6 deletions(-) diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 485073200d..8d4512e65f 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -151,6 +151,12 @@ Notes: - SVGs inherit `size-3.5` (`size-3` at `xs`). Don't re-set icon size. - Polymorph with `asChild` when the button must render as a link/Slot. +## Badges — one component + +`src/components/ui/badge.tsx`. Variants: `default` (tinted primary), `muted`, +`warn`, `destructive`, `outline`, `solid` (primary fill — icon-corner counts). +Sizes: `default`, `xs`, `overlay` (titlebar glyph counts). + ## Form controls - **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 72e297d9ac..33d8d4e235 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -5,9 +5,11 @@ import { useLocation, useNavigate } from 'react-router' import { hudTargetSessionId } from '@/app/hud/handoff' import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode' import { resetLayoutTree } from '@/components/pane-shell/tree/store' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' +import { compactNumber } from '@/lib/format' import { triggerHaptic } from '@/lib/haptics' import { formatModifierToken } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' @@ -15,11 +17,13 @@ import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' import { toggleHud } from '@/store/hud' import { $fileBrowserOpen, + $panesFlipped, $sidebarOpen, toggleFileBrowserOpen, togglePanesFlipped, toggleSidebarOpen } from '@/store/layout' +import { $unreadSessionCount } from '@/store/session-dot-state' import { appViewForPath, isOverlayView } from '../routes' @@ -43,6 +47,8 @@ export interface TitlebarTool { onSelect?: (event?: MouseEvent) => void /** Keybind action id — when set, the tooltip shows the label + keybind hint. */ actionId?: string + /** Overlay count on the glyph (unread sessions). Hidden when 0/undefined. */ + badge?: number title?: string to?: string } @@ -79,6 +85,24 @@ function LayoutGlyph({ modHeld }: { modHeld: boolean }) { ) } +/** Overlay count on a titlebar glyph. Hidden when count is 0/undefined. */ +function withCountBadge(icon: ReactNode, count: number | undefined): ReactNode { + if (!count) { + return icon + } + + return ( + + {icon} + + + {compactNumber(count)} + + + + ) +} + /** Live ⌘/Ctrl tracking — mod-click affordances telegraph themselves (the * layout button morphs into its reset form while the modifier is down). */ function useModifierHeld(): boolean { @@ -109,7 +133,11 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: const modHeld = useModifierHeld() const hapticsMuted = useStore($hapticsMuted) const fileBrowserOpen = useStore($fileBrowserOpen) + const panesFlipped = useStore($panesFlipped) const sidebarOpen = useStore($sidebarOpen) + const unreadCount = useStore($unreadSessionCount) + const unreadBadge = unreadCount > 0 ? unreadCount : undefined + const unreadHint = unreadBadge ? ` · ${t.titlebar.unreadSessions(unreadBadge)}` : '' const toggleHaptics = () => { if (!hapticsMuted) { @@ -130,13 +158,16 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: // show/hide affordances. const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen } const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen } + const leftLabel = leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar + const rightLabel = rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar const leftToolbarTools: TitlebarTool[] = [ { actionId: 'view.toggleSidebar', + badge: panesFlipped ? undefined : unreadBadge, icon: , id: 'sidebar', - label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar, + label: `${leftLabel}${panesFlipped ? '' : unreadHint}`, onSelect: () => { triggerHaptic('tap') leftEdge.toggle() @@ -157,9 +188,10 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: const rightSidebarTool: TitlebarTool = { actionId: 'view.toggleRightSidebar', + badge: panesFlipped ? unreadBadge : undefined, icon: , id: 'right-sidebar', - label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar, + label: `${rightLabel}${panesFlipped ? unreadHint : ''}`, onSelect: () => { triggerHaptic('tap') rightEdge.toggle() @@ -306,7 +338,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType - {tool.icon} + {withCountBadge(tool.icon, tool.badge)} @@ -332,7 +364,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType - {tool.icon} + {withCountBadge(tool.icon, tool.badge)} ) diff --git a/apps/desktop/src/components/ui/badge.tsx b/apps/desktop/src/components/ui/badge.tsx index c4e46e5236..d3a8a50449 100644 --- a/apps/desktop/src/components/ui/badge.tsx +++ b/apps/desktop/src/components/ui/badge.tsx @@ -15,11 +15,14 @@ const badgeVariants = cva( muted: 'bg-muted text-muted-foreground', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', destructive: 'bg-destructive/10 text-destructive', - outline: 'border border-(--ui-stroke-secondary) text-muted-foreground' + outline: 'border border-(--ui-stroke-secondary) text-muted-foreground', + // Solid fill — icon-corner counts (titlebar unread, etc.). + solid: 'bg-primary text-primary-foreground' }, size: { default: 'px-1.5 py-0.5 text-[0.65rem] [&_svg]:size-3', - xs: 'px-1 py-px text-[0.6rem] [&_svg]:size-2.5' + xs: 'px-1 py-px text-[0.6rem] [&_svg]:size-2.5', + overlay: 'h-2 min-w-2 justify-center rounded-[2px] px-px text-[7px] font-semibold tabular-nums' } }, defaultVariants: { variant: 'default', size: 'default' } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 5ed5db5686..2280ab4a4c 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -181,6 +181,7 @@ export const ar = defineLocale({ swapSidebarSides: 'تبديل جانبي الأشرطة', hideRightSidebar: 'إخفاء الشريط الأيمن', showRightSidebar: 'إظهار الشريط الأيمن', + unreadSessions: count => (count === 1 ? 'جلسة واحدة غير مقروءة' : `${count} جلسات غير مقروءة`), muteHaptics: 'كتم الاهتزازات', unmuteHaptics: 'تفعيل الاهتزازات', openSettings: 'فتح الإعدادات', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 5e94e4c4f0..f5f0664aa4 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -211,6 +211,7 @@ export const en: Translations = { swapSidebarSides: 'Swap sidebar sides', hideRightSidebar: 'Hide right sidebar', showRightSidebar: 'Show right sidebar', + unreadSessions: count => (count === 1 ? '1 unread session' : `${count} unread sessions`), muteHaptics: 'Mute haptics', unmuteHaptics: 'Unmute haptics', openSettings: 'Open settings', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 7864578edc..034dc6a631 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -212,6 +212,7 @@ export const ja = defineLocale({ swapSidebarSides: 'サイドバーの向きを切り替え', hideRightSidebar: '右サイドバーを非表示', showRightSidebar: '右サイドバーを表示', + unreadSessions: count => (count === 1 ? '未読セッション 1 件' : `未読セッション ${count} 件`), muteHaptics: '触覚フィードバックをオフ', unmuteHaptics: '触覚フィードバックをオン', openSettings: '設定を開く', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index afd3fda150..dcba755c64 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -253,6 +253,7 @@ export interface Translations { swapSidebarSides: string hideRightSidebar: string showRightSidebar: string + unreadSessions: (count: number) => string muteHaptics: string unmuteHaptics: string openSettings: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 23a3232625..c68b3e2f73 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -206,6 +206,7 @@ export const zhHant = defineLocale({ swapSidebarSides: '交換側邊欄位置', hideRightSidebar: '隱藏右側邊欄', showRightSidebar: '顯示右側邊欄', + unreadSessions: count => (count === 1 ? '1 個未讀工作階段' : `${count} 個未讀工作階段`), muteHaptics: '靜音觸感回饋', unmuteHaptics: '開啟觸感回饋', openSettings: '開啟設定', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 613679cd72..44620ac17e 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -206,6 +206,7 @@ export const zh: Translations = { swapSidebarSides: '交换侧边栏位置', hideRightSidebar: '隐藏右侧栏', showRightSidebar: '显示右侧栏', + unreadSessions: count => (count === 1 ? '1 个未读会话' : `${count} 个未读会话`), muteHaptics: '关闭触感反馈', unmuteHaptics: '开启触感反馈', openSettings: '打开设置', From bbb7b607b4c125a5f0cedf1ce29adc6ae4a59b40 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:49:39 +0000 Subject: [PATCH 05/15] fmt(js): `npm run fix` on merge (#90552) Co-authored-by: github-actions[bot] --- apps/desktop/electron/main.ts | 4 +--- apps/desktop/src/lib/svg-image.test.ts | 4 ++-- apps/desktop/src/store/session-dot-state.test.ts | 8 +++++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 91b3224f5c..85bec53638 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4886,9 +4886,7 @@ function downloadViaTokenToFile(url, token, ctx, options: any = {}) { parsed, { method: 'GET', - headers: options.bearer - ? { Authorization: `Bearer ${options.bearer}` } - : { 'X-Hermes-Session-Token': token } + headers: options.bearer ? { Authorization: `Bearer ${options.bearer}` } : { 'X-Hermes-Session-Token': token } }, res => { // Headers arrived — the connection phase is done. Drop the idle timeout diff --git a/apps/desktop/src/lib/svg-image.test.ts b/apps/desktop/src/lib/svg-image.test.ts index 03e0fc33c9..2e81a5958c 100644 --- a/apps/desktop/src/lib/svg-image.test.ts +++ b/apps/desktop/src/lib/svg-image.test.ts @@ -43,8 +43,8 @@ describe('normalizeSvgSize', () => { }) it('leaves an explicit pixel height when only width is a percentage', () => { - const svg = - '' + const svg = '' + const out = normalizeSvgSize(svg) expect(out).toContain('width="500"') diff --git a/apps/desktop/src/store/session-dot-state.test.ts b/apps/desktop/src/store/session-dot-state.test.ts index 81f20f9b0b..52b614ecf4 100644 --- a/apps/desktop/src/store/session-dot-state.test.ts +++ b/apps/desktop/src/store/session-dot-state.test.ts @@ -4,7 +4,13 @@ import { createClientSessionState } from '@/lib/chat-runtime' import type { SessionInfo } from '@/types/hermes' import { $sessions, $unreadFinishedSessionIds, setSessions } from './session' -import { $delegatingSessionIds, $sessionDotStateById, hasLiveTurn, showsRunningArc, unreadSessionCount } from './session-dot-state' +import { + $delegatingSessionIds, + $sessionDotStateById, + hasLiveTurn, + showsRunningArc, + unreadSessionCount +} from './session-dot-state' import { clearAllSessionStates, publishSessionState } from './session-states' import { $unreadWriteGuard } from './session-unread-remote' import { $subagentsBySession, type SubagentProgress } from './subagents' From 4999af5cc13e93966479c036363f25fe543d511c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:40:41 -0700 Subject: [PATCH 06/15] fix: K3 plan-variant slugs (k3-256k) now get K3's effort vocabulary kimi_supported_efforts() used exact/prefix matching and missed Kimi Coding plan variants like k3-256k, which fell back to the K2-era low/medium/high set and mistranslated efforts on a K3 wire. Replaced with the boundary-token regex from #76427 (credit @ruizanthony), which matches k3/k3-256k/kimi-k3* without matching kimi-k2.6 or mk3000. --- agent/reasoning_effort.py | 15 ++++++++++++--- tests/agent/test_reasoning_effort_module.py | 6 ++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/agent/reasoning_effort.py b/agent/reasoning_effort.py index 231f36fea3..48b44a25ee 100644 --- a/agent/reasoning_effort.py +++ b/agent/reasoning_effort.py @@ -36,8 +36,14 @@ Rules for call sites: from __future__ import annotations +import re from typing import Optional, Sequence +#: K3 slug detector — matches ``k3`` as a delimited token (``k3``, +#: ``k3-256k``, ``kimi-k3``, ``kimi-k3-cot``) without matching K2-era names +#: (``kimi-k2.6``). From #76427 by @ruizanthony. +_KIMI_K3_SLUG_RE = re.compile(r"(?:^|[^a-z0-9])k3(?:[^a-z0-9]|$)") + # Canonical low→high ordering used for nearest-level clamping. Superset of # hermes_constants.VALID_REASONING_EFFORTS ("none" included so an explicit # disable can be clamped too when a provider publishes it as a level). @@ -124,11 +130,14 @@ SOLAR_EFFORTS: tuple[str, ...] = ("low", "medium", "high") def kimi_supported_efforts(model: Optional[str]) -> tuple[str, ...]: """Supported effort set for a Moonshot/Kimi model slug. - K3 is served as the bare slug ``k3`` and the ``kimi-k3*`` aliases; its - documented set is low/high/max. Everything earlier speaks low/medium/high. + K3 is served as the bare slug ``k3``, plan variants like ``k3-256k``, + and the ``kimi-k3*`` aliases; its documented set is low/high/max. + Everything earlier speaks low/medium/high. Boundary-matched so K2-era + names (``kimi-k2.6``) never match (detection regex from #76427 by + @ruizanthony). """ m = (model or "").strip().lower().split("/")[-1] - if m == "k3" or m.startswith("kimi-k3"): + if _KIMI_K3_SLUG_RE.search(m): return KIMI_K3_EFFORTS return KIMI_K2_EFFORTS diff --git a/tests/agent/test_reasoning_effort_module.py b/tests/agent/test_reasoning_effort_module.py index 6cb4c73d02..dc04406a1f 100644 --- a/tests/agent/test_reasoning_effort_module.py +++ b/tests/agent/test_reasoning_effort_module.py @@ -112,13 +112,15 @@ class TestClampEffort: class TestKimiVocabulary: @pytest.mark.parametrize( - "model", ["k3", "kimi-k3", "kimi-k3-cot", "moonshotai/kimi-k3"] + "model", + ["k3", "kimi-k3", "kimi-k3-cot", "moonshotai/kimi-k3", "k3-256k"], ) def test_k3_slugs(self, model): assert kimi_supported_efforts(model) is KIMI_K3_EFFORTS @pytest.mark.parametrize( - "model", ["kimi-k2.6", "moonshotai/kimi-k2-0905", "kimi-latest", None] + "model", + ["kimi-k2.6", "moonshotai/kimi-k2-0905", "kimi-latest", "mk3000", None], ) def test_k2_era_slugs(self, model): assert kimi_supported_efforts(model) is KIMI_K2_EFFORTS From 2d59cb43865d64d04501804aeb7e2d84a214b957 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:46:53 -0700 Subject: [PATCH 07/15] fix(api-server): 'max' and 'ultra' reasoning efforts are no longer silently ignored on API/browser requests _request_reasoning_config() whitelisted none..xhigh, so a client sending max or ultra (valid /reasoning + config.yaml levels) fell through to the default effort with no error. The server now accepts the full internal ladder (hermes_constants.VALID_REASONING_EFFORTS); per-provider wire clamping happens downstream via agent.reasoning_effort, same as every other entry surface. Salvages the api_server hunk of #78216 (credit @snowzlmbot); the un-clamping half of that PR was rejected separately. --- gateway/platforms/api_server.py | 10 +++++- .../test_api_server_reasoning_ladder.py | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_api_server_reasoning_ladder.py diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 57f943eda1..30ed5a8108 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -245,7 +245,15 @@ def _coerce_request_bool(value: Any, default: bool = False) -> bool: _REQUEST_OPTION_MISSING = object() -_REASONING_EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"}) +# Full internal ladder + "none": the API server accepts what /reasoning and +# config.yaml accept (hermes_constants.VALID_REASONING_EFFORTS); wire-level +# clamping to each provider's vocabulary happens downstream in the +# transports/profiles via agent.reasoning_effort. Rejecting "max"/"ultra" +# here made API/browser clients second-class citizens of the ladder +# (#78216's api_server observation). +_REASONING_EFFORTS = frozenset( + {"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"} +) _RUNTIME_AGENT_OVERRIDE_KEYS = ( "api_key", "base_url", diff --git a/tests/gateway/test_api_server_reasoning_ladder.py b/tests/gateway/test_api_server_reasoning_ladder.py new file mode 100644 index 0000000000..0af8770f8e --- /dev/null +++ b/tests/gateway/test_api_server_reasoning_ladder.py @@ -0,0 +1,33 @@ +"""API-server reasoning-effort request parsing accepts the full ladder. + +_request_reasoning_config() previously whitelisted only none..xhigh, so an +API/browser client sending ``max`` or ``ultra`` (both valid /reasoning and +config.yaml levels) was silently ignored and the session ran at the default +effort. The server now accepts the full internal ladder; per-provider wire +clamping happens downstream in the transports/profiles via +agent.reasoning_effort (#78216's api_server observation). +""" + +from gateway.platforms.api_server import _request_reasoning_config +from hermes_constants import VALID_REASONING_EFFORTS + + +class TestRequestReasoningFullLadder: + def test_every_configurable_level_is_accepted(self): + for level in VALID_REASONING_EFFORTS: + out = _request_reasoning_config({"reasoning_effort": level}) + assert out == {"enabled": True, "effort": level}, level + + def test_none_disables(self): + assert _request_reasoning_config({"reasoning_effort": "none"}) == { + "enabled": False + } + + def test_structured_object_shape(self): + out = _request_reasoning_config( + {"reasoning": {"enabled": True, "effort": "ultra"}} + ) + assert out == {"enabled": True, "effort": "ultra"} + + def test_unknown_level_still_ignored(self): + assert _request_reasoning_config({"reasoning_effort": "turbo"}) is None From 188d47919a92ce3fb6ba0c93baccdafb73dade3a Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:58:23 -0600 Subject: [PATCH 08/15] feat(computer-use): expose screenshots for chat delivery --- tests/tools/test_computer_use.py | 52 +++++++++++++++++++++++- tools/computer_use/schema.py | 6 ++- tools/computer_use/tool.py | 69 ++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index a717086f9b..68ca6b7da4 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -6,6 +6,7 @@ import base64 import json import os import sys +from pathlib import Path from typing import Any, Dict, List, cast from unittest.mock import MagicMock, patch @@ -79,7 +80,7 @@ class TestRegistration: from tools.computer_use import cua_backend driver = tmp_path / "custom-cua-driver" - driver.write_text("#!/bin/sh\nexit 0\n") + driver.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") driver.chmod(0o755) monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(driver)) @@ -2553,6 +2554,55 @@ class TestElementSpillFile: assert "elements_file" not in out +class TestCaptureScreenshotPersistence: + """Image captures expose a bounded file for explicit user delivery.""" + + _PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76L" + "AAAADUlEQVR4nGNgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg==" + ) + + def _capture(self): + from tools.computer_use.backend import CaptureResult + + return CaptureResult( + mode="vision", + width=8, + height=8, + png_b64=self._PNG_B64, + image_mime_type="image/png", + png_bytes_len=len(base64.b64decode(self._PNG_B64)), + ) + + def test_multimodal_capture_exposes_shareable_screenshot( + self, tmp_path, monkeypatch, + ): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from tools.computer_use import tool as cu_tool + + monkeypatch.setattr( + cu_tool, "_should_route_through_aux_vision", lambda: False, + ) + out = cu_tool._capture_response(self._capture()) + + screenshot_path = out["meta"]["screenshot_path"] + assert screenshot_path in out["text_summary"] + assert "MEDIA:" not in out["text_summary"] + assert screenshot_path.startswith(str(tmp_path / "cache" / "images")) + assert Path(screenshot_path).read_bytes() == base64.b64decode(self._PNG_B64) + + def test_capture_cache_is_bounded(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from tools.computer_use import tool as cu_tool + + monkeypatch.setattr(cu_tool, "_MAX_CAPTURE_FILES", 2) + for _ in range(3): + assert cu_tool._persist_capture_image(self._capture()) is not None + + captures = list((tmp_path / "cache" / "images").glob("computer_use_*.*")) + assert len(captures) == 2 + + class TestBoundsScaleField: def test_scale_reported_when_spaces_diverge(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tools/computer_use/schema.py b/tools/computer_use/schema.py index 388a4778a9..1c0d322e41 100644 --- a/tools/computer_use/schema.py +++ b/tools/computer_use/schema.py @@ -22,7 +22,11 @@ COMPUTER_USE_SCHEMA: Dict[str, Any] = { "Preferred workflow: call with " "action='capture' (mode='som' gives numbered element overlays), " "then click by `element` index for reliability. Pixel coordinates " - "are supported for models trained on them. Works on any window — " + "are supported for models trained on them. Image captures include a " + "shareable `screenshot_path`; when the user asks to receive the image " + "and the current surface supports attachments, deliver that file using " + "the platform's native MEDIA attachment syntax. Do not automatically " + "send screenshots used only for computer control. Works on any window — " "hidden, minimized, or behind another app. Requires cua-driver to " "be installed." ), diff --git a/tools/computer_use/tool.py b/tools/computer_use/tool.py index 951cf9715c..ab2824b8d0 100644 --- a/tools/computer_use/tool.py +++ b/tools/computer_use/tool.py @@ -1195,6 +1195,11 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME or image_dimensions[1] < _MIN_PROVIDER_IMAGE_DIMENSION ) ) + screenshot_path = ( + _persist_capture_image(cap) + if cap.png_b64 and cap.mode != "ax" and not image_too_small + else None + ) # Index only what's actually surfaced in the response — otherwise the # human-readable summary references element indices the model cannot @@ -1209,6 +1214,10 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME ] if bounds_note: summary_lines.append(f" ({bounds_note})") + if screenshot_path: + summary_lines.append( + f" (shareable screenshot saved to {screenshot_path})" + ) if elements_file: summary_lines.append( f" (full element tree with untruncated labels saved to " @@ -1243,6 +1252,7 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME visible_elements=visible_elements, truncated_elements=truncated_elements, elements_file=elements_file, + screenshot_path=screenshot_path, ) if routed is not None: return routed @@ -1278,6 +1288,8 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME payload["truncated_elements"] = truncated_elements if elements_file: payload["elements_file"] = elements_file + if screenshot_path: + payload["screenshot_path"] = screenshot_path if bounds_scale: payload["bounds_scale"] = bounds_scale return json.dumps(payload) @@ -1303,9 +1315,10 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME ], "text_summary": summary, "meta": {"mode": cap.mode, "width": response_width, "height": response_height, - "elements": total_elements, "png_bytes": cap.png_bytes_len, - **({"elements_file": elements_file} if elements_file else {}), - **({"bounds_scale": bounds_scale} if bounds_scale else {})}, + "elements": total_elements, "png_bytes": cap.png_bytes_len, + **({"screenshot_path": screenshot_path} if screenshot_path else {}), + **({"elements_file": elements_file} if elements_file else {}), + **({"bounds_scale": bounds_scale} if bounds_scale else {})}, } # AX-only (or image-missing fallback): text path actually carries the # `elements` array, so the truncation note applies here. @@ -1446,6 +1459,7 @@ def _route_capture_through_aux_vision( visible_elements: Optional[List[UIElement]] = None, truncated_elements: int = 0, elements_file: Optional[str] = None, + screenshot_path: Optional[str] = None, ) -> Optional[str]: """Pre-analyse the captured PNG via ``vision_analyze`` and return a text result. @@ -1559,6 +1573,8 @@ def _route_capture_through_aux_vision( payload["truncated_elements"] = truncated_elements if elements_file: payload["elements_file"] = elements_file + if screenshot_path: + payload["screenshot_path"] = screenshot_path return json.dumps(payload) @@ -1648,6 +1664,53 @@ _MAX_ELEMENT_LABEL_CHARS = 120 # capture of a dense UI can spill; without pruning the cache grows unbounded. _MAX_SPILL_FILES = 20 +# Keep user-shareable capture files bounded independently from the gateway's +# periodic media-cache cleanup. CLI-only sessions may never start the gateway, +# and capture_after can otherwise leave an unbounded screenshot trail. +_MAX_CAPTURE_FILES = 20 + + +def _persist_capture_image(cap: CaptureResult) -> Optional[str]: + """Save a capture in Hermes' media cache and return its absolute path. + + Captures are normally embedded only in the model's tool context. Persisting + a bounded copy gives attachment-capable surfaces a real file to deliver + when the user explicitly asks for the screenshot. This is best-effort: an + unwritable cache must never break computer control. + """ + if not cap.png_b64: + return None + try: + import uuid as _uuid + + from hermes_constants import get_hermes_dir + + raw = base64.b64decode(cap.png_b64, validate=False) + mime = str(cap.image_mime_type or "").lower() + ext = ".jpg" if mime == "image/jpeg" or ( + not mime and cap.png_b64[:8].startswith("/9j/") + ) else ".png" + + cache_dir = get_hermes_dir("cache/images", "image_cache") + cache_dir.mkdir(parents=True, exist_ok=True) + try: + captures = sorted( + cache_dir.glob("computer_use_*.*"), + key=lambda path: path.stat().st_mtime, + ) + keep_before_write = max(0, _MAX_CAPTURE_FILES - 1) + for stale in captures[: max(0, len(captures) - keep_before_write)]: + stale.unlink(missing_ok=True) + except Exception: + pass + + path = cache_dir / f"computer_use_{_uuid.uuid4().hex}{ext}" + path.write_bytes(raw) + return str(path) + except Exception as exc: # pragma: no cover - defensive + logger.debug("computer_use: screenshot persistence failed: %s", exc) + return None + def _spill_elements_to_file(cap: CaptureResult) -> Optional[str]: """Write the FULL element tree (untruncated labels) to a cache file. From fbca70678967a75659fbd5d4de3081408864ff72 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Thu, 20 Aug 2026 12:52:58 +0800 Subject: [PATCH 09/15] fix(telegram): log the first confirmed getUpdates progress per generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both polling reconnect paths end on the same 'health pending getUpdates progress' line, and _record_polling_progress completed silently — so the log stream for 'reconnected and healthy' was byte-identical to 'reconnected and hung', and a wedged long-poll (#87057 / #69314 / #71239 class) stayed invisible until a user noticed silence. The only detection method was sending the bot a test message (#90504). Emit one INFO on the first confirmed getUpdates round-trip of each generation, inside the existing event-set branch so steady-state polling adds no log volume. This turns the pending line into a resolvable pair ('health pending' -> 'confirmed healthy') whose absence after a reconnect is a reliable hung-poll signature. Fixes #90504 --- plugins/platforms/telegram/adapter.py | 13 +++ ...st_telegram_polling_health_confirmation.py | 110 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 tests/gateway/test_telegram_polling_health_confirmation.py diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index b1f9c67db1..1420fcafaf 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -2517,6 +2517,19 @@ class TelegramAdapter(BasePlatformAdapter): return if generation != self._polling_generation: return + if not self._polling_progress_event.is_set(): + # The first confirmed getUpdates round-trip of this generation + # resolves the "health pending getUpdates progress" line both + # reconnect paths end on. Without it the log stream for + # "reconnected and healthy" is byte-identical to "reconnected + # and hung" — a wedged long-poll is invisible until a user + # notices silence (#90504). + logger.info( + "[%s] Telegram polling confirmed healthy: getUpdates progressing " + "(generation %d)", + self.name, + generation, + ) self._polling_progress_event.set() self._polling_network_error_count = 0 if generation == self._polling_conflict_recovery_generation: diff --git a/tests/gateway/test_telegram_polling_health_confirmation.py b/tests/gateway/test_telegram_polling_health_confirmation.py new file mode 100644 index 0000000000..c458476fff --- /dev/null +++ b/tests/gateway/test_telegram_polling_health_confirmation.py @@ -0,0 +1,110 @@ +"""Regression tests for the Telegram polling health-confirmation log (#90504). + +Both reconnect paths end on ``health pending getUpdates progress`` and +``_record_polling_progress`` used to complete silently, so the log stream for +"reconnected and healthy" was byte-identical to "reconnected and hung". The +first confirmed getUpdates round-trip of each generation now emits an INFO +line, turning the pending line into a resolvable pair whose *absence* after a +reconnect is a reliable hung-poll signature. +""" + +import asyncio +import logging +import sys +from unittest.mock import MagicMock + +import pytest + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + telegram_mod = MagicMock() + telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + telegram_mod.constants.ChatType.GROUP = "group" + telegram_mod.constants.ChatType.SUPERGROUP = "supergroup" + telegram_mod.constants.ChatType.CHANNEL = "channel" + telegram_mod.constants.ChatType.PRIVATE = "private" + telegram_mod.error.NetworkError = type("NetworkError", (OSError,), {}) + telegram_mod.error.TimedOut = type("TimedOut", (OSError,), {}) + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, telegram_mod) + sys.modules.setdefault("telegram.error", telegram_mod.error) + + +_ensure_telegram_mock() + +from gateway.config import Platform # noqa: E402 +from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402 + + +def _bare_adapter(): + a = TelegramAdapter.__new__(TelegramAdapter) + a.platform = Platform.TELEGRAM + a._fatal_error_code = None + a._fatal_error_message = None + a._fatal_error_retryable = True + a._polling_teardown_started = False + a._polling_progress_accepting = True + a._polling_generation = 1 + a._polling_progress_event = asyncio.Event() + a._polling_network_error_count = 2 + a._polling_conflict_count = 3 + a._polling_conflict_recovery_generation = None + a._send_path_degraded = True + return a + + +class TestPollingHealthConfirmation: + def test_first_progress_emits_confirmed_healthy(self, caplog): + a = _bare_adapter() + with caplog.at_level(logging.INFO, logger="plugins.platforms.telegram.adapter"): + a._record_polling_progress(1) + rendered = " | ".join(rec.getMessage() for rec in caplog.records) + assert "confirmed healthy" in rendered + assert "generation 1" in rendered + assert a._polling_progress_event.is_set() + + def test_subsequent_progress_is_silent(self, caplog): + """Only the FIRST round-trip of a generation logs — a quiet evening + must not spam one INFO per getUpdates poll.""" + a = _bare_adapter() + a._record_polling_progress(1) # first — logs + with caplog.at_level(logging.INFO, logger="plugins.platforms.telegram.adapter"): + a._record_polling_progress(1) # second — silent + a._record_polling_progress(1) # third — silent + assert not [ + rec for rec in caplog.records if "confirmed healthy" in rec.getMessage() + ] + + def test_new_generation_logs_again(self, caplog): + """A reconnect starts a new generation with a fresh event; its first + progress must re-emit the confirmation so the pending line of THAT + reconnect also resolves.""" + a = _bare_adapter() + a._record_polling_progress(1) + # reconnect: new generation, event reset, counters possibly nonzero + a._polling_generation = 2 + a._polling_progress_event = asyncio.Event() + a._polling_network_error_count = 1 + a._send_path_degraded = True + with caplog.at_level(logging.INFO, logger="plugins.platforms.telegram.adapter"): + a._record_polling_progress(2) + rendered = " | ".join(rec.getMessage() for rec in caplog.records) + assert "confirmed healthy" in rendered + assert "generation 2" in rendered + + def test_stale_generation_progress_stays_silent(self, caplog): + """Progress from an abandoned generation must neither log nor set the + current event (pre-existing guard, pinned here because the log line + must inherit the same generation-scoping).""" + a = _bare_adapter() + a._polling_generation = 2 + a._polling_progress_event = asyncio.Event() + with caplog.at_level(logging.INFO, logger="plugins.platforms.telegram.adapter"): + a._record_polling_progress(1) + assert not [ + rec for rec in caplog.records if "confirmed healthy" in rec.getMessage() + ] + assert not a._polling_progress_event.is_set() From 37fa4a7c6360e9a53f12d801b9de91aaae2f0927 Mon Sep 17 00:00:00 2001 From: kshitij Date: Thu, 20 Aug 2026 11:22:56 +0530 Subject: [PATCH 10/15] chore: remove unused import pytest from test file Follow-up cleanup from simplify-code review on PR #90521 salvage. --- tests/gateway/test_telegram_polling_health_confirmation.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/gateway/test_telegram_polling_health_confirmation.py b/tests/gateway/test_telegram_polling_health_confirmation.py index c458476fff..74be6364fe 100644 --- a/tests/gateway/test_telegram_polling_health_confirmation.py +++ b/tests/gateway/test_telegram_polling_health_confirmation.py @@ -13,9 +13,6 @@ import logging import sys from unittest.mock import MagicMock -import pytest - - def _ensure_telegram_mock(): if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): return From d5cddae187ba75b8431cd792fa953a8455baa6f8 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Thu, 20 Aug 2026 08:19:32 +0700 Subject: [PATCH 11/15] fix(memory): drop dead memory tool and guidance when built-in stores are off With memory.memory_enabled and memory.user_profile_enabled both false, agent_init never builds a MemoryStore -- but check_memory_requirements() returned True unconditionally and MEMORY_GUIDANCE was gated only on the tool being present in valid_tool_names. So the tool shipped in every request's schema while answering "Memory is not available" on every call, and the system prompt still told the model to save durable facts there. Gate both on the config flags, using the store predicate for the tool and the already-resolved agent state for the guidance (config is not re-read mid-conversation, so the prompt stays byte-stable). Either flag alone still backs the tool, so only turning both off removes it. This lets a user running a third-party provider (Hindsight, Mem0, ...) turn the built-in files off without paying for the dead surface on every API call. The provider's own tools are unaffected: hiding the built-in tool moves the decision onto the toolset gate, and listing memory under agent.disabled_toolsets remains the only switch that takes those down. --- agent/system_prompt.py | 10 +++++++- tools/memory_tool.py | 29 ++++++++++++++++++++-- website/docs/user-guide/features/memory.md | 9 +++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 7d5bba76b3..64399226d5 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -415,7 +415,15 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # Tool-aware behavioral guidance: only inject when the tools are loaded tool_guidance = [] - if "memory" in agent.valid_tool_names: + # MEMORY_GUIDANCE instructs the model to save facts to the built-in + # MEMORY.md/USER.md stores. With both disabled in config no store is built, + # so the guidance would steer the model at a tool whose every call returns + # "Memory is not available". Defaults to True for the rare code paths that + # build an agent view without going through agent_init. + builtin_memory_active = getattr(agent, "_memory_enabled", True) or getattr( + agent, "_user_profile_enabled", True + ) + if "memory" in agent.valid_tool_names and builtin_memory_active: tool_guidance.append(MEMORY_GUIDANCE) if "session_search" in agent.valid_tool_names: tool_guidance.append(SESSION_SEARCH_GUIDANCE) diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 44effd02c2..c2ba72dc0e 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -1143,9 +1143,34 @@ def memory_tool( return json.dumps(result, ensure_ascii=False) +def builtin_memory_stores_enabled() -> bool: + """Return whether either built-in store (MEMORY.md / USER.md) is enabled. + + ``agent_init`` only builds a ``MemoryStore`` when at least one of + ``memory.memory_enabled`` / ``memory.user_profile_enabled`` is true, so with + both off the tool dispatches against ``store=None`` and every call fails + with "Memory is not available". + + Fails open when config can't be read: an unreadable config must not strip a + tool that would otherwise work. + """ + try: + from hermes_cli.config import load_config_readonly + + section = (load_config_readonly() or {}).get("memory") + if not isinstance(section, dict): + return True + return bool(section.get("memory_enabled", True)) or bool( + section.get("user_profile_enabled", True) + ) + except Exception: + logger.debug("Could not read memory config for availability", exc_info=True) + return True + + def check_memory_requirements() -> bool: - """Memory tool has no external requirements -- always available.""" - return True + """Available unless both built-in memory stores are disabled in config.""" + return builtin_memory_stores_enabled() def apply_memory_pending(payload: Dict[str, Any], store: "MemoryStore") -> Dict[str, Any]: diff --git a/website/docs/user-guide/features/memory.md b/website/docs/user-guide/features/memory.md index 624465821e..f76863da65 100644 --- a/website/docs/user-guide/features/memory.md +++ b/website/docs/user-guide/features/memory.md @@ -240,6 +240,15 @@ memory: write_approval: false # false = write freely (default) | true = require approval ``` +Setting **both** `memory_enabled` and `user_profile_enabled` to `false` turns the +built-in stores off completely: the `memory` tool is dropped from the schema and +its guidance block is dropped from the system prompt, so the model is never told +about a tool it cannot use. An external provider set via `memory.provider` +(Hindsight, Mem0, Honcho, …) is unaffected and keeps its own tools — use this +when you want a third-party memory backend *instead of* the built-in files. +Listing `memory` under `agent.disabled_toolsets` is the heavier switch: it hides +external provider tools too. + ## Controlling memory writes (`write_approval`) By default the agent saves memory freely — including from the background From a969c5a93d943df20a37cae8ca6fa55afbb96557 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Thu, 20 Aug 2026 08:19:43 +0700 Subject: [PATCH 12/15] test(memory): cover the disabled built-in memory surface Walks the real resolution chain -- config.yaml on a temp HERMES_HOME -> check_memory_requirements -> get_tool_definitions -- rather than mocking the availability check, since the bug was in how the flags reach the schema. Covers both flags off, either one alone, no config file at all, and a config read that raises (must fail open). Also asserts the external provider's tools survive with the built-in tool gone, so the fix cannot regress into taking Hindsight/Mem0 down with it, while disabled_toolsets keeps its documented "hide everything" meaning. The existing MEMORY_GUIDANCE test built a skip_memory agent whose flags were both false, so it was asserting the old tool-presence-only behavior; it now states its precondition and gains the false-case mirror. --- .../test_builtin_memory_disabled_surface.py | 161 ++++++++++++++++++ tests/run_agent/test_run_agent.py | 29 ++++ 2 files changed, 190 insertions(+) create mode 100644 tests/agent/test_builtin_memory_disabled_surface.py diff --git a/tests/agent/test_builtin_memory_disabled_surface.py b/tests/agent/test_builtin_memory_disabled_surface.py new file mode 100644 index 0000000000..d2771bf463 --- /dev/null +++ b/tests/agent/test_builtin_memory_disabled_surface.py @@ -0,0 +1,161 @@ +"""Built-in memory disabled in config must leave no dead surface behind. + +Setting ``memory.memory_enabled: false`` and ``memory.user_profile_enabled: +false`` stops ``agent_init`` from building a ``MemoryStore``, so the ``memory`` +tool dispatches against ``store=None`` and every call comes back "Memory is not +available". Before the fix the tool stayed in the schema and MEMORY_GUIDANCE +stayed in the system prompt, so users running a third-party provider (Hindsight, +Mem0, …) paid for both on every API call with no way to drop them — listing +``memory`` under ``disabled_toolsets`` takes the provider's tools down too. + +These tests exercise the real resolution chain (config on disk → check_fn → +``get_tool_definitions``) against a temp ``HERMES_HOME``, not mocks. +""" + +import pytest +import yaml + +from model_tools import get_tool_definitions + + +@pytest.fixture(autouse=True) +def _clear_caches(): + """check_fn results and tool definitions are both cached; config written by + a test only takes effect once those are dropped.""" + from model_tools import _clear_tool_defs_cache + from tools.registry import invalidate_check_fn_cache + + invalidate_check_fn_cache() + _clear_tool_defs_cache() + yield + invalidate_check_fn_cache() + _clear_tool_defs_cache() + + +def _write_memory_config(home, **memory_section): + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + yaml.safe_dump({"memory": memory_section}), encoding="utf-8" + ) + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +def _memory_tool_names(): + tools = get_tool_definitions(enabled_toolsets=["memory"], quiet_mode=True) + return {tool["function"]["name"] for tool in tools} + + +class TestBuiltinMemoryToolAvailability: + def test_tool_hidden_when_both_stores_disabled(self, hermes_home): + _write_memory_config( + hermes_home, memory_enabled=False, user_profile_enabled=False + ) + assert "memory" not in _memory_tool_names() + + def test_tool_present_when_only_user_profile_enabled(self, hermes_home): + _write_memory_config( + hermes_home, memory_enabled=False, user_profile_enabled=True + ) + assert "memory" in _memory_tool_names() + + def test_tool_present_when_only_memory_enabled(self, hermes_home): + _write_memory_config( + hermes_home, memory_enabled=True, user_profile_enabled=False + ) + assert "memory" in _memory_tool_names() + + def test_tool_present_by_default(self, hermes_home): + """No config file at all must not strip a working tool.""" + assert "memory" in _memory_tool_names() + + def test_unreadable_config_fails_open(self, hermes_home, monkeypatch): + """A config read error must not silently remove the tool.""" + from tools import memory_tool as memory_tool_module + + def _boom(): + raise RuntimeError("config unreadable") + + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", _boom, raising=False + ) + assert memory_tool_module.check_memory_requirements() is True + + +class TestExternalProviderSurvivesBuiltinDisable: + """Dropping the built-in tool must not drop the external provider's tools. + + ``memory_provider_tools_enabled`` short-circuits on the built-in tool being + present, so hiding that tool moves the decision onto the toolset gate. The + provider must still be reachable for every way a caller can ask for memory. + """ + + def test_provider_tools_enabled_when_memory_toolset_requested(self): + from agent.memory_manager import memory_provider_tools_enabled + + assert memory_provider_tools_enabled( + ["memory", "file"], None, memory_tool_present=False + ) + + def test_provider_tools_enabled_for_unrestricted_toolsets(self): + from agent.memory_manager import memory_provider_tools_enabled + + assert memory_provider_tools_enabled(None, None, memory_tool_present=False) + + def test_disabled_toolsets_still_takes_everything_down(self): + """The heavy switch keeps its documented meaning.""" + from agent.memory_manager import memory_provider_tools_enabled + + assert not memory_provider_tools_enabled( + None, ["memory"], memory_tool_present=False + ) + + +class TestInjectionEndToEnd: + """The real ``inject_memory_provider_tools`` with no built-in memory tool.""" + + def test_provider_tools_injected_without_builtin_memory_tool(self): + from types import SimpleNamespace + + from agent.memory_manager import MemoryManager, inject_memory_provider_tools + from agent.memory_provider import MemoryProvider + + class _Provider(MemoryProvider): + @property + def name(self): + return "fake_hindsight" + + def is_available(self): + return True + + def initialize(self, session_id, **kwargs): + pass + + def get_tool_schemas(self): + return [ + { + "name": "hindsight_retain", + "description": "retain", + "parameters": {"type": "object", "properties": {}}, + } + ] + + manager = MemoryManager() + manager.add_provider(_Provider()) + agent = SimpleNamespace( + _memory_manager=manager, + enabled_toolsets=["memory"], + disabled_toolsets=None, + tools=[], + valid_tool_names=set(), + ) + + added = inject_memory_provider_tools(agent) + + assert added == 1 + assert "hindsight_retain" in agent.valid_tool_names diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index cd4836c575..8713b588fa 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -910,6 +910,35 @@ class TestBuildSystemPrompt: def test_memory_guidance_when_memory_tool_loaded(self, agent_with_memory_tool): from agent.prompt_builder import MEMORY_GUIDANCE + agent_with_memory_tool._memory_enabled = True + prompt = agent_with_memory_tool._build_system_prompt() + assert MEMORY_GUIDANCE in prompt + + def test_no_memory_guidance_when_both_builtin_stores_disabled( + self, agent_with_memory_tool + ): + """Guidance must follow the stores, not just the tool's presence. + + With both built-in stores off, ``agent_init`` never builds a + ``MemoryStore``, so every memory call returns "Memory is not + available" — telling the model to save facts there is a dead + instruction paid for on every API call. + """ + from agent.prompt_builder import MEMORY_GUIDANCE + + agent_with_memory_tool._memory_enabled = False + agent_with_memory_tool._user_profile_enabled = False + prompt = agent_with_memory_tool._build_system_prompt() + assert MEMORY_GUIDANCE not in prompt + + def test_memory_guidance_when_only_user_profile_enabled( + self, agent_with_memory_tool + ): + """USER.md alone still backs the tool, so the guidance stays.""" + from agent.prompt_builder import MEMORY_GUIDANCE + + agent_with_memory_tool._memory_enabled = False + agent_with_memory_tool._user_profile_enabled = True prompt = agent_with_memory_tool._build_system_prompt() assert MEMORY_GUIDANCE in prompt From 481bc9391eac33edfc26754fd8aca001866cca97 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:49:17 -0700 Subject: [PATCH 13/15] fix(memory): profile-only config gets narrow USER_PROFILE_GUIDANCE instead of the full memory block With memory_enabled: false but user_profile_enabled: true, the memory tool stays (it backs USER.md) but the full MEMORY_GUIDANCE told the model to save notes to a MEMORY.md store that does not exist. Split the guidance: a profile-only block is injected for that configuration, directing writes to target='user' only. --- agent/prompt_builder.py | 15 +++++++++++++++ agent/system_prompt.py | 16 +++++++++++----- tests/run_agent/test_run_agent.py | 17 ++++++++++++----- website/docs/user-guide/features/memory.md | 5 +++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index ed1533748b..4f26ffcf2a 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -191,6 +191,21 @@ MEMORY_GUIDANCE = ( "workflows belong in skills, not memory." ) +USER_PROFILE_GUIDANCE = ( + "You have a persistent user profile across sessions. Save durable facts about " + "the user with the memory tool (target='user'): name, role, preferences, " + "corrections, and communication style. The profile is injected into every turn, " + "so keep it compact and focused on facts that will still matter later.\n" + "The built-in memory notes store is disabled — write only to the user profile " + "(target='user'), never target='memory'.\n" + "Prioritize what reduces future user steering — the most valuable entry is one " + "that prevents the user from having to correct or remind you again.\n" + "Write entries as declarative facts, not instructions to yourself. " + "'User prefers concise responses' ✓ — 'Always respond concisely' ✗. " + "Imperative phrasing gets re-read as a directive in later sessions and can " + "cause repeated work or override the user's current request." +) + SESSION_SEARCH_GUIDANCE = ( "When the user references something from a past conversation or you suspect " "relevant cross-session context exists, use session_search to recall it before " diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 64399226d5..b176a2554f 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -38,6 +38,7 @@ from agent.prompt_builder import ( HERMES_AGENT_HELP_GUIDANCE, KANBAN_GUIDANCE, MEMORY_GUIDANCE, + USER_PROFILE_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE, PARALLEL_TOOL_CALL_GUIDANCE, PLATFORM_HINTS, @@ -420,11 +421,16 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # so the guidance would steer the model at a tool whose every call returns # "Memory is not available". Defaults to True for the rare code paths that # build an agent view without going through agent_init. - builtin_memory_active = getattr(agent, "_memory_enabled", True) or getattr( - agent, "_user_profile_enabled", True - ) - if "memory" in agent.valid_tool_names and builtin_memory_active: - tool_guidance.append(MEMORY_GUIDANCE) + # When only the user profile store is enabled, the narrower + # USER_PROFILE_GUIDANCE is injected instead — the full block instructs the + # model to write notes to a MEMORY.md store that does not exist. + _mem_enabled = getattr(agent, "_memory_enabled", True) + _profile_enabled = getattr(agent, "_user_profile_enabled", True) + if "memory" in agent.valid_tool_names: + if _mem_enabled: + tool_guidance.append(MEMORY_GUIDANCE) + elif _profile_enabled: + tool_guidance.append(USER_PROFILE_GUIDANCE) if "session_search" in agent.valid_tool_names: tool_guidance.append(SESSION_SEARCH_GUIDANCE) if "skill_manage" in agent.valid_tool_names: diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 8713b588fa..68eb70bcc8 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -924,23 +924,30 @@ class TestBuildSystemPrompt: available" — telling the model to save facts there is a dead instruction paid for on every API call. """ - from agent.prompt_builder import MEMORY_GUIDANCE + from agent.prompt_builder import MEMORY_GUIDANCE, USER_PROFILE_GUIDANCE agent_with_memory_tool._memory_enabled = False agent_with_memory_tool._user_profile_enabled = False prompt = agent_with_memory_tool._build_system_prompt() assert MEMORY_GUIDANCE not in prompt + assert USER_PROFILE_GUIDANCE not in prompt - def test_memory_guidance_when_only_user_profile_enabled( + def test_profile_guidance_when_only_user_profile_enabled( self, agent_with_memory_tool ): - """USER.md alone still backs the tool, so the guidance stays.""" - from agent.prompt_builder import MEMORY_GUIDANCE + """USER.md alone gets the narrower profile-only guidance. + + The full MEMORY_GUIDANCE block instructs the model to save notes to a + MEMORY.md store that does not exist in this configuration, so the + profile-specific block is injected instead. + """ + from agent.prompt_builder import MEMORY_GUIDANCE, USER_PROFILE_GUIDANCE agent_with_memory_tool._memory_enabled = False agent_with_memory_tool._user_profile_enabled = True prompt = agent_with_memory_tool._build_system_prompt() - assert MEMORY_GUIDANCE in prompt + assert MEMORY_GUIDANCE not in prompt + assert USER_PROFILE_GUIDANCE in prompt diff --git a/website/docs/user-guide/features/memory.md b/website/docs/user-guide/features/memory.md index f76863da65..cf575508ab 100644 --- a/website/docs/user-guide/features/memory.md +++ b/website/docs/user-guide/features/memory.md @@ -249,6 +249,11 @@ when you want a third-party memory backend *instead of* the built-in files. Listing `memory` under `agent.disabled_toolsets` is the heavier switch: it hides external provider tools too. +With only `memory_enabled: false` (user profile still on), the tool stays — +it backs the profile store — but the system prompt swaps the full memory +guidance for a narrower profile-only block, so the model is only instructed to +save user-profile facts and never steered at the disabled notes store. + ## Controlling memory writes (`write_approval`) By default the agent saves memory freely — including from the background From 4dcefed089b39dd18c749e9dfc912931b9e72635 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 20 Aug 2026 00:37:12 -0500 Subject: [PATCH 14/15] fix(tui_gateway): scan remote git roots and scope projects.* to the focused profile A remote desktop cannot crawl the host disk, and projects.* always read the launch profile's stores, so switching profiles left the wrong tree on screen. Bind the requested profile's HERMES_HOME and session db for the whole family, and add scan:true so repos with no Hermes sessions still appear. Co-authored-by: Chen Jin Co-authored-by: Ryan Weddle Co-authored-by: izumi0uu Co-authored-by: webtoolbox <1911826+webtoolbox@users.noreply.github.com> --- tests/tui_gateway/test_projects_rpc.py | 362 +++++++++++++++++++++++++ tui_gateway/methods_config.py | 119 ++++---- tui_gateway/server.py | 101 ++++++- 3 files changed, 519 insertions(+), 63 deletions(-) diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index 55fa043635..a2f9314828 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -2,11 +2,14 @@ from __future__ import annotations +import contextlib import os import subprocess +from pathlib import Path import pytest +from hermes_constants import reset_hermes_home_override, set_hermes_home_override import tui_gateway.server as server @@ -336,6 +339,139 @@ def test_scan_time_is_not_treated_as_session_activity(tmp_path): assert active["last_active"] > idle["last_active"] +def test_remote_scan_failure_merges_instead_of_replacing_cache(tmp_path, monkeypatch): + """A backend scan that can't fully walk its roots must NOT wipe the cache. + + `projects.discover_repos` with `scan:true` asks the remote host to scan its + own discovery roots. When one root fails to walk, the scan result is not the + authoritative full universe — the previously cached repos must survive so a + failed remote refresh can't blank the sidebar back to the silent, empty + state of #81723 (regression for MEDIUM: `replace=True` was wiping on every + call regardless of success). + """ + from hermes_cli import projects_db as pdb + import tui_gateway.server as server + + def _git_repo(path): + repo = path + repo.mkdir(parents=True) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + return str(repo) + + # A cached repo the partial scan never visits, parked OUTSIDE the scan roots. + seed = _git_repo(tmp_path / "elsewhere" / "seed-repo") + with pdb.connect_closing() as conn: + pdb.record_discovered_repos(conn, [(seed, "seed-repo")]) + seeded = [r["root"] for r in pdb.list_discovered_repos(conn)] + assert seed in seeded + + good = _git_repo(tmp_path / "good-repo") + + # Force one root to fail to walk: the scan becomes non-authoritative, so it + # must merge into the cache, never wipe it. + real_walk = os.walk + bad_root = str(tmp_path / "unwalkable") + + def _flaky_walk(top, *a, **k): + if top == bad_root: + raise OSError("boom") + yield from real_walk(top, *a, **k) + + monkeypatch.setattr(server.os, "walk", _flaky_walk) + + policy = {"enabled": True, "roots": [good, bad_root], "exclude_paths": []} + + with pdb.connect_closing() as conn: + authoritative = server._scan_discovered_repos_remote(conn, policy) + joined = [r["root"] for r in pdb.list_discovered_repos(conn)] + + # The scan found the good repo and merged it, but the failed root means the + # result is not authoritative, so it must NOT have replaced the cache. + assert not authoritative + assert good in joined + # The seeded repo that the partial scan never saw is still cached. + assert seed in joined + + +def test_remote_scan_missing_root_does_not_wipe_cache(tmp_path): + """A configured discovery root missing on disk must NOT wipe the cache. + + ``os.walk`` on a non-existent root silently yields nothing instead of + raising, so a temporarily unavailable root (unmounted volume, moved path) + would otherwise make the scan look like a genuinely empty authoritative + set and DELETE-replace every cached repo that lived under it. The missing + root must contribute nothing, and the scan must merge — never wipe. + """ + from hermes_cli import projects_db as pdb + import tui_gateway.server as server + + def _git_repo(path): + repo = path + repo.mkdir(parents=True) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + return str(repo) + + # A cached repo the scan never visits, parked OUTSIDE the scan roots. + seed = _git_repo(tmp_path / "elsewhere" / "seed-repo") + with pdb.connect_closing() as conn: + pdb.record_discovered_repos(conn, [(seed, "seed-repo")]) + seeded = [r["root"] for r in pdb.list_discovered_repos(conn)] + assert seed in seeded + + good = _git_repo(tmp_path / "good-repo") + + # This root is configured but does NOT exist on disk. os.walk on it yields + # nothing silently — without the guard the scan would stay authoritative + # and wipe the cache. + missing_root = str(tmp_path / "missing-root") + + policy = {"enabled": True, "roots": [good, missing_root], "exclude_paths": []} + + with pdb.connect_closing() as conn: + authoritative = server._scan_discovered_repos_remote(conn, policy) + joined = [r["root"] for r in pdb.list_discovered_repos(conn)] + + # The missing root is not authoritative, so the scan must merge, not wipe. + assert not authoritative + assert good in joined + # The seeded repo the missing root would have wiped is still cached. + assert seed in joined + + +def test_remote_scan_full_authoritative_replaces_cache(tmp_path): + """Only a fully-walked scan may replace the stale cache.""" + from hermes_cli import projects_db as pdb + import tui_gateway.server as server + + def _git_repo(path): + repo = path + repo.mkdir(parents=True) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + return str(repo) + + # Park the stale repo OUTSIDE the scan root so the authoritative scan no + # longer sees it, and the fresh repo inside the root it walks. + stale = _git_repo(tmp_path / "outside" / "stale-repo") + scandir = tmp_path / "scandir" + scandir.mkdir() + fresh = _git_repo(scandir / "fresh-repo") + + with pdb.connect_closing() as conn: + pdb.record_discovered_repos(conn, [(stale, "stale-repo")]) + + policy = {"enabled": True, "roots": [str(scandir)], "exclude_paths": []} + + with pdb.connect_closing() as conn: + authoritative = server._scan_discovered_repos_remote(conn, policy) + joined = [r["root"] for r in pdb.list_discovered_repos(conn)] + + assert authoritative + assert fresh in joined + # A full, authoritative scan replaced the stale cache: the old repo the + # scan no longer saw is gone from the authoritative set. + assert stale not in joined + + def test_terminal_session_persists_its_launch_cwd(): """A terminal session's cwd IS its workspace, so the row must record it. @@ -460,3 +596,229 @@ def test_nondefault_policy_rejects_stale_or_legacy_results(monkeypatch, tmp_path assert any(item["root"] == str(root) for item in accepted["repos"]) +def _profile_dir(tmp_path: Path, name: str) -> Path: + home = tmp_path / "homes" / name + home.mkdir(parents=True, exist_ok=True) + return home + + +def _bind_profiles(monkeypatch, tmp_path: Path, homes: dict[str, Path]) -> None: + """Resolve profile names to this test's throwaway homes. + + Unmapped names resolve to a path that does not exist, which is how the + gateway detects "not a real profile on this host" and stays on launch. + """ + monkeypatch.setattr( + "hermes_cli.profiles.get_profile_dir", + lambda name: homes.get(name, tmp_path / "homes" / "missing" / name), + ) + + +def _create_project(home: Path, name: str, folder: Path, *, use: bool = False) -> dict: + """Create a project in ``home``'s projects.db via the real RPC.""" + token = set_hermes_home_override(home) + try: + return _call( + "projects.create", {"name": name, "folders": [str(folder)], "use": use} + )["project"] + finally: + reset_hermes_home_override(token) + + +def _create_session(home: Path, session_id: str, cwd: Path) -> None: + """Seed one message-bearing session in ``home``'s state.db.""" + from hermes_state import SessionDB + + db = SessionDB(db_path=home / "state.db") + try: + db.create_session(session_id, "cli", cwd=str(cwd)) + db.append_message(session_id, "user", f"hello from {session_id}") + finally: + db.close() + + +@contextlib.contextmanager +def _serving_launch_profile(launch_home: Path): + """Run the handlers as a backend launched under ``launch_home``.""" + from hermes_state import SessionDB + + token = set_hermes_home_override(launch_home) + prev_db, prev_error = server._db, server._db_error + server._db = SessionDB(db_path=launch_home / "state.db") + server._db_error = None + try: + yield + finally: + server._db.close() + server._db, server._db_error = prev_db, prev_error + reset_hermes_home_override(token) + + +def _cached_repo_labels(home: Path) -> list[str]: + """Labels in ``home``'s discovered-repo cache, read straight off disk.""" + from hermes_cli import projects_db as pdb + + with pdb.connect_closing(home / "projects.db") as conn: + return sorted(str(entry.get("label") or "") for entry in pdb.list_discovered_repos(conn)) + + +def test_projects_reads_are_scoped_to_the_requested_profile(monkeypatch, tmp_path): + """A ``profile`` param reads that profile's projects.db AND its state.db.""" + launch_home = _profile_dir(tmp_path, "launch") + coder_home = _profile_dir(tmp_path, "coder") + launch_repo = tmp_path / "repos" / "launch-repo" + coder_repo = tmp_path / "repos" / "coder-repo" + launch_repo.mkdir(parents=True) + coder_repo.mkdir(parents=True) + _bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home}) + + launch_project = _create_project(launch_home, "Launch", launch_repo, use=True) + coder_project = _create_project(coder_home, "Coder", coder_repo, use=True) + _create_session(launch_home, "launch-session", launch_repo) + _create_session(coder_home, "coder-session", coder_repo) + + with _serving_launch_profile(launch_home): + launch_listing = _call("projects.list") + coder_listing = _call("projects.list", {"profile": "coder"}) + launch_tree = _call("projects.tree") + coder_tree = _call("projects.tree", {"profile": "coder"}) + coder_sessions = _call( + "projects.project_sessions", + {"profile": "coder", "project_id": coder_project["id"]}, + ) + # The override must not leak: the very next unscoped read is launch again. + launch_again = _call("projects.list") + + assert [p["name"] for p in launch_listing["projects"]] == ["Launch"] + assert [p["name"] for p in coder_listing["projects"]] == ["Coder"] + assert launch_listing["active_id"] == launch_project["id"] + assert coder_listing["active_id"] == coder_project["id"] + assert launch_again == launch_listing + + assert [p["label"] for p in launch_tree["projects"]] == ["Launch"] + assert [p["label"] for p in coder_tree["projects"]] == ["Coder"] + # Session counts prove the SESSION db was swapped too, not just projects.db. + assert launch_tree["projects"][0]["sessionCount"] == 1 + assert coder_tree["projects"][0]["sessionCount"] == 1 + assert launch_tree["scoped_session_ids"] == ["launch-session"] + assert coder_tree["scoped_session_ids"] == ["coder-session"] + + assert coder_sessions["project"]["id"] == coder_project["id"] + assert coder_sessions["project"]["sessionCount"] == 1 + lane = coder_sessions["project"]["repos"][0]["groups"][0] + assert [s["id"] for s in lane["sessions"]] == ["coder-session"] + + +def test_projects_tree_is_scoped_to_the_requested_profile(monkeypatch, tmp_path): + """``projects.tree`` on its own reads the requested profile's stores.""" + launch_home = _profile_dir(tmp_path, "launch") + coder_home = _profile_dir(tmp_path, "coder") + launch_repo = tmp_path / "repos" / "tree-launch" + coder_repo = tmp_path / "repos" / "tree-coder" + launch_repo.mkdir(parents=True) + coder_repo.mkdir(parents=True) + _bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home}) + + _create_project(launch_home, "Launch", launch_repo, use=True) + _create_project(coder_home, "Coder", coder_repo, use=True) + _create_session(launch_home, "tree-launch-session", launch_repo) + _create_session(coder_home, "tree-coder-session", coder_repo) + + with _serving_launch_profile(launch_home): + coder_tree = _call("projects.tree", {"profile": "coder"}) + launch_tree = _call("projects.tree") + + assert [p["label"] for p in coder_tree["projects"]] == ["Coder"] + assert coder_tree["scoped_session_ids"] == ["tree-coder-session"] + assert [p["label"] for p in launch_tree["projects"]] == ["Launch"] + assert launch_tree["scoped_session_ids"] == ["tree-launch-session"] + + +def test_project_sessions_is_scoped_to_the_requested_profile(monkeypatch, tmp_path): + """``projects.project_sessions`` on its own hydrates from the requested profile.""" + launch_home = _profile_dir(tmp_path, "launch") + coder_home = _profile_dir(tmp_path, "coder") + launch_repo = tmp_path / "repos" / "drill-launch" + coder_repo = tmp_path / "repos" / "drill-coder" + launch_repo.mkdir(parents=True) + coder_repo.mkdir(parents=True) + _bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home}) + + launch_project = _create_project(launch_home, "Launch", launch_repo, use=True) + coder_project = _create_project(coder_home, "Coder", coder_repo, use=True) + _create_session(launch_home, "drill-launch-session", launch_repo) + _create_session(coder_home, "drill-coder-session", coder_repo) + + with _serving_launch_profile(launch_home): + coder_drill = _call( + "projects.project_sessions", + {"profile": "coder", "project_id": coder_project["id"]}, + ) + launch_drill = _call( + "projects.project_sessions", {"project_id": launch_project["id"]} + ) + + assert coder_drill["project"] is not None + assert coder_drill["project"]["id"] == coder_project["id"] + coder_lane = coder_drill["project"]["repos"][0]["groups"][0] + assert [s["id"] for s in coder_lane["sessions"]] == ["drill-coder-session"] + + assert launch_drill["project"]["id"] == launch_project["id"] + launch_lane = launch_drill["project"]["repos"][0]["groups"][0] + assert [s["id"] for s in launch_lane["sessions"]] == ["drill-launch-session"] + + +def test_record_repos_writes_to_the_requested_profiles_projects_db(monkeypatch, tmp_path): + """The scan cache is per-profile: a scoped write must not land on launch.""" + launch_home = _profile_dir(tmp_path, "launch") + coder_home = _profile_dir(tmp_path, "coder") + launch_repo = tmp_path / "repos" / "launch-scan" + coder_repo = tmp_path / "repos" / "coder-scan" + launch_repo.mkdir(parents=True) + coder_repo.mkdir(parents=True) + _bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home}) + + with _serving_launch_profile(launch_home): + _call("projects.record_repos", {"repos": [{"root": str(launch_repo), "label": "launch"}]}) + _call( + "projects.record_repos", + {"profile": "coder", "repos": [{"root": str(coder_repo), "label": "coder"}]}, + ) + + launch_repos = _call("projects.discover_repos")["repos"] + coder_repos = _call("projects.discover_repos", {"profile": "coder"})["repos"] + + assert [repo["label"] for repo in launch_repos] == ["launch"] + assert [repo["label"] for repo in coder_repos] == ["coder"] + assert _cached_repo_labels(launch_home) == ["launch"] + assert _cached_repo_labels(coder_home) == ["coder"] + + +def test_projects_without_a_profile_stay_on_the_launch_home(monkeypatch, tmp_path): + """Omitted/blank/unknown profile is a no-op — the pre-scoping behavior.""" + launch_home = _profile_dir(tmp_path, "launch") + coder_home = _profile_dir(tmp_path, "coder") + repo = tmp_path / "repos" / "launch-only" + repo.mkdir(parents=True) + _bind_profiles(monkeypatch, tmp_path, {"default": launch_home, "coder": coder_home}) + + with _serving_launch_profile(launch_home): + created = _call( + "projects.create", {"name": "Launch only", "folders": [str(repo)], "use": True} + )["project"] + _call("projects.record_repos", {"repos": [{"root": str(repo), "label": "only"}]}) + + omitted = _call("projects.list") + blank = _call("projects.list", {"profile": ""}) + unknown = _call("projects.list", {"profile": "not-a-profile"}) + + assert [p["name"] for p in omitted["projects"]] == ["Launch only"] + assert blank == omitted + assert unknown == omitted + assert omitted["active_id"] == created["id"] + + assert _cached_repo_labels(launch_home) == ["only"] + assert not (coder_home / "projects.db").exists() + assert not (Path(os.environ["HERMES_HOME"]) / "projects.db").exists() + + diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index 53ccb5d91c..314b38d904 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -17,31 +17,40 @@ _profile_scoped = _registry.profile_scoped @method("projects.discover_repos") +@_profile_scoped def _(rid, params: dict) -> dict: """Repos for the desktop overview: scanned-from-disk (cached) ∪ session-derived.""" try: - db = _get_db() - if db is None: - return _ok(rid, {"repos": []}) - from hermes_cli import projects_db as pdb + with _profile_db(params) as db: + if db is None: + return _ok(rid, {"repos": []}) + from hermes_cli import projects_db as pdb - policy = _repo_discovery_policy() - policy_key = _repo_discovery_policy_key(policy) - with pdb.connect_closing() as conn: - pdb.reconcile_discovered_repos_policy( - conn, - policy_key, - preserve_unversioned=_repo_discovery_policy_is_default(policy), - ) - repos = _discover_repos_payload( - db, conn=conn, include_cached=policy["enabled"] - ) - return _ok(rid, {"repos": repos, "discovery_policy": policy}) + policy = _repo_discovery_policy() + policy_key = _repo_discovery_policy_key(policy) + with pdb.connect_closing() as conn: + pdb.reconcile_discovered_repos_policy( + conn, + policy_key, + preserve_unversioned=_repo_discovery_policy_is_default(policy), + ) + # `scan=true` (set by the desktop in remote-gateway mode): run a + # backend-side filesystem scan of the policy roots so repos with + # zero Hermes sessions still surface. The desktop's native scan + # only runs on the local filesystem; on a remote connection it + # must ask the host to scan itself (#81723). + if params.get("scan") and policy["enabled"]: + _scan_discovered_repos_remote(conn, policy) + repos = _discover_repos_payload( + db, conn=conn, include_cached=policy["enabled"] + ) + return _ok(rid, {"repos": repos, "discovery_policy": policy}) except Exception as e: return _err(rid, 5061, str(e)) @method("projects.record_repos") +@_profile_scoped def _(rid, params: dict) -> dict: """Persist git repo roots found by the client's filesystem scan, then return the merged repo list. The native crawl runs on the desktop (local fs); this @@ -88,24 +97,25 @@ def _(rid, params: dict) -> dict: elif not policy["enabled"]: pdb.clear_discovered_repos(conn, policy_key=policy_key) - db = _get_db() - return _ok( - rid, - { - "repos": _discover_repos_payload( - db, include_cached=policy["enabled"] - ) - if db is not None - else [], - "accepted": accepted, - "discovery_policy": policy, - }, - ) + with _profile_db(params) as db: + return _ok( + rid, + { + "repos": _discover_repos_payload( + db, include_cached=policy["enabled"] + ) + if db is not None + else [], + "accepted": accepted, + "discovery_policy": policy, + }, + ) except Exception as e: return _err(rid, 5061, str(e)) @method("projects.tree") +@_profile_scoped def _(rid, params: dict) -> dict: """Authoritative project overview: project -> repo -> lane structure with counts + a few preview sessions per project, plus the flat set of session @@ -113,26 +123,27 @@ def _(rid, params: dict) -> dict: Lanes carry no session rows here; drill-in uses ``projects.project_sessions``. """ try: - db = _get_db() - if db is None: - return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []}) + with _profile_db(params) as db: + if db is None: + return _ok(rid, {"projects": [], "active_id": None, "scoped_session_ids": []}) - tree, active_id = _build_project_tree( - db, - preview_limit=int(params.get("preview_limit") or 3), - hydrate=False, - session_limit=int(params.get("session_limit") or 2000), - include_discovered=True, - ) - return _ok( - rid, - {"projects": tree["projects"], "active_id": active_id, "scoped_session_ids": tree["scoped_session_ids"]}, - ) + tree, active_id = _build_project_tree( + db, + preview_limit=int(params.get("preview_limit") or 3), + hydrate=False, + session_limit=int(params.get("session_limit") or 2000), + include_discovered=True, + ) + return _ok( + rid, + {"projects": tree["projects"], "active_id": active_id, "scoped_session_ids": tree["scoped_session_ids"]}, + ) except Exception as e: return _err(rid, 5061, str(e)) @method("projects.project_sessions") +@_profile_scoped def _(rid, params: dict) -> dict: """Fully hydrated lanes (repo -> lane -> session rows) for one project, built from the same authoritative grouping as ``projects.tree`` so ids and @@ -142,18 +153,18 @@ def _(rid, params: dict) -> dict: if not project_id: return _err(rid, 5063, "project_id required") - db = _get_db() - if db is None: - return _ok(rid, {"project": None}) + with _profile_db(params) as db: + if db is None: + return _ok(rid, {"project": None}) - # Drill-in only needs the entered project (which has sessions), so skip - # the zero-session discovery tier entirely. - tree, _active = _build_project_tree( - db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000), - include_discovered=False, - ) - proj = next((p for p in tree["projects"] if p["id"] == project_id), None) - return _ok(rid, {"project": proj}) + # Drill-in only needs the entered project (which has sessions), so skip + # the zero-session discovery tier entirely. + tree, _active = _build_project_tree( + db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000), + include_discovered=False, + ) + proj = next((p for p in tree["projects"] if p["id"] == project_id), None) + return _ok(rid, {"project": proj}) except Exception as e: return _err(rid, 5061, str(e)) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 621032da67..6bb356201c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1534,13 +1534,12 @@ def _profile_home(profile: str | None) -> Path | None: def _profile_scoped(handler): - """Bind ``params['profile']``'s HERMES_HOME around a pet RPC handler. + """Bind ``params['profile']``'s HERMES_HOME around a handler. - Pets are per-profile: ``display.pet.*`` lives in the profile's config.yaml and - sprites install under its ``pets/`` dir (both resolve via ``get_hermes_home``). - The desktop sends ``profile`` on pet calls so config + pets dir resolve to the - focused profile even in app-global remote mode, where one backend serves every - profile. No-op for the launch profile (own-profile backends already resolve it). + Pets (config + sprites) and projects (projects.db, discovery policy) both + resolve via ``get_hermes_home``. The desktop sends ``profile`` so a single + backend serving every profile in app-global remote mode still hits the + focused profile's home. No-op for the launch profile. """ def wrapper(rid, params): @@ -12634,13 +12633,14 @@ def _projects_payload(conn) -> dict: def _projects_method(name: str): """Register a projects RPC, injecting (pdb, conn) and unifying error mapping. - Every project CRUD handler opened the per-profile DB, mapped a missing id to - 5062, bad args to 5063, and everything else to 5061. This collapses that - boilerplate so each handler is just its one meaningful operation. + Binds ``params['profile']`` (via ``@_profile_scoped``) so app-global remote + mode reads that profile's ``projects.db``. Missing id maps to 5062, bad args + to 5063, everything else to 5061. """ def decorator(fn): @method(name) + @_profile_scoped def handler(rid, params: dict) -> dict: try: from hermes_cli import projects_db as pdb @@ -12879,6 +12879,89 @@ def _repo_discovery_policy_is_default(policy: dict) -> bool: ) +def _scan_discovered_repos_remote(conn, policy: dict) -> bool: + """Backend-side disk scan of the discovery policy roots. + + The desktop's native repo scan only runs on the local filesystem. On a + remote gateway connection the host must scan its own disk so repos with + zero Hermes sessions still appear in the sidebar (#81723). Mirrors the + desktop's behavior: walk each root (bounded depth), find `.git` + directories, record (root, label) pairs into the discovery cache. + + Best-effort: any failure logs and leaves the cache untouched — the + session-derived repos from `_discover_repos_payload` still surface. + + Returns True when the scan is authoritative (every root was walked to + completion without error and the per-scan cap was not hit). Only then may + the caller treat the result as a full replacement and pass ``replace=True`` + to the cache write — a partial or errored scan must merge, never wipe, so + a failed remote refresh can't blank the previously cached repos into the + silent, unpopulated sidebar of #81723. + """ + from hermes_cli import projects_db as pdb + + roots = policy.get("roots") or [] + excludes = policy.get("exclude_paths") or [] + pairs: list[tuple[str, str | None]] = [] + seen: set[str] = set() + authoritative = True + + def _is_excluded(path: str) -> bool: + return any(path == ex or path.startswith(ex.rstrip("/\\") + os.sep) for ex in excludes if ex) + + for root in roots: + if not os.path.isdir(root): + # `os.walk` on a missing root silently yields nothing instead of + # raising, so a temporarily unavailable root (unmounted volume, + # moved path) would otherwise look like a genuinely empty scan and + # let `authoritative` stay True — letting the replace wipe every + # cached repo that lived under the missing root. A missing root + # contributes nothing and must not be treated as authoritative. + authoritative = False + logger.debug("discover_repos scan root missing, skipping: %s", root) + continue + try: + for dirpath, dirnames, _filenames in os.walk(root): + if _is_excluded(dirpath): + dirnames[:] = [] + continue + # A `.git` directory marks this directory as a repo root. Check + # BEFORE pruning hidden dirs — `.git` is itself hidden, so a + # prune-first order would drop it and never detect any repo. + if ".git" in dirnames: + repo_root = dirpath + if repo_root not in seen: + seen.add(repo_root) + pairs.append((repo_root, os.path.basename(repo_root))) + # Don't descend into the repo's own .git to hunt nested repos. + dirnames[:] = [] + else: + # Not a repo: skip hidden dirs (e.g. .hermes) and node_modules. + dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in ("node_modules",)] + if len(pairs) >= 500: + break + except Exception: + # A root that can't be walked yields no authoritative set — fall back + # to merging, never replacing, so the prior cache survives. + authoritative = False + logger.debug("discover_repos scan failed for root %s", root, exc_info=True) + if len(pairs) >= 500: + # Cap hit means the walk didn't cover the full roots; the collected + # set must not be treated as the complete authoritative universe. + authoritative = False + break + + if pairs: + try: + pdb.record_discovered_repos( + conn, pairs, replace=authoritative, policy_key=_repo_discovery_policy_key(policy) + ) + except Exception: + logger.debug("discover_repos cache write failed", exc_info=True) + authoritative = False + return authoritative + + def _discover_repos_payload( db, *, conn=None, backfill: bool = True, include_cached: bool = True ) -> list[dict]: From f98382807750abed8735c3ac33f7d627df6290d5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 20 Aug 2026 00:37:12 -0500 Subject: [PATCH 15/15] fix(desktop): stamp the focused profile on projects RPCs and refresh a remote scan Remote mode used to return before asking the host for repos, and never sent profile, so the sidebar stayed on the launch list. Ask discover_repos to scan, forward the focused profile on every projects call, and drop late responses from a profile the user already left. Co-authored-by: Chen Jin Co-authored-by: Ryan Weddle Co-authored-by: izumi0uu Co-authored-by: webtoolbox <1911826+webtoolbox@users.noreply.github.com> --- apps/desktop/src/store/projects.test.ts | 271 +++++++++++++++++++++++- apps/desktop/src/store/projects.ts | 200 ++++++++++++----- 2 files changed, 417 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index d91882032d..5e0e447343 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NO_PROJECT_ID, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' import { $sidebarAgentsGrouped, setSidebarAgentsGrouped } from '@/store/layout' -import { $activeGatewayProfile } from '@/store/profile' +import { $activeGatewayProfile, setShowAllProfiles } from '@/store/profile' import { $currentCwd, $selectedStoredSessionId, $sessions, applyConfiguredDefaultProjectDir } from '@/store/session' import { @@ -21,6 +21,7 @@ import { endSessionMutation, enterProject, exitProjectScope, + fetchProjectSessions, openProjectCreate, pickProjectFolder, projectIdForCwd, @@ -63,6 +64,7 @@ vi.mock('@/lib/desktop-git', async importOriginal => ({ vi.mock('@/hermes', () => ({ getHermesConfig: vi.fn(), getProfiles: vi.fn(), + hermesApi: vi.fn(), setApiRequestProfile: vi.fn(), STARTUP_REQUEST_TIMEOUT_MS: 1000 })) @@ -84,6 +86,16 @@ const getHermesConfig = vi.mocked(hermes.getHermesConfig) const notifications = await import('@/store/notifications') const notify = vi.mocked(notifications.notify) +function deferred() { + let resolve!: (value: T) => void + + const promise = new Promise(done => { + resolve = done + }) + + return { promise, resolve } +} + describe('project scope', () => { beforeEach(() => { window.localStorage.clear() @@ -118,6 +130,50 @@ describe('project scope', () => { }) }) +describe('projects RPC profile forwarding', () => { + beforeEach(() => { + vi.clearAllMocks() + $activeGatewayProfile.set('default') + $activeProjectId.set(null) + $projectTree.set([]) + setShowAllProfiles(false) + }) + + it('forwards the normalized active profile to project read RPCs', async () => { + const request = vi.fn(async () => ({ active_id: null, projects: [], scoped_session_ids: [] })) + const gateway = { connectionState: 'open', request } + activeGateway.mockReturnValue(gateway as never) + gatewayAtom.set(gateway as never) + $activeGatewayProfile.set(' coder ') + + await refreshProjects() + await refreshProjectTree() + await fetchProjectSessions('p_123') + + expect(request).toHaveBeenNthCalledWith(1, 'projects.list', { profile: 'coder' }) + expect(request).toHaveBeenNthCalledWith(2, 'projects.tree', { preview_limit: 3, profile: 'coder' }) + expect(request).toHaveBeenNthCalledWith(3, 'projects.project_sessions', { + profile: 'coder', + project_id: 'p_123' + }) + }) + + it('skips project reads in the all-profiles view rather than forwarding its sentinel', async () => { + const request = vi.fn() + const gateway = { connectionState: 'open', request } + activeGateway.mockReturnValue(gateway as never) + gatewayAtom.set(gateway as never) + setShowAllProfiles(true) + + await refreshProjects() + await refreshProjectTree() + await fetchProjectSessions('p_123') + + expect(request).not.toHaveBeenCalled() + setShowAllProfiles(false) + }) +}) + describe('resolveNewSessionCwd', () => { beforeEach(() => { $projectScope.set(ALL_PROJECTS) @@ -478,6 +534,7 @@ describe('repository discovery policy', () => { expect(scanRepos).not.toHaveBeenCalled() expect(request).toHaveBeenCalledWith('projects.record_repos', { discovery_policy: { enabled: false, exclude_paths: [], roots: [] }, + profile: 'default', repos: [] }) }) @@ -513,24 +570,154 @@ describe('repository discovery policy', () => { exclude_paths: ['/work/vendor'], roots: ['/work'] }, + profile: 'default', repos: [{ label: 'repo', root: '/work/repo' }] }) }) - it('does not scan the local filesystem for remote connections', async () => { + it('does not scan the local filesystem for remote connections but still refreshes the project tree', async () => { isDesktopFsRemoteMode.mockReturnValue(true) const scanRepos = vi.fn() desktopGit.mockReturnValue({ scanRepos } as never) + const request = vi.fn(async (method: string) => + method === 'projects.tree' + ? { active_id: null, projects: [], scoped_session_ids: [] } + : { accepted: false, repos: [] } + ) + gatewayWith(request) + $projectTree.set([{ id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 } satisfies SidebarProjectTree]) await scanAndRecordRepos(true) expect(scanRepos).not.toHaveBeenCalled() expect(getHermesConfig).not.toHaveBeenCalled() + // The desktop can't crawl the remote host's filesystem, so it asks the + // host to scan its own discovery roots (`projects.discover_repos` with + // `scan: true`) — repos with zero Hermes sessions must still surface — + // then refreshes the tree to pick up the merged list. Regression for + // #81723: the sidebar used to go silent in remote mode and never + // refresh again. + expect(request).toHaveBeenCalledWith('projects.discover_repos', { profile: 'default', scan: true }) + expect(request).toHaveBeenCalledWith( + 'projects.tree', + expect.objectContaining({ preview_limit: expect.any(Number), profile: 'default' }) + ) + // A successful scan refreshes the tree (here to the empty list the mock + // tree returns), so a later discover-repos call replaces it instead of + // keeping the stale seed. + expect($projectTree.get()).toEqual([]) + }) + + it('surfaces a reject from remote discover_repos without clearing the sidebar', async () => { + // Backend error (RPC `error` frame) rejects the request — the sidebar must + // keep its last known list and flag the failure, not go silently blank. + isDesktopFsRemoteMode.mockReturnValue(true) + desktopGit.mockReturnValue({ scanRepos: vi.fn() } as never) + const request = vi.fn(async (method: string) => { + if (method === 'projects.discover_repos') { + throw new Error('discover_repos failed') + } + if (method === 'projects.tree') { + return { active_id: null, projects: [], scoped_session_ids: [] } + } + return { accepted: false, repos: [] } + }) + gatewayWith(request) + $projectTree.set([{ id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 } satisfies SidebarProjectTree]) + + await scanAndRecordRepos(true) + + // The tree refresh must NOT run against a failed remote scan ... + expect(request).not.toHaveBeenCalledWith( + 'projects.tree', + expect.objectContaining({ preview_limit: expect.any(Number) }) + ) + // ... the cached tree is preserved ... + expect($projectTree.get()).toEqual([ + { id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 } + ]) + }) + + it('does not treat an error-shaped discover_repos response as a successful refresh', async () => { + // A resolved-but-error-shaped body (`{accepted:false}` / no `repos`) must + // be treated as a failure: keep the old list rather than refreshing into + // the silent, empty sidebar of #81723. + isDesktopFsRemoteMode.mockReturnValue(true) + desktopGit.mockReturnValue({ scanRepos: vi.fn() } as never) + const request = vi.fn(async (method: string) => + method === 'projects.tree' + ? { active_id: null, projects: [], scoped_session_ids: [] } + : { accepted: false } + ) + gatewayWith(request) + $projectTree.set([{ id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 } satisfies SidebarProjectTree]) + + await scanAndRecordRepos(true) + + expect(request).not.toHaveBeenCalledWith( + 'projects.tree', + expect.objectContaining({ preview_limit: expect.any(Number) }) + ) + expect($projectTree.get()).toEqual([ + { id: 'seed', label: 'seed', path: null, repos: [], sessionCount: 0 } + ]) + }) + + it('records repos under the profile the scan started with, not one focused mid-scan', async () => { + const { promise: scanResult, resolve: resolveScan } = deferred>() + const { promise: scanStarted, resolve: markScanStarted } = deferred() + + const request = vi.fn(async (method: string) => + method === 'projects.tree' + ? { + active_id: null, + projects: [{ id: 'p_lured', label: 'Lured', path: null, repos: [], sessionCount: 0 }], + scoped_session_ids: [] + } + : { accepted: true, repos: [] } + ) + + gatewayWith(request) + const scanRepos = vi.fn(() => { + markScanStarted() + return scanResult + }) + desktopGit.mockReturnValue({ scanRepos } as never) + getHermesConfig.mockResolvedValue({ + desktop: { + repo_scan_enabled: true, + repo_scan_exclude_paths: [], + repo_scan_roots: ['/work'] + } + }) + $activeGatewayProfile.set('launch') + $projectTree.set([]) + + const pending = scanAndRecordRepos() + await scanStarted + $activeGatewayProfile.set('coder') + resolveScan([{ label: 'repo', root: '/work/repo' }]) + await pending + + expect(request).toHaveBeenCalledWith('projects.record_repos', { + discovery_policy: { enabled: true, exclude_paths: [], roots: ['/work'] }, + profile: 'launch', + repos: [{ label: 'repo', root: '/work/repo' }] + }) + expect(request).not.toHaveBeenCalledWith('projects.record_repos', expect.objectContaining({ profile: 'coder' })) + expect($projectTree.get()).toEqual([]) }) }) describe('project tree profile isolation', () => { - it('does not publish a late response from the previous profile', async () => { + beforeEach(() => { + setShowAllProfiles(false) + $activeGatewayProfile.set('default') + $projects.set([]) + $projectTree.set([]) + }) + + it('does not publish a late response from the previous gateway', async () => { let resolveA: ((value: unknown) => void) | undefined const responseA = new Promise(resolve => { @@ -566,6 +753,84 @@ describe('project tree profile isolation', () => { expect($projectTree.get().map(project => project.id)).toEqual(['profile-b']) }) + + it('does not publish a late projects.list response from the previous profile', async () => { + const { promise: defaultResponse, resolve: resolveDefault } = deferred() + const request = vi.fn((_method: string, params: Record) => + params.profile === 'default' + ? defaultResponse + : Promise.resolve({ + active_id: null, + projects: [{ id: 'profile-b', label: 'Profile B' }] + }) + ) + const gateway = { connectionState: 'open', request } + activeGateway.mockReturnValue(gateway as never) + gatewayAtom.set(gateway as never) + + const pendingDefault = refreshProjects() + $activeGatewayProfile.set('profile-b') + await refreshProjects() + resolveDefault({ + active_id: null, + projects: [{ id: 'profile-a', label: 'Profile A' }] + }) + await pendingDefault + + expect($projects.get().map(project => project.id)).toEqual(['profile-b']) + }) + + it('does not publish a late projects.tree response from the previous profile', async () => { + const { promise: defaultResponse, resolve: resolveDefault } = deferred() + const request = vi.fn((_method: string, params: Record) => + params.profile === 'default' + ? defaultResponse + : Promise.resolve({ + active_id: null, + projects: [{ id: 'profile-b', label: 'Profile B', path: null, repos: [], sessionCount: 0 }], + scoped_session_ids: [] + }) + ) + const gateway = { connectionState: 'open', request } + activeGateway.mockReturnValue(gateway as never) + gatewayAtom.set(gateway as never) + + const pendingDefault = refreshProjectTree() + $activeGatewayProfile.set('profile-b') + await refreshProjectTree() + resolveDefault({ + active_id: null, + projects: [{ id: 'profile-a', label: 'Profile A', path: null, repos: [], sessionCount: 0 }], + scoped_session_ids: [] + }) + await pendingDefault + + expect($projectTree.get().map(project => project.id)).toEqual(['profile-b']) + }) + + it('drops a late hydrated-project response from the previous profile', async () => { + const { promise: defaultResponse, resolve: resolveDefault } = deferred() + const request = vi.fn((_method: string, params: Record) => + params.profile === 'default' + ? defaultResponse + : Promise.resolve({ + project: { id: 'profile-b', label: 'Profile B', path: null, repos: [], sessionCount: 0 } + }) + ) + const gateway = { connectionState: 'open', request } + activeGateway.mockReturnValue(gateway as never) + gatewayAtom.set(gateway as never) + + const pendingDefault = fetchProjectSessions('p_123') + $activeGatewayProfile.set('profile-b') + const profileB = await fetchProjectSessions('p_123') + resolveDefault({ + project: { id: 'profile-a', label: 'Profile A', path: null, repos: [], sessionCount: 0 } + }) + + expect(profileB?.id).toBe('profile-b') + await expect(pendingDefault).resolves.toBeNull() + }) }) describe('tombstone pruning', () => { diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index b904b0f34e..eced3fe82b 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -16,7 +16,13 @@ import { persistentAtom } from '@/lib/persisted' import { $gateway, activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' import { setSidebarAgentsGrouped } from '@/store/layout' import { notify } from '@/store/notifications' -import { $activeGatewayProfile, $profileScope, ALL_PROFILES, requestFreshSession } from '@/store/profile' +import { + $activeGatewayProfile, + $profileScope, + ALL_PROFILES, + normalizeProfileKey, + requestFreshSession +} from '@/store/profile' import { $selectedStoredSessionId, $sessions, @@ -341,6 +347,23 @@ async function gatewayRequest(method: string, params: Record return gateway.request(method, params) } +function projectProfile(): null | string { + const profile = normalizeProfileKey($activeGatewayProfile.get()) + + return $profileScope.get() === ALL_PROFILES || profile === ALL_PROFILES ? null : profile +} + +function projectParams( + params: Record = {}, + profile: null | string = projectProfile() +): Record { + if (!profile) { + throw new Error('Projects are unavailable while viewing all profiles') + } + + return { ...params, profile } +} + async function gatewayRequestOn( gateway: HermesGateway, method: string, @@ -354,15 +377,24 @@ interface ActiveProjectsContext { profile: string } +function stillOnProjectsContext(context: ActiveProjectsContext): boolean { + return activeGateway() === context.gateway && projectProfile() === context.profile +} + async function activeProjectsContext(): Promise { - const profile = $activeGatewayProfile.get() || 'default' + const profile = projectProfile() + + if (!profile) { + throw new Error('Projects are unavailable while viewing all profiles') + } + let gateway = activeGateway() if (!gateway || gateway.connectionState !== 'open') { gateway = await ensureActiveGatewayOpen() } - if (!gateway || gateway !== activeGateway() || profile !== ($activeGatewayProfile.get() || 'default')) { + if (!gateway || gateway !== activeGateway() || profile !== projectProfile()) { throw new Error('Active Hermes profile changed while connecting') } @@ -380,20 +412,24 @@ let projectsRefreshGeneration = 0 // not up yet) leaves the cached atoms intact so the sidebar doesn't flicker. export async function refreshProjects(): Promise { const generation = ++projectsRefreshGeneration - let gateway: HermesGateway | null = null + let context: ActiveProjectsContext | null = null try { - gateway = (await activeProjectsContext()).gateway - const payload = await gatewayRequestOn(gateway, 'projects.list') + context = await activeProjectsContext() + const payload = await gatewayRequestOn( + context.gateway, + 'projects.list', + projectParams({}, context.profile) + ) - if (generation !== projectsRefreshGeneration || activeGateway() !== gateway) { + if (generation !== projectsRefreshGeneration || !stillOnProjectsContext(context)) { return } applyPayload(payload) markProjectsRpcSuccess() } catch (err) { - if (generation === projectsRefreshGeneration && (!gateway || activeGateway() === gateway)) { + if (context && generation === projectsRefreshGeneration && stillOnProjectsContext(context)) { markProjectsRpcFailure(err) } // Backend may not be ready; keep the last known list. @@ -433,26 +469,29 @@ function applyProjectTreePayload(res: ProjectTreePayload): void { } } -async function refreshProjectTreeOn(gateway: HermesGateway): Promise { +async function refreshProjectTreeOn(context: ActiveProjectsContext): Promise { const generation = ++projectTreeRefreshGeneration + const { gateway, profile } = context if (activeGateway() === gateway) { $projectTreeLoading.set(true) } try { - const res = await gatewayRequestOn(gateway, 'projects.tree', { - preview_limit: PROJECT_TREE_PREVIEW_LIMIT - }) + const res = await gatewayRequestOn( + gateway, + 'projects.tree', + projectParams({ preview_limit: PROJECT_TREE_PREVIEW_LIMIT }, profile) + ) - if (generation !== projectTreeRefreshGeneration || activeGateway() !== gateway) { + if (generation !== projectTreeRefreshGeneration || !stillOnProjectsContext(context)) { return } applyProjectTreePayload(res) markProjectsRpcSuccess() } catch (err) { - if (activeGateway() === gateway) { + if (generation === projectTreeRefreshGeneration && stillOnProjectsContext(context)) { markProjectsRpcFailure(err) } } finally { @@ -473,8 +512,7 @@ export async function refreshProjectTree(): Promise { } try { - const { gateway } = await activeProjectsContext() - await refreshProjectTreeOn(gateway) + await refreshProjectTreeOn(await activeProjectsContext()) } catch { // Backend may not be ready; keep the last known tree. } @@ -514,11 +552,22 @@ async function refreshProjectTreeAcrossProfiles(): Promise { // Fully hydrated lanes (repo -> lane -> session rows) for one project, fetched // when the user enters it. Same backend grouping as `projects.tree`, so ids and // membership match exactly. +let projectSessionsRefreshGeneration = 0 + export async function fetchProjectSessions(projectId: string): Promise { + const generation = ++projectSessionsRefreshGeneration + try { - const res = await gatewayRequest<{ project: SidebarProjectTree | null }>('projects.project_sessions', { - project_id: projectId - }) + const context = await activeProjectsContext() + const res = await gatewayRequestOn<{ project: SidebarProjectTree | null }>( + context.gateway, + 'projects.project_sessions', + projectParams({ project_id: projectId }, context.profile) + ) + + if (generation !== projectSessionsRefreshGeneration || !stillOnProjectsContext(context)) { + return null + } return res.project ?? null } catch { @@ -616,6 +665,41 @@ $gateway.subscribe(syncReposScanning) export async function scanAndRecordRepos(force = false): Promise { if (isDesktopFsRemoteMode()) { + // On a remote backend the desktop can't crawl the host filesystem. + // Ask the host to scan its own discovery roots (`projects.discover_repos` + // with `scan: true` — added in #81723) so repos with zero Hermes + // sessions still surface, then refresh the tree so the sidebar picks up + // the merged session-derived + scanned list. + try { + const context = await activeProjectsContext() + const discovered = await gatewayRequestOn<{ + repos?: unknown + discovery_policy?: unknown + }>(context.gateway, 'projects.discover_repos', projectParams({ scan: true }, context.profile)) + + // A resolved response must be the discovery shape. Anything else (an + // error/`accepted:false` body, or a backend that ignored `scan` and + // returned no repo list) means the scan didn't happen — bail out without + // touching the tree so the sidebar keeps its last known list instead of + // being blanked back to the silent, unpopulated state of #81723. + if (discovered?.repos === undefined) { + markProjectsRpcFailure(new Error('projects.discover_repos returned no repo list')) + return + } + + // Remote scan succeeded: refresh the tree so the merged session-derived + + // scanned list surfaces. Skip if the user moved on — a stale scan must + // not publish into the newly focused profile. + if (stillOnProjectsContext(context)) { + await refreshProjectTreeOn(context) + } + } catch (err) { + // Surface the failure (stale backend, RPC error, gateway drop) instead + // of swallowing it: a silent return is exactly the "sidebar goes quiet" + // symptom `scan:true` was meant to fix (#81723). Keep the old list and + // let the sidebar show the error/absent state. + markProjectsRpcFailure(err) + } return } @@ -649,10 +733,11 @@ export async function scanAndRecordRepos(force = false): Promise { state.runningSignature = signature if (!policy.enabled) { - await gatewayRequestOn(context.gateway, 'projects.record_repos', { - discovery_policy: policy, - repos: [] - }) + await gatewayRequestOn( + context.gateway, + 'projects.record_repos', + projectParams({ discovery_policy: policy, repos: [] }, context.profile) + ) } else { scanningGatewayGenerations.set(context.gateway, generation) syncReposScanning() @@ -666,10 +751,11 @@ export async function scanAndRecordRepos(force = false): Promise { return } - await gatewayRequestOn(context.gateway, 'projects.record_repos', { - discovery_policy: policy, - repos - }) + await gatewayRequestOn( + context.gateway, + 'projects.record_repos', + projectParams({ discovery_policy: policy, repos }, context.profile) + ) } if (state.generation !== generation) { @@ -677,10 +763,13 @@ export async function scanAndRecordRepos(force = false): Promise { } state.completedSignature = signature - // Scope-aware on purpose: the scan records into one profile, but folding - // its result back in through the active scope keeps an all-profiles tree - // from being overwritten by the scanned profile's own. - await refreshProjectTree() + // Completion refresh only when the focused profile still matches the one + // the scan was captured under. refreshProjectTree() re-derives the current + // context, so skipping on mismatch keeps a stale scan from publishing into + // the newly focused profile. + if (stillOnProjectsContext(context)) { + await refreshProjectTree() + } } catch { state.completedSignature = undefined } finally { @@ -808,17 +897,20 @@ export async function createProject(input: CreateProjectInput): Promise('projects.create', { - name: input.name, - folders: input.folders ?? [], - primary_path: input.primaryPath, - slug: input.slug, - description: input.description, - icon: input.icon, - color: input.color, - board_slug: input.boardSlug, - use: input.use ?? false - }) + res = await gatewayRequest<{ project: ProjectInfo | null }>( + 'projects.create', + projectParams({ + name: input.name, + folders: input.folders ?? [], + primary_path: input.primaryPath, + slug: input.slug, + description: input.description, + icon: input.icon, + color: input.color, + board_slug: input.boardSlug, + use: input.use ?? false + }) + ) } catch (err) { if (isMissingRpcMethod(err)) { $projectsRpcAvailable.set(false) @@ -891,12 +983,15 @@ export async function updateProject( // Backend treats null/undefined as "leave unchanged"; "" clears (stores NULL). // Map explicit null → "" so "no color"/"no icon" actually clear. await persistOrRollback(snap, () => - gatewayRequest('projects.update', { - id, - ...patch, - ...(patch.color === null && { color: '' }), - ...(patch.icon === null && { icon: '' }) - }) + gatewayRequest( + 'projects.update', + projectParams({ + id, + ...patch, + ...(patch.color === null && { color: '' }), + ...(patch.icon === null && { icon: '' }) + }) + ) ) } @@ -967,7 +1062,10 @@ export async function addProjectFolder( } await persistOrRollback(snap, () => - gatewayRequest('projects.add_folder', { id, path, label: opts.label, is_primary: opts.isPrimary ?? false }) + gatewayRequest( + 'projects.add_folder', + projectParams({ id, path, label: opts.label, is_primary: opts.isPrimary ?? false }) + ) ) reconcileProjects() } @@ -1010,13 +1108,13 @@ export async function deleteProject(id: string): Promise { } await persistOrRollback(snap, async () => { - applyPayload(await gatewayRequest('projects.delete', { id })) + applyPayload(await gatewayRequest('projects.delete', projectParams({ id }))) }) void refreshProjectTree() } export async function setActiveProject(id: null | string): Promise { - const res = await gatewayRequest<{ active_id: null | string }>('projects.set_active', { id }) + const res = await gatewayRequest<{ active_id: null | string }>('projects.set_active', projectParams({ id })) $activeProjectId.set(res.active_id ?? null) }