molecule-core/workspace-template/tests/test_transcript_lines.py
airenostars 853734aa4e feat: GET /workspaces/:id/transcript — live agent session log
Closes #N (issue to be filed)

Lets canvas / operators see live tool calls + AI thinking instead of
waiting for the high-level activity log to flush. Right now the only
way to "look over an agent's shoulder" is `docker exec ws-XXX cat
/home/agent/.claude/projects/.../<session>.jsonl`, which:
  - doesn't work for remote workspaces (Phase 30 / Fly Machines)
  - requires shell access on the host
  - has no pagination

This PR adds:

1. `BaseAdapter.transcript_lines(since, limit)` — async hook returning
   `{runtime, supported, lines, cursor, more, source}`. Default returns
   `supported: false` so non-claude-code runtimes pass through gracefully.

2. `ClaudeCodeAdapter.transcript_lines` override — reads the most-
   recently-modified `.jsonl` in `~/.claude/projects/<cwd>/`. Resolves
   cwd the same way `ClaudeSDKExecutor._resolve_cwd()` does so the
   project dir name matches what Claude Code actually writes to. Limit
   capped at 1000 to prevent OOM.

3. Workspace HTTP route `GET /transcript` — Starlette handler added
   alongside the A2A app. Trusts the internal Docker network (same
   model as POST / for A2A); Phase 30 remote-workspace auth is a
   follow-up.

4. Platform proxy `GET /workspaces/:id/transcript` — looks up the
   workspace's URL, forwards GET, caps response at 1MB. Gated by
   existing `WorkspaceAuth` middleware (same as /traces, /memories,
   /delegations).

Tests: 6 Python unit tests cover empty dir / pagination / multi-session
/ malformed lines / limit cap, plus 4 Go tests cover 404 / proxy
forwarding / query-string propagation / unreachable-workspace 502.

Verified end-to-end on a live workspace — returns real claude-code
session entries through the platform proxy.

## Follow-ups
- WebSocket variant for live streaming (instead of polling)
- Canvas UI tab "Transcript" between Activity and Traces
- LangGraph / DeepAgents / OpenClaw transcript adapters
- Phase 30 remote-workspace auth on /transcript
2026-04-15 14:29:43 -07:00

148 lines
5.8 KiB
Python

