Merge upstream main into feat/hermes-relay-shared-metrics

This commit is contained in:
Alex Fournier 2026-07-27 15:08:25 -07:00
commit 70d8db7409
42 changed files with 1379 additions and 189 deletions

View file

@ -2040,14 +2040,13 @@ def compress_context(
# (same startswith gate as the restore path); otherwise the
# request layer falls back to the legacy single-breakpoint
# layout with the prompt bytes untouched.
try:
from agent.system_prompt import build_system_prompt_parts as _build_parts
from agent.system_prompt import reconstruct_static_prefix
_static = _build_parts(agent, system_message=system_message)["stable"]
if _static and cached_system_prompt.startswith(_static):
agent._cached_system_prompt_static = _static
except Exception:
pass
reconstruct_static_prefix(
agent,
system_message=system_message,
log_label="compression keep-prompt",
)
else:
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt

View file

@ -72,7 +72,10 @@ from agent.model_metadata import (
save_context_length,
)
from agent.process_bootstrap import _install_safe_stdio
from agent.prompt_caching import apply_anthropic_cache_control
from agent.prompt_caching import (
apply_anthropic_cache_control,
strip_anthropic_cache_control,
)
from agent.retry_utils import (
adaptive_rate_limit_backoff,
is_zai_coding_overload_error,
@ -444,30 +447,13 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# first turn — flip-flopping the wire shape mid-conversation and
# silently degrading to the legacy single-breakpoint layout.
#
# Safety: the rebuilt stable tier is used ONLY when the restored
# prompt literally starts with it (checked here AND re-checked by
# ``_apply_system_cache_markers``'s ``startswith`` gate). If any
# stable-tier input changed since the prompt was persisted (skills
# edited, identity changed), the prefix mismatches, ``_static``
# stays None, and the request falls back to the legacy layout with
# the restored prompt bytes untouched — never a rewritten prompt.
#
# Gated on ``_use_prompt_caching`` so non-Anthropic routes skip the
# rebuild entirely (the static prefix is only consumed by
# ``apply_anthropic_cache_control``).
if getattr(agent, "_use_prompt_caching", False):
try:
from agent.system_prompt import build_system_prompt_parts as _build_parts
# ``reconstruct_static_prefix`` gates on ``_use_prompt_caching`` (so
# non-Anthropic routes skip the rebuild), applies the startswith
# safety gate (stored prompt bytes are never rewritten), and
# fails open to the legacy cache layout.
from agent.system_prompt import reconstruct_static_prefix
_static = _build_parts(agent, system_message=system_message)["stable"]
if _static and stored_prompt.startswith(_static):
agent._cached_system_prompt_static = _static
except Exception:
# Fail-open: restore continues with the legacy cache layout.
logger.debug(
"static system-prefix reconstruction failed on restore",
exc_info=True,
)
reconstruct_static_prefix(agent, system_message=system_message)
return
if stored_prompt:
stored_state = "stale_runtime"
@ -836,6 +822,104 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt):
return sp
def _ensure_cached_system_prompt_static(agent, system_message=None) -> None:
"""Rebuild ``_cached_system_prompt_static`` when caching becomes active.
Sessions restored under a cache-off primary skip the static-prefix rebuild
(gated on ``_use_prompt_caching`` at restore time). A later failover to a
cache-on provider would otherwise redecorate with ``static_system_prefix=
None`` and silently fall back to the legacy system-plus-3 layout (#72626).
Thin wrapper over :func:`agent.system_prompt.reconstruct_static_prefix`,
which memoizes failed rebuilds so this stays cheap on the retry-loop hot
path (it runs at the top of every attempt).
"""
from agent.system_prompt import reconstruct_static_prefix
reconstruct_static_prefix(
agent, system_message=system_message, log_label="failover redecoration"
)
def _peel_moa_guidance(
messages: List[Dict[str, Any]],
guidance: Any,
) -> List[Dict[str, Any]]:
"""Remove MoA reference guidance previously attached by ``_attach_reference_guidance``.
Thin wrapper over :func:`agent.moa_loop.peel_reference_guidance` (kept
adjacent to the attach so the forward/inverse shapes evolve together).
Lazy import mirrors the module's other moa_loop touchpoints.
"""
from agent.moa_loop import peel_reference_guidance
return peel_reference_guidance(messages, guidance)
def _redecorate_prompt_cache_for_provider(
agent,
api_messages: List[Dict[str, Any]],
*,
system_message=None,
moa_prepared: Optional[Dict[str, Any]] = None,
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Strip and re-apply cache_control for the *current* provider policy.
Decoration runs once per call block before the retry loop for the primary
provider. ``try_activate_fallback`` refreshes ``_use_prompt_caching`` /
``_use_native_cache_layout`` but the nine failover ``continue`` paths reused
the old ``api_messages`` (#72626). Mirror ``_reapply_reasoning_echo_for_provider``
by reshaping at the top of each retry attempt.
The source list is the mutated in-flight request (image shrink / ASCII /
reasoning_details recoveries already applied) never a pristine
pre-decoration snapshot. MoA guidance is peeled, the base is redecorated,
then ``rebase_prepared_request`` re-attaches guidance outside the cached
span.
"""
messages: List[Dict[str, Any]] = [
dict(m) if isinstance(m, dict) else m for m in (api_messages or [])
]
prepared = moa_prepared
guidance = prepared.get("guidance") if isinstance(prepared, dict) else None
if guidance:
messages = _peel_moa_guidance(messages, guidance)
strip_anthropic_cache_control(messages)
# Direct attribute access matches the call-block decoration site — the
# flags are unconditionally initialized on AIAgent, and a getattr
# default here would mask a real init bug as silent cache-off.
if agent._use_prompt_caching:
_ensure_cached_system_prompt_static(agent, system_message=system_message)
static = getattr(agent, "_cached_system_prompt_static", None)
messages = apply_anthropic_cache_control(
messages,
cache_ttl=agent._cache_ttl,
native_anthropic=agent._use_native_cache_layout,
static_system_prefix=static if isinstance(static, str) else None,
)
if (
prepared is not None
and getattr(agent, "provider", None) == "moa"
):
# No `and guidance` here: guidance=None is a real prepared shape
# (all-references-failed / silent degraded policy builds the
# prepared request without attaching guidance), and the MoA facade
# sends prepared["messages"] — not api_kwargs["messages"] — so the
# rebase must refresh the prepared object even when there is no
# guidance to re-attach. rebase_prepared_request handles falsy
# guidance by copying the messages and skipping the attach.
completions = getattr(getattr(agent.client, "chat", None), "completions", None)
rebase = getattr(completions, "rebase_prepared_request", None)
if callable(rebase):
prepared = rebase(prepared, messages)
messages = prepared["messages"]
return messages, prepared
def _apply_context_engine_selection(
agent: Any,
api_messages: List[Dict[str, Any]],
@ -1909,6 +1993,18 @@ def run_conversation(
# unless the active provider needs it) so the fallback request
# isn't sent with stale, primary-shaped reasoning fields.
agent._reapply_reasoning_echo_for_provider(api_messages)
# Same story for prompt-cache decoration (#72626): try_activate_
# fallback refreshes the policy flags, but the decorated list
# still carries the primary's breakpoints (or none). Strip and
# re-render for the current provider before building kwargs.
api_messages, _moa_prepared_request = (
_redecorate_prompt_cache_for_provider(
agent,
api_messages,
system_message=system_message,
moa_prepared=_moa_prepared_request,
)
)
api_kwargs = agent._build_api_kwargs(api_messages)
if agent._force_ascii_payload:
_sanitize_structure_non_ascii(api_kwargs)

View file

@ -1337,6 +1337,63 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str
agg_messages.append({"role": "user", "content": guidance})
def peel_reference_guidance(
messages: list[dict[str, Any]],
guidance: Any,
) -> list[dict[str, Any]]:
"""Remove reference guidance previously attached by ``_attach_reference_guidance``.
Exact inverse of the three attach shapes above (string merge, trailing
text part, appended user message) kept adjacent so the two evolve
together; a drifting separator or shape would make the peel silently
no-op and let a cache breakpoint land on the turn-varying guidance
block (the bug class #72626 fixes).
Used by the failover redecoration chokepoint: redecoration must run on
the base transcript so the last cache breakpoint does not land on the
guidance; callers then rebase via ``rebase_prepared_request``.
Returns a new list (input list and its messages are not mutated).
"""
if not guidance or not messages:
return messages
guidance_text = str(guidance)
last = messages[-1]
if not isinstance(last, dict) or last.get("role") != "user":
return messages
content = last.get("content")
if content == guidance_text:
# Attach shape (c): guidance was appended as its own user message.
return list(messages[:-1])
suffix = "\n\n" + guidance_text
if isinstance(content, str) and content.endswith(suffix):
# Attach shape (a): merged into a trailing string user turn.
peeled = dict(last)
peeled["content"] = content[: -len(suffix)]
return [*messages[:-1], peeled]
if isinstance(content, list) and content:
last_part = content[-1]
if isinstance(last_part, dict) and last_part.get("type", "text") == "text":
text = last_part.get("text") or ""
if text == suffix or text == guidance_text:
# Attach shape (b): guidance rode as its own trailing part.
peeled = dict(last)
peeled["content"] = list(content[:-1])
if not peeled["content"]:
# The guidance part was the only content — mirror the
# string shape (c) and drop the whole message rather
# than leaving an empty-content user turn behind.
return list(messages[:-1])
return [*messages[:-1], peeled]
if text.endswith(suffix):
new_part = dict(last_part)
new_part["text"] = text[: -len(suffix)]
peeled = dict(last)
peeled["content"] = [*content[:-1], new_part]
return [*messages[:-1], peeled]
return messages
class MoAChatCompletions:
"""OpenAI-chat-compatible facade where the aggregator is the acting model."""

View file

@ -119,6 +119,59 @@ def _apply_system_cache_markers(
return 1
def strip_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Remove ``cache_control`` markers and undo decoration-produced list shapes.
Used before re-applying decoration after a mid-turn provider failover so
the mutated, undecorated shape (image shrink / ASCII cleanup / etc.) is
preserved while markers match the *new* provider's cache policy (#72626).
Flattening back to a plain string is restricted to the exact shapes
:func:`apply_anthropic_cache_control` produces from string content
a single ``{"type": "text"}`` part, or the two-part ``[static, volatile]``
system split so the ``""``-join is provably byte-exact. Organic
multi-part text (merged user turns, imported transcripts) and parts
carrying extra keys (``citations`` etc.) keep their structure; only
per-part markers are removed. Marker removal is copy-on-write on the
part dicts: content parts may alias the persistent conversation history
(the per-call copy is shallow), and stripping must never rewrite the
stored transcript.
Mutates the top-level message dicts of ``api_messages`` in place and
returns the same list.
"""
for msg in api_messages:
if not isinstance(msg, dict):
continue
msg.pop("cache_control", None)
content = msg.get("content")
if not isinstance(content, list):
continue
if any(isinstance(part, dict) and "cache_control" in part for part in content):
content = [
{k: v for k, v in part.items() if k != "cache_control"}
if isinstance(part, dict) and "cache_control" in part
else part
for part in content
]
msg["content"] = content
decoration_shape = content and all(
isinstance(part, dict)
and part.get("type", "text") == "text"
and isinstance(part.get("text"), str)
and set(part.keys()) <= {"type", "text"}
for part in content
) and (
len(content) == 1
or (msg.get("role") == "system" and len(content) == 2)
)
if decoration_shape:
msg["content"] = "".join(part["text"] for part in content)
return api_messages
def apply_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
cache_ttl: str = "5m",

View file

@ -26,6 +26,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict, List, Optional
@ -51,6 +52,8 @@ from agent.runtime_cwd import resolve_context_cwd
from hermes_constants import get_hermes_home
from utils import is_truthy_value
logger = logging.getLogger(__name__)
def _ra():
"""Lazy reference to the ``run_agent`` module.
@ -582,6 +585,60 @@ def invalidate_system_prompt(agent: Any) -> None:
agent._memory_store.load_from_disk()
def reconstruct_static_prefix(
agent: Any,
system_message: Optional[str] = None,
*,
log_label: str = "restore",
) -> None:
"""Reconstruct ``_cached_system_prompt_static`` for a stored prompt.
The static prefix is not persisted (only the full prompt is), so any
path that adopts a stored/kept ``_cached_system_prompt`` session
restore, the compression keep-prompt path, or a failover to a cache-on
provider mid-turn (#72626) — must rebuild the stable tier to regain the
two-block ``[static, volatile]`` system layout.
Safety: the rebuilt stable tier is used ONLY when the stored prompt
literally starts with it (checked here AND re-checked by
``_apply_system_cache_markers``'s ``startswith`` gate). If any
stable-tier input changed since the prompt was persisted (skills
edited, identity changed), the prefix mismatches, the static stays
None, and requests fall back to the legacy layout with the stored
prompt bytes untouched never a rewritten prompt.
A failed reconstruction is memoized per stored prompt
(``_static_rebuild_failed_for``): ``build_system_prompt_parts`` does
real file I/O (SOUL.md, context files, memory), and callers on the
retry-loop hot path must not re-run it every attempt when the inputs
haven't changed. A legitimately changed stored prompt retries once.
"""
if not getattr(agent, "_use_prompt_caching", False):
return
stored = getattr(agent, "_cached_system_prompt", None)
if not isinstance(stored, str) or not stored:
return
existing = getattr(agent, "_cached_system_prompt_static", None)
if isinstance(existing, str) and existing and stored.startswith(existing):
return
if getattr(agent, "_static_rebuild_failed_for", None) == stored:
return
try:
static = build_system_prompt_parts(agent, system_message=system_message)["stable"]
if static and stored.startswith(static):
agent._cached_system_prompt_static = static
agent._static_rebuild_failed_for = None
return
except Exception:
logger.debug(
"static system-prefix reconstruction failed on %s",
log_label,
exc_info=True,
)
agent._cached_system_prompt_static = None
agent._static_rebuild_failed_for = stored
def format_tools_for_system_message(agent: Any) -> str:
"""Format tool definitions for the system message in the trajectory format.

View file

@ -10,7 +10,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { PANE_TAB_STRIP_LINE, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
import { PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
import { Tip } from '@/components/ui/tooltip'
import { translateNow, useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
@ -131,12 +131,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
// titlebar-height so it opens below the band. 0px elsewhere → unchanged.
style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }}
>
<div
className={cn(
'group/rail-tabs flex h-(--titlebar-height) shrink-0 bg-(--ui-sidebar-surface-background)',
PANE_TAB_STRIP_LINE
)}
>
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 bg-(--ui-sidebar-surface-background)">
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"

View file

@ -615,7 +615,13 @@ export function ChatSidebar({
const sorted = sortProjectsForOverview(
projectTree
.filter(node => !(node.isAuto && dismissed.has(node.id)))
.map(project => ({ ...project, repos: orderRepos(project.repos) })),
.map(project => ({
...project,
// Home is synthetic, so its name is ours to translate — every other
// label is a repo basename or a name the user typed.
label: project.isNoProject ? s.projects.home : project.label,
repos: orderRepos(project.repos)
})),
activeProjectId
)
@ -623,7 +629,7 @@ export function ChatSidebar({
// (default) returns `sorted` untouched; projects the user hasn't ordered yet
// keep their sorted position rather than jumping the hand-picked list.
return orderProjectsByIds(sorted, projectOrderIds)
}, [showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, activeProjectId, projectOrderIds])
}, [showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, activeProjectId, projectOrderIds, s])
// The overview only renders in grouped mode; the model stays live regardless
// so scoping is consistent across views.
@ -685,7 +691,9 @@ export function ChatSidebar({
// The live-session overlay (creates/evictions) is applied per-repo in
// RepoFlatSection, AFTER the visual git-worktree lanes are merged in (so
// out-of-tree worktrees can be placed). Here we just order the snapshot.
return { ...hydrated, repos: orderRepos(hydrated.repos) }
// The label comes from the overview node either way — that's the model's
// presentation copy (Home is translated there), not the raw payload's.
return { ...hydrated, label: overviewEnteredProject.label, repos: orderRepos(hydrated.repos) }
}, [overviewEnteredProject, enteredProjectTree, orderRepos])
// Overlay live `$sessions` onto the entered project so a just-created session
@ -785,7 +793,7 @@ export function ChatSidebar({
// Preview rows come from the backend tree (each project carries its
// most-recent sessions), overlaid with live $sessions so a just-created
// session shows under its project instantly (and with its working arc),
// matching the flat Recents list. Keyed by project path for the rows.
// matching the flat Recents list. Keyed by project id for the rows.
const overviewPreviews = useMemo<Record<string, SessionInfo[]>>(
() => overlayLivePreviews(projectOverview ?? [], agentSessions, projects, PROJECT_PREVIEW_COUNT, removedSessionIds),
[projectOverview, agentSessions, projects, removedSessionIds]
@ -1304,12 +1312,15 @@ export function ChatSidebar({
{enteredProject.path && (
<StartWorkButton onStarted={onNewSessionInWorkspace} repoPath={enteredProject.path} />
)}
<ProjectMenu
isActive={enteredProject.id === activeProjectId}
onExitScope={exitProjectScope}
project={enteredProject}
scoped
/>
{/* Home has no folder and no record to rename, theme, or delete. */}
{!enteredProject.isNoProject && (
<ProjectMenu
isActive={enteredProject.id === activeProjectId}
onExitScope={exitProjectScope}
project={enteredProject}
scoped
/>
)}
<div className="grid size-6 place-items-center">
<Tip label={s.showProjects}>
<Button

View file

@ -55,6 +55,12 @@ export function EnteredProjectContent({
return null
}
// Home's rows aren't anchored to a folder, so there's no repo or worktree
// structure to show — just the chats.
if (project.isNoProject) {
return <>{renderRows(project.repos.flatMap(repo => repo.groups.flatMap(group => group.sessions)))}</>
}
const single = project.repos.length === 1
return (

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { orderProjectsByIds } from './model'
import type { SidebarProjectTree } from './workspace-groups'
import { orderProjectsByIds, sortProjectsForOverview } from './model'
import { NO_PROJECT_ID, type SidebarProjectTree } from './workspace-groups'
function makeProject(id: string, sessionCount: number): SidebarProjectTree {
return {
@ -16,6 +16,13 @@ function makeProject(id: string, sessionCount: number): SidebarProjectTree {
}
}
const home = (): SidebarProjectTree => ({
...makeProject(NO_PROJECT_ID, 2),
isAuto: false,
isNoProject: true,
path: null
})
const ids = (projects: SidebarProjectTree[]) => projects.map(project => project.id)
describe('orderProjectsByIds', () => {
@ -53,4 +60,19 @@ describe('orderProjectsByIds', () => {
expect(ids(orderProjectsByIds(projects, ['gone', 'a']))).toEqual(['a'])
})
it('keeps Home on top of a hand-picked order', () => {
const projects = [makeProject('a', 1), home(), makeProject('b', 1)]
expect(ids(orderProjectsByIds(projects, ['b', 'a']))).toEqual([NO_PROJECT_ID, 'b', 'a'])
})
})
describe('sortProjectsForOverview', () => {
it('puts Home above the active project', () => {
const active = { ...makeProject('active', 5), isAuto: false }
const projects = [makeProject('scanned', 0), active, home()]
expect(ids(sortProjectsForOverview(projects, 'active'))).toEqual([NO_PROJECT_ID, 'active', 'scanned'])
})
})

View file

@ -45,11 +45,18 @@ const projectActivityTime = (project: SidebarProjectTree): number =>
export const latestProjectSessions = (project: SidebarProjectTree, limit: number): SessionInfo[] =>
[...projectSessions(project)].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
// Home is a fixture, not a project: it always leads the overview, above the
// active project and outside any hand-picked order.
const homeFirst = (projects: SidebarProjectTree[]): SidebarProjectTree[] =>
projects[0]?.isNoProject || !projects.some(project => project.isNoProject)
? projects
: [...projects.filter(project => project.isNoProject), ...projects.filter(project => !project.isNoProject)]
export function sortProjectsForOverview(
projects: SidebarProjectTree[],
activeProjectId: null | string
): SidebarProjectTree[] {
return [...projects].sort((a, b) => {
const sorted = [...projects].sort((a, b) => {
const aActive = Boolean(activeProjectId && a.id === activeProjectId && !a.isAuto)
const bActive = Boolean(activeProjectId && b.id === activeProjectId && !b.isAuto)
@ -73,6 +80,8 @@ export function sortProjectsForOverview(
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
)
})
return homeFirst(sorted)
}
// Layer the user's manual drag-order over the deterministic sort.
@ -98,14 +107,14 @@ export function orderProjectsByIds(projects: SidebarProjectTree[], orderIds: str
const fresh = projects.filter(project => !seen.has(project.id))
if (!fresh.length) {
return ordered
return homeFirst(ordered)
}
return [
return homeFirst([
...fresh.filter(project => project.sessionCount > 0),
...ordered,
...fresh.filter(project => project.sessionCount <= 0)
]
])
}
// Project drill-in lanes are git-driven: source them from `git worktree list` so

View file

@ -66,4 +66,12 @@ describe('ProjectOverviewRow', () => {
expect(screen.queryByRole('button', { name: 'Toggle Test D sessions' })).toBeNull()
})
it('drops the "new session" add button on Home, which has no folder to start in', () => {
const home = { id: '__no_project__', isNoProject: true, label: 'Home' } as unknown as SidebarProjectTree
render(<ProjectOverviewRow onNewSession={vi.fn()} project={home} />)
expect(screen.queryByRole('button', { name: 'New session in Home' })).toBeNull()
})
})

View file

@ -28,7 +28,7 @@ import { WorkspaceAddButton } from './workspace-header'
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
// the lead's default tertiary. The glyph wrapper centers + caps size either way.
export function projectIcon({ color, icon }: SidebarProjectTree) {
export function projectIcon({ color, icon, isNoProject }: SidebarProjectTree) {
if (color && !icon) {
return (
<SidebarRowLeadGlyph>
@ -39,7 +39,7 @@ export function projectIcon({ color, icon }: SidebarProjectTree) {
return (
<SidebarRowLeadGlyph style={color ? { color } : undefined}>
<Codicon name={icon || 'folder-library'} size={SIDEBAR_LEAD_ICON_SIZE} />
<Codicon name={icon || (isNoProject ? 'home' : 'folder-library')} size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
)
}
@ -117,10 +117,12 @@ export function ProjectOverviewRow({
<SidebarRowShell
actions={
<>
{onNewSession && (
{/* Home has no folder to start a chat in the sidebar's own "New
session" is that button and no record to rename or delete. */}
{onNewSession && !project.isNoProject && (
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
)}
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
{!project.isNoProject && <ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />}
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}

View file

@ -8,6 +8,7 @@ import {
kanbanWorktreeDir,
liveSessionProjectId,
mergeRepoWorktreeGroups,
NO_PROJECT_ID,
overlayLiveLanes,
overlayLivePreviews,
sessionProjectColor,
@ -458,6 +459,25 @@ const projectNode = (over: Partial<SidebarProjectTree> & Pick<SidebarProjectTree
...over
})
// Home as the backend emits it: no path, one synthetic lane carrying the rows.
const homeNode = (sessions: SessionInfo[]): SidebarProjectTree =>
projectNode({
id: NO_PROJECT_ID,
isNoProject: true,
label: 'Home',
path: null,
repos: [
{
id: NO_PROJECT_ID,
label: 'Home',
path: null,
sessionCount: sessions.length,
groups: [lane({ id: NO_PROJECT_ID, label: 'Home', sessions })]
}
],
sessionCount: sessions.length
})
describe('liveSessionProjectId', () => {
it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => {
expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app')
@ -789,6 +809,26 @@ describe('overlayLiveLanes', () => {
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['keep'])
expect(overlaid.sessionCount).toBe(1)
})
it('adds a brand-new detached chat to Home, and evicts a deleted one', () => {
const existing = makeSession(null, { id: 'old', started_at: 1 })
const doomed = makeSession(null, { id: 'gone', started_at: 2 })
const home = homeNode([existing, doomed])
const overlaid = overlayLiveLanes(home, [makeSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone']))
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['fresh', 'old'])
expect(overlaid.sessionCount).toBe(2)
})
it('leaves Home alone for a session that has a cwd', () => {
// A cwd-carrying row the backend hasn't placed yet (junk root, deleted
// workspace) needs its probes — guessing here would flicker it into Home
// and back out on the next snapshot.
const home = homeNode([])
expect(overlayLiveLanes(home, [makeSession('/www/app', { id: 'fresh' })])).toBe(home)
})
})
describe('overlayLivePreviews', () => {
@ -818,4 +858,10 @@ describe('overlayLivePreviews', () => {
expect(previews['/www/app'].map(s => s.id)).toEqual(['old'])
})
it('previews a detached session under Home, which no cwd could place', () => {
const previews = overlayLivePreviews([homeNode([])], [makeSession(null, { id: 'fresh' })], [], 3)
expect(previews[NO_PROJECT_ID].map(s => s.id)).toEqual(['fresh'])
})
})

View file

@ -56,7 +56,9 @@ export interface SidebarProjectTree {
// A git repo root promoted automatically (not a user-created projects.db row).
// Deletable = dismissable.
isAuto?: boolean
// The synthetic "No project" bucket for cwd-less sessions.
// The synthetic bucket (labeled "Home") holding every session no project
// claimed. It has no folder, so no repo/worktree structure — its one lane
// exists only to carry the rows.
isNoProject?: boolean
repos: SidebarWorkspaceTree[]
sessionCount: number
@ -113,6 +115,18 @@ export function kanbanWorktreeDir(path: string): null | string {
/** Label for a main-checkout lane whose session recorded no branch. */
export const DEFAULT_BRANCH_LABEL = 'main'
/** Id of the Home bucket (must match the backend tree's `NO_PROJECT_ID`). */
export const NO_PROJECT_ID = '__no_project__'
/**
* A session with nowhere to be placed: no cwd and no recorded repo root. These
* are the rows the Home bucket owns, and the only ones the live overlay can
* hand it a row WITH a cwd that the backend still couldn't place (junk root,
* deleted workspace) needs the backend's probes, so it waits for the snapshot.
*/
export const isDetachedSession = (session: SessionInfo): boolean =>
!(session.cwd || '').trim() && !(session.git_repo_root || '').trim()
/** The one definition of a main-checkout lane id (must match the backend tree). */
export const branchLaneId = (repoRoot: string, branch?: string): string =>
`${repoRoot}::branch::${(branch ?? '').trim()}`
@ -568,12 +582,44 @@ export function overlayRepoLanes(
return { ...repo, groups, sessionCount: groups.reduce((n, g) => n + g.sessions.length, 0) }
}
/**
* Home's overlay: its rows have no cwd to place, so this is a plain upsert of
* detached live sessions into its single lane a brand-new project-less chat
* shows the instant it's created, matching the flat Recents list.
*/
function overlayHomeLane(
project: SidebarProjectTree,
live: SessionInfo[],
removed: ReadonlySet<string>
): SidebarProjectTree {
const lane = project.repos[0]?.groups[0]
const detached = live.filter(session => isDetachedSession(session) && !removed.has(session.id))
const kept = (lane?.sessions ?? []).filter(session => !removed.has(session.id))
if (!detached.length && kept.length === (lane?.sessions.length ?? 0)) {
return project
}
const sessions = detached.reduce(upsertSession, kept)
const nextLane = { id: NO_PROJECT_ID, label: project.label, path: null, sessions }
return {
...project,
repos: [{ id: NO_PROJECT_ID, label: project.label, path: null, groups: [nextLane], sessionCount: sessions.length }],
sessionCount: sessions.length
}
}
/** Project-level overlay: {@link overlayRepoLanes} across every repo subtree. */
export function overlayLiveLanes(
project: SidebarProjectTree,
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
): SidebarProjectTree {
if (project.isNoProject) {
return overlayHomeLane(project, live, removed)
}
let changed = false
const repos = project.repos.map(repo => {
@ -591,7 +637,7 @@ export function overlayLiveLanes(
return { ...project, repos, sessionCount: repos.reduce((n, repo) => n + repo.sessionCount, 0) }
}
/** Merge live sessions into per-project overview previews, keyed by project path. */
/** Merge live sessions into per-project overview previews, keyed by project id. */
export function overlayLivePreviews(
projects: SidebarProjectTree[],
live: SessionInfo[],
@ -606,7 +652,8 @@ export function overlayLivePreviews(
continue
}
const projectId = liveSessionProjectId(session, explicitProjects)
const projectId =
liveSessionProjectId(session, explicitProjects) ?? (isDetachedSession(session) ? NO_PROJECT_ID : null)
if (!projectId) {
continue
@ -620,10 +667,6 @@ export function overlayLivePreviews(
const out: Record<string, SessionInfo[]> = {}
for (const node of projects) {
if (!node.path) {
continue
}
const liveRows = byProject.get(node.id) ?? []
const base = (node.previewSessions ?? []).filter(session => !removed.has(session.id))
@ -640,7 +683,7 @@ export function overlayLivePreviews(
}
}
out[node.path] = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
out[node.id] = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
}
return out

View file

@ -109,7 +109,7 @@ interface SidebarSessionsSectionProps {
// which then passes `projectContent` on the next render. Takes precedence
// over `tree` / `groups`.
projectOverview?: SidebarProjectTree[]
// Per-project preview rows (from the backend tree), keyed by project path.
// Per-project preview rows (from the backend tree), keyed by project id.
projectOverviewPreviews?: Record<string, SessionInfo[]>
// True while the backend project tree is loading (overview skeleton).
projectsLoading?: boolean
@ -305,36 +305,45 @@ export function SidebarSessionsSection({
} else if (showEmptyState) {
inner = emptyState
} else if (projectOverview?.length) {
// The model is already ordered (default sort groups explicit-before-auto;
// a manual drag-order, when present, wins). Render in that order and make
// rows drag-to-reorder when a handler is wired.
const projectsDraggable = projectOverview.length > 1 && !!onReorderProjects
// The model is already ordered (Home leads; then the default sort groups
// explicit-before-auto, with a manual drag-order winning when present).
// Render in that order and make rows drag-to-reorder when a handler is
// wired — Home stays outside the sortable list, it's a fixture.
const home = projectOverview[0]?.isNoProject ? projectOverview[0] : undefined
const sortableProjects = home ? projectOverview.slice(1) : projectOverview
const projectsDraggable = sortableProjects.length > 1 && !!onReorderProjects
const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow
const rows = projectOverview.map(project => (
<Row
const projectRow = (project: SidebarProjectTree, Component: typeof ProjectOverviewRow) => (
<Component
activeProjectId={activeProjectId}
key={project.id}
onEnter={onEnterProject}
onNewSession={onNewSessionInWorkspace}
previewSessions={project.path ? projectOverviewPreviews?.[project.path] : undefined}
previewSessions={projectOverviewPreviews?.[project.id]}
project={project}
renderRows={renderRows}
/>
))
)
inner =
projectsDraggable && onReorderProjects ? (
<ReorderableList
ids={projectOverview.map(project => project.id)}
onReorder={onReorderProjects}
sensors={dndSensors}
>
{rows}
</ReorderableList>
) : (
rows
)
const rows = sortableProjects.map(project => projectRow(project, Row))
inner = (
<>
{home && projectRow(home, ProjectOverviewRow)}
{projectsDraggable && onReorderProjects ? (
<ReorderableList
ids={sortableProjects.map(project => project.id)}
onReorder={onReorderProjects}
sensors={dndSensors}
>
{rows}
</ReorderableList>
) : (
rows
)}
</>
)
} else if (groups?.length) {
// Profile/source groups never reorder; render them flat with static rows.
inner = groups.map(group => (

View file

@ -665,7 +665,7 @@ export function ContribController() {
tree-published --workspace-left/right vars (pure CSS, no rect
threading), clamped to clear the REAL TitlebarControls
clusters (fixed, z-70); center is truly window-centered. */}
<div className="relative flex h-[34px] shrink-0 items-center border-b border-(--ui-stroke-tertiary) text-xs">
<div className="relative flex h-[34px] shrink-0 items-center bg-(--ui-sidebar-surface-background) text-xs">
{/* Drag strips, AppShell-style: cut to AVOID the fixed control
clusters instead of overlapping them Electron's no-drag
carve-out of fixed/transformed elements is unreliable, so a

View file

@ -51,9 +51,6 @@ import {
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentModel,
setCurrentModelSource,
setCurrentProvider,
setMessages
} from '@/store/session'
import { focusedSessionNeedsRoute, focusOpenSession } from '@/store/session-states'
@ -269,7 +266,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ activeSessionIdRef })
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
const { applySavedMainModel, refreshCurrentModel, selectModel } = useModelControls({
queryClient,
requestGateway
})
@ -1000,10 +997,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
setCurrentModelSource('default')
updateModelOptionsCache($activeSessionId.get(), provider, model, true)
applySavedMainModel(provider, model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}

View file

@ -140,6 +140,85 @@ describe('useModelControls', () => {
expect($currentProvider.get()).toBe('deepseek')
})
it('keeps a live session authoritative when Settings saves a new profile default', async () => {
const queryClient = new QueryClient()
$activeSessionId.set('runtime-1')
setCurrentModel('tencent/hy3:free')
setCurrentProvider('nous')
setCurrentModelSource('manual')
queryClient.setQueryData(modelOptionsQueryKey('default'), {
model: 'tencent/hy3:free',
provider: 'nous',
providers: []
})
queryClient.setQueryData(modelOptionsQueryKey('default', 'runtime-1'), {
model: 'tencent/hy3:free',
provider: 'nous',
providers: []
})
vi.mocked(getGlobalModelInfo).mockResolvedValue({
model: 'poolside/laguna-xs-2.1:free',
provider: 'nous'
})
const { result } = renderHook(() =>
useModelControls({
queryClient,
requestGateway: vi.fn()
})
)
result.current.applySavedMainModel('nous', 'poolside/laguna-xs-2.1:free')
await result.current.refreshCurrentModel()
// Settings changes the profile default, not the active session. The footer
// and its session-scoped picker cache must keep showing the live runtime.
expect($currentModel.get()).toBe('tencent/hy3:free')
expect($currentProvider.get()).toBe('nous')
expect(queryClient.getQueryData(modelOptionsQueryKey('default', 'runtime-1'))).toMatchObject({
model: 'tencent/hy3:free',
provider: 'nous'
})
// The global cache reflects the save, and the next fresh draft may reseed
// from that default instead of preserving the old session's model.
expect(getCurrentModelSource()).toBe('default')
expect(queryClient.getQueryData(modelOptionsQueryKey('default'))).toMatchObject({
model: 'poolside/laguna-xs-2.1:free',
provider: 'nous'
})
$activeSessionId.set(null)
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('poolside/laguna-xs-2.1:free')
expect($currentProvider.get()).toBe('nous')
})
it('paints a saved profile default immediately when no session is active', () => {
const queryClient = new QueryClient()
setCurrentModel('tencent/hy3:free')
setCurrentProvider('nous')
setCurrentModelSource('manual')
const { result } = renderHook(() =>
useModelControls({
queryClient,
requestGateway: vi.fn()
})
)
result.current.applySavedMainModel('nous', 'poolside/laguna-xs-2.1:free')
expect($currentModel.get()).toBe('poolside/laguna-xs-2.1:free')
expect($currentProvider.get()).toBe('nous')
expect(getCurrentModelSource()).toBe('default')
expect(queryClient.getQueryData(modelOptionsQueryKey('default'))).toMatchObject({
model: 'poolside/laguna-xs-2.1:free',
provider: 'nous'
})
})
it('routes active-session picker changes through config.set with an explicit session-scoped provider', async () => {
$activeSessionId.set('session-1')
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never)

View file

@ -55,6 +55,29 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
[queryClient]
)
// Settings → Model writes the profile default, which the backend applies to
// new sessions only. Keep a live session's renderer state and session-scoped
// model-options cache authoritative instead of briefly painting the saved
// default as if the active agent had switched. Marking the composer as
// default-derived still lets the next fresh draft reseed from profile config.
const applySavedMainModel = useCallback(
(provider: string, model: string) => {
const liveSessionId = $activeSessionId.get()
setCurrentModelSource('default')
if (!liveSessionId) {
setCurrentProvider(provider)
setCurrentModel(model)
}
// A null session id is the profile-global model-options key. Never patch
// the live session key here: only config.set --session may change it.
updateModelOptionsCache(null, provider, model, false)
},
[updateModelOptionsCache]
)
// Seed the composer's model state from the profile default. `force` reseeds
// for a profile swap (the new profile has its own default); otherwise this
// only fills an EMPTY selection so a user's pick (plain UI state in
@ -220,5 +243,5 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
[copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache]
)
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
return { applySavedMainModel, refreshCurrentModel, selectModel }
}

View file

@ -92,7 +92,7 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
<ContextMenuTrigger asChild>
<footer
className={cn(
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
'flex h-5 shrink-0 items-stretch justify-between gap-2 bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
className
)}
data-slot="statusbar"

View file

@ -16,13 +16,7 @@ import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { DecodeText } from '@/components/ui/decode-text'
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
import {
PANE_TAB_STRIP_LINE,
PANE_TAB_STRIP_LINE_LEFT,
PANE_TAB_STRIP_LINE_RIGHT,
PaneTab,
PaneTabLabel
} from '@/components/ui/pane-tab'
import { PANE_TAB_STRIP_LINE_LEFT, PANE_TAB_STRIP_LINE_RIGHT, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { useI18n } from '@/i18n'
@ -404,13 +398,10 @@ export function TreeGroup({
<ZoneMenu {...zoneMenu}>
<div
// Active = sidebar surface (merges into body). Strip =
// `--theme-card-seed` (VS Code `tab.inactiveBackground`). Line =
// PANE_TAB_STRIP_LINE; active tab cuts through it.
// `--theme-card-seed` (VS Code `tab.inactiveBackground`). No bottom
// rule — the active tab's primary underline is the only seam.
// data-zone-tabstrip: a drop over here STACKS (drag-session reads it).
className={cn(
'group/pane-header relative flex h-7 shrink-0 select-none bg-(--pane-tab-strip-bg) [-webkit-app-region:no-drag] [--pane-tab-active-bg:var(--ui-sidebar-surface-background)] [--pane-tab-strip-bg:var(--theme-card-seed)]',
PANE_TAB_STRIP_LINE
)}
className="group/pane-header relative flex h-7 shrink-0 select-none bg-(--pane-tab-strip-bg) [-webkit-app-region:no-drag] [--pane-tab-active-bg:var(--ui-sidebar-surface-background)] [--pane-tab-strip-bg:var(--theme-card-seed)]"
data-zone-tabstrip={node.id}
onContextMenu={e => {
setMenuPane(

View file

@ -591,10 +591,11 @@ function Sash({
>
{/* Persistent hairline: same token as PaneShell's divider sash
(--ui-stroke-secondary) so every seam vertical or horizontal
reads identically. */}
reads identically. Sits at 0.1 so seams recede into the surface,
and comes up to full on hover alongside the thicker grab band. */}
<span
className={cn(
'absolute bg-(--ui-stroke-secondary)',
'absolute bg-(--ui-stroke-secondary) opacity-10 transition-opacity duration-100 group-hover:opacity-100',
horizontal ? 'inset-y-0 left-1/2 w-px -translate-x-1/2' : 'inset-x-0 top-1/2 h-px -translate-y-1/2'
)}
/>

View file

@ -2,9 +2,6 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
/** Inset bottom stroke for a horizontal tab strip — titlebar color, cut by the active tab. */
export const PANE_TAB_STRIP_LINE = 'shadow-[inset_0_-1px_0_var(--ui-stroke-tertiary)]'
/** Inset stroke for a vertical tab rail — content-facing edge. */
export const PANE_TAB_STRIP_LINE_LEFT = 'shadow-[inset_1px_0_0_var(--ui-stroke-tertiary)]'
export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke-tertiary)]'
@ -12,18 +9,20 @@ export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke
const TAB =
'group/tab relative flex shrink-0 items-center border-transparent bg-(--tab-bg) text-[0.6875rem] font-medium [-webkit-app-region:no-drag]'
// Stop 1px short: the strip's rule is an INSET shadow painted in the
// container's own last pixel row, so a full-height tab covers it and reads as
// overhanging the bar. The container owns the line; nothing redraws it.
const TAB_HORIZONTAL =
'h-[calc(100%-1px)] min-w-0 max-w-48 not-first:border-l not-first:border-l-(--ui-stroke-quaternary)'
// Full height: with the strip's rule removed there is no last-pixel row to
// leave uncovered, so tabs fill the bar and no sliver of gutter shows through.
const TAB_HORIZONTAL = 'h-full min-w-0 max-w-48 not-first:border-l not-first:border-l-(--ui-stroke-quaternary)'
const TAB_VERTICAL =
'w-full max-h-48 justify-center not-first:border-t not-first:border-t-(--ui-stroke-quaternary) [writing-mode:vertical-rl]'
// Full height, so the active tab alone covers that row and cuts the rule.
const TAB_ACTIVE = 'h-full text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]'
// Horizontal only: the active tab is the sole seam on the strip — a
// theme-primary underline drawn as an inset shadow in its own last pixel row,
// so it costs no layout and can't shift the tab.
const TAB_ACTIVE_UNDERLINE = 'shadow-[inset_0_-2px_0_var(--pane-tab-active-accent,var(--theme-primary))]'
// Inactive = gutter. Hover DARKENS: active is the lighter content surface, so a
// lightening wash made the two nearly indistinguishable.
const TAB_IDLE =
@ -81,7 +80,9 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
TAB,
vertical ? TAB_VERTICAL : TAB_HORIZONTAL,
edge,
active ? TAB_ACTIVE : cn(TAB_IDLE, edge && `${edge}-(--ui-stroke-tertiary)`),
active
? cn(TAB_ACTIVE, !vertical && TAB_ACTIVE_UNDERLINE)
: cn(TAB_IDLE, edge && `${edge}-(--ui-stroke-tertiary)`),
className
)}
data-active={active}

View file

@ -1546,11 +1546,11 @@ export const ar = defineLocale({
allPinned: 'كل الجلسات مثبتة',
shiftClickHint: 'استخدم Shift للتحديد المتعدد',
noWorkspace: 'بدون مساحة عمل',
noProject: 'لا يوجد مشروع',
projectEmpty: 'لا توجد جلسات بعد',
noSessions: 'لا توجد جلسات بعد',
projects: {
sectionLabel: 'المشاريع',
home: 'الرئيسية',
newButton: 'مشروع جديد',
createTitle: 'مشروع جديد',
createDesc: 'سمِّ مساحة العمل وأضف مجلدا أو أكثر.',

View file

@ -1804,11 +1804,11 @@ export const en: Translations = {
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
shiftClickHint: 'Shift-click a chat to pin',
noWorkspace: 'No workspace',
noProject: 'No project',
projectEmpty: 'No sessions yet',
noSessions: 'No sessions yet',
projects: {
sectionLabel: 'Projects',
home: 'Home',
newButton: 'New project',
createTitle: 'New project',
createDesc: 'Name a workspace and add one or more folders.',

View file

@ -1668,11 +1668,11 @@ export const ja = defineLocale({
allPinned: 'ここにあるものはすべてピン留めされています。チャットのピン留めを解除すると最近のものに表示されます。',
shiftClickHint: 'Shift クリックでピン留め · ドラッグで並べ替え',
noWorkspace: 'ワークスペースなし',
noProject: 'プロジェクトなし',
projectEmpty: 'セッションはまだありません',
noSessions: 'セッションはまだありません',
projects: {
sectionLabel: 'プロジェクト',
home: 'ホーム',
newButton: '新規プロジェクト',
createTitle: '新規プロジェクト',
createDesc: 'ワークスペースに名前を付け、1つ以上のフォルダを追加します。',

View file

@ -1508,11 +1508,11 @@ export interface Translations {
allPinned: string
shiftClickHint: string
noWorkspace: string
noProject: string
projectEmpty: string
noSessions: string
projects: {
sectionLabel: string
home: string
newButton: string
createTitle: string
createDesc: string

View file

@ -1616,11 +1616,11 @@ export const zhHant = defineLocale({
allPinned: '這裡的全部已釘選。取消釘選某個聊天即可在最近中顯示。',
shiftClickHint: 'Shift + 點擊聊天以釘選 · 拖曳以重新排序',
noWorkspace: '無工作區',
noProject: '無專案',
projectEmpty: '尚無工作階段',
noSessions: '尚無工作階段',
projects: {
sectionLabel: '專案',
home: '主頁',
newButton: '新增專案',
createTitle: '新增專案',
createDesc: '為工作區命名並新增一個或多個資料夾。',

View file

@ -1994,11 +1994,11 @@ export const zh: Translations = {
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
shiftClickHint: 'Shift+ 单击对话以置顶 · 拖动以重新排序',
noWorkspace: '无工作区',
noProject: '无项目',
projectEmpty: '暂无会话',
noSessions: '暂无会话',
projects: {
sectionLabel: '项目',
home: '主页',
newButton: '新建项目',
createTitle: '新建项目',
createDesc: '为工作区命名并添加一个或多个文件夹。',

View file

@ -1,9 +1,10 @@
import { atom } from 'nanostores'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import { NO_PROJECT_ID, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import { $sidebarAgentsGrouped } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import { applyConfiguredDefaultProjectDir } from '@/store/session'
import {
$activeProjectId,
@ -25,6 +26,7 @@ import {
refreshProjects,
refreshProjectTree,
refreshWorktrees,
resolveNewSessionCwd,
scanAndRecordRepos,
tombstoneSessions
} from './projects'
@ -99,9 +101,9 @@ describe('project scope', () => {
expect($projectScope.get()).toBe(ALL_PROJECTS)
})
it('entering the synthetic No-project bucket still scopes (no active pin)', () => {
enterProject('__no_project__')
expect($projectScope.get()).toBe('__no_project__')
it('entering the synthetic Home bucket still scopes (no active pin)', () => {
enterProject(NO_PROJECT_ID)
expect($projectScope.get()).toBe(NO_PROJECT_ID)
})
it('persists the scope to localStorage', () => {
@ -110,6 +112,30 @@ describe('project scope', () => {
})
})
describe('resolveNewSessionCwd', () => {
beforeEach(() => {
$projectScope.set(ALL_PROJECTS)
applyConfiguredDefaultProjectDir('/home/user/configured')
})
afterEach(() => {
applyConfiguredDefaultProjectDir(null)
$projectScope.set(ALL_PROJECTS)
})
it('starts a chat detached inside Home, ignoring the configured default dir', () => {
// Attaching the default dir here would move the new chat out of Home the
// moment it was created — "no folder" is what the bucket means.
enterProject(NO_PROJECT_ID)
expect(resolveNewSessionCwd()).toBe('')
})
it('still falls back to the configured default outside Home', () => {
expect(resolveNewSessionCwd()).toBe('/home/user/configured')
})
})
describe('projectNameForCwd', () => {
const treeNode = (
over: Partial<SidebarProjectTree> & Pick<SidebarProjectTree, 'id' | 'label'>

View file

@ -1,6 +1,10 @@
import { atom } from 'nanostores'
import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import {
liveSessionProjectId,
NO_PROJECT_ID,
type SidebarProjectTree
} from '@/app/chat/sidebar/projects/workspace-groups'
import type { HermesGitBaseBranch, HermesGitBranch } from '@/global'
import { getHermesConfig, type HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
@ -146,8 +150,8 @@ export function enterProject(id: string): void {
$projectScope.set(id)
// Only explicit, persisted projects (ids are `p_<hex>`) become active. Auto
// projects (ids are filesystem paths) and the "No project" bucket have no
// durable row to pin, so they're view-scope only.
// projects (ids are filesystem paths) and the Home bucket have no durable row
// to pin, so they're view-scope only.
if (id.startsWith('p_')) {
void setActiveProject(id).catch(() => undefined)
}
@ -166,6 +170,12 @@ export function exitProjectScope(): void {
export function resolveNewSessionCwd(): string {
const scope = $projectScope.get()
// Inside Home, "no folder" is the point: a new chat must stay detached rather
// than silently attaching to the configured default dir and leaving Home.
if (scope === NO_PROJECT_ID) {
return ''
}
if (scope !== ALL_PROJECTS) {
const project = $projectTree.get().find(node => node.id === scope)
const cwd = (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
@ -211,8 +221,8 @@ export function projectIdForCwd(cwd: string): null | string {
// match), or null when the cwd sits in no named project. The status bar reads
// this to label the workspace by project instead of the bare cwd leaf. We skip
// auto-projects (a repo root promoted with no projects.db row) and the synthetic
// "No project" bucket on purpose: those have no human name, so their sessions
// keep the cwd-leaf label — matching the backend `_project_info_for_cwd`, which
// Home bucket on purpose: those have no human name, so their sessions keep the
// cwd-leaf label — matching the backend `_project_info_for_cwd`, which
// only resolves projects.db rows, so the desktop and TUI name the same session
// identically without threading a second per-session copy through session.info.
export function projectNameForCwd(cwd: string): null | string {

View file

@ -1848,7 +1848,7 @@ def run_doctor(args):
_chromium_installed,
_is_camofox_mode,
_get_cloud_provider,
_get_cdp_override,
_get_cdp_override_raw,
_using_lightpanda_engine,
)
except Exception:
@ -1861,7 +1861,7 @@ def run_doctor(args):
# Lightpanda all bypass the local Chromium requirement.
skip_chromium_check = (
_is_camofox_mode()
or bool(_get_cdp_override())
or bool(_get_cdp_override_raw())
or _get_cloud_provider() is not None
or _using_lightpanda_engine()
)

View file

@ -11,7 +11,10 @@ gpt-5.4-mini after a Codex usage-limit 429.
from types import SimpleNamespace
from agent.chat_completion_helpers import rewrite_prompt_model_identity
from agent.conversation_loop import _sync_failover_system_message
from agent.conversation_loop import (
_redecorate_prompt_cache_for_provider,
_sync_failover_system_message,
)
from agent.prompt_caching import apply_anthropic_cache_control
@ -34,6 +37,40 @@ def _agent(prompt=_PROMPT, ephemeral=None):
)
def _count_cache_markers(messages):
count = 0
for msg in messages:
if "cache_control" in msg:
count += 1
content = msg.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and "cache_control" in part:
count += 1
return count
def _cache_agent(
*,
use_caching,
native=False,
prompt=_PROMPT,
static=None,
cache_ttl="5m",
provider="openai",
):
return SimpleNamespace(
_cached_system_prompt=prompt,
_cached_system_prompt_static=static,
ephemeral_system_prompt=None,
_use_prompt_caching=use_caching,
_use_native_cache_layout=native,
_cache_ttl=cache_ttl,
provider=provider,
client=None,
)
class TestRewritePromptModelIdentity:
def test_swaps_identity_lines_to_fallback_runtime(self):
agent = _agent()
@ -189,3 +226,246 @@ class TestSyncFailoverPreservesCacheDecoration:
]
_sync_failover_system_message(agent, api_messages, _PROMPT)
assert api_messages[0]["content"] == agent._cached_system_prompt
class TestRedecoratePromptCacheOnPolicyChange:
"""#72626: failover must re-render breakpoints for the *new* cache policy.
``TestSyncFailoverPreservesCacheDecoration`` only covers same-policy
identity sync (nativenative). These cases cover the policy-change class.
"""
_STATIC = "You are a helpful assistant.\n\nStable brief.\n"
def test_cache_off_to_cache_on_adds_breakpoints(self):
prompt = self._STATIC + "Model: gpt-5.4-mini\nProvider: openai"
# Primary never decorated (cache-off).
undecorated = [
{"role": "system", "content": prompt},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "again"},
]
assert _count_cache_markers(undecorated) == 0
agent = _cache_agent(
use_caching=True,
native=True,
prompt=prompt,
static=self._STATIC,
provider="anthropic",
)
decorated, _ = _redecorate_prompt_cache_for_provider(agent, undecorated)
assert _count_cache_markers(decorated) >= 2
assert isinstance(decorated[0]["content"], list)
assert decorated[0]["content"][0]["text"] == self._STATIC
def test_cache_on_to_cache_off_strips_breakpoints(self):
prompt = self._STATIC + "Model: claude\nProvider: anthropic"
decorated = apply_anthropic_cache_control(
[
{"role": "system", "content": prompt},
{"role": "user", "content": "hello"},
],
native_anthropic=True,
static_system_prefix=self._STATIC,
)
assert _count_cache_markers(decorated) >= 2
agent = _cache_agent(
use_caching=False,
native=False,
prompt=prompt,
static=self._STATIC,
provider="custom",
)
stripped, _ = _redecorate_prompt_cache_for_provider(agent, decorated)
assert _count_cache_markers(stripped) == 0
assert stripped[0]["content"] == prompt
def test_native_to_envelope_relocates_breakpoints_to_carriers(self):
prompt = "Model: claude\nProvider: anthropic"
# Native layout can mark empty assistant turns; envelope cannot.
native = apply_anthropic_cache_control(
[
{"role": "system", "content": prompt},
{"role": "user", "content": "do it"},
{"role": "assistant", "content": "", "tool_calls": [{"id": "1"}]},
{"role": "tool", "content": "ok", "tool_call_id": "1"},
{"role": "user", "content": "thanks"},
],
native_anthropic=True,
)
agent = _cache_agent(
use_caching=True,
native=False,
prompt=prompt,
provider="openrouter",
)
envelope, _ = _redecorate_prompt_cache_for_provider(agent, native)
# Empty assistant must not carry a wasted top-level marker on envelope.
empty_assistant = next(
m for m in envelope if m.get("role") == "assistant" and not m.get("content")
)
assert "cache_control" not in empty_assistant
assert _count_cache_markers(envelope) >= 1
def test_preserves_mutated_tool_result_bytes(self):
# Redecoration must use the in-flight mutated list, not a pristine
# pre-decoration snapshot — image-shrink / ASCII recoveries live here.
prompt = "sys"
messages = [
{"role": "system", "content": prompt},
{"role": "user", "content": "run"},
{"role": "tool", "content": "SHRUNK_IMAGE_PAYLOAD", "tool_call_id": "t1"},
]
agent = _cache_agent(use_caching=True, native=True, prompt=prompt)
out, _ = _redecorate_prompt_cache_for_provider(agent, messages)
tool = next(m for m in out if m.get("role") == "tool")
text = (
tool["content"]
if isinstance(tool["content"], str)
else tool["content"][0]["text"]
)
assert text == "SHRUNK_IMAGE_PAYLOAD"
def test_moa_guidance_stays_outside_last_breakpoint(self):
prompt = "sys"
guidance = (
"[Mixture of Agents context — use this as private guidance for the "
"normal Hermes agent loop.]\nAggregator: agg\n\nadvice"
)
base = [
{"role": "system", "content": prompt},
{"role": "user", "content": "task"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "task\n\n" + guidance},
]
decorated = apply_anthropic_cache_control(base, native_anthropic=True)
class _Completions:
def rebase_prepared_request(self, prepared, messages):
from agent.moa_loop import _attach_reference_guidance
out = [dict(m) for m in messages]
_attach_reference_guidance(out, prepared["guidance"])
return {**prepared, "messages": out}
agent = _cache_agent(use_caching=True, native=True, prompt=prompt, provider="moa")
agent.client = SimpleNamespace(chat=SimpleNamespace(completions=_Completions()))
prepared = {"guidance": guidance, "messages": decorated}
out, new_prepared = _redecorate_prompt_cache_for_provider(
agent, decorated, moa_prepared=prepared
)
assert new_prepared is not None
# Guidance must be present, but the cache marker on the last user
# turn's content parts must terminate *before* the guidance text.
last = out[-1]
assert last.get("role") == "user"
content = last["content"]
if isinstance(content, list):
marked = [p for p in content if isinstance(p, dict) and "cache_control" in p]
guidance_parts = [
p for p in content
if isinstance(p, dict) and guidance in (p.get("text") or "")
]
assert marked, "base transcript should still carry breakpoints"
assert guidance_parts, "guidance must be re-attached"
# Marked part should not contain the guidance body.
assert all(guidance not in (p.get("text") or "") for p in marked)
else:
assert guidance in content
def test_moa_no_guidance_prepared_messages_still_refreshed(self):
# guidance=None is a real prepared shape (all references failed /
# silent degraded policy). The MoA facade sends prepared["messages"],
# so the rebase must refresh the prepared object even without
# guidance — otherwise the aggregator ships the STALE decoration
# and #72626 persists for the no-guidance MoA sub-path.
prompt = "sys"
decorated = apply_anthropic_cache_control(
[
{"role": "system", "content": prompt},
{"role": "user", "content": "task"},
],
native_anthropic=True,
)
class _Completions:
def rebase_prepared_request(self, prepared, messages):
# Mirrors MoAChatCompletions.rebase_prepared_request with
# falsy guidance: copy messages, skip the attach.
return {**prepared, "messages": [dict(m) for m in messages]}
# Policy change while staying on moa: cache-on -> cache-off.
agent = _cache_agent(use_caching=False, prompt=prompt, provider="moa")
agent.client = SimpleNamespace(chat=SimpleNamespace(completions=_Completions()))
prepared = {"guidance": None, "messages": decorated}
out, new_prepared = _redecorate_prompt_cache_for_provider(
agent, decorated, moa_prepared=prepared
)
assert new_prepared is not None
# The prepared object the facade will send must carry the
# redecorated (stripped) messages, not the stale decorated list.
assert _count_cache_markers(new_prepared["messages"]) == 0
assert new_prepared["messages"] == out
class TestPeelReferenceGuidanceRoundTrip:
"""peel must invert every attach shape — the two live adjacent in
moa_loop.py precisely so this contract can't drift silently."""
_GUIDANCE = "[Mixture of Agents reference context]\nAdvice body."
def _round_trip(self, base):
import copy
from agent.moa_loop import _attach_reference_guidance, peel_reference_guidance
attached = copy.deepcopy(base)
_attach_reference_guidance(attached, self._GUIDANCE)
assert attached != base, "attach must change the transcript"
return peel_reference_guidance(attached, self._GUIDANCE)
def test_string_merge_shape(self):
base = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "task"},
]
assert self._round_trip(base) == base
def test_list_part_shape(self):
base = [
{"role": "system", "content": "sys"},
{
"role": "user",
"content": [{"type": "text", "text": "task", "cache_control": {"type": "ephemeral"}}],
},
]
assert self._round_trip(base) == base
def test_appended_user_message_shape(self):
# No trailing user turn — attach appends a separate user message.
base = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "task"},
{"role": "assistant", "content": "done"},
]
assert self._round_trip(base) == base
def test_guidance_only_part_drops_message_not_empty_residue(self):
# If the guidance part is the only content left after peeling, the
# whole message goes — an empty-content user turn must never remain.
from agent.moa_loop import peel_reference_guidance
messages = [
{"role": "user", "content": "task"},
{"role": "assistant", "content": "ok"},
{
"role": "user",
"content": [{"type": "text", "text": self._GUIDANCE}],
},
]
peeled = peel_reference_guidance(messages, self._GUIDANCE)
assert peeled == messages[:-1]

View file

@ -5,6 +5,7 @@ from agent.prompt_caching import (
_apply_cache_marker,
_can_carry_marker,
apply_anthropic_cache_control,
strip_anthropic_cache_control,
)
@ -317,7 +318,10 @@ class TestNormalizationOrdering:
from agent import conversation_loop
src = inspect.getsource(conversation_loop)
mark = src.index("apply_anthropic_cache_control(\n")
# Anchor on the call-block decoration (before the retry loop), not the
# mid-failover redecoration helper which also calls apply_*.
anchor = src.index("Runs LAST, after every message mutation above")
mark = src.index("apply_anthropic_cache_control(\n", anchor)
for earlier in (
'am["content"].strip()', # whitespace normalization
"_sanitize_api_messages(api_messages)", # orphan sweep
@ -327,3 +331,119 @@ class TestNormalizationOrdering:
assert src.index(earlier) < mark, (
f"{earlier!r} must run before cache breakpoints are injected"
)
class TestStripAnthropicCacheControl:
"""strip must undo decoration so failover can re-render for a new policy."""
def test_removes_top_level_and_part_markers(self):
messages = apply_anthropic_cache_control(
[
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"},
],
native_anthropic=True,
)
assert any(
"cache_control" in (m if isinstance(m.get("content"), str) else {})
or (
isinstance(m.get("content"), list)
and any(
isinstance(p, dict) and "cache_control" in p for p in m["content"]
)
)
or "cache_control" in m
for m in messages
)
strip_anthropic_cache_control(messages)
for msg in messages:
assert "cache_control" not in msg
content = msg.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, dict):
assert "cache_control" not in part
def test_flattens_system_static_volatile_back_to_string(self):
static = "You are helpful.\n"
full = static + "Model: claude\nProvider: anthropic"
messages = apply_anthropic_cache_control(
[{"role": "system", "content": full}, {"role": "user", "content": "hi"}],
native_anthropic=True,
static_system_prefix=static,
)
assert isinstance(messages[0]["content"], list)
strip_anthropic_cache_control(messages)
assert messages[0]["content"] == full
def test_preserves_multimodal_part_structure(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "see", "cache_control": {"type": "ephemeral"}},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,xx"}},
],
}
]
strip_anthropic_cache_control(messages)
content = messages[0]["content"]
assert isinstance(content, list) and len(content) == 2
assert content[0] == {"type": "text", "text": "see"}
assert content[1]["type"] == "image_url"
def test_preserves_organic_multipart_text_lists(self):
# Multi-part pure-text lists NOT produced by decoration (merged user
# turns, imported transcripts) must keep their structure — a ""-join
# would fuse "Hello"+"world" into "Helloworld" and change wire bytes
# on the common no-failover path (redecoration runs every attempt).
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "text", "text": "world"},
],
}
]
strip_anthropic_cache_control(messages)
content = messages[0]["content"]
assert isinstance(content, list) and len(content) == 2
assert [p["text"] for p in content] == ["Hello", "world"]
def test_preserves_extra_part_keys_like_citations(self):
# anthropic_adapter deliberately whitelists citations on text blocks;
# strip must not destroy them by flattening the part away.
messages = [
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "answer",
"citations": [{"src": "doc"}],
"cache_control": {"type": "ephemeral"},
}
],
}
]
strip_anthropic_cache_control(messages)
content = messages[0]["content"]
assert isinstance(content, list)
assert content[0]["citations"] == [{"src": "doc"}]
assert "cache_control" not in content[0]
def test_marker_removal_is_copy_on_write_for_part_dicts(self):
# The per-call api_messages copy is SHALLOW (msg.copy()) — content
# part dicts alias the persistent history. Stripping a marker must
# never rewrite the stored transcript's part dicts in place.
shared_part = {"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}
history = [{"role": "user", "content": [shared_part]}]
api_messages = [m.copy() for m in history]
strip_anthropic_cache_control(api_messages)
assert "cache_control" in shared_part # history untouched
api_content = api_messages[0]["content"]
if isinstance(api_content, list):
assert all("cache_control" not in p for p in api_content)

View file

@ -350,5 +350,77 @@ class TestStaticPrefixReconstructionOnRestore:
assert agent._cached_system_prompt_static is None
class TestReconstructStaticPrefixMemoization:
"""A failed static rebuild must not re-run the parts builder every call.
``reconstruct_static_prefix`` sits on the retry-loop hot path via the
failover redecoration chokepoint (#72626); ``build_system_prompt_parts``
does real file I/O (SOUL.md, context files, memory), so a persistent
stable-tier mismatch must be checked once per stored prompt, not on
every attempt of every API call.
"""
def _agent(self, stored):
agent = _make_agent()
agent._use_prompt_caching = True
agent._cached_system_prompt = stored
agent._cached_system_prompt_static = None
return agent
def test_failed_rebuild_is_memoized_per_stored_prompt(self):
from unittest.mock import patch as _patch
from agent.system_prompt import reconstruct_static_prefix
stored = "STORED PROMPT\n\ntail"
agent = self._agent(stored)
with _patch(
"agent.system_prompt.build_system_prompt_parts",
return_value={"stable": "MISMATCH", "context": "", "volatile": ""},
) as build:
reconstruct_static_prefix(agent)
reconstruct_static_prefix(agent)
reconstruct_static_prefix(agent)
assert build.call_count == 1
assert agent._cached_system_prompt_static is None
def test_changed_stored_prompt_retries_once(self):
from unittest.mock import patch as _patch
from agent.system_prompt import reconstruct_static_prefix
agent = self._agent("OLD STORED")
with _patch(
"agent.system_prompt.build_system_prompt_parts",
return_value={"stable": "MISMATCH", "context": "", "volatile": ""},
) as build:
reconstruct_static_prefix(agent)
# A new stored prompt (e.g. after compression) invalidates the
# failure memo and gets exactly one fresh attempt.
agent._cached_system_prompt = "NEW STORED"
reconstruct_static_prefix(agent)
reconstruct_static_prefix(agent)
assert build.call_count == 2
def test_success_clears_failure_memo_and_early_returns(self):
from unittest.mock import patch as _patch
from agent.system_prompt import reconstruct_static_prefix
stable = "STATIC HEAD"
stored = stable + "\n\nvolatile"
agent = self._agent(stored)
with _patch(
"agent.system_prompt.build_system_prompt_parts",
return_value={"stable": stable, "context": "", "volatile": ""},
) as build:
reconstruct_static_prefix(agent)
reconstruct_static_prefix(agent)
# Second call early-returns on the already-valid static prefix.
assert build.call_count == 1
assert agent._cached_system_prompt_static == stable
assert getattr(agent, "_static_rebuild_failed_for", None) is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View file

@ -561,21 +561,36 @@ def test_check_fn_false_when_no_cdp_url(monkeypatch):
import tools.browser_tool as bt
monkeypatch.setattr(bt, "check_browser_requirements", lambda: True)
monkeypatch.setattr(bt, "_get_cdp_override", lambda: "")
monkeypatch.setattr(bt, "_get_cdp_override_raw", lambda: "")
assert browser_cdp_tool._browser_cdp_check() is False
def test_check_fn_true_when_cdp_url_set(monkeypatch):
"""Gate opens as soon as a CDP URL is resolvable."""
"""Gate opens as soon as a CDP URL is configured (no network resolution)."""
import tools.browser_tool as bt
monkeypatch.setattr(bt, "check_browser_requirements", lambda: True)
monkeypatch.setattr(
bt, "_get_cdp_override", lambda: "ws://localhost:9222/devtools/browser/x"
bt, "_get_cdp_override_raw", lambda: "ws://localhost:9222/devtools/browser/x"
)
assert browser_cdp_tool._browser_cdp_check() is True
def test_check_fn_does_not_probe_network(monkeypatch):
"""The availability gate must never hit the network: a stale/unreachable
configured endpoint used to cost multiple blocking HTTP probes at every
CLI/Desktop startup (tool-schema assembly), stalling launch by 10+ s."""
import tools.browser_tool as bt
def _boom(*a, **k): # pragma: no cover — the assertion is that it's unused
raise AssertionError("check_fn must not perform network I/O")
monkeypatch.setattr(bt, "check_browser_requirements", lambda: True)
monkeypatch.setattr(bt.requests, "get", _boom)
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
assert browser_cdp_tool._browser_cdp_check() is True
def test_check_fn_false_when_browser_requirements_fail(monkeypatch):
"""Even with a CDP URL, gate closes if the overall browser toolset is
unavailable (e.g. agent-browser not installed)."""
@ -583,6 +598,6 @@ def test_check_fn_false_when_browser_requirements_fail(monkeypatch):
monkeypatch.setattr(bt, "check_browser_requirements", lambda: False)
monkeypatch.setattr(
bt, "_get_cdp_override", lambda: "ws://localhost:9222/devtools/browser/x"
bt, "_get_cdp_override_raw", lambda: "ws://localhost:9222/devtools/browser/x"
)
assert browser_cdp_tool._browser_cdp_check() is False

View file

@ -91,7 +91,7 @@ class TestNavigationSessionKey:
def test_cdp_override_stays_on_bare_task_id(self, monkeypatch):
"""A user-supplied CDP endpoint owns the whole session — no hybrid."""
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock())
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "ws://localhost:9222")
monkeypatch.setattr(browser_tool, "_get_cdp_override_raw", lambda: "ws://localhost:9222")
key = browser_tool._navigation_session_key("default", "http://localhost:3000/")
assert key == "default"

View file

@ -127,7 +127,7 @@ class TestShouldInjectEngine:
def test_no_inject_with_cdp_override(self):
from tools.browser_tool import _should_inject_engine
with patch("tools.browser_tool._is_camofox_mode", return_value=False), \
patch("tools.browser_tool._get_cdp_override", return_value="ws://localhost:9222"):
patch("tools.browser_tool._get_cdp_override_raw", return_value="ws://localhost:9222"):
assert _should_inject_engine("lightpanda") is False
def test_no_inject_with_cloud_provider(self):

View file

@ -58,6 +58,26 @@ def _lane_ids(project):
return [g["id"] for repo in project["repos"] for g in repo["groups"]]
def _home(tree):
"""The Home bucket, or None when nothing was left unplaced."""
return next((p for p in tree["projects"] if p["id"] == pt.NO_PROJECT_ID), None)
def _home_session_ids(tree):
home = _home(tree)
return [s["id"] for s in _sessions_of(home)] if home else []
def _sessions_of(project):
return [s for repo in project["repos"] for g in repo["groups"] for s in g["sessions"]]
def _real_project_ids(tree):
"""Project ids excluding the Home bucket (which is always present when any
session went unplaced, so asserting on it in every test would be noise)."""
return [p["id"] for p in tree["projects"] if p["id"] != pt.NO_PROJECT_ID]
# ---------------------------------------------------------------------------
@ -306,12 +326,12 @@ def test_scoped_session_ids_is_union_of_placed_sessions():
)
owned = _session("/www/app", branch="main")
auto = _session("/www/repo", branch="main")
homeless = _session(None) # no cwd -> belongs to no project
homeless = _session(None) # no cwd -> the Home bucket
tree = pt.build_tree([project], [owned, auto, homeless], [], resolve, hydrate=True)
assert set(tree["scoped_session_ids"]) == {owned["id"], auto["id"]}
assert homeless["id"] not in tree["scoped_session_ids"]
assert set(tree["scoped_session_ids"]) == {owned["id"], auto["id"], homeless["id"]}
assert _home_session_ids(tree) == [homeless["id"]]
def test_overview_drops_session_rows_but_keeps_counts_and_previews():
@ -403,8 +423,8 @@ def test_nested_project_folders_pick_the_deepest_match():
def test_junk_root_never_becomes_an_auto_project():
# A session whose git root is HERMES_HOME (config/state) must not spawn a
# phantom project; it falls through to flat Recents (unscoped). A real repo
# alongside it still groups normally.
# phantom project; it lands in the Home bucket. A real repo alongside it
# still groups normally.
resolve = _resolver(
{
"/home/me/.hermes": ("/home/me/.hermes", "/home/me/.hermes"),
@ -417,9 +437,8 @@ def test_junk_root_never_becomes_an_auto_project():
tree = pt.build_tree([], [junk, real], [], resolve, hydrate=True, is_junk_root=is_junk)
ids = {p["id"] for p in tree["projects"]}
assert ids == {"/www/app"}
assert junk["id"] not in tree["scoped_session_ids"]
assert _real_project_ids(tree) == ["/www/app"]
assert _home_session_ids(tree) == [junk["id"]]
assert real["id"] in tree["scoped_session_ids"]
@ -462,8 +481,8 @@ def test_broad_default_non_git_cwd_stays_unscoped():
is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"},
)
assert tree["projects"] == []
assert detached["id"] not in tree["scoped_session_ids"]
assert _real_project_ids(tree) == []
assert _home_session_ids(tree) == [detached["id"]]
def test_deleted_sibling_worktree_folds_into_parent_home_checkout():
@ -509,18 +528,19 @@ def test_deleted_unrelated_workspace_does_not_become_a_project():
# A deleted dir the sibling probe can't reach by name (`hermes-salvage-drafts`
# shares no prefix with `hermes-agent`; `/tmp/scratch` was never a worktree)
# must not be promoted to a phantom project — it can never be opened and can
# only be dismissed by hand. The session stays in flat Recents.
# only be dismissed by hand. Those sessions land in the Home bucket.
resolve = _resolver({"/www/hermes-agent": ("/www/hermes-agent", "/www/hermes-agent")})
sessions = [
live, salvage, scratch = (
_session("/www/hermes-agent", branch="main"),
_session("/www/hermes-salvage-drafts/apps/desktop"),
_session("/tmp/scratch"),
]
)
on_disk = {"/www/hermes-agent"}
tree = pt.build_tree([], sessions, [], resolve, hydrate=True, exists=lambda p: p in on_disk)
tree = pt.build_tree([], [live, salvage, scratch], [], resolve, hydrate=True, exists=lambda p: p in on_disk)
assert [p["id"] for p in tree["projects"]] == ["/www/hermes-agent"]
assert _real_project_ids(tree) == ["/www/hermes-agent"]
assert set(_home_session_ids(tree)) == {salvage["id"], scratch["id"]}
def test_existing_non_git_workspace_still_becomes_a_project():
@ -536,11 +556,12 @@ def test_existing_non_git_workspace_still_becomes_a_project():
def test_stale_persisted_repo_root_does_not_become_a_project():
# A session carrying a git_repo_root whose repo has since been deleted must
# not resurrect it as a project on the strength of the persisted value alone.
sessions = [_session("/tmp/gone/sub", repo_root="/tmp/gone")]
stale = _session("/tmp/gone/sub", repo_root="/tmp/gone")
tree = pt.build_tree([], sessions, [], lambda _cwd: None, hydrate=True, exists=lambda _p: False)
tree = pt.build_tree([], [stale], [], lambda _cwd: None, hydrate=True, exists=lambda _p: False)
assert tree["projects"] == []
assert _real_project_ids(tree) == []
assert _home_session_ids(tree) == [stale["id"]]
def test_exists_defaults_to_keeping_everything():
@ -566,6 +587,55 @@ def test_sibling_probe_is_bounded():
assert len(probed) <= pt._MAX_SIBLING_PROBES
def test_home_bucket_leads_the_tree_and_is_lossless():
# Every session a project didn't claim belongs to Home, and Home leads the
# list — so the grouped view shows the same set of sessions as flat Recents.
resolve = _resolver({"/www/app": ("/www/app", "/www/app"), "/home/me": ("/home/me", "/home/me")})
owned = _session("/www/app", branch="main")
cwdless = _session(None)
junked = _session("/home/me", branch="main")
tree = pt.build_tree(
[],
[owned, cwdless, junked],
[],
resolve,
hydrate=True,
is_junk_root=lambda root: root == "/home/me",
)
assert tree["projects"][0]["id"] == pt.NO_PROJECT_ID
assert set(_home_session_ids(tree)) == {cwdless["id"], junked["id"]}
assert {s["id"] for p in tree["projects"] for s in _sessions_of(p)} == {
owned["id"],
cwdless["id"],
junked["id"],
}
def test_home_bucket_is_absent_when_every_session_is_placed():
# No leftovers, no Home row — a fully-organized sidebar shows only projects.
resolve = _resolver({"/www/app": ("/www/app", "/www/app")})
tree = pt.build_tree([], [_session("/www/app", branch="main")], [], resolve, hydrate=True)
assert _home(tree) is None
def test_home_bucket_carries_previews_and_drops_rows_in_overview_mode():
sessions = [_session(None, started_at=t, last_active=t) for t in (10, 30, 20, 40)]
tree = pt.build_tree([], sessions, [], resolve=None, preview_limit=3, hydrate=False)
home = _home(tree)
assert home["sessionCount"] == 4
assert home["lastActive"] == 40
assert [s["last_active"] for s in home["previewSessions"]] == [40, 30, 20]
assert _sessions_of(home) == []
assert home["isNoProject"] is True
assert home["path"] is None
def test_colliding_repo_basenames_disambiguate_labels():
resolve = _resolver(
{

View file

@ -653,7 +653,7 @@ def _browser_cdp_check() -> bool:
"""
try:
from tools.browser_tool import ( # type: ignore[import-not-found]
_get_cdp_override,
_get_cdp_override_raw,
check_browser_requirements,
)
except ImportError as exc: # pragma: no cover — defensive
@ -661,7 +661,10 @@ def _browser_cdp_check() -> bool:
return False
if not check_browser_requirements():
return False
return bool(_get_cdp_override())
# Raw (no-I/O) gate: check_fns run during tool-schema assembly at every
# startup; resolving the endpoint over HTTP here would block launch when
# the configured endpoint is stale/unreachable.
return bool(_get_cdp_override_raw())
registry.register(

View file

@ -457,6 +457,45 @@ def _resolve_cdp_override(cdp_url: str) -> str:
return raw
def _get_cdp_override_raw() -> str:
"""Return the *configured* CDP override without any network I/O.
Precedence is:
1. ``BROWSER_CDP_URL`` env var (live override from ``/browser connect``)
2. ``browser.cdp_url`` in config.yaml (persistent config)
This is the availability-check variant: callers that only need to know
*whether* a CDP override is configured (tool ``check_fn`` gates,
``_is_local_mode`` / ``_is_local_backend`` routing decisions,
``hermes doctor``) MUST use this instead of :func:`_get_cdp_override`.
Rationale: ``_get_cdp_override`` resolves the endpoint over HTTP
(``/json/version`` discovery, 10s timeout). Tool-schema assembly runs at
every CLI/Desktop startup and probes several browser-family check_fns;
when a *stale* ``browser.cdp_url`` points at a dead endpoint (the debug
Chrome it referenced is long gone), each check blocked on a failing
socket connect and startup stalled for 10+ seconds before the banner
with no error, just mystery slowness. Same principle as the existing
"do not execute ``agent-browser --version`` here" rule in
``check_browser_requirements``: no side effects during schema build.
"""
env_override = os.environ.get("BROWSER_CDP_URL", "").strip()
if env_override:
return env_override
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
browser_cfg = cfg.get("browser", {})
if isinstance(browser_cfg, dict):
return str(browser_cfg.get("cdp_url", "") or "").strip()
except Exception as e:
logger.debug("Could not read browser.cdp_url from config: %s", e)
return ""
def _get_cdp_override() -> str:
"""Return a normalized CDP URL override, or empty string.
@ -467,22 +506,16 @@ def _get_cdp_override() -> str:
When either is set, we skip both Browserbase and the local headless
launcher and connect directly to the supplied Chrome DevTools Protocol
endpoint.
NOTE: resolution may perform an HTTP ``/json/version`` discovery request.
Only call this on paths that are about to *connect* (session creation,
supervisor attach). Pure is-it-configured gates must use
:func:`_get_cdp_override_raw`.
"""
env_override = os.environ.get("BROWSER_CDP_URL", "").strip()
if env_override:
return _resolve_cdp_override(env_override)
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
browser_cfg = cfg.get("browser", {})
if isinstance(browser_cfg, dict):
return _resolve_cdp_override(str(browser_cfg.get("cdp_url", "") or ""))
except Exception as e:
logger.debug("Could not read browser.cdp_url from config: %s", e)
return ""
raw = _get_cdp_override_raw()
if not raw:
return ""
return _resolve_cdp_override(raw)
def _get_dialog_policy_config() -> Tuple[str, float]:
@ -789,7 +822,7 @@ def _termux_browser_install_error() -> str:
def _is_local_mode() -> bool:
"""Return True when the browser tool will use a local browser backend."""
if _get_cdp_override():
if _get_cdp_override_raw():
return False
return _get_cloud_provider() is None
@ -821,7 +854,7 @@ def _is_local_backend() -> bool:
# config (both via _get_cdp_override(), and both now suppress camofox in
# browser_camofox.py). _is_local_mode() already treats any CDP override as
# non-local; keep the two helpers in agreement.
if _get_cdp_override():
if _get_cdp_override_raw():
return False
if _is_camofox_mode():
return True
@ -1313,7 +1346,7 @@ def _navigation_session_key(task_id: str, url: str) -> str:
"""
if task_id is None:
task_id = "default"
if _get_cdp_override():
if _get_cdp_override_raw():
return task_id
if _is_camofox_mode():
return task_id
@ -4740,7 +4773,9 @@ def check_browser_requirements() -> bool:
# CDP override mode can connect to an existing remote/local browser endpoint
# without requiring the local agent-browser binary on PATH.
if _get_cdp_override():
# Raw (no-I/O) check: this runs during tool-schema assembly at startup,
# where a stale endpoint must not cost a blocking HTTP probe.
if _get_cdp_override_raw():
return True
# The agent-browser CLI is required for local launch and cloud-provider flows.

View file

@ -12,6 +12,7 @@ all key off these exact strings:
- explicit project id .......... ``p_<hex>`` (from projects.db)
- auto/discovered project id ... the repo root path
- home (no-project) bucket ..... ``__no_project__``
- repo node id ................. the repo root path
- main branch lane id .......... ``<repoRoot>::branch::<branch>`` (or ``::branch::``)
- kanban bucket lane id ........ ``<repoRoot>::kanban``
@ -48,6 +49,14 @@ _KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$")
_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"}
DEFAULT_BRANCH_LABEL = "main"
# The synthetic bucket holding every session no project claimed — a chat with no
# cwd at all, or one whose folder can't be promoted (the bare home dir, HERMES
# state, a workspace that has since been deleted). Without it those sessions are
# invisible in the grouped view. The desktop labels it "Home"; the id/flag stay
# named for what the bucket MEANS, since that's what membership keys off.
NO_PROJECT_ID = "__no_project__"
NO_PROJECT_LABEL = "Home"
# How many sibling candidates to try when recovering a deleted worktree's parent
# repo (see ``_probe_sibling_worktree``). Each miss costs a git probe, so keep it
# tight — real suffixes are one or two segments.
@ -508,6 +517,7 @@ def _project_node(
color: Any = None,
icon: Any = None,
is_auto: bool = False,
is_no_project: bool = False,
) -> dict:
return {
"id": pid,
@ -516,6 +526,7 @@ def _project_node(
"color": color,
"icon": icon,
"isAuto": is_auto,
"isNoProject": is_no_project,
"sessionCount": session_count,
"lastActive": last_active,
"repos": repos,
@ -609,10 +620,13 @@ def build_tree(
# The pre-Projects desktop grouped every non-empty cwd; keeping that fallback
# prevents upgrades from flattening those sessions into Recents.
by_auto_root: dict[str, dict] = {}
# Every session no tier could place. These are the Home bucket's rows.
homeless: list[dict] = []
def _add_auto(root: str, session: dict) -> None:
key = _path_key(root)
if not key:
homeless.append(session)
return
bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []})
bucket["sessions"].append(session)
@ -626,10 +640,13 @@ def build_tree(
# session ran) and must not resurrect as a project.
if not _junk(root) and _exists(root):
_add_auto(root, session)
else:
homeless.append(session)
continue
cwd = (session.get("cwd") or "").strip()
if not cwd or _junk_cwd(cwd):
homeless.append(session)
continue
placement = _place(
cwd,
@ -642,9 +659,11 @@ def build_tree(
# also gone from disk (a deleted worktree whose name shares no prefix
# with its parent, a removed /tmp scratch dir), promoting it mints a
# phantom project that can never be opened and can only be dismissed by
# hand. The session keeps its place in flat Recents.
# hand. The session goes to Home instead.
if placement and _exists(placement["repo_key"]):
_add_auto(placement["repo_key"], session)
else:
homeless.append(session)
seen: set[str] = set()
for bucket in by_auto_root.values():
@ -661,6 +680,7 @@ def build_tree(
None,
)
if repo_node is None:
homeless.extend(auto_sessions)
continue
seen.add(auto_key)
scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id"))
@ -708,4 +728,41 @@ def build_tree(
# Explicit projects keep their user-chosen names untouched.
_disambiguate_labels([p for p in result if p.get("isAuto")])
# Tier 0: everything the tiers above could not place, so the grouped view
# loses no session. It has no folder, hence no repo/lane structure — the one
# synthetic lane exists purely to carry the rows in the tree's shape. Leads
# the list; omitted entirely when empty, so a project-less install is blank.
if homeless:
homeless.sort(key=_session_time, reverse=True)
scoped_ids.extend(s["id"] for s in homeless if s.get("id"))
lane = {
"id": NO_PROJECT_ID,
"label": NO_PROJECT_LABEL,
"path": None,
"isMain": False,
"isKanban": False,
"sessions": homeless if hydrate else [],
}
result.insert(
0,
_project_node(
pid=NO_PROJECT_ID,
label=NO_PROJECT_LABEL,
path=None,
repos=[
{
"id": NO_PROJECT_ID,
"label": NO_PROJECT_LABEL,
"path": None,
"groups": [lane],
"sessionCount": len(homeless),
}
],
session_count=len(homeless),
last_active=_last_active(homeless),
preview_sessions=_previews(homeless),
is_no_project=True,
),
)
return {"projects": result, "scoped_session_ids": scoped_ids}