diff --git a/src/Capacitor.Cli.Core/Harness/Codex/CodexSubagentDiscovery.cs b/src/Capacitor.Cli.Core/Harness/Codex/CodexSubagentDiscovery.cs index 2d3f7b19d..18216a25d 100644 --- a/src/Capacitor.Cli.Core/Harness/Codex/CodexSubagentDiscovery.cs +++ b/src/Capacitor.Cli.Core/Harness/Codex/CodexSubagentDiscovery.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -253,13 +254,79 @@ public static string AgentTypeFrom(string? agentPath, string? agentNickname) { return "subagent"; } + /// + /// The parent's spawn_agent call ids, keyed by the agent_path each spawned, which the call's output + /// names as its task_name. A spawn whose output is not written yet is absent + /// + public static IReadOnlyDictionary ReadSpawnCalls(string parentRolloutPath) { + var spawns = new HashSet(StringComparer.Ordinal); + var paths = new Dictionary(StringComparer.Ordinal); + + try { + using var fs = new FileStream(parentRolloutPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(fs); + + while (reader.ReadLine() is { } line) { + if (!line.Contains("spawn_agent", StringComparison.Ordinal) && !line.Contains("task_name", StringComparison.Ordinal)) continue; + + try { + using var doc = JsonDocument.Parse(line); + + if (doc.RootElement.Obj("payload") is not { } payload || payload.Str("call_id") is not { } callId) continue; + + switch (payload.Str("type")) { + case "function_call" when payload.Str("name") == "spawn_agent": + spawns.Add(callId); + break; + case "function_call_output" when spawns.Contains(callId) && TaskNameOf(payload) is { } agentPath: + paths.TryAdd(agentPath, callId); + break; + } + } catch (JsonException) { + // a line still being written + } + } + } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { + return FrozenDictionary.Empty; + } + + return paths.Count == 0 ? FrozenDictionary.Empty : paths; + } + + /// The call that spawned the agent at , or else the one that spawned its + /// nearest spawned ancestor: a grandchild is imported onto the root, where only its ancestor's call is + public static string? SpawnCallFor(IReadOnlyDictionary spawnCalls, string? agentPath) { + for (var path = agentPath; path is { Length: > 0 }; path = path[..Math.Max(path.LastIndexOf('/'), 0)]) { + if (spawnCalls.TryGetValue(path, out var callId)) return callId; + } + + return null; + } + + static string? TaskNameOf(JsonElement output) { + if (output.Str("output") is { } body) { + try { + using var doc = JsonDocument.Parse(body); + + return doc.RootElement.Str("task_name"); + } catch (JsonException) { + return null; + } + } + + return output.Obj("output")?.Str("task_name"); + } + // ── Hook payloads (the vendor-agnostic /hooks/subagent-start|stop wire shape) ── /// /hooks/subagent-start body. cwd is "" — the child inherits the parent /// session's cwd; the server's HookBase merely requires the (non-null) field - /// (same posture as Gemini/OpenCode subagents). - public static JsonObject BuildStartPayload(string parentSessionId, string agentId, string agentType, string subagentTranscriptPath) => - new() { + /// (same posture as Gemini/OpenCode subagents). places the agent on the turn of + /// the parent's call that spawned it, wherever the start lands on the parent's stream + public static JsonObject BuildStartPayload( + string parentSessionId, string agentId, string agentType, string subagentTranscriptPath, string? spawnCallId = null + ) { + var payload = new JsonObject { ["hook_event_name"] = "subagent_start", ["session_id"] = parentSessionId, ["agent_id"] = agentId, @@ -268,6 +335,11 @@ public static JsonObject BuildStartPayload(string parentSessionId, string agentI ["cwd"] = "", }; + if (spawnCallId is not null) payload["spawn_call_id"] = spawnCallId; + + return payload; + } + /// /hooks/subagent-stop body. public static JsonObject BuildStopPayload(string parentSessionId, string agentId, string agentType, string subagentTranscriptPath) => new() { diff --git a/src/Capacitor.Cli/Commands/SessionImporter.cs b/src/Capacitor.Cli/Commands/SessionImporter.cs index d6253edd9..92f5d1e23 100644 --- a/src/Capacitor.Cli/Commands/SessionImporter.cs +++ b/src/Capacitor.Cli/Commands/SessionImporter.cs @@ -141,12 +141,15 @@ internal static async Task ImportSessionAsync( // lets chat/trace nest it), strict transcript delivery, and no subagent-stop after a // failed tail — a re-import retries (deterministic event ids make that idempotent). if (isCodex) { + var spawnCalls = CodexSubagentDiscovery.ReadSpawnCalls(transcriptPath); + foreach (var sub in CodexSubagentDiscovery.EnumerateDescendantRollouts(transcriptPath, sessionId)) { var subType = CodexSubagentDiscovery.AgentTypeFrom(sub.AgentPath, sub.AgentNickname); var subAgentId = sub.ChildDashlessId; if (!await PostSubagentHookAsync(httpClient, baseUrl, "subagent-start", - CodexSubagentDiscovery.BuildStartPayload(sessionId, subAgentId, subType, sub.FilePath), time)) { + CodexSubagentDiscovery.BuildStartPayload(sessionId, subAgentId, subType, sub.FilePath, + CodexSubagentDiscovery.SpawnCallFor(spawnCalls, sub.AgentPath)), time)) { continue; } diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs index 099001c26..65cf572b5 100644 --- a/src/Capacitor.Cli/Commands/WatchCommand.cs +++ b/src/Capacitor.Cli/Commands/WatchCommand.cs @@ -1687,6 +1687,8 @@ CancellationToken ct return; // discovery is best-effort — never break the main drain loop } + IReadOnlyDictionary? spawnCalls = null; + foreach (var sub in subs) { if (ct.IsCancellationRequested) return; @@ -1703,7 +1705,9 @@ CancellationToken ct // Fail-closed: register the subagent (→ SubagentStarted) before its child watcher // streams content. On POST failure, drop from `seen` so the next tick retries. - if (isNew && !await PostCodexSubagentStartAsync(sessionId, childAgentId, agentType, sub.FilePath, ct)) { + if (isNew + && !await PostCodexSubagentStartAsync(sessionId, childAgentId, agentType, sub.FilePath, + CodexSubagentDiscovery.SpawnCallFor(spawnCalls ??= CodexSubagentDiscovery.ReadSpawnCalls(transcriptPath), sub.AgentPath), ct)) { seen.Remove(sub.FilePath); continue; } @@ -1717,11 +1721,11 @@ await watchers.EnsureWatcherRunning(key: $"{sessionId}-{childAgentId}", transcri } async Task PostCodexSubagentStartAsync( - string sessionId, string agentId, string agentType, string subFile, CancellationToken ct + string sessionId, string agentId, string agentType, string subFile, string? spawnCallId, CancellationToken ct ) { try { using var client = await http.ForBackgroundAsync(ct); - var payload = CodexSubagentDiscovery.BuildStartPayload(sessionId, agentId, agentType, subFile); + var payload = CodexSubagentDiscovery.BuildStartPayload(sessionId, agentId, agentType, subFile, spawnCallId); using var content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"); using var resp = await client.PostWithRetryAsync($"{Url}/hooks/subagent-start", content, time, ct: ct); diff --git a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs index 18c3b75a0..5d4b514eb 100644 --- a/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Harness/Codex/CodexSubagentDiscoveryTests.cs @@ -1,7 +1,13 @@ +using System.Text.Json.Nodes; using Capacitor.Cli.Commands; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Codex; using Capacitor.Cli.Harness.Codex; using Capacitor.Cli.PrDetection; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; namespace Capacitor.Cli.Tests.Unit.Harness.Codex; @@ -241,6 +247,61 @@ await Assert.That(subs.Select(s => s.ChildDashlessId).ToList()) .Contains(Dashless(grandDashed)); } + // ── Spawning calls ──────────────────────────────────────────────────── + + static string Item(string payload) => $$"""{"timestamp":"2026-08-10T15:21:00.000Z","type":"response_item","payload":{{payload}}}"""; + + /// An import posts every subagent's start after the whole parent transcript, so each start names the + /// parent's spawn_agent call whose output names the agent's path; a grandchild names its ancestor's call, and an + /// agent no spawn output names, here one whose path only another tool's output carries, names none + [Test] + public async Task An_imported_subagent_start_names_the_call_that_spawned_it_or_its_ancestor() { + using var tmp = new TempDir(); + using var server = WireMockServer.Start(); + server.Given(Request.Create().UsingAnyMethod()).RespondWith(Response.Create().WithStatusCode(200)); + + var day = DayDir(tmp.Path); + var parent = WriteParent(day); + File.AppendAllLines(parent, [ + Item("""{"type":"function_call","name":"spawn_agent","namespace":"collaboration","call_id":"call_spawn","arguments":"{\"task_name\":\"spec_quality\"}"}"""), + Item("""{"type":"function_call_output","call_id":"call_spawn","output":"{\"task_name\":\"/root/spec_quality\"}"}"""), + Item("""{"type":"function_call","name":"exec_command","call_id":"call_exec","arguments":"{}"}"""), + Item("""{"type":"function_call_output","call_id":"call_exec","output":"{\"task_name\":\"/root/other\"}"}"""), + """{"timestamp":"2026-08-10T15:22:00.000Z","type":"response_item","payload":{"type":"function_call","na""", + ]); + + const string grandDashed = "019fec46-51e6-7a03-9f0b-cc6f701a9134"; + WriteChild(day); + WriteChild(day, dashedId: grandDashed, parentDashedId: ChildDashed, agentPath: "/root/spec_quality/deep_dive", stamp: "2026-08-10T17-25-00"); + WriteChild(day, dashedId: OtherDashed, agentPath: "/root/other", stamp: "2026-08-10T17-26-00"); + + using var client = new HttpClient(); + await SessionImporter.ImportSessionAsync(client, server.Url!, parent, Dashless(ParentDashed), + new SessionMetadata { Cwd = "/w" }, encodedCwd: null, TimeProvider.System, vendor: HarnessId.Codex); + + var named = server.LogEntries + .Where(e => e.RequestMessage.Path == "/hooks/subagent-start") + .Select(e => JsonNode.Parse(e.RequestMessage.Body!)!) + .ToDictionary(n => (string)n["agent_id"]!, n => (string?)n["spawn_call_id"]); + + await Assert.That(named).IsEquivalentTo(new Dictionary { + [Dashless(ChildDashed)] = "call_spawn", + [Dashless(grandDashed)] = "call_spawn", + [Dashless(OtherDashed)] = null, + }); + } + + [Test] + [Arguments("/root/a", "call_a")] + [Arguments("/root/a/b/c", "call_a")] + [Arguments("/root/ab", null)] + [Arguments(null, null)] + public async Task A_path_takes_its_own_spawning_call_or_its_nearest_ancestors(string? agentPath, string? expected) { + var calls = new Dictionary { ["/root/a"] = "call_a", ["/root/b"] = "call_b" }; + + await Assert.That(CodexSubagentDiscovery.SpawnCallFor(calls, agentPath)).IsEqualTo(expected); + } + // ── CodexImportSource.DiscoverAsync filter ──────────────────────────── [Test]