Merge remote-tracking branch 'origin/main' into feat/keyless-tavily-firecrawl-failover
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+15
-1
@@ -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,
|
||||
@@ -415,8 +416,21 @@ 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 = []
|
||||
# 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.
|
||||
# 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:
|
||||
tool_guidance.append(MEMORY_GUIDANCE)
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<span className="relative inline-flex">
|
||||
{icon}
|
||||
<span className="pointer-events-none absolute -top-2.5 -right-1.5 z-1">
|
||||
<Badge aria-hidden size="overlay" variant="solid">
|
||||
{compactNumber(count)}
|
||||
</Badge>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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: <TitlebarIcon name="layout-sidebar-left" />,
|
||||
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: <TitlebarIcon name="layout-sidebar-right" />,
|
||||
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<typeof us
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{tool.icon}
|
||||
{withCountBadge(tool.icon, tool.badge)}
|
||||
</a>
|
||||
</Button>
|
||||
</Tip>
|
||||
@@ -332,7 +364,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{tool.icon}
|
||||
{withCountBadge(tool.icon, tool.badge)}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -181,6 +181,7 @@ export const ar = defineLocale({
|
||||
swapSidebarSides: 'تبديل جانبي الأشرطة',
|
||||
hideRightSidebar: 'إخفاء الشريط الأيمن',
|
||||
showRightSidebar: 'إظهار الشريط الأيمن',
|
||||
unreadSessions: count => (count === 1 ? 'جلسة واحدة غير مقروءة' : `${count} جلسات غير مقروءة`),
|
||||
muteHaptics: 'كتم الاهتزازات',
|
||||
unmuteHaptics: 'تفعيل الاهتزازات',
|
||||
openSettings: 'فتح الإعدادات',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -212,6 +212,7 @@ export const ja = defineLocale({
|
||||
swapSidebarSides: 'サイドバーの向きを切り替え',
|
||||
hideRightSidebar: '右サイドバーを非表示',
|
||||
showRightSidebar: '右サイドバーを表示',
|
||||
unreadSessions: count => (count === 1 ? '未読セッション 1 件' : `未読セッション ${count} 件`),
|
||||
muteHaptics: '触覚フィードバックをオフ',
|
||||
unmuteHaptics: '触覚フィードバックをオン',
|
||||
openSettings: '設定を開く',
|
||||
|
||||
@@ -253,6 +253,7 @@ export interface Translations {
|
||||
swapSidebarSides: string
|
||||
hideRightSidebar: string
|
||||
showRightSidebar: string
|
||||
unreadSessions: (count: number) => string
|
||||
muteHaptics: string
|
||||
unmuteHaptics: string
|
||||
openSettings: string
|
||||
|
||||
@@ -206,6 +206,7 @@ export const zhHant = defineLocale({
|
||||
swapSidebarSides: '交換側邊欄位置',
|
||||
hideRightSidebar: '隱藏右側邊欄',
|
||||
showRightSidebar: '顯示右側邊欄',
|
||||
unreadSessions: count => (count === 1 ? '1 個未讀工作階段' : `${count} 個未讀工作階段`),
|
||||
muteHaptics: '靜音觸感回饋',
|
||||
unmuteHaptics: '開啟觸感回饋',
|
||||
openSettings: '開啟設定',
|
||||
|
||||
@@ -206,6 +206,7 @@ export const zh: Translations = {
|
||||
swapSidebarSides: '交换侧边栏位置',
|
||||
hideRightSidebar: '隐藏右侧栏',
|
||||
showRightSidebar: '显示右侧栏',
|
||||
unreadSessions: count => (count === 1 ? '1 个未读会话' : `${count} 个未读会话`),
|
||||
muteHaptics: '关闭触感反馈',
|
||||
unmuteHaptics: '开启触感反馈',
|
||||
openSettings: '打开设置',
|
||||
|
||||
@@ -43,8 +43,8 @@ describe('normalizeSvgSize', () => {
|
||||
})
|
||||
|
||||
it('leaves an explicit pixel height when only width is a percentage', () => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="70" viewBox="0 0 500 200"><rect/></svg>'
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="70" viewBox="0 0 500 200"><rect/></svg>'
|
||||
|
||||
const out = normalizeSvgSize(svg)
|
||||
|
||||
expect(out).toContain('width="500"')
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
|
||||
const promise = new Promise<T>(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<Array<{ label: string; root: string }>>()
|
||||
const { promise: scanStarted, resolve: markScanStarted } = deferred<void>()
|
||||
|
||||
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<unknown>()
|
||||
const request = vi.fn((_method: string, params: Record<string, unknown>) =>
|
||||
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<unknown>()
|
||||
const request = vi.fn((_method: string, params: Record<string, unknown>) =>
|
||||
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<unknown>()
|
||||
const request = vi.fn((_method: string, params: Record<string, unknown>) =>
|
||||
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', () => {
|
||||
|
||||
@@ -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<T>(method: string, params: Record<string, unknown>
|
||||
return gateway.request<T>(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<string, unknown> = {},
|
||||
profile: null | string = projectProfile()
|
||||
): Record<string, unknown> {
|
||||
if (!profile) {
|
||||
throw new Error('Projects are unavailable while viewing all profiles')
|
||||
}
|
||||
|
||||
return { ...params, profile }
|
||||
}
|
||||
|
||||
async function gatewayRequestOn<T>(
|
||||
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<ActiveProjectsContext> {
|
||||
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<void> {
|
||||
const generation = ++projectsRefreshGeneration
|
||||
let gateway: HermesGateway | null = null
|
||||
let context: ActiveProjectsContext | null = null
|
||||
|
||||
try {
|
||||
gateway = (await activeProjectsContext()).gateway
|
||||
const payload = await gatewayRequestOn<ProjectsPayload>(gateway, 'projects.list')
|
||||
context = await activeProjectsContext()
|
||||
const payload = await gatewayRequestOn<ProjectsPayload>(
|
||||
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<void> {
|
||||
async function refreshProjectTreeOn(context: ActiveProjectsContext): Promise<void> {
|
||||
const generation = ++projectTreeRefreshGeneration
|
||||
const { gateway, profile } = context
|
||||
|
||||
if (activeGateway() === gateway) {
|
||||
$projectTreeLoading.set(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await gatewayRequestOn<ProjectTreePayload>(gateway, 'projects.tree', {
|
||||
preview_limit: PROJECT_TREE_PREVIEW_LIMIT
|
||||
})
|
||||
const res = await gatewayRequestOn<ProjectTreePayload>(
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
// 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<SidebarProjectTree | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<ProjectI
|
||||
let res: { project: ProjectInfo | null }
|
||||
|
||||
try {
|
||||
res = await gatewayRequest<{ project: ProjectInfo | null }>('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<void> {
|
||||
}
|
||||
|
||||
await persistOrRollback(snap, async () => {
|
||||
applyPayload(await gatewayRequest<ProjectsPayload>('projects.delete', { id }))
|
||||
applyPayload(await gatewayRequest<ProjectsPayload>('projects.delete', projectParams({ id })))
|
||||
})
|
||||
void refreshProjectTree()
|
||||
}
|
||||
|
||||
export async function setActiveProject(id: null | string): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } 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 +154,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Record<string, SessionDotState>>,
|
||||
...lists: Array<readonly { archived?: boolean; id: string }[]>
|
||||
): 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)
|
||||
)
|
||||
|
||||
@@ -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 "✅"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,107 @@
|
||||
"""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
|
||||
|
||||
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()
|
||||
@@ -910,9 +910,45 @@ 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, 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_profile_guidance_when_only_user_profile_enabled(
|
||||
self, agent_with_memory_tool
|
||||
):
|
||||
"""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 not in prompt
|
||||
assert USER_PROFILE_GUIDANCE in prompt
|
||||
|
||||
|
||||
|
||||
def test_datetime_is_date_only_not_minute_precision(self, agent):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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/<hash>.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 <N> restore to checkpoint N")
|
||||
lines.append(" /rollback diff <N> preview changes since checkpoint N")
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
+27
-2
@@ -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]:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
+92
-9
@@ -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]:
|
||||
|
||||
@@ -240,6 +240,20 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user