Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,29 @@ describe('resumeSession failure recovery', () => {

expect($resumeFailedSessionId.get()).toBeNull()
})

it('resumes via the gateway default (deferred build) — not lazy, no eager opt-out', async () => {
// The switch-latency fix lives backend-side: a normal cold resume gets the
// gateway's default DEFERRED build (transcript returns immediately, agent
// pre-warms in the background). The client must NOT force the synchronous
// path (eager_build) and is only `lazy` for subagent watch windows.
let resumeParams: Record<string, unknown> | undefined

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
resumeParams = params

return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}

return {} as never
})

vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)

await runResume(requestGateway)

expect(resumeParams).not.toHaveProperty('lazy')
expect(resumeParams).not.toHaveProperty('eager_build')
})
})
13 changes: 12 additions & 1 deletion apps/desktop/src/app/session/hooks/use-session-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,11 @@ export function useSessionActions({
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
// Watch windows attach lazily (live mirror). Every other cold resume
// gets the gateway's default deferred build: the RPC returns the
// transcript immediately instead of blocking the switch on _make_agent
// (MCP discovery / prompt build), and the agent pre-warms in the
// background while the prefetch above paints the transcript.
...(watchWindow ? { lazy: true } : {}),
...(sessionProfile ? { profile: sessionProfile } : {})
})
Expand Down Expand Up @@ -754,7 +759,13 @@ export function useSessionActions({
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
})()

const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages)
// Prefetch-hit fast path: `preferredMessages` IS the live `$messages`
// array (already error-merged when `localSnapshot` was built), so reuse
// the ref instead of rebuilding a throwaway transcript+Map every switch.
const messagesForView =
preferredMessages === currentMessages
? currentMessages
: preserveLocalAssistantErrors(preferredMessages, currentMessages)

setActiveSessionId(resumed.session_id)
activeSessionIdRef.current = resumed.session_id
Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,11 @@ def _ensure_hermes_home_managed(home: Path):
# Global active chat session cap across CLI, TUI/dashboard, and messaging.
# None/0 = unbounded.
"max_concurrent_sessions": None,
# Soft LRU cap on in-memory TUI/desktop/dashboard sessions. When more than
# this many are live, the gateway evicts the least-recently-active DETACHED
# sessions (no live client) so accumulated agents don't pile up under memory
# pressure. Reopening one re-resumes it from disk. 0/null disables.
"max_live_sessions": 16,
"agent": {
"max_turns": 90,
# Inactivity timeout for gateway agent execution (seconds).
Expand Down
14 changes: 11 additions & 3 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1001,8 +1001,11 @@ def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
)

try:
# eager_build: this asserts the synchronously-built agent binds to the
# resolved tip (captured["agent_session_id"]); the compression-tip
# resolution itself runs before the build and is mode-agnostic.
resp = server.handle_request(
{"id": "1", "method": "session.resume", "params": {"session_id": "parent_root"}}
{"id": "1", "method": "session.resume", "params": {"session_id": "parent_root", "eager_build": True}}
)
finally:
db.close()
Expand Down Expand Up @@ -1049,8 +1052,11 @@ def fake_init_session(sid, key, agent, history, cols=80, **_kwargs):

monkeypatch.setattr(server, "_init_session", fake_init_session)

# eager_build: this asserts the synchronous build contract (stored runtime
# overrides reach _make_agent, info comes from _session_info). The deferred
# default restores the same overrides via _start_agent_build off-thread.
resp = server.handle_request(
{"id": "1", "method": "session.resume", "params": {"session_id": "stored-session"}}
{"id": "1", "method": "session.resume", "params": {"session_id": "stored-session", "eager_build": True}}
)

assert resp["result"]["info"] == {"model": "gpt-5.4", "provider": "openai-codex"}
Expand Down Expand Up @@ -1137,11 +1143,13 @@ def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
monkeypatch.setattr(approval, "load_permanent_allowlist", lambda: None)

try:
# eager_build: asserts the synchronous build receives the profile's db
# (the deferred default builds with the same db via _start_agent_build).
resp = server.handle_request(
{
"id": "1",
"method": "session.resume",
"params": {"session_id": target, "profile": "worker"},
"params": {"session_id": target, "profile": "worker", "eager_build": True},
}
)

Expand Down
153 changes: 149 additions & 4 deletions tests/tui_gateway/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,9 @@ def get_messages_as_conversation(self, _sid, include_ancestors=False):
{
"id": "r1",
"method": "session.resume",
"params": {"session_id": "20260409_010101_abc123", "cols": 100},
# eager_build: exercise the synchronous build path (this test
# monkeypatches _make_agent/_init_session/_session_info).
"params": {"session_id": "20260409_010101_abc123", "cols": 100, "eager_build": True},
}
)

Expand All @@ -336,6 +338,147 @@ def get_messages_as_conversation(self, _sid, include_ancestors=False):
]


def test_session_resume_defaults_to_deferred_build(server, monkeypatch):
"""A normal cold resume (no ``eager_build``) must return the full display
transcript immediately and register an upgradable live session WITHOUT
building the agent on the response path — that eager build is the
multi-second switch latency. Deferred is the default; ``eager_build: true``
opts back into the synchronous path."""

