mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge upstream main into feat/hermes-relay-shared-metrics
This commit is contained in:
commit
70d8db7409
42 changed files with 1379 additions and 189 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)')}
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1546,11 +1546,11 @@ export const ar = defineLocale({
|
|||
allPinned: 'كل الجلسات مثبتة',
|
||||
shiftClickHint: 'استخدم Shift للتحديد المتعدد',
|
||||
noWorkspace: 'بدون مساحة عمل',
|
||||
noProject: 'لا يوجد مشروع',
|
||||
projectEmpty: 'لا توجد جلسات بعد',
|
||||
noSessions: 'لا توجد جلسات بعد',
|
||||
projects: {
|
||||
sectionLabel: 'المشاريع',
|
||||
home: 'الرئيسية',
|
||||
newButton: 'مشروع جديد',
|
||||
createTitle: 'مشروع جديد',
|
||||
createDesc: 'سمِّ مساحة العمل وأضف مجلدا أو أكثر.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -1668,11 +1668,11 @@ export const ja = defineLocale({
|
|||
allPinned: 'ここにあるものはすべてピン留めされています。チャットのピン留めを解除すると最近のものに表示されます。',
|
||||
shiftClickHint: 'Shift クリックでピン留め · ドラッグで並べ替え',
|
||||
noWorkspace: 'ワークスペースなし',
|
||||
noProject: 'プロジェクトなし',
|
||||
projectEmpty: 'セッションはまだありません',
|
||||
noSessions: 'セッションはまだありません',
|
||||
projects: {
|
||||
sectionLabel: 'プロジェクト',
|
||||
home: 'ホーム',
|
||||
newButton: '新規プロジェクト',
|
||||
createTitle: '新規プロジェクト',
|
||||
createDesc: 'ワークスペースに名前を付け、1つ以上のフォルダを追加します。',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1616,11 +1616,11 @@ export const zhHant = defineLocale({
|
|||
allPinned: '這裡的全部已釘選。取消釘選某個聊天即可在最近中顯示。',
|
||||
shiftClickHint: 'Shift + 點擊聊天以釘選 · 拖曳以重新排序',
|
||||
noWorkspace: '無工作區',
|
||||
noProject: '無專案',
|
||||
projectEmpty: '尚無工作階段',
|
||||
noSessions: '尚無工作階段',
|
||||
projects: {
|
||||
sectionLabel: '專案',
|
||||
home: '主頁',
|
||||
newButton: '新增專案',
|
||||
createTitle: '新增專案',
|
||||
createDesc: '為工作區命名並新增一個或多個資料夾。',
|
||||
|
|
|
|||
|
|
@ -1994,11 +1994,11 @@ export const zh: Translations = {
|
|||
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
|
||||
shiftClickHint: 'Shift+ 单击对话以置顶 · 拖动以重新排序',
|
||||
noWorkspace: '无工作区',
|
||||
noProject: '无项目',
|
||||
projectEmpty: '暂无会话',
|
||||
noSessions: '暂无会话',
|
||||
projects: {
|
||||
sectionLabel: '项目',
|
||||
home: '主页',
|
||||
newButton: '新建项目',
|
||||
createTitle: '新建项目',
|
||||
createDesc: '为工作区命名并添加一个或多个文件夹。',
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (native→native). 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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue