From 611d27d7ae20eb5912e7e0998f299863efcd9ad2 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 07:44:46 -0400 Subject: [PATCH 01/11] Docs: Spec for agent-neutral timeline attribution (#1559) Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- ...026-10-30-agent-neutral-timeline-design.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md diff --git a/docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md b/docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md new file mode 100644 index 000000000..ca4aa5533 --- /dev/null +++ b/docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md @@ -0,0 +1,101 @@ +# Agent-neutral timeline attribution — design + +Date: 2026-10-30 +Issue: rossoctl/cortex#1559 +Branch: `feat/agent-neutral-timeline` + +## Problem + +The timeline (`GET /v1/sessions/{id}/timeline`) attributes turns, tools and +agents only to Claude Code. The gate is `agentOf` +(`core/session/timeline/agents.go:361`): it names an agent only when the event +carries Claude Code's markers — `Subagent` (the `X-Claude-Code-Agent-Id` +header) or `Inference.AgentRole` (from the `cc_is_subagent` billing marker). +Every other client's inference requests are dropped in `pairCalls`, so those +sessions render host rows only. + +Measured live on a Bob session: 160 host spans, 0 turns, 0 tools, 0 agents. +Bob's traffic carries everything the timeline needs — inference parses with +`messages` and `model` on `api.us-east.bob.ibm.com +/inference/v1/chat/completions`, and tool calls — but none of it is +attributed. + +## Goals + +- Bob sessions show turns (all model aliases) and tools on the timeline. +- The fix is agent-neutral: any known agent (Codex, Pi, OpenCode, …) gets the + same treatment. No per-agent adapter. +- Claude Code's existing timeline is unchanged. + +## Non-goals + +- Subagents, returns and jobs for non-Claude-Code agents — they key on + Claude marker strings (`Agent`/`Task` tool names, ``, + ``) those clients don't send. Empty is correct. +- Cost per span and needs-you notification — #1560, builds on this. +- The Bob-on-Ollama gap: a live session against `localhost:11434` had no + inference parses at all. Likely Ollama native-API parser coverage or an + older proxy; separate issue. + +## Design + +### 1. Attribution by client + +`agentOf` gains a fallback after the Claude cases: a request whose inference +parsed but carries no Claude markers attributes to `MainAgent` when +`e.Client` is a known agent (`pipeline.IsKnownAgent(e.Client.Name)`). +`SessionEvent.Client` is already recorded on every event from User-Agent +sniffing (`core/pipeline/client.go`). + +Non-Claude-Code agents get a single `main` row. Unknown clients (curl, +scripts) stay host-only: the timeline must not claim agency where there is +none. + +### 2. Side-call filter scoped to Claude Code + +`isSideCall` (`agents.go:373`) treats "no tools" as a side call for *any* +inference. Under generic attribution that would eat Bob's `security` and +`background` turns, which never carry tools. Gate the whole filter to Claude +Code traffic — `Inference.AgentRole != ""` is the signal, since only Claude +Code sets it — so non-Claude side calls render as turns, labeled by model. + +### 3. `Turn.Model` + +Add `Model string` (omitempty) to the `Turn` span, populated from the paired +request's `Inference.Model`. Without it Bob's `premium-ide`, `security` and +`background` turns are indistinguishable. agentop renders it as a label. + +### 4. Parser: chat-completions tool results + +`InferenceExtension.ToolResults` is populated only for Anthropic +(`tool_result` blocks) and the Responses API (`function_call_output`). OpenAI +chat-completions requests carry results as `role: "tool"` messages with +`tool_call_id`, which the parser currently drops — `InferenceMessage` has no +id field. Map those messages onto `ToolResults[].ToolUseID` in the +chat-completions request path so tool spans close. Live Bob traffic +confirmed the shape: 1 assistant `tool_calls`, 2 `role: "tool"` results. + +Tool span pairing (`ToolCalls[].ID` → first request carrying the matching +`ToolResults[].ToolUseID`) then works unchanged. + +### 5. Docs + +`docs/session-api.md:135` — "Agents, turns, tools and returns are Claude Code +only" — rewritten: attribution is by known client; subagent tree, returns and +jobs remain Claude Code only. + +## Testing + +- Red-first unit test: a non-Claude known-agent event with an inference parse + produces a turn (today: none). +- Bob-shaped fixture in `core/session/timeline`: chat-completions events with + `premium-ide`/`security`/`background` turns (all three render) and one tool + call closed by a `role: "tool"` result. +- Parser test: `role: "tool"` + `tool_call_id` lands in `ToolResults`. +- Existing Claude Code timeline tests must pass unchanged. +- Live verification against the archived Bob sessions on the laptop proxy + after a scratch deploy: host-rows-only → turns + tools. + +## Size + +One PR, ~350–450 lines including tests. From 6c115e811c00199def90f27094d2c8f3c085c255 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:35:23 -0400 Subject: [PATCH 02/11] Docs: Implementation plan for agent-neutral timeline attribution Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../2026-10-30-agent-neutral-timeline.md | 471 ++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-30-agent-neutral-timeline.md diff --git a/docs/superpowers/plans/2026-10-30-agent-neutral-timeline.md b/docs/superpowers/plans/2026-10-30-agent-neutral-timeline.md new file mode 100644 index 000000000..4bfe4c956 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-agent-neutral-timeline.md @@ -0,0 +1,471 @@ +# Agent-Neutral Timeline Attribution — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Non-Claude-Code agents (Bob first) get turns, tools and a main row on `GET /v1/sessions/{id}/timeline`, attributed by known client instead of Claude Code markers. + +**Architecture:** One gate changes — `agentOf` in `core/session/timeline/agents.go` falls back to `MainAgent` when the event's client is a known agent. The side-call filter is scoped to Claude Code (`AgentRole != ""`), a `Model` field is added to `Turn`, and the inference parser maps chat-completions `role:"tool"` + `tool_call_id` results onto `ToolResults` so tool spans close. agentop names the model in the timeline cursor strip. + +**Tech Stack:** Go 1.26.10, stdlib `encoding/json`; modules `core` and `cmd/agentop`. + +**Spec:** `docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md` + +## Global Constraints + +- Work only in the worktree `.worktrees/timeline-bob`, branch `feat/agent-neutral-timeline`. +- Every commit: `git commit -s` (DCO) + `Assisted-By: Claude (Anthropic AI) `; never `Co-Authored-By`. +- Run Go per-module with `GOWORK=off` for `cmd/agentop`; `core` may use the workspace. Run `gofmt -l ` and `go vet` on every touched module before pushing. +- Red-first: each test is written, run, and seen to fail before its implementation. +- Claude Code's existing timeline output must not change — the existing suite in `core/session/timeline` is the regression gate. + +## Review Focus + +- **Unknown client** (curl, scripts) sending an inference-parseable body must NOT get a turn — the timeline must not claim agency. Test in Task 2. +- **`role:"tool"` without `tool_call_id`** must not produce a `ToolResults` entry — an empty id pairs with nothing and would poison the ends map. Test in Task 1. +- **Nil `Client`** (events from proxies predating client capture, or skipped capture) must not attribute and must not panic. Test in Task 2. +- **Bob side calls with no tools** must remain turns, not be filtered by the no-tools rule. Test in Task 2. +- **A `role:"tool"` message must keep its Content in `Messages`** — settle's cost math reads message content sizes; dropping the text would change recorded usage. Test in Task 1. + +--- + +### Task 1: Parser maps chat-completions tool results + +**Files:** +- Modify: `core/plugins/inferenceparser/plugin.go:962-982` (`inferenceMessage` + its `UnmarshalJSON`) and `plugin.go:117-165` (`parseOpenAIRequest`) +- Test: `core/plugins/inferenceparser/toolresults_test.go` (new) + +**Interfaces:** +- Consumes: `pipeline.InferenceToolResult{ToolUseID, Content, IsError}` (`core/pipeline/extensions.go:414`). +- Produces: `InferenceExtension.ToolResults` populated on chat-completions requests — consumed by Task 3's tool-span pairing via the existing `ends` map in `buildAgents`. No new exported API. + +- [ ] **Step 1: Write the failing test** + +```go +package inferenceparser + +import ( + "encoding/json" + "testing" +) + +func TestChatCompletionsToolResultsCarryTheCallID(t *testing.T) { + body := []byte(`{"model":"premium-ide","messages":[ + {"role":"user","content":"list the files"}, + {"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function", + "function":{"name":"read_dir","arguments":"{\"path\":\"/tmp\"}"}}]}, + {"role":"tool","tool_call_id":"call_1","content":"file_a\nfile_b"}, + {"role":"tool","tool_call_id":"","content":"no id"}]}`) + ext := parseOpenAIRequest(body) + if ext == nil { + t.Fatal("request not parsed") + } + if len(ext.ToolResults) != 1 { + t.Fatalf("ToolResults = %+v, want the one call_1 result", ext.ToolResults) + } + r := ext.ToolResults[0] + if r.ToolUseID != "call_1" || r.Content != "file_a\nfile_b" { + t.Fatalf("ToolResults[0] = %+v, want {call_1 file_a\\nfile_b}", r) + } + // The result text stays in Messages: settle reads message content sizes. + var toolMsg bool + for _, m := range ext.Messages { + if m.Role == "tool" && m.Content == "file_a\nfile_b" { + toolMsg = true + } + } + if !toolMsg { + t.Fatal("role:tool message content missing from Messages") + } + // The wire tool_calls on the assistant request message are not ToolCalls (that field is + // response-side); nothing to assert beyond no panic. + var _ = json.Valid +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd core && GOWORK=off go test ./plugins/inferenceparser/ -run TestChatCompletionsToolResults -v` +Expected: FAIL — `ToolResults = [], want the one call_1 result`. + +- [ ] **Step 3: Implement** + +In `plugin.go`, extend `inferenceMessage` and its `UnmarshalJSON`: + +```go +type inferenceMessage struct { + Role string + Content string + ContentBytes int + // ToolCallID is the tool_call_id of a chat-completions "role":"tool" result message: + // the id of the assistant tool_calls entry it answers. Empty on every other role. + ToolCallID string +} + +func (m *inferenceMessage) UnmarshalJSON(data []byte) error { + var raw struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolCallID string `json:"tool_call_id"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + m.Role = raw.Role + m.Content = flattenContent(raw.Content) + m.ContentBytes = contentBytes(raw.Content) + m.ToolCallID = raw.ToolCallID + return nil +} +``` + +In `parseOpenAIRequest`, after the `ext.Messages` loop (plugin.go:146-152), add: + +```go + // A chat-completions tool result is a "role":"tool" message naming the call it answers + // by tool_call_id — the pairing key the timeline's tool spans close on. Anthropic and + // the Responses API surface theirs through the same ToolResults field. + for _, msg := range req.Messages { + if msg.Role == "tool" && msg.ToolCallID != "" { + ext.ToolResults = append(ext.ToolResults, pipeline.InferenceToolResult{ + ToolUseID: msg.ToolCallID, + Content: msg.Content, + }) + } + } +``` + +- [ ] **Step 4: Run tests** + +Run: `cd core && GOWORK=off go test ./plugins/inferenceparser/` +Expected: PASS, whole package. + +- [ ] **Step 5: Commit** + +```bash +git add core/plugins/inferenceparser/plugin.go core/plugins/inferenceparser/toolresults_test.go +git commit -s -m "Feat: Read chat-completions tool results by tool_call_id + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 2: Attribution by known client, scoped side-call filter, Turn.Model + +**Files:** +- Modify: `core/session/timeline/agents.go:361-383` (`agentOf`, `isSideCall`), `agents.go:170` (turn construction), `core/session/timeline/timeline.go:76-91` (`Turn` struct) +- Test: `core/session/timeline/timeline_test.go` (append) + +**Interfaces:** +- Consumes: `pipeline.IsKnownAgent(name string) bool` (`core/pipeline/client.go:408`); `SessionEvent.Client *pipeline.EventClient` with field `Name string` (`core/pipeline/client.go:39`); Task 1's `ToolResults`. +- Produces: `Turn.Model string` (`json:"model,omitempty"`) — consumed by Task 4's cursor strip. + +- [ ] **Step 1: Write the failing tests** + +Append to `timeline_test.go`, reusing the file's `rec`, `at`, `user`, `result`, `tools` helpers: + +```go +// bobTurn records a chat-completions call from a known non-Claude client: no Subagent, no +// AgentRole, attributed only by Client. +func (r *rec) bobTurn(model string, start, dur float64, msgs []pipeline.InferenceMessage, results []pipeline.InferenceToolResult, calls ...pipeline.InferenceToolCall) { + id := r.id() + client := &pipeline.EventClient{Name: "bob"} + r.add(pipeline.SessionEvent{At: at(start), Phase: pipeline.SessionRequest, RequestID: id, Host: "api.us-east.bob.ibm.com", Client: client, + Inference: &pipeline.InferenceExtension{Model: model, Messages: msgs, ToolResults: results}}) + r.add(pipeline.SessionEvent{At: at(start + dur), Phase: pipeline.SessionResponse, RequestID: id, Host: "api.us-east.bob.ibm.com", Client: client, + StatusCode: 200, Duration: time.Duration(dur * float64(time.Second)), + Inference: &pipeline.InferenceExtension{Model: model, FinishReason: "stop", ToolCalls: calls}}) +} + +func TestKnownNonClaudeClientGetsTurnsOnMainRow(t *testing.T) { + var r rec + r.bobTurn("premium-ide", 0, 10, []pipeline.InferenceMessage{user("hi")}, nil) + r.bobTurn("security", 12, 2, []pipeline.InferenceMessage{user("check")}, nil) // side call: no tools + tl := Build("s", r.events, at(30)) + if len(tl.Turns) != 2 { + t.Fatalf("Turns = %d, want 2 (side calls of a non-Claude client are turns)", len(tl.Turns)) + } + for _, tu := range tl.Turns { + if tu.Agent != MainAgent { + t.Fatalf("turn agent = %q, want %q", tu.Agent, MainAgent) + } + } + if tl.Turns[0].Model != "premium-ide" || tl.Turns[1].Model != "security" { + t.Fatalf("models = %q, %q; want premium-ide, security", tl.Turns[0].Model, tl.Turns[1].Model) + } + if len(tl.Agents) != 1 || tl.Agents[0].ID != MainAgent { + t.Fatalf("Agents = %+v, want one main row", tl.Agents) + } +} + +func TestUnknownClientGetsNoTurns(t *testing.T) { + var r rec + id := r.id() + r.add(pipeline.SessionEvent{At: at(0), Phase: pipeline.SessionRequest, RequestID: id, Host: "llm.example", + Client: &pipeline.EventClient{Name: ""}, + Inference: &pipeline.InferenceExtension{Model: "m", Messages: []pipeline.InferenceMessage{user("hi")}, Tools: tools}}) + r.add(pipeline.SessionEvent{At: at(5), Phase: pipeline.SessionResponse, RequestID: id, Host: "llm.example", StatusCode: 200}) + tl := Build("s", r.events, at(10)) + if len(tl.Turns) != 0 { + t.Fatalf("Turns = %d, want 0 for an unknown client", len(tl.Turns)) + } + // Nil Client (pre-capture events) is the same story: no attribution, no panic. + var r2 rec + id2 := r2.id() + r2.add(pipeline.SessionEvent{At: at(0), Phase: pipeline.SessionRequest, RequestID: id2, Host: "llm.example", + Inference: &pipeline.InferenceExtension{Model: "m", Messages: []pipeline.InferenceMessage{user("hi")}, Tools: tools}}) + if tl := Build("s2", r2.events, at(10)); len(tl.Turns) != 0 { + t.Fatalf("Turns = %d, want 0 for a nil Client", len(tl.Turns)) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd core && GOWORK=off go test ./session/timeline/ -run 'KnownNonClaude|UnknownClient' -v` +Expected: both FAIL — 0 turns (nothing attributed), models empty. + +- [ ] **Step 3: Implement** + +`timeline.go`, in `Turn` (after `Seq`): + +```go + // Model is the model the call asked for, so a client that runs several models side by + // side (Bob's premium-ide turns beside its security classifier) reads apart. + Model string `json:"model,omitempty"` +``` + +`agents.go` — replace `agentOf` and `isSideCall`: + +```go +// agentOf names the agent that sent a request. Claude Code's requests carry their own +// markers: AgentRole from its cc_is_subagent header, Subagent from its agent id header. +// Every other known client gets the single main row — nothing on the wire says which of +// its calls belong together beyond that. +func agentOf(e *pipeline.SessionEvent) (string, bool) { + switch { + case e.Subagent != "": + return e.Subagent, true + case e.Inference.AgentRole == pipeline.AgentRoleSubagent: + return UnseparatedAgent, true + case e.Inference.AgentRole == pipeline.AgentRoleMain: + return MainAgent, true + case e.Client != nil && pipeline.IsKnownAgent(e.Client.Name): + return MainAgent, true + } + return "", false +} + +// isSideCall is Claude Code only: its literals match Claude's progress and recap prompts, and +// its no-tools rule holds because Claude always sends a manifest on real turns. Another +// client's tool-less calls (Bob's security classifier) are real turns with real cost. +func isSideCall(inf *pipeline.InferenceExtension) bool { + if inf.AgentRole == "" { + return false + } + if len(inf.Tools) == 0 && inf.ToolCount == 0 { + return true + } + for i := len(inf.Messages) - 1; i >= 0; i-- { + if m := inf.Messages[i]; m.Role != "system" { + return strings.Contains(m.Content, progressPrompt) || strings.Contains(m.Content, recapPrompt) + } + } + return false +} +``` + +`agents.go:170` — carry the model onto the turn: + +```go + turn := Turn{Agent: c.agent, Start: c.req.At, Resent: resent, Seq: c.req.Seq, Model: inf.Model} +``` + +- [ ] **Step 4: Run the whole package** + +Run: `cd core && GOWORK=off go test ./session/timeline/` +Expected: PASS — the new tests and every existing Claude Code test (the regression gate). + +- [ ] **Step 5: Commit** + +```bash +git add core/session/timeline/agents.go core/session/timeline/timeline.go core/session/timeline/timeline_test.go +git commit -s -m "Feat: Attribute timeline turns to any known client (#1559) + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 3: Bob-shaped session — tools close, returns stay empty + +**Files:** +- Test: `core/session/timeline/timeline_test.go` (append) + +**Interfaces:** +- Consumes: Task 1's `ToolResults` pairing and Task 2's `bobTurn`. + +- [ ] **Step 1: Write the failing test** + +```go +func TestBobSessionToolsCloseAndReturnsStayEmpty(t *testing.T) { + var r rec + r.bobTurn("premium-ide", 0, 10, []pipeline.InferenceMessage{user("list files")}, + nil, pipeline.InferenceToolCall{ID: "call_1", Name: "read_dir", Arguments: `{"path":"/tmp"}`}) + // The next request carries the tool result by tool_call_id (Task 1's parse shape). + r.bobTurn("premium-ide", 12, 8, []pipeline.InferenceMessage{user("list files")}, + []pipeline.InferenceToolResult{result("call_1", "file_a\nfile_b")}) + tl := Build("s", r.events, at(30)) + tool := findTool(t, tl, "call_1") + if tool.Name != "read_dir" { + t.Fatalf("tool name = %q", tool.Name) + } + if tool.End == nil || tool.Lost { + t.Fatalf("tool = %+v, want closed by the tool_call_id result", tool) + } + if want := at(12); !tool.End.Equal(want) { + t.Fatalf("tool end = %v, want the result-bearing request at %v", tool.End, want) + } + if len(tl.Returns) != 0 || len(tl.Jobs) != 0 { + t.Fatalf("Returns = %+v, Jobs = %+v; both are Claude Code markers Bob never sends", tl.Returns, tl.Jobs) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd core && GOWORK=off go test ./session/timeline/ -run TestBobSession -v` +Expected: FAIL — before Task 2 the call is not attributed at all, so `findTool` fatals with `no tool "call_1"`. If Tasks 1–2 are already landed this test may pass on first run; that is acceptable only after verifying it fails with `agentOf`'s new case reverted — do that once by commenting the case out, running, seeing the failure, and restoring it. + +- [ ] **Step 3: Run tests** + +Run: `cd core && GOWORK=off go test ./session/timeline/` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add core/session/timeline/timeline_test.go +git commit -s -m "Test: Bob-shaped session closes tools and keeps returns empty + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 4: agentop names the turn's model in the cursor strip + +**Files:** +- Modify: `cmd/agentop/tui/timeline_pane.go:813-865` (`cursorStrip`) +- Test: `cmd/agentop/tui/timeline_pane_test.go` (append) + +**Interfaces:** +- Consumes: `timeline.Turn.Model` from Task 2. +- Produces: nothing other tasks use. + +- [ ] **Step 1: Write the failing test** + +Follows the shape of `TestTimelineStrip_NamesTheRequestWhenNoToolRan` (`timeline_pane_test.go:1209`) — same model, same cell, asserting the model name joins the host: + +```go +// A cell inside a turn that carries a model names it, so a client running several models +// side by side (Bob's classifier beside its main turns) reads apart. +func TestTimelineStrip_NamesTheTurnModel(t *testing.T) { + m := timelineModel(t, 140, 46) + m.timeline.windowIdx = 3 + lay, _ := m.timelineLayoutNow() + m.timeline.cursor = cellOf(t, lay, 5) // main's first model call: no tool yet + for i := range m.timeline.tl.Turns { + if m.timeline.tl.Turns[i].Agent == timeline.MainAgent && m.timeline.tl.Turns[i].End != nil && + !lay.axis.cells[m.timeline.cursor].from.Before(m.timeline.tl.Turns[i].Start) && + lay.axis.cells[m.timeline.cursor].from.Before(*m.timeline.tl.Turns[i].End) { + m.timeline.tl.Turns[i].Model = "premium-ide" + break + } + } + m.timeline.layoutKey = tlLayoutKey{} + lay, _ = m.timelineLayoutNow() + strip := stripANSI(m.cursorStrip(lay)) + if !strings.Contains(strip, "premium-ide") { + t.Errorf("strip = %q, want the turn's model named", strip) + } +} +``` + +If no main-agent turn in the sample covers the cursor cell (check by running once with a `t.Log` of `m.timeline.tl.Turns`), pick the cell of a turn that does — e.g. `cellOf(t, lay, 20)` — and keep the covering-turn lookup as written. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd cmd/agentop && GOWORK=off go test ./tui/ -run TestCursorStripNamesTheTurnModel -v` +Expected: FAIL — strip says "no tool running…", no model. + +- [ ] **Step 3: Implement** + +In `cursorStrip`, in the `len(parts) == 0` branch, name the covering turn's model before the host: + +```go + if len(parts) == 0 { + suffix := "" + for _, tu := range m.timeline.tl.Turns { + if tu.Model != "" && !cell.from.Before(tu.Start) && (tu.End == nil || cell.from.Before(*tu.End)) { + suffix = " " + sanitizeLabel(tu.Model) + " turn" + break + } + } + if h, ok := m.cellHost(lay, cell); ok { + return styleMuted.Render(head + "no tool running;" + suffix + "; a request to " + sanitizeLabel(h.Host) + " [enter] events") + } + return styleMuted.Render(head + "no tool running" + suffix + " in this cell") + } +``` + +(Keep the exact existing phrasing when `suffix` is empty — `"no tool running; a request to …"` — so existing strip tests hold; if the `;` doubling reads badly, gate the semicolons on `suffix != ""`.) + +- [ ] **Step 4: Run tests** + +Run: `cd cmd/agentop && GOWORK=off go test ./tui/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/agentop/tui/timeline_pane.go cmd/agentop/tui/timeline_pane_test.go +git commit -s -m "Feat: Name the turn's model in the timeline cursor strip + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +### Task 5: Docs + +**Files:** +- Modify: `docs/session-api.md:135` (the "Claude Code only" paragraph in the timeline section) +- Modify: `CLAUDE.md` — the `GET /v1/sessions/{id}/timeline` row in the Session Events API endpoint table + +**Interfaces:** none. + +- [ ] **Step 1: Rewrite the docs** + +`docs/session-api.md`, replace the sentence beginning "**Agents, turns, tools and returns are Claude Code only.**" with: + +> **Turns and tools are attributed to any known client** (Claude Code, Bob, Codex, OpenCode, Pi — the canonical name parsed from the User-Agent); each gets a single `main` row, and every model call is a turn, including classifier side calls, labeled by `turn.model`. Claude Code additionally gets its subagent tree, returns and background jobs — those key on marker strings no other client sends. Requests from unknown clients render as host rows only. + +`CLAUDE.md`, in the timeline row of the endpoint table, change "Claude Code agents (main and subagents, linked by the `Agent` call that spawned them), model turns" to "agents (any known client on a single `main` row; Claude Code additionally its subagent tree), model turns". + +- [ ] **Step 2: Commit** + +```bash +git add docs/session-api.md CLAUDE.md +git commit -s -m "Docs: Timeline attribution is by known client, not Claude Code only + +Assisted-By: Claude (Anthropic AI) " +``` + +--- + +## Final verification + +- [ ] `cd core && GOWORK=off go test ./...` and `cd cmd/agentop && GOWORK=off go test ./...` — both green. +- [ ] `gofmt -l core/session/timeline core/plugins/inferenceparser cmd/agentop/tui` — empty; `go vet` on both modules. +- [ ] Live check on the laptop proxy (coordinate before restarting it — see the worktree rules): the archived Bob sessions must go from host-rows-only to turns (premium-ide/security/background, labeled) and a closed tool span. From 3e21cab625b4cbd31389d487615b64871b3834c4 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:39:58 -0400 Subject: [PATCH 03/11] Feat: Read chat-completions tool results by tool_call_id Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/plugins/inferenceparser/plugin.go | 20 +++++++++-- .../inferenceparser/toolresults_test.go | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 core/plugins/inferenceparser/toolresults_test.go diff --git a/core/plugins/inferenceparser/plugin.go b/core/plugins/inferenceparser/plugin.go index 64d5a53f5..6b1984728 100644 --- a/core/plugins/inferenceparser/plugin.go +++ b/core/plugins/inferenceparser/plugin.go @@ -149,6 +149,17 @@ func parseOpenAIRequest(body []byte) *pipeline.InferenceExtension { ContentBytes: msg.ContentBytes, }) } + // A chat-completions tool result is a "role":"tool" message naming the call it answers + // by tool_call_id — the pairing key the timeline's tool spans close on. Anthropic and + // the Responses API surface theirs through the same ToolResults field. + for _, msg := range req.Messages { + if msg.Role == "tool" && msg.ToolCallID != "" { + ext.ToolResults = append(ext.ToolResults, pipeline.InferenceToolResult{ + ToolUseID: msg.ToolCallID, + Content: msg.Content, + }) + } + } for _, tool := range req.Tools { if tool.Function.Name == "" { continue @@ -963,12 +974,16 @@ type inferenceMessage struct { Role string Content string ContentBytes int + // ToolCallID is the tool_call_id of a chat-completions "role":"tool" result message: + // the id of the assistant tool_calls entry it answers. Empty on every other role. + ToolCallID string } func (m *inferenceMessage) UnmarshalJSON(data []byte) error { var raw struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolCallID string `json:"tool_call_id"` } if err := json.Unmarshal(data, &raw); err != nil { return err @@ -976,6 +991,7 @@ func (m *inferenceMessage) UnmarshalJSON(data []byte) error { m.Role = raw.Role m.Content = flattenContent(raw.Content) m.ContentBytes = contentBytes(raw.Content) + m.ToolCallID = raw.ToolCallID return nil } diff --git a/core/plugins/inferenceparser/toolresults_test.go b/core/plugins/inferenceparser/toolresults_test.go new file mode 100644 index 000000000..6df586d56 --- /dev/null +++ b/core/plugins/inferenceparser/toolresults_test.go @@ -0,0 +1,36 @@ +package inferenceparser + +import "testing" + +// A chat-completions tool result is a "role":"tool" message naming the call it answers by +// tool_call_id. Those ids are the pairing key the timeline's tool spans close on, so they +// must reach InferenceExtension.ToolResults; the result text stays in Messages, where +// settle's cost math reads it. +func TestChatCompletionsToolResultsCarryTheCallID(t *testing.T) { + body := []byte(`{"model":"premium-ide","messages":[ + {"role":"user","content":"list the files"}, + {"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function", + "function":{"name":"read_dir","arguments":"{\"path\":\"/tmp\"}"}}]}, + {"role":"tool","tool_call_id":"call_1","content":"file_a\nfile_b"}, + {"role":"tool","tool_call_id":"","content":"no id"}]}`) + ext := parseOpenAIRequest(body) + if ext == nil { + t.Fatal("request not parsed") + } + if len(ext.ToolResults) != 1 { + t.Fatalf("ToolResults = %+v, want only the call_1 result (an empty id pairs with nothing)", ext.ToolResults) + } + r := ext.ToolResults[0] + if r.ToolUseID != "call_1" || r.Content != "file_a\nfile_b" { + t.Fatalf("ToolResults[0] = %+v, want {call_1 file_a\\nfile_b}", r) + } + var toolMsg bool + for _, m := range ext.Messages { + if m.Role == "tool" && m.Content == "file_a\nfile_b" { + toolMsg = true + } + } + if !toolMsg { + t.Fatal("role:tool message content missing from Messages") + } +} From 12361633aae58c5d64ed22f0fc2efed0cb8ce32f Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:41:00 -0400 Subject: [PATCH 04/11] Feat: Attribute timeline turns to any known client (#1559) Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/session/timeline/agents.go | 16 ++++++-- core/session/timeline/timeline.go | 3 ++ core/session/timeline/timeline_test.go | 56 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/core/session/timeline/agents.go b/core/session/timeline/agents.go index 403142e63..20cbddc76 100644 --- a/core/session/timeline/agents.go +++ b/core/session/timeline/agents.go @@ -167,7 +167,7 @@ func buildAgents(t *Timeline, events []pipeline.SessionEvent) { } st.prev = msgs - turn := Turn{Agent: c.agent, Start: c.req.At, Resent: resent, Seq: c.req.Seq} + turn := Turn{Agent: c.agent, Start: c.req.At, Resent: resent, Seq: c.req.Seq, Model: inf.Model} if c.resp == nil { t.Turns = append(t.Turns, turn) continue @@ -356,8 +356,10 @@ func pairCalls(events []pipeline.SessionEvent) []call { return calls } -// agentOf names the agent that sent a request. Only Claude Code's requests are attributed: -// AgentRole is set by its cc_is_subagent marker, and Subagent by its agent id header. +// agentOf names the agent that sent a request. Claude Code's requests carry their own +// markers: AgentRole from its cc_is_subagent header, Subagent from its agent id header. +// Every other known client gets the single main row — nothing on the wire says which of +// its calls belong together beyond that. func agentOf(e *pipeline.SessionEvent) (string, bool) { switch { case e.Subagent != "": @@ -366,11 +368,19 @@ func agentOf(e *pipeline.SessionEvent) (string, bool) { return UnseparatedAgent, true case e.Inference.AgentRole == pipeline.AgentRoleMain: return MainAgent, true + case e.Client != nil && pipeline.IsKnownAgent(e.Client.Name): + return MainAgent, true } return "", false } +// isSideCall is Claude Code only: its literals match Claude's progress and recap prompts, and +// its no-tools rule holds because Claude always sends a manifest on real turns. Another +// client's tool-less calls (Bob's security classifier) are real turns with real cost. func isSideCall(inf *pipeline.InferenceExtension) bool { + if inf.AgentRole == "" { + return false + } if len(inf.Tools) == 0 && inf.ToolCount == 0 { return true } diff --git a/core/session/timeline/timeline.go b/core/session/timeline/timeline.go index 44170dfcb..e0ba56468 100644 --- a/core/session/timeline/timeline.go +++ b/core/session/timeline/timeline.go @@ -86,6 +86,9 @@ type Turn struct { // retry, so the time before it went to retrying rather than to the user, who added nothing. Resent bool `json:"resent,omitempty"` Seq uint64 `json:"seq"` + // Model is the model the call asked for, so a client that runs several models side by + // side (Bob's premium-ide turns beside its security classifier) reads apart. + Model string `json:"model,omitempty"` } // Tool is one tool call: from the response that requested it to the agent's first request diff --git a/core/session/timeline/timeline_test.go b/core/session/timeline/timeline_test.go index 906c6dc49..db4e691b1 100644 --- a/core/session/timeline/timeline_test.go +++ b/core/session/timeline/timeline_test.go @@ -851,3 +851,59 @@ func TestLostTurnIsNotASuccess(t *testing.T) { t.Errorf("sp = %+v, want it ended at the first failure (300s)", x) } } + +// bobTurn records a chat-completions call from a known non-Claude client: no Subagent, no +// AgentRole, attributed only by Client. +func (r *rec) bobTurn(model string, start, dur float64, msgs []pipeline.InferenceMessage, results []pipeline.InferenceToolResult, calls ...pipeline.InferenceToolCall) { + id := r.id() + client := &pipeline.EventClient{Name: "bob"} + r.add(pipeline.SessionEvent{At: at(start), Phase: pipeline.SessionRequest, RequestID: id, Host: "api.us-east.bob.ibm.com", Client: client, + Inference: &pipeline.InferenceExtension{Model: model, Messages: msgs, ToolResults: results}}) + r.add(pipeline.SessionEvent{At: at(start + dur), Phase: pipeline.SessionResponse, RequestID: id, Host: "api.us-east.bob.ibm.com", Client: client, + StatusCode: 200, Duration: time.Duration(dur * float64(time.Second)), + Inference: &pipeline.InferenceExtension{Model: model, FinishReason: "stop", ToolCalls: calls}}) +} + +// A known client that sends no Claude markers still gets turns, all on the single main row — +// including its tool-less side calls, which are real model spend, labeled by model. +func TestKnownNonClaudeClientGetsTurnsOnMainRow(t *testing.T) { + var r rec + r.bobTurn("premium-ide", 0, 10, []pipeline.InferenceMessage{user("hi")}, nil) + r.bobTurn("security", 12, 2, []pipeline.InferenceMessage{user("check")}, nil) // side call: no tools + tl := Build("s", r.events, at(30)) + if len(tl.Turns) != 2 { + t.Fatalf("Turns = %d, want 2 (side calls of a non-Claude client are turns)", len(tl.Turns)) + } + for _, tu := range tl.Turns { + if tu.Agent != MainAgent { + t.Fatalf("turn agent = %q, want %q", tu.Agent, MainAgent) + } + } + if tl.Turns[0].Model != "premium-ide" || tl.Turns[1].Model != "security" { + t.Fatalf("models = %q, %q; want premium-ide, security", tl.Turns[0].Model, tl.Turns[1].Model) + } + if len(tl.Agents) != 1 || tl.Agents[0].ID != MainAgent { + t.Fatalf("Agents = %+v, want one main row", tl.Agents) + } +} + +// Unknown and pre-capture clients get no turns: the timeline must not claim agency where +// there is none, and a nil Client must not panic. +func TestUnknownClientGetsNoTurns(t *testing.T) { + var r rec + id := r.id() + r.add(pipeline.SessionEvent{At: at(0), Phase: pipeline.SessionRequest, RequestID: id, Host: "llm.example", + Client: &pipeline.EventClient{Name: ""}, + Inference: &pipeline.InferenceExtension{Model: "m", Messages: []pipeline.InferenceMessage{user("hi")}, Tools: tools}}) + r.add(pipeline.SessionEvent{At: at(5), Phase: pipeline.SessionResponse, RequestID: id, Host: "llm.example", StatusCode: 200}) + if tl := Build("s", r.events, at(10)); len(tl.Turns) != 0 { + t.Fatalf("Turns = %d, want 0 for an unknown client", len(tl.Turns)) + } + var r2 rec + id2 := r2.id() + r2.add(pipeline.SessionEvent{At: at(0), Phase: pipeline.SessionRequest, RequestID: id2, Host: "llm.example", + Inference: &pipeline.InferenceExtension{Model: "m", Messages: []pipeline.InferenceMessage{user("hi")}, Tools: tools}}) + if tl := Build("s2", r2.events, at(10)); len(tl.Turns) != 0 { + t.Fatalf("Turns = %d, want 0 for a nil Client", len(tl.Turns)) + } +} From fa04cb4ace94e5b0282388a9b069b568f0f27867 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:41:35 -0400 Subject: [PATCH 05/11] Test: Bob-shaped session closes tools and keeps returns empty Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/session/timeline/timeline_test.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/core/session/timeline/timeline_test.go b/core/session/timeline/timeline_test.go index db4e691b1..a0c232d8c 100644 --- a/core/session/timeline/timeline_test.go +++ b/core/session/timeline/timeline_test.go @@ -907,3 +907,28 @@ func TestUnknownClientGetsNoTurns(t *testing.T) { t.Fatalf("Turns = %d, want 0 for a nil Client", len(tl.Turns)) } } + +// A Bob-shaped session end to end: the tool call closes on the tool_call_id result, and the +// Claude-Code marker machinery (returns, jobs) stays empty because Bob sends none of it. +func TestBobSessionToolsCloseAndReturnsStayEmpty(t *testing.T) { + var r rec + r.bobTurn("premium-ide", 0, 10, []pipeline.InferenceMessage{user("list files")}, + nil, pipeline.InferenceToolCall{ID: "call_1", Name: "read_dir", Arguments: `{"path":"/tmp"}`}) + // The next request carries the tool result by tool_call_id (the parser's parse shape). + r.bobTurn("premium-ide", 12, 8, []pipeline.InferenceMessage{user("list files")}, + []pipeline.InferenceToolResult{result("call_1", "file_a\nfile_b")}) + tl := Build("s", r.events, at(30)) + tool := findTool(t, tl, "call_1") + if tool.Name != "read_dir" { + t.Fatalf("tool name = %q", tool.Name) + } + if tool.End == nil || tool.Lost { + t.Fatalf("tool = %+v, want closed by the tool_call_id result", tool) + } + if want := at(12); !tool.End.Equal(want) { + t.Fatalf("tool end = %v, want the result-bearing request at %v", tool.End, want) + } + if len(tl.Returns) != 0 || len(tl.Jobs) != 0 { + t.Fatalf("Returns = %+v, Jobs = %+v; both are Claude Code markers Bob never sends", tl.Returns, tl.Jobs) + } +} From 2f4bd3baf6c7fe372c2e9dfa6fbb6158794d3447 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:42:58 -0400 Subject: [PATCH 06/11] Feat: Name the turn's model in the timeline cursor strip Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/tui/timeline_pane.go | 13 +++++++++++-- cmd/agentop/tui/timeline_pane_test.go | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/cmd/agentop/tui/timeline_pane.go b/cmd/agentop/tui/timeline_pane.go index 68f61096c..8cf95963d 100644 --- a/cmd/agentop/tui/timeline_pane.go +++ b/cmd/agentop/tui/timeline_pane.go @@ -837,10 +837,19 @@ func (m *model) cursorStrip(lay tlLayout) string { parts = append(parts, sanitizeLabel(p)) } if len(parts) == 0 { + // The turn covering the cell names its model, so a client running several models side + // by side (Bob's classifier beside its main turns) reads apart. + suffix := "" + for _, tu := range m.timeline.tl.Turns { + if tu.Model != "" && !cell.from.Before(tu.Start) && (tu.End == nil || cell.from.Before(*tu.End)) { + suffix = " (" + sanitizeLabel(tu.Model) + " turn)" + break + } + } if h, ok := m.cellHost(lay, cell); ok { - return styleMuted.Render(head + "no tool running; a request to " + sanitizeLabel(h.Host) + " [enter] events") + return styleMuted.Render(head + "no tool running" + suffix + "; a request to " + sanitizeLabel(h.Host) + " [enter] events") } - return styleMuted.Render(head + "no tool running in this cell") + return styleMuted.Render(head + "no tool running" + suffix + " in this cell") } if len(parts) > 1 { head += fmt.Sprintf("(%d) ", len(parts)) diff --git a/cmd/agentop/tui/timeline_pane_test.go b/cmd/agentop/tui/timeline_pane_test.go index f2fecc5e4..e90ca8684 100644 --- a/cmd/agentop/tui/timeline_pane_test.go +++ b/cmd/agentop/tui/timeline_pane_test.go @@ -1306,3 +1306,23 @@ func TestTimelineWaits_AskUserQuestionIsWaitingOnYou(t *testing.T) { t.Errorf("an open question's wait has tail %d, want the capped %d", ax.tail, timelineTailCells) } } + +// A cell inside a turn that carries a model names it, so a client running several models +// side by side (Bob's classifier beside its main turns) reads apart. +func TestTimelineStrip_NamesTheTurnModel(t *testing.T) { + m := timelineModel(t, 140, 46) + m.timeline.windowIdx = 3 + lay, _ := m.timelineLayoutNow() + m.timeline.cursor = cellOf(t, lay, 5) // main's first model call: no tool yet + for i := range m.timeline.tl.Turns { + if tu := &m.timeline.tl.Turns[i]; tu.Agent == timeline.MainAgent && tu.Start.Equal(tlAt(0)) { + tu.Model = "premium-ide" + } + } + m.timeline.layoutKey = tlLayoutKey{} + lay, _ = m.timelineLayoutNow() + strip := stripANSI(m.cursorStrip(lay)) + if !strings.Contains(strip, "premium-ide") { + t.Errorf("strip = %q, want the turn's model named", strip) + } +} From 0ad27999893037fda5ebb9654659caf275de4e18 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:43:21 -0400 Subject: [PATCH 07/11] Docs: Timeline attribution is by known client, not Claude Code only Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- CLAUDE.md | 2 +- docs/session-api.md | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6fc149803..c86830420 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -526,7 +526,7 @@ When `session.enabled` is true (default) and `listener.session_api_addr` is non- | `GET /` | text | One-line-per-endpoint index. Answers "is this the session API, and on the right port?" — the reason a 404 here was worth replacing. | | `GET /v1/sessions` | `application/json` | List active sessions: `{sessions: [{id, createdAt, updatedAt, eventCount, title, agent, totalTokens, costMicros, avoidedMicros, saturated, active, promptContext, inferenceHost, inferenceHostFromHistory, procs, lastRequestAt, linkedTo, linkedAll}]}`. `id`, `createdAt`, `updatedAt`, `eventCount` and `active` are always present; every other field is `omitempty` — absent rather than zero, on the standing rule that an unknown value must not render as a real one. (Do not read that off the position of `active`: it sits among the `omitempty` fields.) **`procs`** are the processes (`.`) whose own session header named the session, and **`linkedTo`/`linkedAll`** — on a `pending:@.` bucket only, computed when the list is read (`session.Link`) — say which sessions that process named its calls belong to (`{session, first, last}`) and whether every request in the bucket came before one of them. A pending bucket is never renamed into a session: agentop hides a `linkedAll` bucket that links to one session and merges its calls into that session's timeline. **`inferenceHost`** is the host of the session's most recent request that carried an inference parse — after a redirect, the server it was sent to rather than the host the client named — folded at append time, latest wins, and left alone by tunnel rows, MCP calls, responses and denials; agentop's sessions-table `SERVER` column names a session's inference server from it, and both `S`'s result line and `agentop server remove`'s warning count running sessions by it. **`inferenceHostFromHistory`** is `true` when that host came only from the archive's fold, because no request the proxy's entry for the session holds has set it — a session resumed after a restart that has sent no inference since. On a local install the inference-router keeps its pins in the plugin store (`~/.cortex/plugin-state.json`), which a restart keeps, so both of agentop's counts include such a session. **`title` is a suggestion, not an identifier:** the proxy derives it from the session's own events (a `/rename`, else a ``, else ordinary user prose, with `` blocks excised), so it is a display convenience and nothing addresses a session by it. Absent when nothing in the events named it. **Folded at append time and FIRST-WINS, except that a `/rename` always overrides** — so ordinary conversation does not re-title a session on every turn, and a `/rename` survives eviction of the event that carried it. agentop reads this field as a FALLBACK: its TITLE column prefers a harvested Claude Code transcript title and uses the served title only for a session the harvest cannot name. That precedence is fixed rather than a judgement about which string is better — both sides rank candidates their own way and do not agree on every session. That is the case worth having: an agent with no transcript tree on the operator's disk still routes through the proxy, so a row that used to render blank now has a name. agentop deliberately still treats such a row as unnamed for its own re-harvest backoff, so a served title does not stop it looking for a harvested one. **`?archived=true` adds history** where the proxy runs a session archive (`session.archive`, laptop only — see the chatty-traffic gotcha): every session the archive holds and memory does not, marked `resident: false`, plus a top-level `archive` object — `{bytes, maxBytes, retentionDays}` and, only when nonzero, `droppedEvents`, `writeErrors`, `droppedRenames`, `paused`, any of which means the history has gaps. A session in both is listed once, as its resident row. **That row covers the session's whole history**: `eventCount`, `totalTokens`, `costMicros`, `avoidedMicros`, `currencies`, `saturated`, `title`, `agent`, `createdAt`, `promptContext` and `inferenceHost` continue from the archive's fold of what came before this process's entry, each by its own rule, in the default list too — so with an archive and `session.max_events` unset (the default) a restart changes no figure, and without an archive they start over. Without an archive the parameter is ignored and no `archive` object is sent, which is how a client tells "no history here" from "no history yet". | | `GET /v1/sessions/{id}` | `application/json` | The session's most recent events. `?limit=N` (default 500, max 2000) sets the window; `?before=` returns the page ending just before that event, so the whole session is reachable by paging backward from the tail. `totalEvents` is the session's true length and `oldestSeq` the oldest event the store still holds — both present only when this response is not the whole session, so a client can tell "this is the beginning" from "there is more behind me" without a second request. 404 if unknown/expired. **One response is still not a full snapshot:** with `session.max_events` unset a session can hold thousands of events, and one real session's whole history encoded to 1.1GB — 17s to write, against clients that time out in 10. That cap is why `before` exists — until it did, a session past 2000 events had a beginning no request could reach at any limit, while still costing memory. The response is written one event at a time rather than encoded whole, so serving it costs the proxy heap proportional to one event; see the chatty-traffic gotcha below. **With a session archive, a page continues from disk** below the oldest event memory holds: a session resumed after a restart pages back through its whole history, one evicted from memory is served instead of 404'd, and `totalEvents`/`oldestSeq` describe the merged history. `GET /v1/sessions/{id}/events/{seq}` falls back to the archive the same way, and answers 404 rather than a neighbour when neither holds that seq. | -| `GET /v1/sessions/{id}/timeline` | `application/json` | The session as spans for agentop's Timeline pane: Claude Code agents (main and subagents, linked by the `Agent` call that spawned them), model turns, tool calls (request to result, matched by tool-use id), subagent returns, and every host request or tunnel. Computed from memory plus the archive, cached per session until its newest event changes; `?from=`/`?to=` clip. Fields and rules in [`docs/session-api.md`](docs/session-api.md#get-v1sessionsidtimeline--agents-tools-and-hosts-on-one-time-axis). | +| `GET /v1/sessions/{id}/timeline` | `application/json` | The session as spans for agentop's Timeline pane: agents (any known client on a single `main` row; Claude Code additionally its subagent tree, linked by the `Agent` call that spawned them), model turns (labeled by model), tool calls (request to result, matched by tool-use id — Anthropic `tool_result`, Responses API `function_call_output`, or chat-completions `tool_call_id`), subagent returns, and every host request or tunnel. Computed from memory plus the archive, cached per session until its newest event changes; `?from=`/`?to=` clip. Fields and rules in [`docs/session-api.md`](docs/session-api.md#get-v1sessionsidtimeline--agents-tools-and-hosts-on-one-time-axis). | | `DELETE /v1/sessions` | `application/json` | Clear every session: the store, and the session archive where one runs. **The cost ledger is kept** — spend totals, no content. Answers `{sessions, archivedSessions, bytes}`; a failed disk half is a 500 adding `archiveError`, with memory cleared regardless (the store clears under its lock before the archive is waited on, up to 10s). **State-changing on an unauthenticated API, so it is guarded against a browser**, each guard pinned by its own test in `core/sessionapi/clear_test.go`: DELETE only (a cross-site DELETE needs a preflight this server never approves; a simple cross-site POST needs none); a loopback `Host` — exactly `localhost`, `127.0.0.1` or `::1`, which defeats DNS rebinding; no `Origin` header; and `WithClearAllowed`, which `cmd/cortex` sets from `listener.bind_loopback_only`. A refusal is a 403 `{"error": ...}`. agentop's `X` drives it. | | `DELETE /v1/sessions/{id}` | `application/json` | Erase one session: the store's entry and, where the archive runs, its files — a session only the archive holds too. **The same four guards as `DELETE /v1/sessions`** (pinned again in `core/sessionapi/delete_test.go`) and the same answer, each count 0 or 1; **404** when neither half held the id, **500** with `archiveError` when the disk half failed (memory is erased regardless). The cost ledger, usage totals and inference-router pins are kept. An agent still using the session brings it back with its next request. agentop's `x` drives it, one request per session for an agent-wide erase. | | `GET /v1/events` | `text/event-stream` | SSE stream of new events. Optional `?session=` filters to one session. Heartbeat every 30s. | diff --git a/docs/session-api.md b/docs/session-api.md index c0231aa9e..53cd628a6 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -132,13 +132,18 @@ the whole list. "hosts": [{"host": "api.github.com", "kind": "tunnel", "start": "…", "end": "…", "status": 200, "seq": 7}]} ``` -**Agents, turns, tools and returns are Claude Code only.** They come from the -requests the inference parser attributed to a Claude Code agent (`agentRole`), -and from the agent ids Claude Code sends (`subagent`, `parentSubagent`): +**Turns and tools are attributed to any known client** (Claude Code, Bob, Codex, +OpenCode, Pi — the canonical name parsed from the User-Agent); each gets a +single `main` row, and every model call is a turn, including classifier side +calls, labeled by `model`. Claude Code additionally gets its subagent tree, +returns and background jobs — those key on the agent ids and marker strings +(`agentRole`, `subagent`, `parentSubagent`) no other client sends. Requests +from unknown clients render as host rows only. - A **turn** is one model call, request to end of response. Claude Code's progress-label and away-recap calls (the prompt in the last non-system - message) and tool-less side calls (titler, classifier) are left out. `error` + message) and tool-less side calls (titler, classifier) are left out; another + client's side calls are kept, cost and all. `error` marks a call that failed: an upstream error, a refusal, the client giving up, a finish of `content_filter`, `failed` or `cancelled`, or a response that ended with no finish reason, which is a stream cut mid-reply. `resent` marks From ed9a347d69ec5cd54499e0e1adb454073e48dd2b Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:49:52 -0400 Subject: [PATCH 08/11] Style: gofmt the timeline tests Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/session/timeline/timeline_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/session/timeline/timeline_test.go b/core/session/timeline/timeline_test.go index a0c232d8c..59944a32a 100644 --- a/core/session/timeline/timeline_test.go +++ b/core/session/timeline/timeline_test.go @@ -893,7 +893,7 @@ func TestUnknownClientGetsNoTurns(t *testing.T) { var r rec id := r.id() r.add(pipeline.SessionEvent{At: at(0), Phase: pipeline.SessionRequest, RequestID: id, Host: "llm.example", - Client: &pipeline.EventClient{Name: ""}, + Client: &pipeline.EventClient{Name: ""}, Inference: &pipeline.InferenceExtension{Model: "m", Messages: []pipeline.InferenceMessage{user("hi")}, Tools: tools}}) r.add(pipeline.SessionEvent{At: at(5), Phase: pipeline.SessionResponse, RequestID: id, Host: "llm.example", StatusCode: 200}) if tl := Build("s", r.events, at(10)); len(tl.Turns) != 0 { From e3649d84a5a5eda5e63d45d6838969cf56ef0943 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:57:00 -0400 Subject: [PATCH 09/11] Fix: Count a chat-completions tool result once in cpex policy input The parser records it in both Messages (with its tool_call_id on the message) and ToolResults; the cpex walkers assumed the two disjoint and emitted it twice, inflating tool_result_count. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/pipeline/extensions.go | 7 +++++ core/plugins/cpex/cmf_body.go | 3 ++ core/plugins/cpex/cmf_inference.go | 5 ++- core/plugins/cpex/cmf_inference_test.go | 31 +++++++++++++++++++ core/plugins/inferenceparser/plugin.go | 1 + .../inferenceparser/toolresults_test.go | 3 ++ 6 files changed, 49 insertions(+), 1 deletion(-) diff --git a/core/pipeline/extensions.go b/core/pipeline/extensions.go index ce9f52a48..9424f22b2 100644 --- a/core/pipeline/extensions.go +++ b/core/pipeline/extensions.go @@ -321,6 +321,13 @@ type InferenceMessage struct { // same content — tens of percent apart on a deeply nested tool result. // Comparable across messages from one client; not across clients. ContentBytes int `json:"contentBytes,omitempty"` + + // ToolUseID is the tool_call_id of a chat-completions "role":"tool" result message: + // the id of the assistant tool_calls entry it answers. The parser also records the + // result in InferenceExtension.ToolResults, so a consumer walking both must skip the + // message half of a pair this id names — the result would otherwise count twice. + // Empty on every other message. + ToolUseID string `json:"toolUseId,omitempty"` } // RawJSON is a JSON value held exactly as it arrived, rather than decoded into Go values. diff --git a/core/plugins/cpex/cmf_body.go b/core/plugins/cpex/cmf_body.go index e17c07198..96888f128 100644 --- a/core/plugins/cpex/cmf_body.go +++ b/core/plugins/cpex/cmf_body.go @@ -716,6 +716,9 @@ func inferenceToCMFParts(inf *pipeline.InferenceExtension) []cmfPart { for _, m := range inf.Messages { switch { case m.Content == "": + case m.Role == roleTool && m.ToolUseID != "": + // A chat-completions result the parser also recorded in ToolResults; the + // loop below emits the id-carrying copy, so this one would count twice. case m.Role == roleTool: parts = append(parts, cmfPart{Kind: cmfPartToolResult, Content: m.Content}) default: diff --git a/core/plugins/cpex/cmf_inference.go b/core/plugins/cpex/cmf_inference.go index f1fd012b9..368c814c4 100644 --- a/core/plugins/cpex/cmf_inference.go +++ b/core/plugins/cpex/cmf_inference.go @@ -408,7 +408,10 @@ func inferenceToolResultsCustom(inf *pipeline.InferenceExtension) (count int, re count++ } for _, m := range inf.Messages { - if m.Role == roleTool { + // A chat-completions result the parser also recorded in ToolResults (named by its + // ToolUseID) is added by the loop below; adding the message copy too would count it + // twice in the policy input. + if m.Role == roleTool && m.ToolUseID == "" { add("", m.Content, false) } } diff --git a/core/plugins/cpex/cmf_inference_test.go b/core/plugins/cpex/cmf_inference_test.go index ce287bac9..ba1dff36f 100644 --- a/core/plugins/cpex/cmf_inference_test.go +++ b/core/plugins/cpex/cmf_inference_test.go @@ -586,3 +586,34 @@ func TestApplyInferenceRequestMod_NoMessagesFailsClosed(t *testing.T) { t.Fatalf("unchanged: mutated=%v err=%v", mutated, err) } } + +// The parser records a chat-completions tool result in BOTH places: the role:"tool" +// message (now carrying its tool_call_id) and inf.ToolResults. The two were disjoint +// sources when these walkers were written; a result present in both must count once. +func TestInferenceToCMFParts_ParserShapedToolResultCountsOnce(t *testing.T) { + inf := &pipeline.InferenceExtension{Messages: []pipeline.InferenceMessage{ + {Role: "user", Content: "read .env"}, + {Role: "tool", Content: "TOKEN=xyz", ToolUseID: "call_1"}, + }, ToolResults: []pipeline.InferenceToolResult{{ToolUseID: "call_1", Content: "TOKEN=xyz"}}} + parts := inferenceToCMFParts(inf) + want := []cmfPart{ + {Kind: cmfPartText, Text: "read .env"}, + {Kind: cmfPartToolResult, CorrelationID: "call_1", Content: "TOKEN=xyz"}, + } + if !reflect.DeepEqual(parts, want) { + t.Fatalf("parts = %+v, want %+v", parts, want) + } +} + +func TestInferenceToolResultsCustom_ParserShapedToolResultCountsOnce(t *testing.T) { + inf := &pipeline.InferenceExtension{Messages: []pipeline.InferenceMessage{ + {Role: "tool", Content: "TOKEN=xyz", ToolUseID: "call_1"}, + }, ToolResults: []pipeline.InferenceToolResult{{ToolUseID: "call_1", Content: "TOKEN=xyz"}}} + count, results := inferenceToolResultsCustom(inf) + if count != 1 || len(results) != 1 { + t.Fatalf("count = %d, results = %+v; want the result once", count, results) + } + if results["0"].(map[string]any)["tool_use_id"] != "call_1" { + t.Fatalf("results = %+v, want the id-carrying copy", results) + } +} diff --git a/core/plugins/inferenceparser/plugin.go b/core/plugins/inferenceparser/plugin.go index 6b1984728..5afd8c194 100644 --- a/core/plugins/inferenceparser/plugin.go +++ b/core/plugins/inferenceparser/plugin.go @@ -147,6 +147,7 @@ func parseOpenAIRequest(body []byte) *pipeline.InferenceExtension { Role: msg.Role, Content: msg.Content, ContentBytes: msg.ContentBytes, + ToolUseID: msg.ToolCallID, }) } // A chat-completions tool result is a "role":"tool" message naming the call it answers diff --git a/core/plugins/inferenceparser/toolresults_test.go b/core/plugins/inferenceparser/toolresults_test.go index 6df586d56..b05d4a503 100644 --- a/core/plugins/inferenceparser/toolresults_test.go +++ b/core/plugins/inferenceparser/toolresults_test.go @@ -28,6 +28,9 @@ func TestChatCompletionsToolResultsCarryTheCallID(t *testing.T) { for _, m := range ext.Messages { if m.Role == "tool" && m.Content == "file_a\nfile_b" { toolMsg = true + if m.ToolUseID != "call_1" { + t.Fatalf("role:tool message ToolUseID = %q, want call_1", m.ToolUseID) + } } } if !toolMsg { From 2191df2d3f6c90ccba43ca1ab7ffd1a7b819a651 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 09:07:06 -0400 Subject: [PATCH 10/11] Fix: Keep the recap filter for pre-marker Claude Code traffic The prompt literals match Claude's own progress and recap prompt text, which no other client sends, so they now run for every client; only the no-tools rule stays gated on AgentRole. Fixes review: isSideCall's AgentRole gate disabled the progress/recap prompt-literal filter for pre-marker Claude Code traffic, rendering recap storms as turns. Files: core/session/timeline/agents.go, core/session/timeline/timeline_test.go Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/session/timeline/agents.go | 14 +++++++------- core/session/timeline/timeline_test.go | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/core/session/timeline/agents.go b/core/session/timeline/agents.go index 20cbddc76..0fb28301f 100644 --- a/core/session/timeline/agents.go +++ b/core/session/timeline/agents.go @@ -374,14 +374,14 @@ func agentOf(e *pipeline.SessionEvent) (string, bool) { return "", false } -// isSideCall is Claude Code only: its literals match Claude's progress and recap prompts, and -// its no-tools rule holds because Claude always sends a manifest on real turns. Another -// client's tool-less calls (Bob's security classifier) are real turns with real cost. +// isSideCall drops a call that is not an agent's work. The no-tools rule is Claude Code only +// (AgentRole set): it holds because Claude always sends a manifest on real turns, while another +// client's tool-less calls (Bob's security classifier) are real turns with real cost. The prompt +// literals run for every client: they match Claude's own progress and recap prompt text, which +// no other client sends, and a recap storm reads as turns on any session that carries one — +// including pre-marker Claude Code traffic, attributed by client name alone. func isSideCall(inf *pipeline.InferenceExtension) bool { - if inf.AgentRole == "" { - return false - } - if len(inf.Tools) == 0 && inf.ToolCount == 0 { + if inf.AgentRole != "" && len(inf.Tools) == 0 && inf.ToolCount == 0 { return true } for i := len(inf.Messages) - 1; i >= 0; i-- { diff --git a/core/session/timeline/timeline_test.go b/core/session/timeline/timeline_test.go index 59944a32a..ee4d3d665 100644 --- a/core/session/timeline/timeline_test.go +++ b/core/session/timeline/timeline_test.go @@ -932,3 +932,26 @@ func TestBobSessionToolsCloseAndReturnsStayEmpty(t *testing.T) { t.Fatalf("Returns = %+v, Jobs = %+v; both are Claude Code markers Bob never sends", tl.Returns, tl.Jobs) } } + +// A pre-marker Claude Code call (AgentRole empty, attributed by its client name) still drops +// its recap: the prompt literals are Claude's own prompt text, so they run for every client, +// while the no-tools rule stays Claude Code only. +func TestPreMarkerRecapIsNotATurn(t *testing.T) { + var r rec + client := &pipeline.EventClient{Name: "claude-code"} + id := r.id() + r.add(pipeline.SessionEvent{At: at(0), Phase: pipeline.SessionRequest, RequestID: id, Host: "llm.example", Client: client, + Inference: &pipeline.InferenceExtension{Tools: tools, Messages: []pipeline.InferenceMessage{ + user("The user stepped away and is coming back. Recap in under 40 words.")}}}) + r.add(pipeline.SessionEvent{At: at(1), Phase: pipeline.SessionResponse, RequestID: id, Host: "llm.example", Client: client, + StatusCode: 200, Inference: &pipeline.InferenceExtension{FinishReason: "end_turn"}}) + id = r.id() + r.add(pipeline.SessionEvent{At: at(2), Phase: pipeline.SessionRequest, RequestID: id, Host: "llm.example", Client: client, + Inference: &pipeline.InferenceExtension{Tools: tools, Messages: []pipeline.InferenceMessage{user("go")}}}) + r.add(pipeline.SessionEvent{At: at(3), Phase: pipeline.SessionResponse, RequestID: id, Host: "llm.example", Client: client, + StatusCode: 200, Inference: &pipeline.InferenceExtension{FinishReason: "end_turn"}}) + tl := Build("s", r.events, at(4)) + if len(tl.Turns) != 1 || !tl.Turns[0].Start.Equal(at(2)) { + t.Errorf("turns = %+v, want only the real call at 2s", tl.Turns) + } +} From 9f782a822a63407069457b7401388e71e1417e11 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 09:12:16 -0400 Subject: [PATCH 11/11] Docs: The recap literals filter every client's turns Fixes review: docs/session-api.md's turn bullet still scoped the progress/recap literal exclusion to Claude Code after the code change widened it; the design spec's side-call section now notes the amendment. Files: docs/session-api.md, docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- docs/session-api.md | 9 +++++---- .../specs/2026-10-30-agent-neutral-timeline-design.md | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/session-api.md b/docs/session-api.md index 53cd628a6..f3185f2c9 100644 --- a/docs/session-api.md +++ b/docs/session-api.md @@ -140,10 +140,11 @@ returns and background jobs — those key on the agent ids and marker strings (`agentRole`, `subagent`, `parentSubagent`) no other client sends. Requests from unknown clients render as host rows only. -- A **turn** is one model call, request to end of response. Claude Code's - progress-label and away-recap calls (the prompt in the last non-system - message) and tool-less side calls (titler, classifier) are left out; another - client's side calls are kept, cost and all. `error` +- A **turn** is one model call, request to end of response. Left out: + progress-label and away-recap calls (matched by their prompt text in the + last non-system message — Claude's own prompt strings, applied to every + client), and Claude Code's tool-less side calls (titler, classifier). + Another client's side calls are kept, cost and all. `error` marks a call that failed: an upstream error, a refusal, the client giving up, a finish of `content_filter`, `failed` or `cancelled`, or a response that ended with no finish reason, which is a stream cut mid-reply. `resent` marks diff --git a/docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md b/docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md index ca4aa5533..9ee341fa9 100644 --- a/docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md +++ b/docs/superpowers/specs/2026-10-30-agent-neutral-timeline-design.md @@ -59,6 +59,11 @@ inference. Under generic attribution that would eat Bob's `security` and Code traffic — `Inference.AgentRole != ""` is the signal, since only Claude Code sets it — so non-Claude side calls render as turns, labeled by model. +Amended in review: only the no-tools rule took the gate. The prompt literals +run for every client, so pre-marker Claude Code traffic (no `AgentRole`) +still drops its recaps — they are Claude's own prompt text, which no other +client sends. + ### 3. `Turn.Model` Add `Model string` (omitempty) to the `Turn` span, populated from the paired