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
148 lines
5.8 KiB
Python
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"]
|