Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions src/Capacitor.Cli.Core/Harness/Codex/CodexSubagentDiscovery.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Frozen;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -253,13 +254,79 @@ public static string AgentTypeFrom(string? agentPath, string? agentNickname) {
return "subagent";
}

/// <summary>
/// The parent's <c>spawn_agent</c> call ids, keyed by the <c>agent_path</c> each spawned, which the call's output
/// names as its <c>task_name</c>. A spawn whose output is not written yet is absent
/// </summary>
public static IReadOnlyDictionary<string, string> ReadSpawnCalls(string parentRolloutPath) {
var spawns = new HashSet<string>(StringComparer.Ordinal);
var paths = new Dictionary<string, string>(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<string, string>.Empty;
}

return paths.Count == 0 ? FrozenDictionary<string, string>.Empty : paths;
}

/// <summary>The call that spawned the agent at <paramref name="agentPath"/>, or else the one that spawned its
/// nearest spawned ancestor: a grandchild is imported onto the root, where only its ancestor's call is</summary>
public static string? SpawnCallFor(IReadOnlyDictionary<string, string> 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) ──

/// <summary><c>/hooks/subagent-start</c> 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).</summary>
public static JsonObject BuildStartPayload(string parentSessionId, string agentId, string agentType, string subagentTranscriptPath) =>
new() {
/// (same posture as Gemini/OpenCode subagents). <paramref name="spawnCallId"/> places the agent on the turn of
/// the parent's call that spawned it, wherever the start lands on the parent's stream</summary>
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,
Expand All @@ -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;
}

/// <summary><c>/hooks/subagent-stop</c> body.</summary>
public static JsonObject BuildStopPayload(string parentSessionId, string agentId, string agentType, string subagentTranscriptPath) =>
new() {
Expand Down
5 changes: 4 additions & 1 deletion src/Capacitor.Cli/Commands/SessionImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,15 @@ internal static async Task<ImportResult> 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;
}

Expand Down
10 changes: 7 additions & 3 deletions src/Capacitor.Cli/Commands/WatchCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,8 @@ CancellationToken ct
return; // discovery is best-effort — never break the main drain loop
}

IReadOnlyDictionary<string, string>? spawnCalls = null;

foreach (var sub in subs) {
if (ct.IsCancellationRequested) return;

Expand All @@ -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;
}
Expand All @@ -1717,11 +1721,11 @@ await watchers.EnsureWatcherRunning(key: $"{sessionId}-{childAgentId}", transcri
}

async Task<bool> 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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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}}}""";

/// <summary>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</summary>
[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":"{}"}"""),
Comment thread
realtonyyoung marked this conversation as resolved.
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<string, string?> {
[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<string, string> { ["/root/a"] = "call_a", ["/root/b"] = "call_b" };

await Assert.That(CodexSubagentDiscovery.SpawnCallFor(calls, agentPath)).IsEqualTo(expected);
}

// ── CodexImportSource.DiscoverAsync filter ────────────────────────────

[Test]
Expand Down
Loading