From 7ff56b96de5180b491481cd01bb0c98d12680447 Mon Sep 17 00:00:00 2001 From: Hari Srinivasan Date: Tue, 23 Jun 2026 00:58:59 +0000 Subject: [PATCH] fix(sessions): resolve agent version lookup --- observal-server/api/routes/sessions.py | 19 ++++++- observal_cli/sessions/base.py | 38 +++++++++----- tests/test_resolve_agent_version.py | 71 +++++++++++++++++++++++--- web/src/lib/types/sessions.ts | 4 ++ web/src/pages/user/traces/detail.tsx | 21 ++++++++ web/src/pages/user/traces/index.tsx | 3 +- 6 files changed, 134 insertions(+), 22 deletions(-) diff --git a/observal-server/api/routes/sessions.py b/observal-server/api/routes/sessions.py index 91fbbd49d..d49b30c4f 100644 --- a/observal-server/api/routes/sessions.py +++ b/observal-server/api/routes/sessions.py @@ -173,6 +173,7 @@ async def list_sessions( agent_id = row.get("agent_id") or None row["agent_id"] = agent_id if agent_id else None row["agent_name"] = agent_id_to_name.get(agent_id) if agent_id else None + row["agent_version"] = row.get("agent_version") or None if status == "active": rows = [r for r in rows if r["is_active"]] @@ -228,6 +229,7 @@ async def _list_sessions_query( "model, " "harness, " "agent_id, " + "agent_version, " "user_id " "FROM session_stats_agg FINAL " + where_clause + "ORDER BY last_event_time DESC " f"LIMIT {int(limit)} OFFSET {int(offset)}", @@ -335,7 +337,7 @@ async def get_session( _main_sql = ( "SELECT " "line_offset, timestamp, event_type, content_preview, tool_name, tool_id, " - "uuid, parent_uuid, content_length, harness, raw_line, raw_line_truncated, " + "uuid, parent_uuid, content_length, harness, agent_id, agent_version, raw_line, raw_line_truncated, " "credits, ingested_at " "FROM session_events FINAL " "WHERE session_id = {sid:String} " + _offset_filter + "ORDER BY line_offset ASC " @@ -366,6 +368,18 @@ async def get_session( return {"session_id": session_id, "service_name": "", "events": [], "traces": []} harness = rows[0].get("harness", "claude-code") + agent_id = next((r.get("agent_id") for r in rows if r.get("agent_id")), None) + agent_version = next((r.get("agent_version") for r in rows if r.get("agent_version")), None) + agent_name = None + if agent_id: + try: + from models.agent import Agent + + async with async_session() as db: + result = await db.execute(select(Agent.name).where(Agent.id == _uuid.UUID(agent_id))) + agent_name = result.scalar_one_or_none() + except Exception: + optic.warning("Agent name resolution failed", exc_info=True) # Track max line_offset for incremental fetch cursor max_offset = max(int(r.get("line_offset", 0)) for r in rows) if rows else (after_offset or 0) @@ -400,6 +414,9 @@ async def get_session( return { "session_id": session_id, "service_name": harness, + "agent_id": agent_id, + "agent_name": agent_name, + "agent_version": agent_version, "events": events, "traces": [], "subagent_sessions": subagent_sessions, diff --git a/observal_cli/sessions/base.py b/observal_cli/sessions/base.py index efe84e4e2..3cef8523d 100644 --- a/observal_cli/sessions/base.py +++ b/observal_cli/sessions/base.py @@ -472,8 +472,8 @@ def _resolve_agent(cwd: str, lines: list[str], session_jsonl: Path | None) -> tu if not agent_name: agent_name = _parse_agent_from_lines(lines) - # Look up version from lockfile (authoritative source for version attribution) - lockfile_entry = _lookup_lockfile_agent(cwd) if cwd else None + # Look up version from lockfile. Agent name is the reliable signal for per-agent hooks. + lockfile_entry = _lookup_lockfile_agent(cwd, agent_name=agent_name) if cwd or agent_name else None if agent_name: # Only use lockfile version when the entry matches this agent @@ -494,22 +494,32 @@ def _resolve_agent(cwd: str, lines: list[str], session_jsonl: Path | None) -> tu return None, None -def _lookup_lockfile_agent(cwd: str) -> dict | None: - """Find the agent entry in the lockfile for the given directory. - - Checks all harnesses since the calling hook may not know which harness wrote - the lockfile entry. - """ +def _lookup_lockfile_agent(cwd: str, agent_name: str | None = None) -> dict | None: + """Find the lockfile agent for a directory and optional agent name.""" try: from observal_cli.harness_registry import get_valid_harnesses - from observal_cli.lockfile import get_agent_for_directory + from observal_cli.lockfile import read_lockfile + data = read_lockfile() + name_matches: list[dict] = [] for harness in get_valid_harnesses(): - agent = get_agent_for_directory(harness, cwd) - if agent: - return agent - except Exception: - pass + for agent in data.get("harnesses", {}).get(harness, {}).get("agents", []): + same_dir = bool(cwd) and agent.get("directory") == cwd + same_agent = agent_name and (agent.get("name") == agent_name or agent.get("id") == agent_name) + if agent_name: + if same_agent and same_dir: + return agent + if same_agent: + name_matches.append(agent) + elif same_dir: + return agent + if name_matches: + for agent in name_matches: + if agent.get("scope") != "project": + return agent + return name_matches[0] + except Exception as e: + optic.debug("lockfile agent lookup failed: {}", e) return None diff --git a/tests/test_resolve_agent_version.py b/tests/test_resolve_agent_version.py index bb5b44c25..9b18f7729 100644 --- a/tests/test_resolve_agent_version.py +++ b/tests/test_resolve_agent_version.py @@ -6,7 +6,7 @@ import json from unittest.mock import patch -from observal_cli.sessions.base import _resolve_agent +from observal_cli.sessions.base import _lookup_lockfile_agent, _resolve_agent def _make_lockfile_entry( @@ -126,10 +126,69 @@ def test_env_var_name_mismatch_returns_none_version(self, tmp_path): # Version is None because lockfile entry doesn't match this agent assert agent_version is None - def test_empty_cwd_skips_lockfile(self): - """When cwd is empty, lockfile lookup is skipped.""" - with patch.dict("os.environ", {"OBSERVAL_AGENT_NAME": "my-agent"}): + def test_empty_cwd_uses_agent_name_lockfile_lookup(self): + """When cwd is empty, per-agent hooks can still resolve the version by name.""" + entry = _make_lockfile_entry(name="my-agent", agent_id="uuid-123", version="1.2.0") + + with ( + patch.dict("os.environ", {"OBSERVAL_AGENT_NAME": "my-agent"}), + patch("observal_cli.sessions.base._lookup_lockfile_agent", return_value=entry), + ): agent_id, agent_version = _resolve_agent("", [], None) - assert agent_id == "my-agent" - assert agent_version is None + assert agent_id == "uuid-123" + assert agent_version == "1.2.0" + + def test_lockfile_lookup_prefers_named_agent_over_first_directory_match(self, tmp_path): + """Multiple agents can share a directory, so name must disambiguate.""" + data = { + "harnesses": { + "kiro": { + "agents": [ + _make_lockfile_entry( + name="old-agent", agent_id="old-uuid", version="1.0.0", directory=str(tmp_path) + ), + _make_lockfile_entry( + name="my-agent", agent_id="new-uuid", version="1.2.0", directory=str(tmp_path) + ), + ] + } + } + } + + with ( + patch("observal_cli.harness_registry.get_valid_harnesses", return_value=["kiro"]), + patch("observal_cli.lockfile.read_lockfile", return_value=data), + ): + entry = _lookup_lockfile_agent(str(tmp_path), agent_name="my-agent") + + assert entry is not None + assert entry["id"] == "new-uuid" + assert entry["version"] == "1.2.0" + + def test_lockfile_lookup_uses_agent_name_when_cwd_does_not_match(self, tmp_path): + """Kiro hook cwd can differ from the user-scoped lockfile directory.""" + data = { + "harnesses": { + "kiro": { + "agents": [ + _make_lockfile_entry( + name="my-agent", + agent_id="uuid-123", + version="1.2.0", + directory=str(tmp_path / ".observal"), + scope="user", + ) + ] + } + } + } + + with ( + patch("observal_cli.harness_registry.get_valid_harnesses", return_value=["kiro"]), + patch("observal_cli.lockfile.read_lockfile", return_value=data), + ): + entry = _lookup_lockfile_agent(str(tmp_path / "repo"), agent_name="my-agent") + + assert entry is not None + assert entry["version"] == "1.2.0" diff --git a/web/src/lib/types/sessions.ts b/web/src/lib/types/sessions.ts index 037bfd7d1..fd7fab826 100644 --- a/web/src/lib/types/sessions.ts +++ b/web/src/lib/types/sessions.ts @@ -41,6 +41,9 @@ export interface SessionData { events: RawSessionEvent[]; traces: unknown[]; service_name: string; + agent_id?: string | null; + agent_name?: string | null; + agent_version?: string | null; subagent_sessions?: SubagentSession[]; max_offset?: number; } @@ -78,6 +81,7 @@ export interface Session { tools_used?: string; agent_id?: string | null; agent_name?: string | null; + agent_version?: string | null; } export interface SessionsSummary { diff --git a/web/src/pages/user/traces/detail.tsx b/web/src/pages/user/traces/detail.tsx index 96c2b88b6..9de92e662 100644 --- a/web/src/pages/user/traces/detail.tsx +++ b/web/src/pages/user/traces/detail.tsx @@ -2069,10 +2069,16 @@ const isRealTs = (ts: string | undefined): ts is string => function SessionInfoTab({ events, serviceName, + agentName, + agentId, + agentVersion, }: { events: RawSessionEvent[]; sessionId: string; serviceName: string; + agentName?: string | null; + agentId?: string | null; + agentVersion?: string | null; }) { // Uses isRealTs to skip 1970-epoch sentinel timestamps from Kiro lines // that don't carry a real timestamp (AssistantMessage/ToolResults). @@ -2117,6 +2123,18 @@ function SessionInfoTab({
Service {serviceName || "unknown"} + {(agentName || agentId) && ( + <> + Agent + {agentName || agentId} + + )} + {agentVersion && ( + <> + Agent Version + v{agentVersion} + + )} Source {sessionMeta.source} @@ -2466,6 +2484,9 @@ export default function TraceDetailPage() { events={events} sessionId={id} serviceName={session.service_name} + agentName={session.agent_name} + agentId={session.agent_id} + agentVersion={session.agent_version} /> diff --git a/web/src/pages/user/traces/index.tsx b/web/src/pages/user/traces/index.tsx index 8afb2fcb5..628a2e727 100644 --- a/web/src/pages/user/traces/index.tsx +++ b/web/src/pages/user/traces/index.tsx @@ -280,7 +280,8 @@ function sessionLabel(row: Session): string { const model = shortModel(row.model); const count = row.prompt_count ?? 0; const suffix = count === 1 ? "prompt" : "prompts"; - const agent = row.agent_name ? `${row.agent_name} \u00b7 ` : ""; + const version = row.agent_version ? ` v${row.agent_version}` : ""; + const agent = row.agent_name ? `${row.agent_name}${version} \u00b7 ` : ""; if (model) return `${agent}${model} \u00b7 ${count} ${suffix}`; return `${agent}${count} ${suffix}`; }