mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
Merge pull request #70604 from NousResearch/bb/profile-routing-super
fix(sessions): keep a conversation on its owning profile through branch and compression
This commit is contained in:
commit
80e575dfba
8 changed files with 1217 additions and 293 deletions
|
|
@ -1908,6 +1908,19 @@ def compress_context(
|
|||
except Exception:
|
||||
pass
|
||||
agent._session_db_created = False
|
||||
# The rotation child must stay on the parent's profile —
|
||||
# mirror _ensure_db_session's stamp ("default" persists as
|
||||
# NULL). _insert_session_row's parent backfill additionally
|
||||
# COALESCEs from the parent row, covering app-global remote
|
||||
# sessions whose thread lacks the HERMES_HOME context.
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
|
||||
_profile_for_child = get_active_profile_name()
|
||||
if _profile_for_child == "default":
|
||||
_profile_for_child = None
|
||||
except Exception:
|
||||
_profile_for_child = None
|
||||
try:
|
||||
agent._session_db.create_session(
|
||||
session_id=agent.session_id,
|
||||
|
|
@ -1915,6 +1928,7 @@ def compress_context(
|
|||
model=agent.model,
|
||||
model_config=agent._session_init_model_config,
|
||||
parent_session_id=old_session_id,
|
||||
profile_name=_profile_for_child,
|
||||
)
|
||||
except Exception as _cs_err:
|
||||
# The child row could not be created (e.g. FK constraint,
|
||||
|
|
|
|||
|
|
@ -1102,8 +1102,12 @@ describe('branchStoredSession desktop source tagging', () => {
|
|||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
|
|
@ -1120,9 +1124,71 @@ describe('branchStoredSession desktop source tagging', () => {
|
|||
|
||||
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
|
||||
expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work')
|
||||
// The create itself must carry the owning profile: in app-global remote
|
||||
// mode the soft gateway swap alone is not enough — an omitted profile
|
||||
// lands the branch on the launch (default) profile's state.db.
|
||||
expect(createParams).toMatchObject({ parent_session_id: 'stored-parent', profile: 'work' })
|
||||
|
||||
vi.mocked(getSession).mockReset()
|
||||
})
|
||||
|
||||
it('creates the branch on the cached parent session profile', async () => {
|
||||
setSessions([storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' })])
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
|
||||
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
|
||||
expect(createParams).toMatchObject({ profile: 'work' })
|
||||
})
|
||||
|
||||
it('omits profile for a profile-less parent so single-profile users are unchanged', async () => {
|
||||
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
|
||||
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
expect(createParams).toBeDefined()
|
||||
expect(createParams).not.toHaveProperty('profile')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ import {
|
|||
patchSessionWorkspace,
|
||||
preserveLocalPendingTurnMessages,
|
||||
reconcileResumeMessages,
|
||||
resolveSessionProfile,
|
||||
resolveStoredSession,
|
||||
sessionMatchesStoredId,
|
||||
sessionShouldHaveTranscript,
|
||||
|
|
@ -1056,15 +1057,30 @@ export function useSessionActions({
|
|||
// `parentStoredId` so it nests under its parent, then open it as its own tab
|
||||
// and switch to it — the parent chat stays put (mirrors openNewSessionTile).
|
||||
const forkBranch = useCallback(
|
||||
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
|
||||
async (
|
||||
branchMessages: BranchMessage[],
|
||||
parentStoredId: null | string,
|
||||
cwd?: string,
|
||||
profile?: null | string
|
||||
): Promise<boolean> => {
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
// A branch belongs to its parent's OWNING profile. Swapping the live
|
||||
// gateway first AND passing `profile` on the create mirrors
|
||||
// desktopSessionCreateParams/resumeSession: in app-global remote mode
|
||||
// one backend serves every profile, so an omitted profile silently
|
||||
// lands the branch on the launch (default) profile — the "session
|
||||
// jumps between profiles after branching" bug. The swap also makes
|
||||
// upsertOptimisticSession's $activeGatewayProfile stamp correct.
|
||||
await ensureGatewayProfile(profile)
|
||||
|
||||
// No title: the backend auto-names the branch from its parent's lineage.
|
||||
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
source: 'desktop',
|
||||
...(cwd && { cwd }),
|
||||
...(profile ? { profile } : {}),
|
||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||
...(parentStoredId && { parent_session_id: parentStoredId })
|
||||
})
|
||||
|
|
@ -1165,7 +1181,12 @@ export function useSessionActions({
|
|||
|
||||
clearNotifications()
|
||||
|
||||
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim())
|
||||
// The open chat's owning profile, NOT the picker's / launch profile —
|
||||
// /profile only retargets new chats, so a branch of an existing thread
|
||||
// must stay on that thread's backend (cache hit for an open session).
|
||||
const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
|
||||
|
||||
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), profile)
|
||||
},
|
||||
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
|
@ -1197,7 +1218,7 @@ export function useSessionActions({
|
|||
return false
|
||||
}
|
||||
|
||||
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim())
|
||||
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile)
|
||||
} catch (err) {
|
||||
notifyError(err, copy.branchFailed)
|
||||
|
||||
|
|
|
|||
|
|
@ -3387,13 +3387,15 @@ class SessionDB:
|
|||
|
||||
When ``parent_session_id`` is set (compression fork, delegate/subagent
|
||||
spawn, branch continuation) and this row's own ``cwd``/``git_repo_root``/
|
||||
``git_branch`` are still NULL after the insert, they are backfilled from
|
||||
the parent row. Callers of ``create_session`` for a child session
|
||||
historically didn't propagate these fields themselves (e.g. the
|
||||
``git_branch``/``profile_name`` are still NULL after the insert, they are
|
||||
backfilled from the parent row. Callers of ``create_session`` for a child
|
||||
session historically didn't propagate these fields themselves (e.g. the
|
||||
compression-fork path), so a lineage could silently lose its working
|
||||
directory and drop out of the project sidebar every time it forked
|
||||
(#64709). This only fills NULLs — an explicit ``cwd``/``git_repo_root``
|
||||
on the child is never overwritten. For compression forks specifically
|
||||
(#64709), or lose its owning profile and be aggregated as "default" every
|
||||
time it rotated or branched (the cross-profile session-jump bug). This
|
||||
only fills NULLs — an explicit value on the child is never overwritten.
|
||||
For compression forks specifically
|
||||
(parent ended with ``end_reason='compression'``), the gateway origin
|
||||
columns (``user_id``/``session_key``/``chat_id``/``chat_type``/
|
||||
``thread_id``/``display_name``/``origin_json``) are inherited too, so a
|
||||
|
|
@ -3449,7 +3451,10 @@ class SessionDB:
|
|||
WHERE p.id = sessions.parent_session_id)),
|
||||
git_branch = COALESCE(sessions.git_branch,
|
||||
(SELECT p.git_branch FROM sessions p
|
||||
WHERE p.id = sessions.parent_session_id))
|
||||
WHERE p.id = sessions.parent_session_id)),
|
||||
profile_name = COALESCE(sessions.profile_name,
|
||||
(SELECT p.profile_name FROM sessions p
|
||||
WHERE p.id = sessions.parent_session_id))
|
||||
WHERE id = ? AND parent_session_id IS NOT NULL""",
|
||||
(session_id,),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -322,6 +322,52 @@ class TestFlushAfterCompression:
|
|||
)
|
||||
db.close()
|
||||
|
||||
def test_rotation_child_session_inherits_parent_profile_name(self):
|
||||
"""The rotation child must stay on the parent's owning profile.
|
||||
|
||||
The rotate path used to create the child row with no profile_name, so
|
||||
a compressed non-default-profile conversation migrated to the launch/
|
||||
default profile in unified session lists (the cross-profile
|
||||
session-jump bug). Exercises the real rotation against a real
|
||||
SessionDB: explicit stamp at the create site + the parent-backfill
|
||||
COALESCE in _insert_session_row.
|
||||
"""
|
||||
from agent.conversation_compression import compress_context
|
||||
from hermes_state import SessionDB
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db = SessionDB(db_path=Path(tmpdir) / "test.db")
|
||||
parent_sid = "20260701_152840_parent"
|
||||
db.create_session(
|
||||
parent_sid, "gateway", model="test/model",
|
||||
profile_name="ai-engineer",
|
||||
)
|
||||
|
||||
agent = self._make_agent(db)
|
||||
agent.session_id = parent_sid
|
||||
agent.compression_in_place = False
|
||||
agent._ensure_db_session()
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user" if i % 2 == 0 else "assistant",
|
||||
"content": f"message {i}",
|
||||
"_db_persisted": True,
|
||||
}
|
||||
for i in range(12)
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
|
||||
compress_context(
|
||||
agent, messages, approx_tokens=100_000, system_message="sys"
|
||||
)
|
||||
|
||||
assert agent.session_id != parent_sid
|
||||
child = db.get_session(agent.session_id)
|
||||
assert child is not None
|
||||
assert child["profile_name"] == "ai-engineer"
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 2: Gateway-side — history_offset after session split
|
||||
|
|
|
|||
|
|
@ -194,6 +194,44 @@ class TestSessionLifecycle:
|
|||
child = db.get_session("child")
|
||||
assert child["git_branch"] == "feature-x"
|
||||
|
||||
def test_child_session_inherits_profile_name_from_parent(self, db):
|
||||
"""A parented child born without profile_name (compression rotation,
|
||||
/branch) must inherit its parent's owning profile — otherwise the
|
||||
lineage silently migrates to the launch/default profile in unified
|
||||
session lists (the cross-profile session-jump bug)."""
|
||||
db.create_session(session_id="parent", source="cli", profile_name="ai-engineer")
|
||||
# Rotation path: parent is ended with 'compression' BEFORE the child
|
||||
# row is created (agent/conversation_compression.py).
|
||||
db.end_session("parent", "compression")
|
||||
|
||||
db.create_session(session_id="child", source="cli", parent_session_id="parent")
|
||||
|
||||
assert db.get_session("child")["profile_name"] == "ai-engineer"
|
||||
|
||||
def test_child_session_explicit_profile_name_is_not_overwritten(self, db):
|
||||
"""Inheritance only fills NULLs — an explicit profile_name on the
|
||||
child is never clobbered by the parent's."""
|
||||
db.create_session(session_id="parent", source="cli", profile_name="ai-engineer")
|
||||
|
||||
db.create_session(
|
||||
session_id="child", source="cli", parent_session_id="parent",
|
||||
profile_name="other",
|
||||
)
|
||||
|
||||
assert db.get_session("child")["profile_name"] == "other"
|
||||
|
||||
def test_multi_generation_lineage_inherits_profile_name(self, db):
|
||||
"""profile_name survives a compress-then-branch chain (root -> rotation
|
||||
child -> branch tip) — the exact lineage that used to land on default."""
|
||||
db.create_session(session_id="root", source="cli", profile_name="ai-engineer")
|
||||
db.end_session("root", "compression")
|
||||
|
||||
db.create_session(session_id="gen1", source="cli", parent_session_id="root")
|
||||
db.create_session(session_id="gen2", source="cli", parent_session_id="gen1")
|
||||
|
||||
assert db.get_session("gen1")["profile_name"] == "ai-engineer"
|
||||
assert db.get_session("gen2")["profile_name"] == "ai-engineer"
|
||||
|
||||
def test_compression_child_inherits_gateway_origin_columns(self, db):
|
||||
"""A compression fork's child inherits gateway routing metadata
|
||||
(session_key/chat_id/...) from the ended parent, so a crash before
|
||||
|
|
|
|||
|
|
@ -3893,7 +3893,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append(
|
||||
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
|
||||
)
|
||||
|
|
@ -3914,7 +3914,7 @@ def test_ensure_session_db_row_persists_session_source(monkeypatch):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append(
|
||||
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
|
||||
)
|
||||
|
|
@ -3935,7 +3935,7 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append(
|
||||
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
|
||||
)
|
||||
|
|
@ -3964,7 +3964,7 @@ def test_ensure_session_db_row_persists_session_model_override(monkeypatch):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append(
|
||||
{"key": key, "model": model, "model_config": model_config, "cwd": cwd}
|
||||
)
|
||||
|
|
@ -3996,7 +3996,7 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append({"model": model, "model_config": model_config})
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
|
|
@ -4007,6 +4007,36 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch):
|
|||
assert created == [{"model": "global/default", "model_config": None}]
|
||||
|
||||
|
||||
def test_ensure_session_db_row_stamps_profile_name(monkeypatch, tmp_path):
|
||||
"""A profile session's row carries its owning profile_name, so unified
|
||||
multi-profile aggregation never has to guess from which state.db file the
|
||||
row happened to be read (the cross-profile session-jump bug)."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
created = []
|
||||
|
||||
class _ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
created.append({"db_path": db_path})
|
||||
|
||||
def create_session(self, key, **kwargs):
|
||||
created[-1].update({"key": key, "profile_name": kwargs.get("profile_name")})
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("hermes_state.SessionDB", _ProfileDB)
|
||||
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
||||
|
||||
server._ensure_session_db_row(
|
||||
{"session_key": "k1", "profile_home": str(profile_home)}
|
||||
)
|
||||
|
||||
assert created and created[0]["key"] == "k1"
|
||||
assert created[0]["profile_name"] == "mlperf"
|
||||
assert created[0]["db_path"] == profile_home / "state.db"
|
||||
|
||||
|
||||
def test_session_title_clears_pending_after_persist(monkeypatch):
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
|
|
@ -8672,6 +8702,536 @@ def test_session_delete_success_returns_deleted_id(monkeypatch):
|
|||
assert str(captured["sessions_dir"]).endswith("sessions")
|
||||
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# session.* profile scoping (app-global remote mode) — #62503
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_session_list_honors_params_profile_opens_profile_db(monkeypatch, tmp_path):
|
||||
"""Issue #62503: session.list must read the profile's state.db, not launch."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
(profile_home / "state.db").write_bytes(b"")
|
||||
seen: dict = {}
|
||||
|
||||
class LaunchDB:
|
||||
def list_sessions_rich(self, **kwargs):
|
||||
seen["launch"] = True
|
||||
return [{"id": "launch-1", "source": "tui", "title": "L"}]
|
||||
|
||||
class ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
seen["db_path"] = db_path
|
||||
|
||||
def list_sessions_rich(self, **kwargs):
|
||||
seen["profile"] = True
|
||||
return [
|
||||
{
|
||||
"id": "ml-1",
|
||||
"source": "tui",
|
||||
"title": "M",
|
||||
"preview": "",
|
||||
"started_at": 1,
|
||||
"message_count": 1,
|
||||
}
|
||||
]
|
||||
|
||||
def close(self):
|
||||
seen["closed"] = True
|
||||
|
||||
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.list",
|
||||
"params": {"profile": "mlperf", "limit": 5},
|
||||
}
|
||||
)
|
||||
assert "result" in resp, resp
|
||||
assert resp["result"]["sessions"][0]["id"] == "ml-1"
|
||||
assert seen.get("profile") is True
|
||||
assert seen.get("launch") is None
|
||||
assert str(seen.get("db_path")).endswith("state.db")
|
||||
assert seen.get("closed") is True
|
||||
|
||||
|
||||
def test_session_most_recent_honors_params_profile(monkeypatch, tmp_path):
|
||||
"""Issue #62503: session.most_recent must not return the launch profile tip."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
|
||||
class LaunchDB:
|
||||
def list_sessions_rich(self, **kwargs):
|
||||
return [{"id": "launch-tip", "source": "tui", "title": "L", "started_at": 9}]
|
||||
|
||||
class ProfileDB2:
|
||||
def __init__(self, db_path=None):
|
||||
self.db_path = db_path
|
||||
|
||||
def list_sessions_rich(self, **kwargs):
|
||||
return [
|
||||
{"id": "tool-noise", "source": "tool", "title": "t", "started_at": 9},
|
||||
{"id": "ml-tip", "source": "desktop", "title": "M", "started_at": 3},
|
||||
]
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.SessionDB", ProfileDB2)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.most_recent",
|
||||
"params": {"profile": "mlperf"},
|
||||
}
|
||||
)
|
||||
assert resp["result"]["session_id"] == "ml-tip"
|
||||
|
||||
|
||||
def test_session_create_reports_requested_profile_name(monkeypatch, tmp_path):
|
||||
"""Issue #62503: session.create info.profile_name must not always be launch."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
|
||||
def _clear():
|
||||
for session in list(server._sessions.values()):
|
||||
server._teardown_session(session)
|
||||
server._sessions.clear()
|
||||
|
||||
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
||||
monkeypatch.setattr(server, "_schedule_agent_build", lambda *a, **k: None)
|
||||
monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda *a, **k: None)
|
||||
monkeypatch.setattr(server, "_completion_cwd", lambda params=None: str(tmp_path))
|
||||
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
|
||||
monkeypatch.setattr(server, "_current_profile_name", lambda: "default")
|
||||
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))
|
||||
_clear()
|
||||
try:
|
||||
resp = server._methods["session.create"]("r1", {"profile": "mlperf", "cols": 80})
|
||||
assert "result" in resp, resp
|
||||
assert resp["result"]["info"]["profile_name"] == "mlperf"
|
||||
sid = resp["result"]["session_id"]
|
||||
assert server._sessions[sid]["profile_home"] == str(profile_home)
|
||||
finally:
|
||||
_clear()
|
||||
|
||||
|
||||
def test_session_delete_honors_params_profile_sessions_dir(monkeypatch, tmp_path):
|
||||
"""Issue #62503: delete must target the profile state.db + sessions dir."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
(profile_home / "sessions").mkdir(parents=True)
|
||||
captured: dict = {}
|
||||
|
||||
class ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
captured["db_path"] = db_path
|
||||
|
||||
def delete_session(self, sid, sessions_dir=None):
|
||||
captured["sid"] = sid
|
||||
captured["sessions_dir"] = sessions_dir
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
captured["closed"] = True
|
||||
|
||||
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.delete",
|
||||
"params": {"session_id": "old-ml", "profile": "mlperf"},
|
||||
}
|
||||
)
|
||||
assert "result" in resp, resp
|
||||
assert resp["result"] == {"deleted": "old-ml"}
|
||||
assert str(captured["db_path"]).endswith("state.db")
|
||||
assert Path(captured["sessions_dir"]) == profile_home / "sessions"
|
||||
assert captured.get("closed") is True
|
||||
|
||||
|
||||
def test_session_title_uses_session_profile_db_not_launch(monkeypatch, tmp_path):
|
||||
"""session.title on a non-launch profile session must not touch launch DB."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
seen: dict = {}
|
||||
|
||||
class LaunchDB:
|
||||
def get_session_title(self, _key):
|
||||
seen["launch_read"] = True
|
||||
return "from-launch"
|
||||
|
||||
def set_session_title(self, _key, _title):
|
||||
seen["launch_write"] = True
|
||||
return True
|
||||
|
||||
def get_session(self, _key):
|
||||
return {"id": _key, "title": "from-launch"}
|
||||
|
||||
class ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
self.db_path = db_path
|
||||
seen["db_path"] = db_path
|
||||
|
||||
def get_session_title(self, _key):
|
||||
return seen.get("title")
|
||||
|
||||
def get_session(self, _key):
|
||||
if "title" in seen:
|
||||
return {"id": _key, "title": seen["title"]}
|
||||
return None
|
||||
|
||||
def set_session_title(self, _key, title):
|
||||
seen["title"] = title
|
||||
seen["profile_write"] = True
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
seen["closed"] = True
|
||||
|
||||
server._sessions["sid"] = {
|
||||
"session_key": "ml-sess",
|
||||
"history": [],
|
||||
"history_lock": __import__("threading").Lock(),
|
||||
"running": False,
|
||||
"pending_title": None,
|
||||
"profile_home": str(profile_home),
|
||||
"agent": None,
|
||||
"created_at": 1.0,
|
||||
"last_active": 1.0,
|
||||
}
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
|
||||
try:
|
||||
set_resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.title",
|
||||
"params": {"session_id": "sid", "title": "profile-title"},
|
||||
}
|
||||
)
|
||||
assert "result" in set_resp, set_resp
|
||||
assert set_resp["result"]["title"] == "profile-title"
|
||||
assert seen.get("profile_write") is True
|
||||
assert seen.get("launch_write") is None
|
||||
assert str(seen.get("db_path")).endswith("state.db")
|
||||
|
||||
get_resp = server.handle_request(
|
||||
{"id": "2", "method": "session.title", "params": {"session_id": "sid"}}
|
||||
)
|
||||
assert get_resp["result"]["title"] == "profile-title"
|
||||
assert seen.get("launch_read") is None
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_history_uses_session_profile_db(monkeypatch, tmp_path):
|
||||
"""session.history must read durable messages from the profile state.db."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
seen: dict = {}
|
||||
|
||||
class LaunchDB:
|
||||
def get_messages_as_conversation(self, _key, include_ancestors=True):
|
||||
seen["launch"] = True
|
||||
return [{"role": "user", "content": "launch"}]
|
||||
|
||||
class ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
seen["db_path"] = db_path
|
||||
|
||||
def get_messages_as_conversation(self, _key, include_ancestors=True):
|
||||
seen["profile"] = True
|
||||
return [{"role": "user", "content": "from-profile"}]
|
||||
|
||||
def close(self):
|
||||
seen["closed"] = True
|
||||
|
||||
server._sessions["sid"] = {
|
||||
"session_key": "ml-sess",
|
||||
"history": [{"role": "user", "content": "mem"}],
|
||||
"history_lock": __import__("threading").Lock(),
|
||||
"running": False,
|
||||
"profile_home": str(profile_home),
|
||||
"agent": None,
|
||||
"created_at": 1.0,
|
||||
"last_active": 1.0,
|
||||
}
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.history", "params": {"session_id": "sid"}}
|
||||
)
|
||||
assert "result" in resp, resp
|
||||
assert seen.get("profile") is True
|
||||
assert seen.get("launch") is None
|
||||
# Count comes from profile-backed conversation (1 msg), not bare mem list alone.
|
||||
assert resp["result"]["count"] == 1
|
||||
texts = []
|
||||
for m in resp["result"]["messages"]:
|
||||
texts.append(str(m))
|
||||
assert any("from-profile" in t for t in texts) or resp["result"]["count"] == 1
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_status_uses_session_profile_db(monkeypatch, tmp_path):
|
||||
"""session.status must load meta from the session profile state.db."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
seen: dict = {}
|
||||
|
||||
class LaunchDB:
|
||||
def get_session(self, _key):
|
||||
seen["launch"] = True
|
||||
return {"id": _key, "title": "launch-title", "started_at": 1}
|
||||
|
||||
class ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
seen["db_path"] = db_path
|
||||
|
||||
def get_session(self, _key):
|
||||
seen["profile"] = True
|
||||
return {"id": _key, "title": "profile-title", "started_at": 42}
|
||||
|
||||
def close(self):
|
||||
seen["closed"] = True
|
||||
|
||||
server._sessions["sid"] = {
|
||||
"session_key": "ml-sess",
|
||||
"history": [],
|
||||
"history_lock": __import__("threading").Lock(),
|
||||
"running": False,
|
||||
"profile_home": str(profile_home),
|
||||
"agent": None,
|
||||
"created_at": 1.0,
|
||||
"last_active": 1.0,
|
||||
}
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.status", "params": {"session_id": "sid"}}
|
||||
)
|
||||
assert "result" in resp, resp
|
||||
assert "profile-title" in resp["result"]["output"]
|
||||
assert seen.get("profile") is True
|
||||
assert seen.get("launch") is None
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_teardown_ends_session_in_profile_db(monkeypatch, tmp_path):
|
||||
"""_teardown_session must end_session on the profile store, not launch."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
seen: dict = {}
|
||||
|
||||
class LaunchDB:
|
||||
def get_session(self, _key):
|
||||
seen["launch"] = True
|
||||
return {"id": _key, "source": "tui"}
|
||||
|
||||
def end_session(self, _key, _reason):
|
||||
seen["launch_end"] = True
|
||||
|
||||
class ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
seen["db_path"] = db_path
|
||||
|
||||
def get_session(self, _key):
|
||||
seen["profile"] = True
|
||||
return {"id": _key, "source": "tui"}
|
||||
|
||||
def end_session(self, key, reason):
|
||||
seen["ended"] = (key, reason)
|
||||
|
||||
def close(self):
|
||||
seen["closed"] = True
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
|
||||
session = {
|
||||
"session_key": "ml-sess",
|
||||
"profile_home": str(profile_home),
|
||||
"agent": None,
|
||||
"history": [],
|
||||
"source": "tui",
|
||||
}
|
||||
server._teardown_session(session, end_reason="closed")
|
||||
assert seen.get("ended") == ("ml-sess", "closed")
|
||||
assert seen.get("launch_end") is None
|
||||
assert seen.get("launch") is None
|
||||
assert str(seen.get("db_path")).endswith("state.db")
|
||||
|
||||
|
||||
def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path):
|
||||
"""session.branch must copy history into the parent's profile state.db."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
seen: dict = {"msgs": []}
|
||||
|
||||
class LaunchDB:
|
||||
def get_session_title(self, _key):
|
||||
seen["launch"] = True
|
||||
return "L"
|
||||
|
||||
def create_session(self, *a, **k):
|
||||
seen["launch_create"] = True
|
||||
|
||||
def append_message(self, **k):
|
||||
seen["launch_msg"] = True
|
||||
|
||||
def set_session_title(self, *a, **k):
|
||||
return True
|
||||
|
||||
class ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
seen["db_path"] = db_path
|
||||
seen.setdefault("inits", 0)
|
||||
seen["inits"] += 1
|
||||
|
||||
def get_session_title(self, _key):
|
||||
return "parent"
|
||||
|
||||
def get_next_title_in_lineage(self, current):
|
||||
return f"{current} (branch)"
|
||||
|
||||
def create_session(self, new_key, **kwargs):
|
||||
seen["created"] = new_key
|
||||
seen["parent"] = kwargs.get("parent_session_id")
|
||||
seen["profile_name"] = kwargs.get("profile_name")
|
||||
|
||||
def append_message(self, **kwargs):
|
||||
seen["msgs"].append(kwargs)
|
||||
|
||||
def set_session_title(self, key, title):
|
||||
seen["title"] = (key, title)
|
||||
return True
|
||||
|
||||
def get_session(self, key):
|
||||
return {"id": key, "cwd": str(tmp_path)}
|
||||
|
||||
def update_session_cwd(self, *a, **k):
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
seen["closed"] = True
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self):
|
||||
self.model = "test-model"
|
||||
self.session_id = None
|
||||
|
||||
parent = {
|
||||
"session_key": "parent-key",
|
||||
"history": [{"role": "user", "content": "hi"}],
|
||||
"history_lock": __import__("threading").Lock(),
|
||||
"running": False,
|
||||
"cols": 80,
|
||||
"profile_home": str(profile_home),
|
||||
"source": "tui",
|
||||
"agent": FakeAgent(),
|
||||
"created_at": 1.0,
|
||||
"last_active": 1.0,
|
||||
"cwd": str(tmp_path),
|
||||
}
|
||||
server._sessions["parent"] = parent
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
|
||||
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))
|
||||
|
||||
def _fake_make_agent(*a, **k):
|
||||
seen["agent_session_db"] = k.get("session_db")
|
||||
return FakeAgent()
|
||||
|
||||
monkeypatch.setattr(server, "_make_agent", _fake_make_agent)
|
||||
monkeypatch.setattr(server, "_set_session_context", lambda *a, **k: {})
|
||||
monkeypatch.setattr(server, "_clear_session_context", lambda *a, **k: None)
|
||||
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
||||
monkeypatch.setattr(server, "_session_cwd", lambda s: str(tmp_path))
|
||||
monkeypatch.setattr(server, "_register_session_cwd", lambda *a, **k: None)
|
||||
monkeypatch.setattr(server, "_attach_worker", lambda *a, **k: None)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.branch",
|
||||
"params": {"session_id": "parent", "name": "forked"},
|
||||
}
|
||||
)
|
||||
assert "result" in resp, resp
|
||||
assert seen.get("created")
|
||||
assert seen.get("parent") == "parent-key"
|
||||
# The branch row is self-describing: stamped with the parent's owning
|
||||
# profile, not left NULL for aggregators to mis-tag as "default".
|
||||
assert seen.get("profile_name") == "mlperf"
|
||||
assert seen.get("title") == (seen["created"], "forked")
|
||||
assert len(seen["msgs"]) == 1
|
||||
assert seen.get("launch") is None
|
||||
assert seen.get("launch_create") is None
|
||||
child_sid = resp["result"]["session_id"]
|
||||
assert server._sessions[child_sid]["profile_home"] == str(profile_home)
|
||||
# The branched AGENT must be bound to the parent profile's state.db —
|
||||
# not just the row. Otherwise its own flushes (and a later compression
|
||||
# rotation) land on the launch db, splitting the lineage again.
|
||||
assert isinstance(seen.get("agent_session_db"), ProfileDB)
|
||||
finally:
|
||||
for k in list(server._sessions):
|
||||
server._sessions.pop(k, None)
|
||||
|
||||
|
||||
def test_pending_title_finalizer_uses_session_profile_db(monkeypatch, tmp_path):
|
||||
"""Post-turn pending_title must land in the session profile store."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
seen: dict = {}
|
||||
|
||||
class LaunchDB:
|
||||
def set_session_title(self, _key, _title):
|
||||
seen["launch"] = True
|
||||
return True
|
||||
|
||||
class ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
seen["db_path"] = db_path
|
||||
|
||||
def set_session_title(self, key, title):
|
||||
seen["set"] = (key, title)
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
seen["closed"] = True
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.SessionDB", ProfileDB)
|
||||
session = {
|
||||
"session_key": "ml-sess",
|
||||
"pending_title": "deferred-title",
|
||||
"profile_home": str(profile_home),
|
||||
"history": [],
|
||||
}
|
||||
# Exercise the same close pattern as the post-turn finalizer.
|
||||
with server._session_db(session) as db:
|
||||
assert db is not None
|
||||
assert db.set_session_title(session["session_key"], session["pending_title"])
|
||||
session["pending_title"] = None
|
||||
assert seen.get("set") == ("ml-sess", "deferred-title")
|
||||
assert seen.get("launch") is None
|
||||
assert session["pending_title"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# model.options — curated-list parity with `hermes model` and classic /model
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -673,20 +673,22 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
|
|||
_tui_owns_lifecycle = True
|
||||
if session_id:
|
||||
try:
|
||||
db = _get_db()
|
||||
if db is not None:
|
||||
# Don't end gateway-originated sessions — the gateway owns
|
||||
# their lifecycle. The TUI is a viewer, not the owner.
|
||||
# Ending a gateway session in state.db triggers a Groundhog
|
||||
# Day routing loop: the gateway's #54878 self-heal detects
|
||||
# the stale entry, recovers to the parent session, context
|
||||
# compression splits back to the reaped child, and the cycle
|
||||
# repeats on every inbound message. (#60609)
|
||||
row = db.get_session(session_id)
|
||||
source = (row or {}).get("source", "")
|
||||
_tui_owns_lifecycle = not _is_gateway_owned_source(source)
|
||||
if _tui_owns_lifecycle:
|
||||
db.end_session(session_id, end_reason)
|
||||
# End the row in the *session's* profile state.db (app-global
|
||||
# remote mode), not the launch profile's shared handle.
|
||||
with _session_db(session) as db:
|
||||
if db is not None:
|
||||
# Don't end gateway-originated sessions — the gateway owns
|
||||
# their lifecycle. The TUI is a viewer, not the owner.
|
||||
# Ending a gateway session in state.db triggers a Groundhog
|
||||
# Day routing loop: the gateway's #54878 self-heal detects
|
||||
# the stale entry, recovers to the parent session, context
|
||||
# compression splits back to the reaped child, and the cycle
|
||||
# repeats on every inbound message. (#60609)
|
||||
row = db.get_session(session_id)
|
||||
source = (row or {}).get("source", "")
|
||||
_tui_owns_lifecycle = not _is_gateway_owned_source(source)
|
||||
if _tui_owns_lifecycle:
|
||||
db.end_session(session_id, end_reason)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -1062,6 +1064,63 @@ def _get_db():
|
|||
return _db
|
||||
|
||||
|
||||
def _db_for_profile(profile: str | None = None):
|
||||
"""Return SessionDB for ``params.profile`` when it differs from launch.
|
||||
|
||||
App-global remote mode passes ``profile`` on session.* RPCs so history/list/
|
||||
create operate on that profile's ``state.db``. Launch/own profile → shared
|
||||
``_get_db()`` handle (left open). Non-launch profile → a dedicated handle
|
||||
the caller should ``close()`` (see :func:`_profile_db` contextmanager).
|
||||
|
||||
Returns (db, owns_handle). ``db`` is None when unavailable.
|
||||
"""
|
||||
profile_home = _profile_home(profile)
|
||||
if profile_home is None:
|
||||
return _get_db(), False
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
return SessionDB(db_path=Path(profile_home) / "state.db"), True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"TUI profile session store unavailable for %s: %s",
|
||||
profile,
|
||||
exc,
|
||||
)
|
||||
return None, False
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _profile_db(params: dict | None = None):
|
||||
"""Yield the SessionDB for ``params['profile']`` (app-global remote mode).
|
||||
|
||||
Closes dedicated profile handles; leaves the launch-profile shared handle open.
|
||||
Yields None when the db is unavailable.
|
||||
"""
|
||||
profile = None
|
||||
if isinstance(params, dict):
|
||||
profile = (params.get("profile") or "").strip() or None
|
||||
db, owns = _db_for_profile(profile)
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
if owns and db is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
db.close()
|
||||
|
||||
|
||||
def _response_profile_name(profile: str | None = None) -> str:
|
||||
"""Profile name to report on session.* payloads.
|
||||
|
||||
Prefer the RPC's requested profile when it is a real non-launch profile;
|
||||
otherwise the process launch profile.
|
||||
"""
|
||||
name = (profile or "").strip()
|
||||
if name and _profile_home(name) is not None:
|
||||
return name
|
||||
return _current_profile_name()
|
||||
|
||||
|
||||
def _db_unavailable_error(rid, *, code: int):
|
||||
detail = _db_error or "state.db unavailable"
|
||||
return _err(rid, code, f"state.db unavailable: {detail}")
|
||||
|
|
@ -2085,6 +2144,10 @@ def _ensure_session_db_row(session: dict) -> None:
|
|||
model_config=model_config or None,
|
||||
parent_session_id=parent_session_id,
|
||||
cwd=_session_cwd(session) if session.get("explicit_cwd") else None,
|
||||
# Self-describing rows: aggregators that merge multiple profile DBs
|
||||
# into one list can't rely on which file a row came from alone. NULL
|
||||
# means the launch/default profile (matches run_agent's convention).
|
||||
profile_name=Path(profile_home).name if profile_home else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("failed to persist desktop session row", exc_info=True)
|
||||
|
|
@ -4108,7 +4171,13 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
|||
"update_behind": None,
|
||||
"update_command": "",
|
||||
"usage": _session_usage_snapshot(session),
|
||||
"profile_name": _current_profile_name(),
|
||||
"profile_name": _response_profile_name(
|
||||
Path(session["profile_home"]).name
|
||||
if isinstance(session, dict) and session.get("profile_home")
|
||||
else None
|
||||
)
|
||||
if isinstance(session, dict) and session.get("profile_home")
|
||||
else _current_profile_name(),
|
||||
}
|
||||
try:
|
||||
from hermes_cli import __version__, __release_date__
|
||||
|
|
@ -5473,6 +5542,7 @@ def _init_session(
|
|||
cwd: str | None = None,
|
||||
session_db=None,
|
||||
source: str | None = None,
|
||||
profile_home: str | None = None,
|
||||
):
|
||||
now = time.time()
|
||||
with _sessions_lock:
|
||||
|
|
@ -5496,6 +5566,10 @@ def _init_session(
|
|||
"tool_progress_mode": _load_tool_progress_mode(),
|
||||
"edit_snapshots": {},
|
||||
"tool_started_at": {},
|
||||
# Profile-scoped HERMES_HOME for app-global remote mode; None =
|
||||
# launch profile. SessionBranch copies the parent's value so the
|
||||
# child stays on the same state.db.
|
||||
"profile_home": profile_home,
|
||||
# Per-session model override set by an in-session /model switch.
|
||||
# Honored on rebuild (/new, resume) so a switch in THIS session
|
||||
# never leaks into siblings via process-global env vars.
|
||||
|
|
@ -5504,21 +5578,43 @@ def _init_session(
|
|||
# session (stdio for Ink, JSON-RPC WS for the dashboard sidebar).
|
||||
"transport": current_transport() or _stdio_transport,
|
||||
}
|
||||
db = session_db if session_db is not None else _get_db()
|
||||
if db is not None:
|
||||
row = db.get_session(key)
|
||||
if row and row.get("cwd"):
|
||||
with _sessions_lock:
|
||||
if sid in _sessions:
|
||||
_sessions[sid]["cwd"] = row["cwd"]
|
||||
else:
|
||||
_init_owns_db = False
|
||||
if session_db is not None:
|
||||
db = session_db
|
||||
elif profile_home:
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path=Path(profile_home) / "state.db")
|
||||
_init_owns_db = True
|
||||
except Exception:
|
||||
db = _get_db()
|
||||
else:
|
||||
db = _get_db()
|
||||
try:
|
||||
if db is not None:
|
||||
row = db.get_session(key) if hasattr(db, "get_session") else None
|
||||
if row and row.get("cwd"):
|
||||
with _sessions_lock:
|
||||
if sid in _sessions:
|
||||
_sessions[sid]["cwd"] = row["cwd"]
|
||||
else:
|
||||
try:
|
||||
_cwd = _sessions[sid]["cwd"]
|
||||
if hasattr(db, "update_session_cwd"):
|
||||
db.update_session_cwd(key, _cwd)
|
||||
# git branch/root probes run off the hot path (see _set_session_cwd).
|
||||
_persist_session_git_meta(_sessions[sid], _cwd)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"failed to persist resumed session cwd", exc_info=True
|
||||
)
|
||||
finally:
|
||||
if _init_owns_db and db is not None:
|
||||
try:
|
||||
_cwd = _sessions[sid]["cwd"]
|
||||
db.update_session_cwd(key, _cwd)
|
||||
# git branch/root probes run off the hot path (see _set_session_cwd).
|
||||
_persist_session_git_meta(_sessions[sid], _cwd)
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("failed to persist resumed session cwd", exc_info=True)
|
||||
pass
|
||||
_register_session_cwd(_sessions[sid])
|
||||
try:
|
||||
_attach_worker(
|
||||
|
|
@ -6301,7 +6397,7 @@ def _(rid, params: dict) -> dict:
|
|||
"project": _project_info_for_cwd(_sessions[sid]["cwd"]),
|
||||
"lazy": True,
|
||||
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
|
||||
"profile_name": _current_profile_name(),
|
||||
"profile_name": _response_profile_name(profile),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -6309,48 +6405,53 @@ def _(rid, params: dict) -> dict:
|
|||
|
||||
@method("session.list")
|
||||
def _(rid, params: dict) -> dict:
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5006)
|
||||
try:
|
||||
# Resume picker should surface human conversation sessions from every
|
||||
# user-facing surface — CLI, TUI, all gateway platforms (including new
|
||||
# ones not enumerated here), ACP adapter clients, webhook sessions,
|
||||
# custom `HERMES_SESSION_SOURCE` values, and older installs with
|
||||
# different source labels. We deny-list only the noisy internal
|
||||
# sources (``tool`` sub-agent runs) rather than allow-listing a
|
||||
# fixed set of platform names that goes stale whenever a new
|
||||
# platform is added or a user names their own source.
|
||||
deny = frozenset({"tool"})
|
||||
with _profile_db(params) as db:
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5006)
|
||||
try:
|
||||
# Resume picker should surface human conversation sessions from every
|
||||
# user-facing surface — CLI, TUI, all gateway platforms (including new
|
||||
# ones not enumerated here), ACP adapter clients, webhook sessions,
|
||||
# custom `HERMES_SESSION_SOURCE` values, and older installs with
|
||||
# different source labels. We deny-list only the noisy internal
|
||||
# sources (``tool`` sub-agent runs) rather than allow-listing a
|
||||
# fixed set of platform names that goes stale whenever a new
|
||||
# platform is added or a user names their own source.
|
||||
deny = frozenset({"tool"})
|
||||
|
||||
limit = int(params.get("limit", 200) or 200)
|
||||
# Over-fetch modestly so per-source filtering doesn't leave us
|
||||
# short; the compression-tip projection in ``list_sessions_rich``
|
||||
# can also merge rows.
|
||||
fetch_limit = max(limit * 2, 200)
|
||||
rows = [
|
||||
s
|
||||
for s in db.list_sessions_rich(source=None, limit=fetch_limit, order_by_last_active=True, compact_rows=True)
|
||||
if (s.get("source") or "").strip().lower() not in deny
|
||||
][:limit]
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": s["id"],
|
||||
"title": s.get("title") or "",
|
||||
"preview": s.get("preview") or "",
|
||||
"started_at": s.get("started_at") or 0,
|
||||
"message_count": s.get("message_count") or 0,
|
||||
"source": s.get("source") or "",
|
||||
}
|
||||
for s in rows
|
||||
]
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5006, str(e))
|
||||
limit = int(params.get("limit", 200) or 200)
|
||||
# Over-fetch modestly so per-source filtering doesn't leave us
|
||||
# short; the compression-tip projection in ``list_sessions_rich``
|
||||
# can also merge rows.
|
||||
fetch_limit = max(limit * 2, 200)
|
||||
rows = [
|
||||
s
|
||||
for s in db.list_sessions_rich(
|
||||
source=None,
|
||||
limit=fetch_limit,
|
||||
order_by_last_active=True,
|
||||
compact_rows=True,
|
||||
)
|
||||
if (s.get("source") or "").strip().lower() not in deny
|
||||
][:limit]
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": s["id"],
|
||||
"title": s.get("title") or "",
|
||||
"preview": s.get("preview") or "",
|
||||
"started_at": s.get("started_at") or 0,
|
||||
"message_count": s.get("message_count") or 0,
|
||||
"source": s.get("source") or "",
|
||||
}
|
||||
for s in rows
|
||||
]
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5006, str(e))
|
||||
|
||||
|
||||
@method("session.most_recent")
|
||||
|
|
@ -6367,34 +6468,39 @@ def _(rid, params: dict) -> dict:
|
|||
session found right now". Errors are also folded into that
|
||||
null-result shape (and logged) so callers don't have to special-
|
||||
case JSON-RPC error envelopes for what is a normal "no answer".
|
||||
|
||||
Honors ``params.profile`` so app-global remote mode lists from the
|
||||
focused profile's ``state.db`` (mirrors ``session.resume``).
|
||||
"""
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _ok(rid, {"session_id": None})
|
||||
try:
|
||||
deny = frozenset({"tool"})
|
||||
# Over-fetch by a generous bounded amount so heavy sub-agent
|
||||
# users (lots of recent ``tool`` rows) don't get a false
|
||||
# "no eligible session" answer. ``session.list`` uses a
|
||||
# similar over-fetch strategy.
|
||||
rows = db.list_sessions_rich(source=None, limit=200, order_by_last_active=True, compact_rows=True)
|
||||
for row in rows:
|
||||
src = (row.get("source") or "").strip().lower()
|
||||
if src in deny:
|
||||
continue
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"session_id": row.get("id"),
|
||||
"title": row.get("title") or "",
|
||||
"started_at": row.get("started_at") or 0,
|
||||
"source": row.get("source") or "",
|
||||
},
|
||||
with _profile_db(params) as db:
|
||||
if db is None:
|
||||
return _ok(rid, {"session_id": None})
|
||||
try:
|
||||
deny = frozenset({"tool"})
|
||||
# Over-fetch by a generous bounded amount so heavy sub-agent
|
||||
# users (lots of recent ``tool`` rows) don't get a false
|
||||
# "no eligible session" answer. ``session.list`` uses a
|
||||
# similar over-fetch strategy.
|
||||
rows = db.list_sessions_rich(
|
||||
source=None, limit=200, order_by_last_active=True, compact_rows=True
|
||||
)
|
||||
return _ok(rid, {"session_id": None})
|
||||
except Exception:
|
||||
logger.exception("session.most_recent failed")
|
||||
return _ok(rid, {"session_id": None})
|
||||
for row in rows:
|
||||
src = (row.get("source") or "").strip().lower()
|
||||
if src in deny:
|
||||
continue
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"session_id": row.get("id"),
|
||||
"title": row.get("title") or "",
|
||||
"started_at": row.get("started_at") or 0,
|
||||
"source": row.get("source") or "",
|
||||
},
|
||||
)
|
||||
return _ok(rid, {"session_id": None})
|
||||
except Exception:
|
||||
logger.exception("session.most_recent failed")
|
||||
return _ok(rid, {"session_id": None})
|
||||
|
||||
|
||||
@method("project.facts")
|
||||
|
|
@ -6439,7 +6545,13 @@ def _(rid, params: dict) -> dict:
|
|||
return _ok(rid, {"verification": {"status": "unknown", "evidence": None}})
|
||||
|
||||
|
||||
def _lazy_resume_info(cwd: str, *, model: str = "", provider: str = "") -> dict:
|
||||
def _lazy_resume_info(
|
||||
cwd: str,
|
||||
*,
|
||||
model: str = "",
|
||||
provider: str = "",
|
||||
profile: str | None = None,
|
||||
) -> dict:
|
||||
"""session.info for a not-yet-built session (the shape session.create
|
||||
returns). tools/skills land later when the deferred build emits session.info."""
|
||||
info = {
|
||||
|
|
@ -6451,7 +6563,7 @@ def _lazy_resume_info(cwd: str, *, model: str = "", provider: str = "") -> dict:
|
|||
"skills": {},
|
||||
"lazy": True,
|
||||
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
|
||||
"profile_name": _current_profile_name(),
|
||||
"profile_name": _response_profile_name(profile),
|
||||
}
|
||||
if provider:
|
||||
info["provider"] = provider
|
||||
|
|
@ -6703,7 +6815,7 @@ def _(rid, params: dict) -> dict:
|
|||
"resumed": target,
|
||||
"message_count": len(messages),
|
||||
"messages": messages,
|
||||
"info": _lazy_resume_info(cwd),
|
||||
"info": _lazy_resume_info(cwd, profile=profile),
|
||||
"inflight": None,
|
||||
"running": child_running,
|
||||
"session_key": target,
|
||||
|
|
@ -6790,6 +6902,7 @@ def _(rid, params: dict) -> dict:
|
|||
cwd,
|
||||
model=model_override.get("model") or "",
|
||||
provider=overrides.get("provider_override") or "",
|
||||
profile=profile,
|
||||
),
|
||||
"inflight": None,
|
||||
"running": False,
|
||||
|
|
@ -6990,12 +7103,12 @@ def _message_preview(history: list) -> str:
|
|||
|
||||
def _session_live_title(session: dict, key: str) -> str:
|
||||
title = str(session.get("pending_title") or "").strip()
|
||||
db = _get_db()
|
||||
if db is not None:
|
||||
try:
|
||||
title = str(db.get_session_title(key) or title or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with _session_db(session) as db:
|
||||
if db is not None:
|
||||
title = str(db.get_session_title(key) or title or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return title
|
||||
|
||||
|
||||
|
|
@ -7247,13 +7360,13 @@ def _(rid, params: dict) -> dict:
|
|||
still being written to and removing them out from under the live
|
||||
agent corrupts message ordering and trips FK constraints when the
|
||||
next message append flushes.
|
||||
|
||||
Honors ``params.profile`` so app-global remote mode deletes from the
|
||||
focused profile's ``state.db`` + sessions dir (mirrors ``session.resume``).
|
||||
"""
|
||||
target = params.get("session_id", "")
|
||||
if not target:
|
||||
return _err(rid, 4006, "session_id required")
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5036)
|
||||
# Block deletion of any session currently bound to a live TUI session
|
||||
# in this process. The picker hides the active session anyway, but a
|
||||
# racing caller could still target it. Snapshot via ``list(...)``
|
||||
|
|
@ -7269,14 +7382,22 @@ def _(rid, params: dict) -> dict:
|
|||
active = {s.get("session_key") for s in snapshot if s.get("session_key")}
|
||||
if target in active:
|
||||
return _err(rid, 4023, "cannot delete an active session")
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
try:
|
||||
deleted = db.delete_session(target, sessions_dir=sessions_dir)
|
||||
except Exception as e:
|
||||
return _err(rid, 5036, f"delete failed: {e}")
|
||||
if not deleted:
|
||||
return _err(rid, 4007, "session not found")
|
||||
return _ok(rid, {"deleted": target})
|
||||
profile = (params.get("profile") or "").strip() or None
|
||||
profile_home = _profile_home(profile)
|
||||
with _profile_db(params) as db:
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5036)
|
||||
if profile_home is not None:
|
||||
sessions_dir = Path(profile_home) / "sessions"
|
||||
else:
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
try:
|
||||
deleted = db.delete_session(target, sessions_dir=sessions_dir)
|
||||
except Exception as e:
|
||||
return _err(rid, 5036, f"delete failed: {e}")
|
||||
if not deleted:
|
||||
return _err(rid, 4007, "session not found")
|
||||
return _ok(rid, {"deleted": target})
|
||||
|
||||
|
||||
@method("session.title")
|
||||
|
|
@ -7284,83 +7405,84 @@ def _(rid, params: dict) -> dict:
|
|||
session, err = _sess_nowait(params, rid)
|
||||
if err:
|
||||
return err
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5007)
|
||||
key = session["session_key"]
|
||||
if "title" not in params:
|
||||
fallback = session.get("pending_title") or ""
|
||||
try:
|
||||
resolved_title = db.get_session_title(key) or ""
|
||||
if fallback:
|
||||
if db.set_session_title(key, fallback):
|
||||
session["pending_title"] = None
|
||||
resolved_title = fallback
|
||||
else:
|
||||
existing_row = db.get_session(key)
|
||||
existing_title = ((existing_row or {}).get("title") or "").strip()
|
||||
if existing_title == fallback:
|
||||
with _session_db(session) as db:
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5007)
|
||||
key = session["session_key"]
|
||||
if "title" not in params:
|
||||
fallback = session.get("pending_title") or ""
|
||||
try:
|
||||
resolved_title = db.get_session_title(key) or ""
|
||||
if fallback:
|
||||
if db.set_session_title(key, fallback):
|
||||
session["pending_title"] = None
|
||||
resolved_title = fallback
|
||||
elif not resolved_title:
|
||||
resolved_title = fallback
|
||||
elif resolved_title:
|
||||
session["pending_title"] = None
|
||||
except Exception:
|
||||
resolved_title = fallback
|
||||
_emit_session_info_for_session(params.get("session_id", ""), session)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"title": resolved_title,
|
||||
"session_key": key,
|
||||
},
|
||||
)
|
||||
title = (params.get("title", "") or "").strip()
|
||||
if not title:
|
||||
return _err(rid, 4021, "title required")
|
||||
try:
|
||||
if db.set_session_title(key, title):
|
||||
session["pending_title"] = None
|
||||
_emit_session_info_for_session(params.get("session_id", ""), session)
|
||||
return _ok(rid, {"pending": False, "title": title})
|
||||
# rowcount == 0 can mean "same value" as well as "missing row".
|
||||
existing_row = db.get_session(key)
|
||||
if existing_row:
|
||||
session["pending_title"] = None
|
||||
else:
|
||||
existing_row = db.get_session(key)
|
||||
existing_title = ((existing_row or {}).get("title") or "").strip()
|
||||
if existing_title == fallback:
|
||||
session["pending_title"] = None
|
||||
resolved_title = fallback
|
||||
elif not resolved_title:
|
||||
resolved_title = fallback
|
||||
elif resolved_title:
|
||||
session["pending_title"] = None
|
||||
except Exception:
|
||||
resolved_title = fallback
|
||||
_emit_session_info_for_session(params.get("session_id", ""), session)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"pending": False,
|
||||
"title": (existing_row.get("title") or title),
|
||||
"title": resolved_title,
|
||||
"session_key": key,
|
||||
},
|
||||
)
|
||||
# No row yet (the DB write is deferred to the first prompt so empty
|
||||
# drafts don't litter the sidebar). An explicit /title is clear user
|
||||
# intent, not an abandoned draft — so persist the row NOW and set the
|
||||
# title, mirroring the messaging gateway's _handle_title_command. The
|
||||
# old behavior only queued pending_title and relied on the post-turn
|
||||
# apply block; if that turn never landed under this session_key the
|
||||
# title was silently lost and the sidebar fell back to the message
|
||||
# preview. Creating the row up front removes that race entirely. The
|
||||
# min-messages sidebar filter keeps a titled 0-message row hidden, so
|
||||
# a /title'd-but-never-used draft still doesn't clutter the list.
|
||||
_ensure_session_db_row(session)
|
||||
with _session_db(session) as scoped_db:
|
||||
if scoped_db is not None and scoped_db.set_session_title(key, title):
|
||||
title = (params.get("title", "") or "").strip()
|
||||
if not title:
|
||||
return _err(rid, 4021, "title required")
|
||||
try:
|
||||
if db.set_session_title(key, title):
|
||||
session["pending_title"] = None
|
||||
_emit_session_info_for_session(params.get("session_id", ""), session)
|
||||
return _ok(rid, {"pending": False, "title": title})
|
||||
# Row creation didn't take (DB unavailable, or a concurrent writer) —
|
||||
# fall back to queuing so the post-turn apply block can still recover.
|
||||
session["pending_title"] = title
|
||||
_emit_session_info_for_session(params.get("session_id", ""), session)
|
||||
return _ok(rid, {"pending": True, "title": title})
|
||||
except ValueError as e:
|
||||
return _err(rid, 4022, str(e))
|
||||
except Exception as e:
|
||||
return _err(rid, 5007, str(e))
|
||||
# rowcount == 0 can mean "same value" as well as "missing row".
|
||||
existing_row = db.get_session(key)
|
||||
if existing_row:
|
||||
session["pending_title"] = None
|
||||
_emit_session_info_for_session(params.get("session_id", ""), session)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"pending": False,
|
||||
"title": (existing_row.get("title") or title),
|
||||
},
|
||||
)
|
||||
# No row yet (the DB write is deferred to the first prompt so empty
|
||||
# drafts don't litter the sidebar). An explicit /title is clear user
|
||||
# intent, not an abandoned draft — so persist the row NOW and set the
|
||||
# title, mirroring the messaging gateway's _handle_title_command. The
|
||||
# old behavior only queued pending_title and relied on the post-turn
|
||||
# apply block; if that turn never landed under this session_key the
|
||||
# title was silently lost and the sidebar fell back to the message
|
||||
# preview. Creating the row up front removes that race entirely. The
|
||||
# min-messages sidebar filter keeps a titled 0-message row hidden, so
|
||||
# a /title'd-but-never-used draft still doesn't clutter the list.
|
||||
_ensure_session_db_row(session)
|
||||
with _session_db(session) as scoped_db:
|
||||
if scoped_db is not None and scoped_db.set_session_title(key, title):
|
||||
session["pending_title"] = None
|
||||
_emit_session_info_for_session(params.get("session_id", ""), session)
|
||||
return _ok(rid, {"pending": False, "title": title})
|
||||
# Row creation didn't take (DB unavailable, or a concurrent writer) —
|
||||
# fall back to queuing so the post-turn apply block can still recover.
|
||||
session["pending_title"] = title
|
||||
_emit_session_info_for_session(params.get("session_id", ""), session)
|
||||
return _ok(rid, {"pending": True, "title": title})
|
||||
except ValueError as e:
|
||||
return _err(rid, 4022, str(e))
|
||||
except Exception as e:
|
||||
return _err(rid, 5007, str(e))
|
||||
|
||||
|
||||
|
||||
def _main_runtime_from_agent(agent) -> dict | None:
|
||||
|
|
@ -9169,12 +9291,27 @@ def _(rid, params: dict) -> dict:
|
|||
key = session.get("session_key") or params.get("session_id") or ""
|
||||
agent = session.get("agent")
|
||||
meta = {}
|
||||
db = _get_db()
|
||||
if db and key:
|
||||
try:
|
||||
meta = db.get_session(key) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
# Prefer the live session's bound profile db, else params.profile, else launch.
|
||||
status_params = dict(params or {})
|
||||
if not status_params.get("profile") and session.get("profile_home"):
|
||||
# profile_home is a path; still allow _session_db via a synthetic session
|
||||
pass
|
||||
with _session_db(session) as db:
|
||||
if db is None:
|
||||
# Fall back to ~params.profile naming for not-yet-mapped sessions.
|
||||
with _profile_db(params) as db2:
|
||||
db = db2
|
||||
if db and key:
|
||||
try:
|
||||
meta = db.get_session(key) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
db = None # prevent double-use
|
||||
if db is not None and key:
|
||||
try:
|
||||
meta = db.get_session(key) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
|
||||
def _dt(value, fallback: datetime | None = None) -> datetime:
|
||||
if value:
|
||||
|
|
@ -9225,14 +9362,15 @@ def _(rid, params: dict) -> dict:
|
|||
if err:
|
||||
return err
|
||||
history = list(session.get("history", []))
|
||||
db = _get_db()
|
||||
if db is not None and session.get("session_key"):
|
||||
try:
|
||||
history = db.get_messages_as_conversation(
|
||||
session["session_key"], include_ancestors=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if session.get("session_key"):
|
||||
with _session_db(session) as db:
|
||||
if db is not None:
|
||||
try:
|
||||
history = db.get_messages_as_conversation(
|
||||
session["session_key"], include_ancestors=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
|
|
@ -9531,77 +9669,113 @@ def _(rid, params: dict) -> dict:
|
|||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5008)
|
||||
old_key = session["session_key"]
|
||||
with session["history_lock"]:
|
||||
history = [dict(msg) for msg in session.get("history", [])]
|
||||
if not history:
|
||||
return _err(rid, 4008, "nothing to branch — send a message first")
|
||||
new_key = _new_session_key()
|
||||
new_sid = uuid.uuid4().hex[:8]
|
||||
source = _session_source(session)
|
||||
lease, limit_message = _claim_active_session_slot(
|
||||
new_key, live_session_id=new_sid, surface=source
|
||||
)
|
||||
if limit_message is not None:
|
||||
return _err(rid, 4090, limit_message)
|
||||
branch_name = params.get("name", "")
|
||||
try:
|
||||
if branch_name:
|
||||
title = branch_name
|
||||
else:
|
||||
current = db.get_session_title(old_key) or "branch"
|
||||
title = (
|
||||
db.get_next_title_in_lineage(current)
|
||||
if hasattr(db, "get_next_title_in_lineage")
|
||||
else f"{current} (branch)"
|
||||
)
|
||||
db.create_session(
|
||||
new_key,
|
||||
source=source,
|
||||
model=_resolve_model(),
|
||||
# Stable _branched_from marker so list_sessions_rich() keeps the
|
||||
# branch visible in /resume and /sessions. The TUI branch leaves
|
||||
# the parent live (no end_reason='branched'), so the legacy
|
||||
# end_reason heuristic never matches it — the marker is the only
|
||||
# thing that surfaces TUI branches. See issue #20856.
|
||||
model_config={"_branched_from": old_key},
|
||||
parent_session_id=old_key,
|
||||
cwd=_session_cwd(session),
|
||||
# Branch must write into the parent's profile-scoped state.db (app-global
|
||||
# remote mode). Using the launch handle would orphan branch rows + history.
|
||||
with _session_db(session) as db:
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5008)
|
||||
old_key = session["session_key"]
|
||||
with session["history_lock"]:
|
||||
history = [dict(msg) for msg in session.get("history", [])]
|
||||
if not history:
|
||||
return _err(rid, 4008, "nothing to branch — send a message first")
|
||||
new_key = _new_session_key()
|
||||
new_sid = uuid.uuid4().hex[:8]
|
||||
source = _session_source(session)
|
||||
lease, limit_message = _claim_active_session_slot(
|
||||
new_key, live_session_id=new_sid, surface=source
|
||||
)
|
||||
for msg in history:
|
||||
db.append_message(
|
||||
session_id=new_key,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content"),
|
||||
timestamp=msg.get("timestamp"),
|
||||
)
|
||||
db.set_session_title(new_key, title)
|
||||
except Exception as e:
|
||||
if lease is not None:
|
||||
lease.release()
|
||||
return _err(rid, 5008, f"branch failed: {e}")
|
||||
try:
|
||||
tokens = _set_session_context(new_key)
|
||||
if limit_message is not None:
|
||||
return _err(rid, 4090, limit_message)
|
||||
branch_name = params.get("name", "")
|
||||
try:
|
||||
agent = _make_agent(
|
||||
if branch_name:
|
||||
title = branch_name
|
||||
else:
|
||||
current = db.get_session_title(old_key) or "branch"
|
||||
title = (
|
||||
db.get_next_title_in_lineage(current)
|
||||
if hasattr(db, "get_next_title_in_lineage")
|
||||
else f"{current} (branch)"
|
||||
)
|
||||
db.create_session(
|
||||
new_key,
|
||||
source=source,
|
||||
model=_resolve_model(),
|
||||
# Stable _branched_from marker so list_sessions_rich() keeps the
|
||||
# branch visible in /resume and /sessions. The TUI branch leaves
|
||||
# the parent live (no end_reason='branched'), so the legacy
|
||||
# end_reason heuristic never matches it — the marker is the only
|
||||
# thing that surfaces TUI branches. See issue #20856.
|
||||
model_config={"_branched_from": old_key},
|
||||
parent_session_id=old_key,
|
||||
cwd=_session_cwd(session),
|
||||
# The branch stays on its parent's profile. Explicit stamp (not
|
||||
# just the parent-backfill) so it holds even when the parent row
|
||||
# predates the profile_name column.
|
||||
profile_name=(
|
||||
Path(session["profile_home"]).name
|
||||
if session.get("profile_home")
|
||||
else None
|
||||
),
|
||||
)
|
||||
for msg in history:
|
||||
db.append_message(
|
||||
session_id=new_key,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content"),
|
||||
# Preserve the parent's original message timestamps —
|
||||
# branch copies are history, not new activity (9d73006ad).
|
||||
timestamp=msg.get("timestamp"),
|
||||
)
|
||||
db.set_session_title(new_key, title)
|
||||
except Exception as e:
|
||||
if lease is not None:
|
||||
lease.release()
|
||||
return _err(rid, 5008, f"branch failed: {e}")
|
||||
try:
|
||||
# Bind the branched AGENT to the parent's profile, mirroring
|
||||
# session.create/resume: home override so config/skills/memory resolve
|
||||
# to the profile during the build, and the profile's own state.db
|
||||
# handle so the live agent's message flushes — and any later
|
||||
# compression rotation — persist there. Writing only the row to the
|
||||
# parent's db while the agent stayed on the launch handle would
|
||||
# recreate the cross-profile split one turn later.
|
||||
parent_home = session.get("profile_home")
|
||||
branch_db = None
|
||||
if parent_home:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
branch_db = SessionDB(db_path=Path(parent_home) / "state.db")
|
||||
home_token = (
|
||||
set_hermes_home_override(parent_home) if parent_home else None
|
||||
)
|
||||
try:
|
||||
tokens = _set_session_context(new_key)
|
||||
try:
|
||||
agent = _make_agent(
|
||||
new_sid,
|
||||
new_key,
|
||||
session_id=new_key,
|
||||
session_db=branch_db,
|
||||
platform_override=source,
|
||||
)
|
||||
finally:
|
||||
_clear_session_context(tokens)
|
||||
_init_session(
|
||||
new_sid,
|
||||
new_key,
|
||||
session_id=new_key,
|
||||
platform_override=source,
|
||||
agent,
|
||||
list(history),
|
||||
cols=session.get("cols", 80),
|
||||
cwd=_session_cwd(session),
|
||||
session_db=branch_db,
|
||||
source=source,
|
||||
profile_home=parent_home,
|
||||
)
|
||||
finally:
|
||||
_clear_session_context(tokens)
|
||||
_init_session(
|
||||
new_sid,
|
||||
new_key,
|
||||
agent,
|
||||
list(history),
|
||||
cols=session.get("cols", 80),
|
||||
source=source,
|
||||
)
|
||||
if home_token is not None:
|
||||
reset_hermes_home_override(home_token)
|
||||
if new_sid in _sessions:
|
||||
_sessions[new_sid]["active_session_lease"] = lease
|
||||
except Exception as e:
|
||||
|
|
@ -10984,26 +11158,26 @@ def _run_prompt_submit(
|
|||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Apply pending_title now that the DB row exists.
|
||||
# Apply pending_title now that the DB row exists — in the
|
||||
# session-owned profile store (not the launch profile).
|
||||
_pending = session.get("pending_title")
|
||||
if _pending and status == "complete":
|
||||
_pdb = _get_db()
|
||||
if _pdb:
|
||||
_session_key = session.get("session_key") or sid
|
||||
try:
|
||||
if _pdb.set_session_title(_session_key, _pending):
|
||||
_session_key = session.get("session_key") or sid
|
||||
try:
|
||||
with _session_db(session) as _pdb:
|
||||
if _pdb and _pdb.set_session_title(_session_key, _pending):
|
||||
session["pending_title"] = None
|
||||
except ValueError as exc:
|
||||
# Invalid/duplicate title — non-retryable, drop it.
|
||||
# Auto-title will take over. Fix for #19029.
|
||||
session["pending_title"] = None
|
||||
logger.info(
|
||||
"Dropping pending title for session %s: %s",
|
||||
_session_key, exc,
|
||||
)
|
||||
except Exception:
|
||||
# Transient DB failure — keep pending_title for retry.
|
||||
pass
|
||||
except ValueError as exc:
|
||||
# Invalid/duplicate title — non-retryable, drop it.
|
||||
# Auto-title will take over. Fix for #19029.
|
||||
session["pending_title"] = None
|
||||
logger.info(
|
||||
"Dropping pending title for session %s: %s",
|
||||
_session_key, exc,
|
||||
)
|
||||
except Exception:
|
||||
# Transient DB failure — keep pending_title for retry.
|
||||
pass
|
||||
|
||||
if (
|
||||
status == "complete"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue