Skip to content

Commit abb97ac

Browse files
Discover nested Claude workflow subagents (#602)
## Summary - Walk Claude session `subagents/` directories recursively so workflow/deep-research subagent transcripts are discovered under `subagents/workflows/<workflow-id>/` - Preserve existing direct subagent discovery and continue ignoring `.meta.json` sidecars/non-agent JSONL files - Add regression coverage for nested workflow subagent discovery Fixes #596 ## Validation - `go test ./internal/parser -run TestDiscoverClaudeProjects/Nested_workflow_subagents -count=1` failed before the implementation - `go test ./internal/parser` - `go test ./...` - Reviewer subagent approved the diff with no issues Co-authored-by: Marius van Niekerk <mariusvniekerk@users.noreply.github.com>
1 parent 61d3d11 commit abb97ac

4 files changed

Lines changed: 95 additions & 32 deletions

File tree

internal/parser/discovery.go

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -344,35 +344,37 @@ func DiscoverClaudeProjects(projectsDir string) []DiscoveredFile {
344344
})
345345
}
346346

347-
// Scan session directories for subagent files
347+
// Scan session directories for subagent files. Claude workflow
348+
// tools group subagents under nested paths such as
349+
// subagents/workflows/<workflow-id>/agent-<id>.jsonl, so walk the
350+
// whole subagents tree instead of assuming transcripts are direct
351+
// children of subagents/.
348352
for _, sf := range sessionFiles {
349353
if !sf.IsDir() {
350354
continue
351355
}
352356
subagentsDir := filepath.Join(
353357
projDir, sf.Name(), "subagents",
354358
)
355-
subFiles, err := os.ReadDir(subagentsDir)
356-
if err != nil {
357-
continue
358-
}
359-
for _, sub := range subFiles {
360-
if sub.IsDir() {
361-
continue
362-
}
363-
name := sub.Name()
364-
if !strings.HasPrefix(name, "agent-") ||
365-
!strings.HasSuffix(name, ".jsonl") {
366-
continue
367-
}
368-
files = append(files, DiscoveredFile{
369-
Path: filepath.Join(
370-
subagentsDir, name,
371-
),
372-
Project: entry.Name(),
373-
Agent: AgentClaude,
374-
})
375-
}
359+
_ = filepath.WalkDir(
360+
subagentsDir,
361+
func(path string, sub os.DirEntry, err error) error {
362+
if err != nil || sub.IsDir() {
363+
return nil
364+
}
365+
name := sub.Name()
366+
if !strings.HasPrefix(name, "agent-") ||
367+
!strings.HasSuffix(name, ".jsonl") {
368+
return nil
369+
}
370+
files = append(files, DiscoveredFile{
371+
Path: path,
372+
Project: entry.Name(),
373+
Agent: AgentClaude,
374+
})
375+
return nil
376+
},
377+
)
376378
}
377379
}
378380

@@ -458,7 +460,7 @@ func FindClaudeSourceFile(
458460
}
459461