"""Tests for the new BaseAdapter.transcript_lines() method + claude-code override."""
import asyncio
import json
import os
import tempfile
from pathlib import Path
import pytest
# ── Default (BaseAdapter) ───────────────────────────────────────────────────
def test_base_adapter_returns_unsupported():
"""Adapters that don't override return supported:False."""
from adapters.langgraph.adapter import LangGraphAdapter
a = LangGraphAdapter()
r = asyncio.run(a.transcript_lines())
assert r["supported"] is False
assert r["lines"] == []
assert r["cursor"] == 0
assert r["runtime"] == "langgraph"
assert r["more"] is False
# ── Claude Code override ────────────────────────────────────────────────────
def _write_jsonl(path: Path, entries: list[dict]) -> None:
with path.open("w") as f:
for e in entries:
f.write(json.dumps(e) + "\n")
def test_claude_code_no_projects_dir():
"""Returns supported:True with empty lines when projects dir missing."""
from adapters.claude_code.adapter import ClaudeCodeAdapter
with tempfile.TemporaryDirectory() as tmp:
os.environ["HOME"] = tmp
os.environ["CLAUDE_PROJECT_CWD"] = "/configs"
try:
r = asyncio.run(ClaudeCodeAdapter().transcript_lines())
assert r["supported"] is True
assert r["lines"] == []
assert r["cursor"] == 0
assert "-configs" in r["source"]
finally:
del os.environ["CLAUDE_PROJECT_CWD"]
def test_claude_code_reads_jsonl_with_pagination():
from adapters.claude_code.adapter import ClaudeCodeAdapter
with tempfile.TemporaryDirectory() as tmp:
os.environ["HOME"] = tmp
os.environ["CLAUDE_PROJECT_CWD"] = "/configs"
try:
projdir = Path(tmp) / ".claude" / "projects" / "-configs"
projdir.mkdir(parents=True)
_write_jsonl(projdir / "abc.jsonl", [
{"type": "user", "n": 1},
{"type": "assistant", "n": 2},
{"type": "user", "n": 3},
{"type": "assistant", "n": 4},
{"type": "user", "n": 5},
])
a = ClaudeCodeAdapter()
# First page (limit=2)
r1 = asyncio.run(a.transcript_lines(since=0, limit=2))
assert r1["supported"] is True
assert [l["n"] for l in r1["lines"]] == [1, 2]
assert r1["cursor"] == 2
assert r1["more"] is True
# Second page (since=2, limit=2)
r2 = asyncio.run(a.transcript_lines(since=2, limit=2))
assert [l["n"] for l in r2["lines"]] == [3, 4]
assert r2["cursor"] == 4
assert r2["more"] is True
# Third page exhausts
r3 = asyncio.run(a.transcript_lines(since=4, limit=2))
assert [l["n"] for l in r3["lines"]] == [5]
assert r3["cursor"] == 5
assert r3["more"] is False
finally:
del os.environ["CLAUDE_PROJECT_CWD"]
def test_claude_code_picks_most_recent_jsonl():
"""When multiple .jsonl files exist, picks the most-recently-modified."""
from adapters.claude_code.adapter import ClaudeCodeAdapter
with tempfile.TemporaryDirectory() as tmp:
os.environ["HOME"] = tmp
os.environ["CLAUDE_PROJECT_CWD"] = "/configs"
try:
projdir = Path(tmp) / ".claude" / "projects" / "-configs"
projdir.mkdir(parents=True)
old = projdir / "old.jsonl"
new = projdir / "new.jsonl"
_write_jsonl(old, [{"src": "old"}])
_write_jsonl(new, [{"src": "new"}])
# Force new to be more recent
os.utime(old, (1000, 1000))
os.utime(new, (2000, 2000))
r = asyncio.run(ClaudeCodeAdapter().transcript_lines())
assert r["lines"] == [{"src": "new"}]
assert r["source"].endswith("new.jsonl")
finally:
del os.environ["CLAUDE_PROJECT_CWD"]
def test_claude_code_skips_malformed_lines():
"""Bad JSON lines surface as ``_parse_error: True`` rather than 500'ing."""
from adapters.claude_code.adapter import ClaudeCodeAdapter
with tempfile.TemporaryDirectory() as tmp:
os.environ["HOME"] = tmp
os.environ["CLAUDE_PROJECT_CWD"] = "/configs"
try:
projdir = Path(tmp) / ".claude" / "projects" / "-configs"
projdir.mkdir(parents=True)
with (projdir / "x.jsonl").open("w") as f:
f.write('{"good": 1}\n')
f.write("not-json garbage\n")
f.write('{"good": 2}\n')
r = asyncio.run(ClaudeCodeAdapter().transcript_lines())
assert r["lines"][0] == {"good": 1}
assert r["lines"][1].get("_parse_error") is True
assert r["lines"][2] == {"good": 2}
finally:
del os.environ["CLAUDE_PROJECT_CWD"]
def test_claude_code_caps_limit():
"""Limit is capped at 1000 to prevent OOM via paranoid client."""
from adapters.claude_code.adapter import ClaudeCodeAdapter
with tempfile.TemporaryDirectory() as tmp:
os.environ["HOME"] = tmp
os.environ["CLAUDE_PROJECT_CWD"] = "/configs"
try:
projdir = Path(tmp) / ".claude" / "projects" / "-configs"
projdir.mkdir(parents=True)
_write_jsonl(projdir / "x.jsonl", [{"i": i} for i in range(1500)])
r = asyncio.run(ClaudeCodeAdapter().transcript_lines(limit=999999))
assert len(r["lines"]) == 1000 # capped
assert r["more"] is True
assert r["cursor"] == 1000
finally:
del os.environ["CLAUDE_PROJECT_CWD"]