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
19 changes: 18 additions & 1 deletion observal-server/api/routes/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]]
Expand Down Expand Up @@ -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)}",
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 24 additions & 14 deletions observal_cli/sessions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down
71 changes: 65 additions & 6 deletions tests/test_resolve_agent_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"
4 changes: 4 additions & 0 deletions web/src/lib/types/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions web/src/pages/user/traces/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -2117,6 +2123,18 @@ function SessionInfoTab({
<div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm bg-surface-sunken rounded-lg p-4">
<span className="text-muted-foreground">Service</span>
<span>{serviceName || "unknown"}</span>
{(agentName || agentId) && (
<>
<span className="text-muted-foreground">Agent</span>
<span>{agentName || agentId}</span>
</>
)}
{agentVersion && (
<>
<span className="text-muted-foreground">Agent Version</span>
<span>v{agentVersion}</span>
</>
)}
<span className="text-muted-foreground">Source</span>
<span className="capitalize">
{sessionMeta.source}
Expand Down Expand Up @@ -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}
/>
</TabsContent>
</Tabs>
Expand Down
3 changes: 2 additions & 1 deletion web/src/pages/user/traces/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Expand Down
Loading