460462
// Subagent files live under session directories:
461-
// <project>/<session>/subagents/agent-<id>.jsonl
463+
// <project>/<session>/subagents/**/agent-<id>.jsonl
462464
if strings.HasPrefix(sessionID, "agent-") {
463465
for _, entry := range entries {
464466
if !entry.IsDir() {
@@ -475,12 +477,22 @@ func FindClaudeSourceFile(
475477
if !sd.IsDir() {
476478
continue
477479
}
478-
candidate := filepath.Join(
479-
projDir, sd.Name(),
480-
"subagents", target,
480+
var found string
481+
subagentsDir := filepath.Join(
482+
projDir, sd.Name(), "subagents",
481483
)
482-
if _, err := os.Stat(candidate); err == nil {
483-
return candidate
484+
_ = filepath.WalkDir(
485+
subagentsDir,
486+
func(path string, d os.DirEntry, err error) error {
487+
if err != nil || d.IsDir() || d.Name() != target {
488+
return nil
489+
}
490+
found = path
491+
return filepath.SkipAll
492+
},
493+
)
494+
if found != "" {
495+
return found
484496
}
485497
}
486498
}

internal/parser/discovery_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,19 @@ func TestDiscoverClaudeProjects(t *testing.T) {
9191
"agent-def.jsonl",
9292
},
9393
},
94+
{
95+
name: "Nested workflow subagents",
96+
files: map[string]string{
97+
filepath.Join("project-a", "parent-session.jsonl"): "{}",
98+
filepath.Join("project-a", "parent-session", "subagents", "workflows", "wf-123", "agent-deep.jsonl"): "{}",
99+
filepath.Join("project-a", "parent-session", "subagents", "workflows", "wf-123", "agent-deep.meta.json"): "{}",
100+
filepath.Join("project-a", "parent-session", "subagents", "workflows", "wf-123", "not-agent.jsonl"): "{}",
101+
},
102+
wantFiles: []string{
103+
"parent-session.jsonl",
104+
"agent-deep.jsonl",
105+
},
106+
},
94107
{
95108
name: "Empty",
96109
files: map[string]string{},
@@ -235,6 +248,14 @@ func TestFindClaudeSourceFile(t *testing.T) {
235248
targetID: "agent-sub1",
236249
wantFile: filepath.Join("project-a", "parent-sess", "subagents", "agent-sub1.jsonl"),
237250
},
251+
{
252+
name: "Nested workflow subagent",
253+
files: map[string]string{
254+
filepath.Join("project-a", "parent-sess", "subagents", "workflows", "wf-123", "agent-deep.jsonl"): "{}",
255+
},
256+
targetID: "agent-deep",
257+
wantFile: filepath.Join("project-a", "parent-sess", "subagents", "workflows", "wf-123", "agent-deep.jsonl"),
258+
},
238259
{
239260
name: "Nonexistent",
240261
files: map[string]string{

internal/sync/engine.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ func (e *Engine) classifyOnePath(
458458
}
459459

460460
// Claude: <claudeDir>/<project>/<session>.jsonl
461-
// or: <claudeDir>/<project>/<session>/subagents/agent-<id>.jsonl
461+
// or: <claudeDir>/<project>/<session>/subagents/**/agent-<id>.jsonl
462462
for _, claudeDir := range e.agentDirs[parser.AgentClaude] {
463463
if claudeDir == "" {
464464
continue
@@ -484,10 +484,10 @@ func (e *Engine) classifyOnePath(
484484
}, true
485485
}
486486

487-
// Subagent: project/session/subagents/agent-*.jsonl
488-
if len(parts) == 4 && parts[2] == "subagents" {
487+
// Subagent: project/session/subagents/**/agent-*.jsonl
488+
if len(parts) >= 4 && parts[2] == "subagents" {
489489
stem := strings.TrimSuffix(
490-
parts[3], ".jsonl",
490+
parts[len(parts)-1], ".jsonl",
491491
)
492492
if !strings.HasPrefix(stem, "agent-") {
493493
continue

internal/sync/engine_integration_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2270,6 +2270,36 @@ func TestSyncPathsClaudeSubagent(t *testing.T) {
22702270
)
22712271
}
22722272

2273+
func TestSyncPathsClaudeNestedWorkflowSubagent(t *testing.T) {
2274+
env := setupTestEnv(t)
2275+
2276+
subagentContent := testjsonl.NewSessionBuilder().
2277+
AddClaudeUserWithSessionID(
2278+
tsZero, "Deep research", "parent-sess",
2279+
).
2280+
AddClaudeAssistant(tsZeroS5, "Done.").
2281+
String()
2282+
2283+
subPath := env.writeSession(
2284+
t, env.claudeDir,
2285+
filepath.Join(
2286+
"test-proj", "parent-sess",
2287+
"subagents", "workflows", "wf-123",
2288+
"agent-deep.jsonl",
2289+
),
2290+
subagentContent,
2291+
)
2292+
2293+
env.engine.SyncPaths([]string{subPath})
2294+
2295+
assertSessionState(
2296+
t, env.db, "agent-deep",
2297+
func(sess *db.Session) {
2298+
assert.Equal(t, "claude", sess.Agent, "agent = %q, want claude", sess.Agent)
2299+
},
2300+
)
2301+
}
2302+
22732303
func TestSyncPathsClaudeRejectsNonAgentInSubagents(t *testing.T) {
22742304
env := setupTestEnv(t)
22752305

0 commit comments

Comments
 (0)