fix: read Daybreak at each turn and bind its writes to the clicked connection

Standalone hermes serve read agent.daybreak when an agent was built and
then relied on a live-agent scan and a compute-host refresh. A build that
raced a flip could still publish the old value, and the compute-host read
bound only the home, not the profile's secrets and terminal policy.
_prepare_turn_input now reads it once the turn's final agent is selected,
inside the turn's own profile scope (prompt_turn._sync_agent_daybreak).
That one read covers in-process and compute-host turns, so the build-time
read, the live-agent scan and the compute-host refresh are gone.

Desktop: a queued write kept the primary menu's focus-following
requester, so after a focus switch it could reach another connection's
same-named profile. The model menu now binds Daybreak reads and writes to
the clicked surface's own gateway.
This commit is contained in:
unsupportedpastels authored and Mark S. committed 2026-11-02 15:06:27 -05:00
1 parent b51e4272ea
commit 40b566502a
7 files changed
+144 -104

No files matched your search

@@ -3,6 +3,8 @@ import { renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $daybreakByProfile, daybreakKey } from '@/store/daybreak'
import { $notifications } from '@/store/notifications'
import { $activeSessionId, $currentModel, $currentProvider } from '@/store/session'
@@ -15,7 +17,9 @@ it('a level picked while a reply streams applies from the next message instead o
$notifications.set([])
const requestGateway = vi.fn(async (method: string) =>
method === 'config.set' ? { ok: true, operation: 'reasoning', status: 'deferred', value: 'high' } : { providers: [] }
method === 'config.set'
? { ok: true, operation: 'reasoning', status: 'deferred', value: 'high' }
: { providers: [] }
)
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
@@ -29,7 +33,10 @@ it('a level picked while a reply streams applies from the next message instead o
{ wrapper }
)
result.current.controller.setOptions({ effort: 'high' }, { isActive: true, model: 'gemini-3.1-pro', provider: 'google' })
result.current.controller.setOptions(
{ effort: 'high' },
{ isActive: true, model: 'gemini-3.1-pro', provider: 'google' }
)
await waitFor(() =>
expect($notifications.get()).toContainEqual(
@@ -38,3 +45,55 @@ it('a level picked while a reply streams applies from the next message instead o
)
expect($notifications.get().some(n => n.kind === 'error')).toBe(false)
})
it('a queued Daybreak flip goes to the connection that was clicked, not the one focused later', async () => {
$daybreakByProfile.set({})
let releaseFirst!: (value: unknown) => void
const firstReply = new Promise(resolve => {
releaseFirst = resolve
})
const local = {
request: vi.fn((method: string, params?: Record<string, unknown>) =>
method === 'config.set' && params?.value === true ? firstReply : Promise.resolve({ value: params?.value })
)
}
const remote = {
request: vi.fn(async (_method: string, params?: Record<string, unknown>) => ({ value: params?.value }))
}
// The primary menu's shared requester follows focus: it reaches whichever connection is focused when called.
const focusedRequest = vi.fn(async () => ({ providers: [] }))
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
const { rerender, result } = renderHook(
({ gateway, connection }: { connection: string; gateway: typeof local }) =>
useModelMenuController({
gateway: gateway as unknown as HermesGateway,
onSelectModel: vi.fn(),
ownerConnectionId: connection,
profile: 'default',
requestGateway: focusedRequest as never
}),
{ initialProps: { connection: 'local', gateway: local }, wrapper }
)
result.current.controller.daybreak?.set(true)
result.current.controller.daybreak?.set(false) // queued behind the first write
rerender({ connection: 'remote', gateway: remote }) // focus moves to another connection's `default`
releaseFirst({ value: true })
await waitFor(() => expect(local.request).toHaveBeenCalledTimes(2))
expect(local.request).toHaveBeenLastCalledWith('config.set', { key: 'daybreak', profile: 'default', value: false })
expect(remote.request).not.toHaveBeenCalledWith('config.set', expect.anything())
expect(focusedRequest).not.toHaveBeenCalledWith('config.set', expect.anything())
await waitFor(() => expect($daybreakByProfile.get()[daybreakKey('default', 'local')]).toBe(false))
})
@@ -2,7 +2,7 @@ import { DEFAULT_REASONING_EFFORT, type ModelOptionsResult } from '@hermes/share
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { atom } from 'nanostores'
import { useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import type { HermesGateway } from '@/hermes'
@@ -107,8 +107,20 @@ export function useModelMenuController({
)
const daybreakOn = useStore($daybreakByProfile)[daybreakKey(profile, ownerConnectionId)] ?? false
const daybreakRequest = useRef(requestGateway)
daybreakRequest.current = requestGateway
// The primary menu's shared requester follows focus when called, and a queued write (store/daybreak.ts)
// can go out after focus moved: bind reads and writes to THIS connection's gateway when the host has one.
// A tile's requester is already bound to its owner route.
const daybreakRequester = useCallback(
(requester: typeof requestGateway) =>
gateway
? (method: string, params?: Record<string, unknown>) => gateway.request(method, params)
: (method: string, params?: Record<string, unknown>) => requester(method, params),
[gateway]
)
const daybreakRequest = useRef(daybreakRequester(requestGateway))
daybreakRequest.current = daybreakRequester(requestGateway)
useEffect(() => {
if (daybreakListed) {
@@ -222,7 +234,7 @@ export function useModelMenuController({
daybreak: {
on: daybreakOn,
set: on => {
void setDaybreak(requestGateway, profile, on, ownerConnectionId).then(ok => {
void setDaybreak(daybreakRequester(requestGateway), profile, on, ownerConnectionId).then(ok => {
if (!ok) {
notify({ id: 'daybreak-update-failed', kind: 'error', message: t.shell.modelOptions.daybreakFailed })
}
+54 -65
View File
@@ -50,72 +50,61 @@ def test_a_non_boolean_is_refused_rather_than_written(homes):
assert not (launch / "config.yaml").exists()
def test_a_flip_reaches_only_this_profiles_local_agents(homes, monkeypatch):
class _StopAfterDaybreak(Exception):
pass
def _admit_turn(monkeypatch, session):
"""Run real turn admission (``_prepare_turn_input``, under the session's real profile scope) and stop just
after the turn's agent is selected and its Daybreak setting synced."""
from tools import approval_context
monkeypatch.setattr(server, "_set_session_context", lambda *a, **k: [])
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
for name in ("_apply_pending_model_switch", "_sync_agent_model_with_config", "_sync_agent_compression_with_config",
"_sync_agent_fallback_with_config", "_sync_bot_capabilities"):
monkeypatch.setattr(server, name, lambda sid, session: None)
monkeypatch.setattr(server, "_adopt_out_of_band_turns", lambda session: None)
def stop(*args):
raise _StopAfterDaybreak()
monkeypatch.setattr(server, "_stage_first_contact_onboarding_note", stop)
st = server._TurnRun(agent=None, one_turn_restore=None, terminal_callback=None, receipt_committed=False)
try:
with pytest.raises(_StopAfterDaybreak):
server._prepare_turn_input("sid", session, st, "hello", [])
finally:
server._release_turn_scopes("sid", session, st)
assert approval_context.get_current_session_key(default="") == ""
@pytest.mark.parametrize(("platform", "expected"), [
("desktop", True), ("tui", True), ("cli", True), ("telegram", False), ("acp", False), (None, False)])
def test_every_turn_reads_the_switch_for_the_owners_own_surfaces(homes, monkeypatch, platform, expected):
"""Each turn reads ``agent.daybreak`` from the session's profile once the final agent is selected, so a
flip, a build that raced one, or a compute-host child (same turn path) all send the saved value. A plugin
session tagged ``telegram`` (or an editor client) built by this backend gets standard requests."""
import threading
_launch, worker = homes
launch_agent = SimpleNamespace(daybreak=False, platform="desktop")
worker_agent = SimpleNamespace(daybreak=False, platform="tui")
bot_agent = SimpleNamespace(daybreak=False, platform="telegram") # a plugin session in the same profile
monkeypatch.setitem(server._sessions, "launch-sid", {"agent": launch_agent, "profile_home": None})
monkeypatch.setitem(server._sessions, "worker-sid", {"agent": worker_agent, "profile_home": str(worker)})
monkeypatch.setitem(server._sessions, "bot-sid", {"agent": bot_agent, "profile_home": str(worker)})
monkeypatch.setitem(server._sessions, "unbuilt-sid", {"agent": None, "profile_home": str(worker)})
assert _call("config.set", key="daybreak", value=True, profile="code")["result"]["value"] is True
assert worker_agent.daybreak is True
assert launch_agent.daybreak is False # another profile's setting
assert bot_agent.daybreak is False # someone else's chat: OpenAI allows no third-party access
assert yaml.safe_load((worker / "config.yaml").read_text())["agent"]["daybreak"] is True
assert _call("config.set", key="daybreak", value=True)["result"]["value"] is True # launch profile
assert launch_agent.daybreak is True
def test_only_the_owners_local_surfaces_carry_it():
from agent.session_source import DAYBREAK_SOURCES
assert DAYBREAK_SOURCES == {"tui", "desktop", "cli"}
@pytest.mark.parametrize(("source", "expected"), [(None, True), ("desktop", True), ("telegram", False)])
def test_new_agents_read_the_switch_only_for_local_surfaces(monkeypatch, source, expected):
"""``_make_agent`` end to end with a stand-in agent class: the switch is on in config.yaml, and a plugin
session tagged ``telegram`` built by this backend still gets standard requests."""
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
monkeypatch.delenv("HERMES_MODEL", raising=False)
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"daybreak": True},
"model": {"default": "gpt-6-sol", "provider": "openai-codex"}})
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider",
lambda requested=None, target_model=None: {
"provider": "openai-codex", "base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "token", "api_mode": "codex_responses", "credential_pool": None})
monkeypatch.setattr("run_agent.AIAgent", lambda **kwargs: SimpleNamespace(model=kwargs.get("model")))
monkeypatch.setattr(server, "_load_enabled_toolsets", lambda platform=None: ["file"])
monkeypatch.setattr(server, "_get_db", lambda: None)
agent = server._make_agent("sid", "session-key", platform_override=source)
assert agent.daybreak is expected
def test_an_isolated_turns_reused_child_agent_follows_the_switch(homes, monkeypatch):
"""With turn isolation the live agent is in the compute-host child, out of reach of the parent's live
flip, so each turn the child re-reads the switch from its profile's config.yaml, in both directions."""
from tui_gateway.compute_host import ComputeHost
_launch, worker = homes
child = object.__new__(ComputeHost)
child._transport = object()
desktop = SimpleNamespace(daybreak=True, platform="desktop")
bot = SimpleNamespace(daybreak=False, platform="telegram")
monkeypatch.setitem(server._sessions, "desktop-sid", {"agent": desktop, "profile_home": str(worker)})
monkeypatch.setitem(server._sessions, "bot-sid", {"agent": bot, "profile_home": str(worker)})
agent = SimpleNamespace(daybreak=not expected, platform=platform)
session = {"agent": agent, "session_key": "daybreak-key", "profile_home": str(worker),
"history": [], "history_lock": threading.Lock()}
config = worker / "config.yaml"
config.write_text("agent:\n daybreak: false\n", encoding="utf-8")
child._ensure_server_session(server, {"sid": "desktop-sid", "profile_home": str(worker)})
assert desktop.daybreak is False
config.write_text("agent:\n daybreak: true\n", encoding="utf-8")
for sid in ("desktop-sid", "bot-sid"):
child._ensure_server_session(server, {"sid": sid, "profile_home": str(worker)})
assert desktop.daybreak is True
assert bot.daybreak is False
_admit_turn(monkeypatch, session)
assert agent.daybreak is expected
config.write_text("agent:\n daybreak: false\n", encoding="utf-8") # flipped off between turns
_admit_turn(monkeypatch, session)
assert agent.daybreak is False
def test_the_turn_reads_its_own_profile_not_the_launch_one(homes, monkeypatch):
import threading
launch, worker = homes
(launch / "config.yaml").write_text("agent:\n daybreak: true\n", encoding="utf-8")
(worker / "config.yaml").write_text("agent:\n daybreak: false\n", encoding="utf-8")
agent = SimpleNamespace(daybreak=True, platform="desktop")
_admit_turn(monkeypatch, {"agent": agent, "session_key": "worker-key", "profile_home": str(worker),
"history": [], "history_lock": threading.Lock()})
assert agent.daybreak is False
-2
View File
@@ -686,8 +686,6 @@ def _make_agent(
if context_cwd_is_launch_artifact is None:
context_cwd_is_launch_artifact = _context_cwd_is_launch_artifact(session)
agent._context_cwd_is_launch_artifact = bool(context_cwd_is_launch_artifact)
from agent.session_source import DAYBREAK_SOURCES # live flips: methods_config_set._set_daybreak
agent.daybreak = platform in DAYBREAK_SOURCES and (cfg.get("agent") or {}).get("daybreak") is True
if fallback_notice:
# Emitted once on the first successful reply via _emit_pending_fallback_notice -> status_callback.
agent._pending_fallback_notice = fallback_notice
-19
View File
@@ -331,7 +331,6 @@ class ComputeHost:
session[key] = str(frame[key])
if "explicit_cwd" in frame:
session["explicit_cwd"] = bool(frame["explicit_cwd"])
self._refresh_daybreak(server, session)
else:
session = self._build_server_session(server, frame, sid)
if isinstance(frame.get("attached_images"), list):
@@ -343,24 +342,6 @@ class ComputeHost:
session["pending_model_switch"] = dict(frame["pending_model_switch"])
return session
@staticmethod
def _refresh_daybreak(server: Any, session: dict) -> None:
"""``agent.daybreak`` is read when the agent is built, and the parent's live flip only reaches agents in
its own process, so a reused child agent re-reads it each turn from its profile's config.yaml."""
agent = session.get("agent")
if agent is None:
return
from agent.session_source import DAYBREAK_SOURCES
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
profile_home = str(session.get("profile_home") or "")
token = set_hermes_home_override(profile_home) if profile_home else None
try:
on = (server._load_cfg().get("agent") or {}).get("daybreak") is True
finally:
if token is not None:
reset_hermes_home_override(token)
agent.daybreak = on and getattr(agent, "platform", None) in DAYBREAK_SOURCES
def _build_server_session(self, server: Any, frame: dict[str, Any], sid: str) -> dict:
"""Build the agent under the frame's profile scope and register the session."""
key = str(frame.get("session_key") or sid)
+2 -12
View File
@@ -510,22 +510,12 @@ def _set_display_toggle(rid, params, key, value, session):
def _set_daybreak(rid, params, key, value, session):
"""``agent.daybreak``: profile-wide like the native owner's (gateway/session_tui_config.py). This
profile's live local-surface agents take it at once (agent.session_source.DAYBREAK_SOURCES); requests
check eligibility (agent/codex_service_tiers.py)."""
"""``agent.daybreak``: profile-wide like the native owner's (gateway/session_tui_config.py). Every turn
reads it (prompt_turn._sync_agent_daybreak); requests check eligibility (agent/codex_service_tiers.py)."""
on = _BOOL_WORDS.get(str(value).strip().lower())
if on is None:
return _err(rid, 4002, f"{key} takes true or false")
_write_config_key("agent.daybreak", on)
from agent.session_source import DAYBREAK_SOURCES
from hermes_constants import get_hermes_home
home = get_hermes_home().resolve()
with _sessions_lock:
live = list(_sessions.values())
for live_session in live:
agent = live_session.get("agent")
if agent is not None and Path(live_session.get("profile_home") or _hermes_home).resolve() == home:
agent.daybreak = on and getattr(agent, "platform", None) in DAYBREAK_SOURCES
return _kv(rid, key, on)
+11
View File
@@ -659,6 +659,16 @@ def _stage_first_contact_onboarding_note(session: dict, agent, history_empty: bo
logger.debug("first-contact onboarding note failed", exc_info=True)
def _sync_agent_daybreak(agent) -> None:
"""``agent.daybreak`` for this turn, read inside the turn's profile scope once the final agent is selected,
so a build that raced a flip, a model switch and a compute-host child all take the saved value. Only the
owner's own surfaces carry it (agent.session_source.DAYBREAK_SOURCES): OpenAI allows no third-party use."""
from agent.session_source import DAYBREAK_SOURCES
if agent is not None:
agent.daybreak = (getattr(agent, "platform", None) in DAYBREAK_SOURCES
and (_load_cfg().get("agent") or {}).get("daybreak") is True)
def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images: list[str]):
"""Bind scopes, sync the agent, snapshot history, build the run message; returns
``(prompt, run_message, cols, streamer)`` or None when @-expansion was refused.
@@ -695,6 +705,7 @@ def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images
_sync_bot_capabilities(sid, session) # Bot Chat: adopt Settings->Capabilities edits
_adopt_out_of_band_turns(session)
st.agent = agent = session["agent"]
_sync_agent_daybreak(agent)
# Snapshot after the model sync: a deferred switch's history mutation belongs to this turn.
with session["history_lock"]:
st.history = list(session["history"])