From eafc90b602eb53a58371d68874b0658f918cb455 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 9 Nov 2026 16:18:38 -0500 Subject: [PATCH] Fix: Label workflow subagents in the agentop timeline The timeline pane labeled each agent that Claude Code's Workflow tool starts as "subagent ". Such an agent has no Agent tool call on the wire, so the server has no description for it. Claude Code writes the label to disk, in subagents/workflows//agent-.meta.json. The harvest now reads that folder. An incremental harvest also re-reads the meta files of a session whose transcript did not change, because a workflow starts agents without a write to the main transcript. The timeline pane uses the harvested description for an agent with no spawn call. It asks for a harvest when such an agent has no label, at most once per 15 s. Fixes #1756 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/tui/app.go | 7 +++-- cmd/agentop/tui/timeline_pane.go | 45 +++++++++++++++++++++++++-- cmd/agentop/tui/timeline_pane_test.go | 45 +++++++++++++++++++++++++++ core/observe/claude/harvest.go | 38 ++++++++++++++++------ core/observe/claude/harvest_test.go | 39 +++++++++++++++++++++++ 5 files changed, 158 insertions(+), 16 deletions(-) diff --git a/cmd/agentop/tui/app.go b/cmd/agentop/tui/app.go index b0de98b34..6a86879be 100644 --- a/cmd/agentop/tui/app.go +++ b/cmd/agentop/tui/app.go @@ -479,8 +479,9 @@ type model struct { // version is the build agentop was started as, shown after its name on every title bar. // Empty renders the bare name, which is what tests construct. version string - // lastHarvest is when the most recent harvest was STARTED. Read by ONE caller: the sessions - // pane's backoff gate, which asks whether untitledBackoff has elapsed since then. The picker + // lastHarvest is when the most recent harvest was STARTED. Read by TWO callers: the sessions + // pane's backoff gate, which asks whether untitledBackoff has elapsed since then, and the + // timeline pane's gate for unlabeled subagents (timelineLabelHarvestEvery). The picker // no longer has a cadence to measure — it harvests on arrival — so nothing there reads this. // // STAMPED BY EVERY PATH, THOUGH, INCLUDING THE ONES THAT NEVER READ IT, and that is a real @@ -1635,7 +1636,7 @@ func (m *model) update(msg tea.Msg) (tea.Model, tea.Cmd) { case timelineLoadedMsg: m.handleTimelineLoaded(msg) - return m, nil + return m, m.harvestForTimeline() case timelineTickMsg: return m, m.handleTimelineTick(msg) diff --git a/cmd/agentop/tui/timeline_pane.go b/cmd/agentop/tui/timeline_pane.go index 0c6aca154..a442d491f 100644 --- a/cmd/agentop/tui/timeline_pane.go +++ b/cmd/agentop/tui/timeline_pane.go @@ -762,8 +762,46 @@ func axisLabels(lay tlLayout) string { return strings.TrimRight(string(line[:min(len(line), w+8)]), " ") } +// timelineLabelHarvestEvery is the shortest time between two harvests that the timeline pane +// starts for subagents with no label. +const timelineLabelHarvestEvery = 15 * time.Second + +// harvestedLabel is the description Claude Code wrote for a subagent with no spawn call on +// the wire, or "". An agent that Claude Code's Workflow tool starts has no Agent tool call, so +// the server can only label it "subagent ". Its meta file on disk holds the real label. +func (m *model) harvestedLabel(a timeline.Agent) string { + if a.SpawnTool != "" { + return "" + } + return m.sessionsData[m.timeline.session].Subagents[a.ID].Description +} + +func (m *model) timelineLabel(a timeline.Agent) string { + if l := m.harvestedLabel(a); l != "" { + return l + } + return a.Label +} + +// harvestForTimeline starts a harvest when the timeline shows a subagent that has no label +// yet. A workflow starts agents while its main transcript stays unchanged, so nothing else +// starts a harvest during the run. +func (m *model) harvestForTimeline() tea.Cmd { + if m.harvest == nil || m.harvesting || m.timeline.tl == nil || time.Since(m.lastHarvest) < timelineLabelHarvestEvery { + return nil + } + for _, a := range m.timeline.tl.Agents { + if a.SpawnTool == "" && a.ID != timeline.MainAgent && a.ID != timeline.UnseparatedAgent && m.harvestedLabel(a) == "" { + m.harvesting = true + m.lastHarvest = time.Now() + return harvestCmd(m.harvest) + } + } + return nil +} + func (m *model) agentLine(r tlAgentRow, lay tlLayout) string { - label := r.agent.Label + label := m.timelineLabel(r.agent) if r.depth > 0 { label = strings.Repeat(" ", r.depth) + label } @@ -907,11 +945,12 @@ func (m *model) shortAgent(id string) string { } for _, a := range m.timeline.tl.Agents { if a.ID == id { - l := []rune(a.Label) + label := m.timelineLabel(a) + l := []rune(label) if len(l) > 14 { return string(l[:13]) + "…" } - return a.Label + return label } } return id diff --git a/cmd/agentop/tui/timeline_pane_test.go b/cmd/agentop/tui/timeline_pane_test.go index 872b34e41..ea7b45fed 100644 --- a/cmd/agentop/tui/timeline_pane_test.go +++ b/cmd/agentop/tui/timeline_pane_test.go @@ -11,6 +11,7 @@ import ( "github.com/rossoctl/cortex/cmd/agentop/tui/table" "github.com/rossoctl/cortex/cmd/agentop/apiclient" + "github.com/rossoctl/cortex/core/observe/claude" "github.com/rossoctl/cortex/core/pipeline" "github.com/rossoctl/cortex/core/session" "github.com/rossoctl/cortex/core/session/timeline" @@ -1380,3 +1381,47 @@ func TestTimelineStrip_NamesTheTurnModel(t *testing.T) { t.Errorf("strip = %q, want the turn's model named", strip) } } + +// A workflow agent has no spawn call on the wire, so the server labels it "subagent ". The +// pane uses the description Claude Code wrote to disk, and asks for a harvest while one is +// missing. An agent that a spawn call names keeps its wire label. +func TestTimeline_WorkflowAgentUsesHarvestedLabel(t *testing.T) { + m := timelineModel(t, 160, 40) + m.timeline.tl.Agents = append(m.timeline.tl.Agents, + timeline.Agent{ID: "wf1", Parent: timeline.MainAgent, Label: "subagent wf1"}) + m.timeline.tl.Turns = append(m.timeline.tl.Turns, + timeline.Turn{Agent: "wf1", Start: tlAt(700), End: tlPtr(800), Finish: "end_turn", Seq: 25}) + harvests := 0 + m.harvest = func() (map[string]SessionMetadata, error) { harvests++; return nil, nil } + + if !strings.Contains(stripANSI(m.renderTimeline()), "subagent wf1") { + t.Fatalf("no harvest yet: want the server's label") + } + cmd := m.harvestForTimeline() + if cmd == nil || !m.harvesting { + t.Fatalf("unlabeled agent: want a harvest, got cmd=%v harvesting=%v", cmd != nil, m.harvesting) + } + cmd() + if harvests != 1 { + t.Fatalf("harvests = %d, want 1", harvests) + } + if m.harvestForTimeline() != nil { + t.Errorf("a harvest in flight: want no second one") + } + + m.harvesting, m.lastHarvest = false, time.Time{} + m.sessionsData = map[string]SessionMetadata{"sess-1": {Subagents: map[string]claude.SubagentInfo{ + "wf1": {AgentType: "workflow-subagent", Description: "implement T1: x"}, + "a1": {Description: "not the wire label"}, + }}} + out := stripANSI(m.renderTimeline()) + if !strings.Contains(out, "implement T1: x") || strings.Contains(out, "subagent wf1") { + t.Errorf("want the harvested label for wf1:\n%s", out) + } + if !strings.Contains(out, "Survey the code") || strings.Contains(out, "not the wire label") { + t.Errorf("a spawned agent keeps its wire label:\n%s", out) + } + if m.harvestForTimeline() != nil { + t.Errorf("every agent labeled: want no harvest") + } +} diff --git a/core/observe/claude/harvest.go b/core/observe/claude/harvest.go index c93ed29f6..8c4a07fc5 100644 --- a/core/observe/claude/harvest.go +++ b/core/observe/claude/harvest.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "path/filepath" "strings" @@ -460,6 +461,14 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin // converging. won[id] = st.ModTime() delete(out, id) + // The subagent labels are re-read anyway. A workflow (Claude Code's Workflow + // tool) starts its agents without a write to the main transcript, so the + // mtime above does not move while new agents start. The meta files are small. + if subs := readSubagents(filepath.Join(projDir, id, "subagents")); !maps.Equal(subs, since[id].Subagents) { + kept := since[id] + kept.Subagents = subs + out[id] = kept + } skipped++ continue } @@ -479,11 +488,8 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin LogFile: path, LogModTime: st.ModTime(), // The subagents' labels live in a sibling DIRECTORY of the transcript - // (/subagents), whose mtime the skip above knows nothing about. That - // is fine, because a session only gains a subagent by appending the Agent - // tool call that spawned it to the transcript — which moves the - // transcript's mtime and re-parses the session, meta files and all. A - // skipped session keeps the Subagents its `since` entry already carried. + // (/subagents), whose mtime the skip above knows nothing about, so the + // skip re-reads them on its own. Subagents: readSubagents(filepath.Join(projDir, id, "subagents")), } } @@ -492,19 +498,31 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin } // readSubagents reads one session's subagents/agent-.meta.json files into a map keyed -// by the bare id. Nil when the directory is missing or holds nothing harvestable — a -// session that never spawned a subagent, which is most of them, must not carry an empty -// map the JSON would then spell out. +// by the bare id. It also reads subagents/workflows//agent-.meta.json, where +// Claude Code puts the agents that its Workflow tool starts. Nil when the directory is +// missing or holds nothing harvestable — a session that never spawned a subagent, which +// is most of them, must not carry an empty map the JSON would then spell out. // // Best-effort per file, for the same reason titleFromTranscript is: one unparseable meta // file must not cost the other labels. The transcripts sitting beside the meta files are // deliberately not read — see SessionMetadata.Subagents. func readSubagents(dir string) map[string]SubagentInfo { + out := readSubagentDir(dir, nil) + runs, _ := os.ReadDir(filepath.Join(dir, "workflows")) + for _, r := range runs { + if r.IsDir() { + out = readSubagentDir(filepath.Join(dir, "workflows", r.Name()), out) + } + } + return out +} + +// readSubagentDir adds the agent-.meta.json files in dir to out, and returns out. +func readSubagentDir(dir string, out map[string]SubagentInfo) map[string]SubagentInfo { entries, err := os.ReadDir(dir) if err != nil { - return nil + return out } - var out map[string]SubagentInfo for _, e := range entries { name := e.Name() if !strings.HasPrefix(name, "agent-") || !strings.HasSuffix(name, ".meta.json") { diff --git a/core/observe/claude/harvest_test.go b/core/observe/claude/harvest_test.go index 13a29976b..1bedf998b 100644 --- a/core/observe/claude/harvest_test.go +++ b/core/observe/claude/harvest_test.go @@ -2995,6 +2995,45 @@ func TestHarvest_ReadsSubagentMetaFiles(t *testing.T) { } } +// The Workflow tool's agents keep their meta files in subagents/workflows//. They +// start without a write to the main transcript, so an incremental harvest that skips the +// unchanged transcript must still find an agent that started after the last harvest. +func TestHarvest_ReadsWorkflowSubagentsWhenTranscriptIsUnchanged(t *testing.T) { + metadataHome(t) + cfg := filepath.Join(t.TempDir(), "claude") + proj := filepath.Join(cfg, "projects", "-p") + writeSessionTranscript(t, proj, "s1.jsonl", `{"type":"ai-title","aiTitle":"t"}`) + subs := filepath.Join(proj, "s1", "subagents") + writeSessionTranscript(t, subs, "agent-a1.meta.json", `{"description":"Agent tool child"}`) + run := filepath.Join(subs, "workflows", "wf_1") + writeSessionTranscript(t, run, "agent-b1.meta.json", `{"agentType":"workflow-subagent","description":"implement T1: x"}`) + + if _, err := Harvest(Options{ConfigDir: cfg, Merge: true, Incremental: true}); err != nil { + t.Fatal(err) + } + writeSessionTranscript(t, run, "agent-b2.meta.json", `{"description":"merge T1: x"}`) + res, err := Harvest(Options{ConfigDir: cfg, Merge: true, Incremental: true}) + if err != nil { + t.Fatal(err) + } + if res.Skipped != 1 { + t.Fatalf("Skipped = %d, want 1: the transcript did not change", res.Skipped) + } + got := readMetadataFile(t, res.Path)["s1"] + want := map[string]string{"a1": "Agent tool child", "b1": "implement T1: x", "b2": "merge T1: x"} + if len(got.Subagents) != len(want) { + t.Fatalf("Subagents = %v, want %v", got.Subagents, want) + } + for id, d := range want { + if got.Subagents[id].Description != d { + t.Errorf("Subagents[%s] = %+v, want description %q", id, got.Subagents[id], d) + } + } + if got.Title != "t" { + t.Errorf("Title = %q, want the kept title", got.Title) + } +} + // A session with no subagents carries no Subagents map at all, so the JSON spells nothing // out for the sessions that have none — which is most of them. func TestHarvest_SessionWithoutSubagentsHasNoMap(t *testing.T) {