target = "20260409_010101_abc123"

class _DB:
def get_session(self, _sid):
return {
"id": target,
"model": "vendor/cool-model",
"model_config": {"provider": "vendor"},
}

def get_session_by_title(self, _title):
return None

def resolve_resume_session_id(self, sid):
return sid

def reopen_session(self, _sid):
return None

def get_messages_as_conversation(self, _sid, include_ancestors=False):
return [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "yo"},
]

builds: list = []

monkeypatch.setattr(server, "_get_db", lambda: _DB())
# The response path must never call _make_agent; route the deferred timer
# through a recorder so a 50ms fire can't build (or crash) under the test.
monkeypatch.setattr(
server, "_make_agent", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no eager build"))
)
monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: builds.append(sid))
monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda: None)

resp = server.handle_request(
{
"id": "r1",
"method": "session.resume",
"params": {"session_id": target, "cols": 100},
}
)

assert "error" not in resp
result = resp["result"]
assert result["resumed"] == target
assert result["session_key"] == target
assert result["message_count"] == 2
assert result["messages"] == [
{"role": "user", "text": "hello"},
{"role": "assistant", "text": "yo"},
]
# Lazy info contract (same shape session.create returns), with the session's
# persisted model/provider restored rather than the global default.
assert result["info"]["lazy"] is True
assert result["info"]["model"] == "vendor/cool-model"
assert result["info"]["provider"] == "vendor"
assert result["info"]["desktop_contract"] == server.DESKTOP_BACKEND_CONTRACT

sid = result["session_id"]
session = server._sessions[sid]
# Registered but not built: agent is None and the resume key is carried so a
# later prompt.submit / _sess() upgrade continues THIS stored conversation.
assert session["agent"] is None
assert session["resume_session_id"] == target
assert not session["agent_ready"].is_set()
# Not a watch spectator: a normal deferred resume is a real session.
assert not session.get("lazy")
# The persisted runtime identity is stashed for the deferred build so it
# can't drop the provider ("No LLM provider configured").
assert session["resume_runtime_overrides"]["model_override"]["model"] == "vendor/cool-model"
assert server._find_live_session_by_key(target) == (sid, session)


def test_enforce_session_cap_evicts_oldest_detached_only(server, monkeypatch):
"""The LRU cap frees the least-recently-active DETACHED sessions when over
the limit, and never a live-transport / running / mid-build one."""

monkeypatch.setattr(server, "_load_cfg", lambda: {"max_live_sessions": 2})
evicted: list[str] = []
monkeypatch.setattr(
server, "_close_session_by_id", lambda sid, end_reason=None: evicted.append(sid)
)

def _ready() -> threading.Event:
ev = threading.Event()
ev.set()
return ev

detached = server._detached_ws_transport
live = object() # no _closed attr -> live transport, never evictable

server._sessions.clear()
server._sessions.update(
{
"old_detached": {"transport": detached, "last_active": 100.0, "agent_ready": _ready()},
"new_detached": {"transport": detached, "last_active": 300.0, "agent_ready": _ready()},
"running_detached": {
"transport": detached,
"last_active": 50.0,
"running": True,
"agent_ready": _ready(),
},
"focused_live": {"transport": live, "last_active": 200.0, "agent_ready": _ready()},
}
)

server._enforce_session_cap()

# 4 sessions, cap 2 -> evict 2. Only detached+idle+built are eligible, oldest
# first; the running one and the live-transport one are exempt.
assert evicted == ["old_detached", "new_detached"]


def test_enforce_session_cap_disabled_is_noop(server, monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda: {"max_live_sessions": 0})
evicted: list[str] = []
monkeypatch.setattr(
server, "_close_session_by_id", lambda sid, end_reason=None: evicted.append(sid)
)
server._sessions.clear()
server._sessions.update(
{
f"s{i}": {"transport": server._detached_ws_transport, "last_active": float(i)}
for i in range(5)
}
)

server._enforce_session_cap()

assert evicted == []


def test_session_resume_handles_multimodal_list_content(server, monkeypatch):
"""A user message persisted with list-shaped multimodal content used to
crash session resume with ``'list' object has no attribute 'strip'``."""
Expand Down Expand Up @@ -374,7 +517,7 @@ def get_messages_as_conversation(self, _sid, include_ancestors=False):
{
"id": "r1",
"method": "session.resume",
"params": {"session_id": "20260502_000000_listcontent", "cols": 100},
"params": {"session_id": "20260502_000000_listcontent", "cols": 100, "eager_build": True},
}
)

Expand Down Expand Up @@ -688,7 +831,9 @@ def resume_first():
{
"id": "first",
"method": "session.resume",
"params": {"session_id": target, "cols": 100},
# eager_build: this test drives the synchronous build race +
# double-checked locking that only the eager path exercises.
"params": {"session_id": target, "cols": 100, "eager_build": True},
}
)

Expand All @@ -703,7 +848,7 @@ def resume_second():
{
"id": "second",
"method": "session.resume",
"params": {"session_id": target, "cols": 120},
"params": {"session_id": target, "cols": 120, "eager_build": True},
}
)

Expand Down
Loading
Loading