From 162fe34e01504503c0ed4b60757e4b794c7688f0 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:58:55 -0400 Subject: [PATCH 1/7] [AI-1218] Watcher: scan messages/ + POST antigravity subagent-link Antigravity fires no subagent hooks, so a live subagent is only discoverable once the child reports back by writing a message file into the parent's brain//.system_generated/messages/ dir. Add AntigravitySubagents.ChildrenOf, a parent-scoped variant of the existing BuildParentMap (import-time full scan) that reads only one parent's messages dir and returns the distinct child ids that have recipient == parent. Wire it into the antigravity watcher: scanned once per drain tick (mirrors the Gemini/OpenCode live subagent discovery) and once more on final drain before exit, so a late-reporting child isn't missed. Newly seen children are POSTed to /hooks/antigravity/subagent-link (the server normalizes dashed/dashless ids via CanonicalSessionId.Normalize, so no id-form translation is needed here); a child is only recorded in the in-memory WatchState.PostedSubagentLinks set once its POST succeeds, so a failed POST retries fail-open on the next scan. --- .../Antigravity/AntigravitySubagents.cs | 41 +++++++++ src/Capacitor.Cli.Core/Models.cs | 5 ++ src/Capacitor.Cli/Commands/WatchCommand.cs | 85 ++++++++++++++++++- .../AntigravitySubagentLinkScanTests.cs | 84 ++++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 test/Capacitor.Cli.Tests.Unit/AntigravitySubagentLinkScanTests.cs diff --git a/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs b/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs index 619ebb1da3..2c9d6a72bb 100644 --- a/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs +++ b/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs @@ -67,6 +67,47 @@ public static IReadOnlyDictionary BuildParentMap( public static string? ResolveParent(string childConversationId, string? home = null, string? geminiCliHome = null) => BuildParentMap(home, geminiCliHome).TryGetValue(childConversationId, out var parent) ? parent : null; + /// + /// Live-watcher scoped scan (AI-1218): children that have reported back to + /// so far, read from ONLY that parent's own + /// messages/*.json dir (unlike , which scans every brain + /// dir to build the full cross-conversation map for import). A live parent watcher polls + /// this each drain to detect newly-linked subagents while its own conversation is still + /// running. Best-effort: unreadable / malformed message files are skipped; deduped; + /// self-messages excluded. + /// + public static IReadOnlyList ChildrenOf( + string parentSessionId, string? home = null, string? geminiCliHome = null, CancellationToken ct = default) { + var children = new HashSet(StringComparer.Ordinal); + + var messages = AntigravityPaths.MessagesDir(parentSessionId, home, geminiCliHome); + if (!Directory.Exists(messages)) return []; + + foreach (var file in Directory.EnumerateFiles(messages, "*.json")) { + ct.ThrowIfCancellationRequested(); + try { + using var doc = JsonDocument.Parse(File.ReadAllText(file)); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) continue; + + var sender = root.TryGetProperty("sender", out var s) ? s.GetString() : null; + var recipient = root.TryGetProperty("recipient", out var r) ? r.GetString() : null; + + if (string.IsNullOrEmpty(sender) + || string.IsNullOrEmpty(recipient) + || !string.Equals(recipient, parentSessionId, StringComparison.Ordinal) + || string.Equals(sender, parentSessionId, StringComparison.Ordinal)) + continue; + + children.Add(sender!); + } catch { + // Skip a malformed / unreadable message file — never abort the scan. + } + } + + return [.. children]; + } + /// /// Walks parent links up to the top-level ancestor (a conversation with no parent), so a /// deep chain P←C←G resolves G's ancestor to P. Cycle-safe: if a cycle is encountered diff --git a/src/Capacitor.Cli.Core/Models.cs b/src/Capacitor.Cli.Core/Models.cs index 7a3aae2c47..a29a0be379 100644 --- a/src/Capacitor.Cli.Core/Models.cs +++ b/src/Capacitor.Cli.Core/Models.cs @@ -170,6 +170,11 @@ class WatchState { // the Codex PendingCodexToolCalls guard). public int PendingAntigravityToolCalls { get; set; } + // Antigravity live subagent nesting (AI-1218): child conversation ids already POSTed to + // /hooks/antigravity/subagent-link for this parent watcher. A child stays OUT of this set + // until its link POST succeeds, so a failed POST retries on the next scan (fail-open). + public HashSet PostedSubagentLinks { get; } = new(StringComparer.Ordinal); + public const int TranscriptThreshold = 10; } diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs index 56cdb55705..4d6e64f2f6 100644 --- a/src/Capacitor.Cli/Commands/WatchCommand.cs +++ b/src/Capacitor.Cli/Commands/WatchCommand.cs @@ -317,11 +317,15 @@ public static async Task RunWatch( // Live subagent discovery: only the parent (agentId == null) watcher scans; // child subagent watchers (agentId != null) just stream their file. Gemini // scans its native nested chat files; OpenCode scans the nested dir the - // kcap plugin writes child {info,parts} into (AI-919 phase 2). + // kcap plugin writes child {info,parts} into (AI-919 phase 2); Antigravity + // scans its own brain messages dir for children that have reported back + // (AI-1218 — nesting only, subagents are captured standalone already). if (agentId is null && vendor == "gemini") { await ScanGeminiSubagents(baseUrl, sessionId, transcriptPath, seenSubagents, cts.Token); } else if (agentId is null && vendor == "opencode") { await ScanOpenCodeSubagents(baseUrl, sessionId, transcriptPath, seenSubagents, cts.Token); + } else if (agentId is null && vendor == "antigravity") { + await ScanAntigravitySubagentLinks(baseUrl, sessionId, transcriptPath, state.PostedSubagentLinks, cts.Token); } if (ShouldEndOnIdle( @@ -361,6 +365,13 @@ public static async Task RunWatch( } else { Log("Draining remaining lines..."); await DrainNewLines(hubConnection, sessionId, transcriptPath, agentId, state, vendor, CancellationToken.None); + + // One last subagent-link scan on the way out — a child may have reported back + // (written its messages/*.json) after the main loop's last tick but before exit, + // and this is the watcher's final chance to link it (AI-1218). + if (agentId is null && vendor == "antigravity") { + await ScanAntigravitySubagentLinks(baseUrl, sessionId, transcriptPath, state.PostedSubagentLinks, CancellationToken.None); + } } // Signal drain complete to server. @@ -1351,6 +1362,78 @@ internal static void UpdateAntigravityPendingToolCalls(WatchState state, string } } + /// + /// Live subagent nesting (AI-1218): Antigravity fires no subagent hooks, so a child that + /// reports back is discovered by scanning the PARENT's own + /// brain/<parent>/.system_generated/messages/*.json dir (see + /// ) — the same linkage the offline importer + /// (AI-1160) reads, but scoped to one parent so a live watcher can poll it cheaply on every + /// drain. is the canonical dashless id the watcher/server use + /// everywhere else; the real (dashed) conversation id — required to locate the brain dir — + /// is derived from (mirrors + /// 's use of + /// ). Each newly-seen child is + /// POSTed to /hooks/antigravity/subagent-link and only added to + /// on success, so a failed POST retries on the next scan + /// (fail-open — never breaks the drain loop). + /// + static async Task ScanAntigravitySubagentLinks( + string baseUrl, + string sessionId, + string transcriptPath, + HashSet posted, + CancellationToken ct + ) { + // Same validated path-walk as ConversationDbFromTranscript, just stopping one segment + // earlier at the conversation id itself rather than the sibling db file. + if (AntigravityPaths.ConversationDbFromTranscript(transcriptPath) is not { } dbPath) return; + var conversationId = Path.GetFileNameWithoutExtension(dbPath); + if (string.IsNullOrEmpty(conversationId)) return; + + IReadOnlyList children; + try { + children = AntigravitySubagents.ChildrenOf(conversationId, ct: ct); + } catch (OperationCanceledException) { + throw; + } catch (Exception ex) { + Log($"Antigravity subagent-link scan failed: {ex.Message}"); + return; + } + + foreach (var child in children) { + if (ct.IsCancellationRequested) return; + if (posted.Contains(child)) continue; + + if (await PostAntigravitySubagentLinkAsync(baseUrl, sessionId, child, ct)) { + posted.Add(child); + Log($"Antigravity subagent {child} linked to parent {sessionId}"); + } + // On failure, leave out of `posted` so the next scan retries (fail-open). + } + } + + static async Task PostAntigravitySubagentLinkAsync( + string baseUrl, string sessionId, string childId, CancellationToken ct + ) { + try { + using var client = await HttpClientExtensions.CreateAuthenticatedClientAsync(baseUrl, ct); + var payload = new JsonObject { + ["hook_event_name"] = "subagent-link", + ["session_id"] = sessionId, + ["agent_id"] = childId, + }; + using var content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"); + using var resp = await client.PostWithRetryAsync($"{baseUrl}/hooks/antigravity/subagent-link", content, ct: ct); + + return resp.IsSuccessStatusCode; + } catch (OperationCanceledException) { + throw; + } catch (Exception ex) { + Log($"Antigravity subagent-link POST failed for child {childId}: {ex.Message}"); + return false; + } + } + // ── Kiro extractors (AI-888) ─────────────────────────────────────────── // // Kiro CLI writes ~/.kiro/sessions/cli/{id}.jsonl as {"version","kind","data"} diff --git a/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentLinkScanTests.cs b/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentLinkScanTests.cs new file mode 100644 index 0000000000..77687e7df7 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentLinkScanTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Nodes; +using Capacitor.Cli.Core.Antigravity; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// Unit tests for (AI-1218): the live-watcher +/// scoped scan of a single parent's messages/*.json dir, used to detect subagents that +/// have reported back while the parent conversation is still running (as opposed to +/// , which covers the full-history import scan). +/// +public class AntigravitySubagentLinkScanTests { + static string NewHome() { + var home = Path.Combine(Path.GetTempPath(), "kcap-agsublink-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(home); + return home; + } + + // Writes brain//.system_generated/messages/.json with sender→recipient. + static void WriteMessage(string home, string owner, string name, string sender, string recipient) { + var dir = Path.Combine(home, ".gemini", "antigravity", "brain", owner, ".system_generated", "messages"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, name + ".json"), + new JsonObject { ["sender"] = sender, ["recipient"] = recipient }.ToJsonString()); + } + + [Test] + public async Task ChildrenOf_returns_only_children_reporting_to_the_parent_deduped() { + var home = NewHome(); + try { + // Two children report back to the parent. + WriteMessage(home, owner: "P", name: "m1", sender: "child1", recipient: "P"); + WriteMessage(home, owner: "P", name: "m2", sender: "child2", recipient: "P"); + // A duplicate message from child1 (e.g. re-sent) must not double the result. + WriteMessage(home, owner: "P", name: "m3", sender: "child1", recipient: "P"); + // Unrelated: a message in the parent's dir addressed to someone else must be excluded. + WriteMessage(home, owner: "P", name: "m4", sender: "child3", recipient: "someone-else"); + // Malformed file must be skipped, not throw. + var dir = Path.Combine(home, ".gemini", "antigravity", "brain", "P", ".system_generated", "messages"); + File.WriteAllText(Path.Combine(dir, "bad.json"), "{ not json"); + + var children = AntigravitySubagents.ChildrenOf("P", home: home, geminiCliHome: ""); + + await Assert.That(children.Count).IsEqualTo(2); + await Assert.That(children.OrderBy(x => x).ToList()).IsEquivalentTo(new List { "child1", "child2" }); + } finally { Directory.Delete(home, recursive: true); } + } + + [Test] + public async Task ChildrenOf_excludes_self_messages() { + var home = NewHome(); + try { + WriteMessage(home, owner: "P", name: "self", sender: "P", recipient: "P"); + + var children = AntigravitySubagents.ChildrenOf("P", home: home, geminiCliHome: ""); + await Assert.That(children.Count).IsEqualTo(0); + } finally { Directory.Delete(home, recursive: true); } + } + + [Test] + public async Task ChildrenOf_is_empty_when_no_messages_dir() { + var home = NewHome(); + try { + var children = AntigravitySubagents.ChildrenOf("P", home: home, geminiCliHome: ""); + await Assert.That(children.Count).IsEqualTo(0); + } finally { Directory.Delete(home, recursive: true); } + } + + [Test] + public async Task ChildrenOf_honors_cancellation() { + var home = NewHome(); + try { + // A handful of message files so the per-file loop actually runs. + for (var i = 0; i < 5; i++) + WriteMessage(home, owner: "P", name: $"m{i}", sender: $"child{i}", recipient: "P"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.That(() => AntigravitySubagents.ChildrenOf("P", home: home, geminiCliHome: "", ct: cts.Token)) + .Throws(); + } finally { Directory.Delete(home, recursive: true); } + } +} From e80f8cea3ec3ef610dcaf36f307160d07b498989 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:34:20 -0400 Subject: [PATCH 2/7] [AI-1218] Antigravity: exclude async subagent tool calls from idle-end suppression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parent that invokes subagents never idle-ended (only ended on Antigravity quit), so its adopted children never consolidated onto the parent card until the app was quit. Root cause: UpdateAntigravityPendingToolCalls counted invoke_subagent/define_subagent as tool calls in flight, but those resolve ASYNCHRONOUSLY via a separate conversation (child reports back through brain//messages) and never produce a RUN_COMMAND/VIEW_FILE/LIST_DIRECTORY/CODE_ACTION result step to decrement the counter — so PendingAntigravityToolCalls stayed > 0 forever and ShouldEndOnIdle was permanently suppressed. Exclude the async subagent-orchestration tools from the in-flight count; real command tools still suppress idle-end while genuinely running. Test: PendingToolCalls_excludes_async_subagent_orchestration_so_a_parent_can_idle_end (9/9). --- src/Capacitor.Cli/Commands/WatchCommand.cs | 24 +++++++++++++++---- .../AntigravityWatchExtractorTests.cs | 18 ++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs index 4d6e64f2f6..13218f5ced 100644 --- a/src/Capacitor.Cli/Commands/WatchCommand.cs +++ b/src/Capacitor.Cli/Commands/WatchCommand.cs @@ -1327,11 +1327,26 @@ static long AppendAntigravityUsageLines(WatchState state, List newLines, return maxIdx; } + /// + /// Subagent-orchestration tool calls whose result arrives ASYNCHRONOUSLY via a separate + /// conversation (the child reports back through brain/<parent>/.system_generated/ + /// messages, not as a result STEP in the parent's own transcript). They therefore never + /// produce a decrement step, so counting them as "in flight" would pin + /// above zero forever and permanently + /// suppress the parent's idle-end — a subagent-invoking conversation would only end when + /// Antigravity quits (AI-1218). They do not block the parent's idleness, so they are excluded + /// from the pending count. + /// + static readonly HashSet AsyncAntigravityToolCalls = + new(StringComparer.OrdinalIgnoreCase) { "invoke_subagent", "define_subagent" }; + /// /// Tracks Antigravity tool calls in flight from a transcript line: a PLANNER_RESPONSE adds - /// its tool_calls count; a result step (RUN_COMMAND/VIEW_FILE/LIST_DIRECTORY/CODE_ACTION) - /// removes one. Safe to call for any line — non-matching / malformed lines are ignored — so - /// the count reflects whether a (possibly long-running) tool is awaiting its result. + /// its tool_calls count (excluding async subagent-orchestration calls, see + /// ); a result step (RUN_COMMAND/VIEW_FILE/ + /// LIST_DIRECTORY/CODE_ACTION) removes one. Safe to call for any line — non-matching / + /// malformed lines are ignored — so the count reflects whether a (possibly long-running) + /// tool is awaiting its in-transcript result. /// internal static void UpdateAntigravityPendingToolCalls(WatchState state, string line) { try { @@ -1342,7 +1357,8 @@ internal static void UpdateAntigravityPendingToolCalls(WatchState state, string switch (root.Str("type")) { case "PLANNER_RESPONSE": if (root.Arr("tool_calls") is { } calls) - state.PendingAntigravityToolCalls += calls.EnumerateArray().Count(tc => tc.Str("name") is not null); + state.PendingAntigravityToolCalls += calls.EnumerateArray() + .Count(tc => tc.Str("name") is { } name && !AsyncAntigravityToolCalls.Contains(name)); break; case "RUN_COMMAND" or "VIEW_FILE" or "LIST_DIRECTORY" or "CODE_ACTION": if (state.PendingAntigravityToolCalls > 0) state.PendingAntigravityToolCalls--; diff --git a/test/Capacitor.Cli.Tests.Unit/AntigravityWatchExtractorTests.cs b/test/Capacitor.Cli.Tests.Unit/AntigravityWatchExtractorTests.cs index f25034b564..7c6dc9d2ba 100644 --- a/test/Capacitor.Cli.Tests.Unit/AntigravityWatchExtractorTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/AntigravityWatchExtractorTests.cs @@ -78,6 +78,24 @@ public async Task PendingToolCalls_tracks_calls_versus_results_and_suppresses_id await Assert.That(state.PendingAntigravityToolCalls).IsEqualTo(0); } + [Test] + public async Task PendingToolCalls_excludes_async_subagent_orchestration_so_a_parent_can_idle_end() { + // AI-1218: define_subagent/invoke_subagent resolve via a SEPARATE conversation (the child + // reports back through brain//messages), never as a result step in this transcript. + // Counting them would pin the count > 0 forever and suppress idle-end, so a subagent-invoking + // parent would only end when Antigravity quits. They must be excluded from the in-flight count. + var state = new Capacitor.Cli.Core.WatchState(); + + WatchCommand.UpdateAntigravityPendingToolCalls(state, + """{"type":"PLANNER_RESPONSE","tool_calls":[{"name":"define_subagent"},{"name":"invoke_subagent"},{"name":"invoke_subagent"},{"name":"list_dir"}]}"""); + // Only the real command (list_dir) counts as in flight — the 3 subagent calls are excluded. + await Assert.That(state.PendingAntigravityToolCalls).IsEqualTo(1); + + // Its result step clears it → count 0 → idle-end is no longer suppressed. + WatchCommand.UpdateAntigravityPendingToolCalls(state, """{"type":"LIST_DIRECTORY","content":"…"}"""); + await Assert.That(state.PendingAntigravityToolCalls).IsEqualTo(0); + } + [Test] public async Task ShouldEndOnIdle_suppressed_while_a_tool_is_in_flight() { var now = DateTimeOffset.UnixEpoch.AddHours(5); From 0007483e7a7e53f6e6ffb6147d91946ce51ce676 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:47:04 -0400 Subject: [PATCH 3/7] [AI-1218] Add INVOKE_SUBAGENT transcript parser (spawn-time child ids) Shared static parser (ChildConversationIdsFromLine / IsInvokeSubagentLine) that reads the authoritative spawn-time parent-to-child signal from a parent's INVOKE_SUBAGENT transcript step. Foundation for the live watcher and import parent-map redesign (Tasks 2/3). --- .../Antigravity/AntigravitySubagents.cs | 105 ++++++++++++++++++ .../AntigravitySubagentsTests.cs | 53 +++++++++ 2 files changed, 158 insertions(+) diff --git a/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs b/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs index 2c9d6a72bb..c2d1508e0f 100644 --- a/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs +++ b/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs @@ -146,4 +146,109 @@ public static IReadOnlyDictionary> BuildRootDescendants( return byRoot; } + + /// + /// AI-1218 redesign — the authoritative spawn-time parent→child signal. A parent's transcript + /// records an INVOKE_SUBAGENT step whose content embeds JSON listing the child + /// conversation(s) it spawned ({ "conversationId": "<child>", … }, one object or an + /// array). Returns the child ids for a single transcript line — empty for any non-INVOKE_SUBAGENT, + /// blank, or malformed line. Strict: the id is read ONLY from the step's content payload (parsed + /// as JSON), not matched by a regex over the whole line, and each id must be GUID-shaped. + /// + public static IReadOnlyList ChildConversationIdsFromLine(string line) { + if (!TryReadInvokeContent(line, out var content)) return []; + + var raw = new List(); + // The content can embed more than one top-level JSON value (e.g. several + // "{ \"conversationId\": … }" objects separated by narrative text/newlines), so scan for + // every balanced block rather than stopping at the first. + foreach (var json in ExtractJsonBlocks(content)) { + try { + using var doc = JsonDocument.Parse(json); + CollectConversationIds(doc.RootElement, raw); + } catch { + // Skip an unparsable block; a sibling block may still be valid. + } + } + + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + foreach (var id in raw) { + if (!Guid.TryParse(id, out _)) continue; // GUID-shaped only; brain-dir ids are dashed + if (seen.Add(id)) result.Add(id); + } + return result; + } + + /// True iff the line is a structurally-valid INVOKE_SUBAGENT step (used by callers to + /// log a drift diagnostic when such a step yields no parseable child id). + public static bool IsInvokeSubagentLine(string line) => TryReadInvokeContent(line, out _); + + static bool TryReadInvokeContent(string line, out string content) { + content = ""; + if (string.IsNullOrWhiteSpace(line)) return false; + try { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + if (!root.TryGetProperty("type", out var t) || t.ValueKind != JsonValueKind.String + || t.GetString() != "INVOKE_SUBAGENT") return false; + if (!root.TryGetProperty("content", out var c) || c.ValueKind != JsonValueKind.String) { + content = ""; // an INVOKE step with no string content is still an invoke line + return true; + } + content = c.GetString() ?? ""; + return true; + } catch { + return false; + } + } + + /// Every balanced {…} or […] block found in , scanning left to + /// right (content can embed several sibling JSON values, not just one). String-aware so braces + /// inside quoted text don't break balancing. An unbalanced/truncated trailing block is dropped + /// rather than yielded partially. + static List ExtractJsonBlocks(string s) { + var blocks = new List(); + var i = 0; + while (i < s.Length) { + var start = s.IndexOfAny(['{', '['], i); + if (start < 0) break; + char open = s[start], close = open == '{' ? '}' : ']'; + int depth = 0; bool inStr = false, esc = false; + var end = -1; + for (var j = start; j < s.Length; j++) { + var ch = s[j]; + if (inStr) { + if (esc) esc = false; + else if (ch == '\\') esc = true; + else if (ch == '"') inStr = false; + continue; + } + if (ch == '"') inStr = true; + else if (ch == open) depth++; + else if (ch == close && --depth == 0) { end = j; break; } + } + if (end < 0) break; // unbalanced / truncated — stop scanning + blocks.Add(s.Substring(start, end - start + 1)); + i = end + 1; + } + return blocks; + } + + static void CollectConversationIds(JsonElement el, List into) { + switch (el.ValueKind) { + case JsonValueKind.Object: + foreach (var p in el.EnumerateObject()) { + if (p.NameEquals("conversationId") && p.Value.ValueKind == JsonValueKind.String) + into.Add(p.Value.GetString()!); + else + CollectConversationIds(p.Value, into); + } + break; + case JsonValueKind.Array: + foreach (var item in el.EnumerateArray()) CollectConversationIds(item, into); + break; + } + } } diff --git a/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentsTests.cs b/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentsTests.cs index 74ccb2b9b5..7f973ecbda 100644 --- a/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentsTests.cs @@ -146,4 +146,57 @@ public async Task BuildRootDescendants_imports_cycle_members_standalone() { await Assert.That(byRoot["B"].Count).IsEqualTo(0); await Assert.That(byRoot["R"]).IsEquivalentTo(new List { "C" }); } + + [Test] + public async Task ChildConversationIds_extracts_all_children_from_one_invoke_step() { + // One INVOKE_SUBAGENT step listing multiple children (as c8a36fda does on disk). + const string line = """ + {"type":"INVOKE_SUBAGENT","content":"Created the following subagents:\n[{\"conversationId\":\"9999c82b-0000-0000-0000-000000000001\"},{\"conversationId\":\"189ba558-0000-0000-0000-000000000002\"}]\nThey will report back."} + """; + var ids = AntigravitySubagents.ChildConversationIdsFromLine(line); + await Assert.That(ids).IsEquivalentTo(new List { + "9999c82b-0000-0000-0000-000000000001", "189ba558-0000-0000-0000-000000000002" }); + } + + [Test] + public async Task ChildConversationIds_single_child_object_payload() { + const string line = """ + {"type":"INVOKE_SUBAGENT","content":"Created the following subagents:\n{\"conversationId\":\"6111e615-3caa-4fe8-9d55-b85c43f2cf1f\",\"logAbsoluteUri\":\"file:///x\"}"} + """; + var ids = AntigravitySubagents.ChildConversationIdsFromLine(line); + await Assert.That(ids).IsEquivalentTo(new List { "6111e615-3caa-4fe8-9d55-b85c43f2cf1f" }); + } + + [Test] + public async Task ChildConversationIds_ignores_conversationId_outside_an_invoke_step() { + // A conversationId quoted in a non-INVOKE line (e.g. error text) must NOT match — proves + // structural, content-scoped parsing rather than a whole-line regex. + const string line = """ + {"type":"PLANNER_RESPONSE","content":"note conversationId 6111e615-3caa-4fe8-9d55-b85c43f2cf1f in passing"} + """; + await Assert.That(AntigravitySubagents.ChildConversationIdsFromLine(line)).IsEmpty(); + } + + [Test] + public async Task ChildConversationIds_drops_non_guid_and_dedupes() { + const string line = """ + {"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"not-a-guid\"}\n{\"conversationId\":\"6111e615-3caa-4fe8-9d55-b85c43f2cf1f\"}\n{\"conversationId\":\"6111e615-3caa-4fe8-9d55-b85c43f2cf1f\"}"} + """; + var ids = AntigravitySubagents.ChildConversationIdsFromLine(line); + await Assert.That(ids).IsEquivalentTo(new List { "6111e615-3caa-4fe8-9d55-b85c43f2cf1f" }); + } + + [Test] + public async Task ChildConversationIds_blank_partial_or_malformed_line_is_empty() { + await Assert.That(AntigravitySubagents.ChildConversationIdsFromLine("")).IsEmpty(); + await Assert.That(AntigravitySubagents.ChildConversationIdsFromLine("{\"type\":\"INVOKE_SUBAGENT\",\"content\":\"trunca")).IsEmpty(); + await Assert.That(AntigravitySubagents.ChildConversationIdsFromLine("not json")).IsEmpty(); + } + + [Test] + public async Task IsInvokeSubagentLine_true_only_for_invoke_steps() { + await Assert.That(AntigravitySubagents.IsInvokeSubagentLine("""{"type":"INVOKE_SUBAGENT","content":"x"}""")).IsTrue(); + await Assert.That(AntigravitySubagents.IsInvokeSubagentLine("""{"type":"PLANNER_RESPONSE"}""")).IsFalse(); + await Assert.That(AntigravitySubagents.IsInvokeSubagentLine("nonsense")).IsFalse(); + } } From 7a99c27470f3ffe7147777a02d204d8e57eb2040 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:57:42 -0400 Subject: [PATCH 4/7] [AI-1218] Live watcher: link subagents from INVOKE_SUBAGENT (drop messages scan) --- .../Antigravity/AntigravitySubagents.cs | 41 ------- src/Capacitor.Cli/Commands/WatchCommand.cs | 108 ++++++++---------- .../AntigravitySubagentLinkScanTests.cs | 96 ++++------------ 3 files changed, 74 insertions(+), 171 deletions(-) diff --git a/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs b/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs index c2d1508e0f..ffaa8c27f3 100644 --- a/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs +++ b/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs @@ -67,47 +67,6 @@ public static IReadOnlyDictionary BuildParentMap( public static string? ResolveParent(string childConversationId, string? home = null, string? geminiCliHome = null) => BuildParentMap(home, geminiCliHome).TryGetValue(childConversationId, out var parent) ? parent : null; - /// - /// Live-watcher scoped scan (AI-1218): children that have reported back to - /// so far, read from ONLY that parent's own - /// messages/*.json dir (unlike , which scans every brain - /// dir to build the full cross-conversation map for import). A live parent watcher polls - /// this each drain to detect newly-linked subagents while its own conversation is still - /// running. Best-effort: unreadable / malformed message files are skipped; deduped; - /// self-messages excluded. - /// - public static IReadOnlyList ChildrenOf( - string parentSessionId, string? home = null, string? geminiCliHome = null, CancellationToken ct = default) { - var children = new HashSet(StringComparer.Ordinal); - - var messages = AntigravityPaths.MessagesDir(parentSessionId, home, geminiCliHome); - if (!Directory.Exists(messages)) return []; - - foreach (var file in Directory.EnumerateFiles(messages, "*.json")) { - ct.ThrowIfCancellationRequested(); - try { - using var doc = JsonDocument.Parse(File.ReadAllText(file)); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) continue; - - var sender = root.TryGetProperty("sender", out var s) ? s.GetString() : null; - var recipient = root.TryGetProperty("recipient", out var r) ? r.GetString() : null; - - if (string.IsNullOrEmpty(sender) - || string.IsNullOrEmpty(recipient) - || !string.Equals(recipient, parentSessionId, StringComparison.Ordinal) - || string.Equals(sender, parentSessionId, StringComparison.Ordinal)) - continue; - - children.Add(sender!); - } catch { - // Skip a malformed / unreadable message file — never abort the scan. - } - } - - return [.. children]; - } - /// /// Walks parent links up to the top-level ancestor (a conversation with no parent), so a /// deep chain P←C←G resolves G's ancestor to P. Cycle-safe: if a cycle is encountered diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs index 13218f5ced..93f228e3fc 100644 --- a/src/Capacitor.Cli/Commands/WatchCommand.cs +++ b/src/Capacitor.Cli/Commands/WatchCommand.cs @@ -312,20 +312,21 @@ public static async Task RunWatch( state.LastRepoDetection = DateTimeOffset.UtcNow; } - await DrainNewLines(hubConnection, sessionId, transcriptPath, agentId, state, vendor, cts.Token); + var drained = await DrainNewLines(hubConnection, sessionId, transcriptPath, agentId, state, vendor, cts.Token); // Live subagent discovery: only the parent (agentId == null) watcher scans; // child subagent watchers (agentId != null) just stream their file. Gemini // scans its native nested chat files; OpenCode scans the nested dir the // kcap plugin writes child {info,parts} into (AI-919 phase 2); Antigravity - // scans its own brain messages dir for children that have reported back - // (AI-1218 — nesting only, subagents are captured standalone already). + // links subagents from the parent transcript's INVOKE_SUBAGENT steps — the + // spawn-time signal — drained this tick (AI-1218 — nesting only, subagents are + // captured standalone already). if (agentId is null && vendor == "gemini") { await ScanGeminiSubagents(baseUrl, sessionId, transcriptPath, seenSubagents, cts.Token); } else if (agentId is null && vendor == "opencode") { await ScanOpenCodeSubagents(baseUrl, sessionId, transcriptPath, seenSubagents, cts.Token); } else if (agentId is null && vendor == "antigravity") { - await ScanAntigravitySubagentLinks(baseUrl, sessionId, transcriptPath, state.PostedSubagentLinks, cts.Token); + await ScanAntigravitySubagentLinks(baseUrl, sessionId, drained, state.PostedSubagentLinks, cts.Token); } if (ShouldEndOnIdle( @@ -364,13 +365,13 @@ public static async Task RunWatch( Log($"Session below threshold ({state.BufferedLines.Count}/{WatchState.TranscriptThreshold} lines), skipping final drain"); } else { Log("Draining remaining lines..."); - await DrainNewLines(hubConnection, sessionId, transcriptPath, agentId, state, vendor, CancellationToken.None); + var finalDrained = await DrainNewLines(hubConnection, sessionId, transcriptPath, agentId, state, vendor, CancellationToken.None); - // One last subagent-link scan on the way out — a child may have reported back - // (written its messages/*.json) after the main loop's last tick but before exit, - // and this is the watcher's final chance to link it (AI-1218). + // One last subagent-link scan on the way out — the parent may have emitted an + // INVOKE_SUBAGENT step after the main loop's last tick but before exit, and this is + // the watcher's final chance to link it (AI-1218). if (agentId is null && vendor == "antigravity") { - await ScanAntigravitySubagentLinks(baseUrl, sessionId, transcriptPath, state.PostedSubagentLinks, CancellationToken.None); + await ScanAntigravitySubagentLinks(baseUrl, sessionId, finalDrained, state.PostedSubagentLinks, CancellationToken.None); } } @@ -769,7 +770,7 @@ internal static async Task PostSessionEndOnParentExitAsync( static readonly Regex CommandNameRegex = CommandNameRx(); static bool parseErrorLogged; - static async Task DrainNewLines( + static async Task> DrainNewLines( HubConnection hubConnection, string sessionId, string transcriptPath, @@ -780,7 +781,7 @@ CancellationToken ct ) { try { if (!File.Exists(transcriptPath)) { - return; + return []; } var newLines = new List(); @@ -950,7 +951,7 @@ CancellationToken ct if (state.BufferedLines.Count < WatchState.TranscriptThreshold) { Log($"Buffering {newLines.Count} line(s) ({state.BufferedLines.Count}/{WatchState.TranscriptThreshold} threshold)"); - return; + return newLines; } // Threshold reached — flush the entire buffer @@ -974,7 +975,7 @@ CancellationToken ct // No new content lines while buffering — track file position state.LinesReadAhead = linesRead; - return; + return newLines; } // Only include repository info when it has changed since last send @@ -986,7 +987,7 @@ CancellationToken ct // No content lines and no repo changes — safe to advance past blank/whitespace lines state.LinesProcessed = linesRead; - return; + return newLines; } try { @@ -1045,11 +1046,15 @@ await hubConnection.InvokeAsync( Log($"Repo info send failed, will retry in 60s: {ex.Message}"); } } + + return newLines; } catch (IOException ex) { Log($"Error reading file: {ex.Message}"); } catch (OperationCanceledException) { // Expected during shutdown } + + return []; } static void SetFirstUserText(WatchState state, string userText) { @@ -1379,55 +1384,42 @@ internal static void UpdateAntigravityPendingToolCalls(WatchState state, string } /// - /// Live subagent nesting (AI-1218): Antigravity fires no subagent hooks, so a child that - /// reports back is discovered by scanning the PARENT's own - /// brain/<parent>/.system_generated/messages/*.json dir (see - /// ) — the same linkage the offline importer - /// (AI-1160) reads, but scoped to one parent so a live watcher can poll it cheaply on every - /// drain. is the canonical dashless id the watcher/server use - /// everywhere else; the real (dashed) conversation id — required to locate the brain dir — - /// is derived from (mirrors - /// 's use of - /// ). Each newly-seen child is - /// POSTed to /hooks/antigravity/subagent-link and only added to - /// on success, so a failed POST retries on the next scan - /// (fail-open — never breaks the drain loop). + /// AI-1218 redesign: link subagents from the parent transcript's INVOKE_SUBAGENT steps (the + /// spawn-time signal), replacing the messages/*.json scan. For each child id not already + /// posted, POST the link once via (returns true on success); a failed + /// POST is left un-posted so a later scan retries. Pure over its inputs for unit testing. /// - static async Task ScanAntigravitySubagentLinks( - string baseUrl, - string sessionId, - string transcriptPath, - HashSet posted, - CancellationToken ct - ) { - // Same validated path-walk as ConversationDbFromTranscript, just stopping one segment - // earlier at the conversation id itself rather than the sibling db file. - if (AntigravityPaths.ConversationDbFromTranscript(transcriptPath) is not { } dbPath) return; - var conversationId = Path.GetFileNameWithoutExtension(dbPath); - if (string.IsNullOrEmpty(conversationId)) return; - - IReadOnlyList children; - try { - children = AntigravitySubagents.ChildrenOf(conversationId, ct: ct); - } catch (OperationCanceledException) { - throw; - } catch (Exception ex) { - Log($"Antigravity subagent-link scan failed: {ex.Message}"); - return; - } - - foreach (var child in children) { - if (ct.IsCancellationRequested) return; - if (posted.Contains(child)) continue; - - if (await PostAntigravitySubagentLinkAsync(baseUrl, sessionId, child, ct)) { - posted.Add(child); - Log($"Antigravity subagent {child} linked to parent {sessionId}"); + internal static async Task ExtractAndPostSubagentLinks( + IEnumerable lines, HashSet posted, Func> post) { + foreach (var line in lines) { + var children = AntigravitySubagents.ChildConversationIdsFromLine(line); + if (children.Count == 0) { + if (AntigravitySubagents.IsInvokeSubagentLine(line)) + Log("Antigravity INVOKE_SUBAGENT step had no parseable child conversationId (format drift?)"); + continue; + } + foreach (var child in children) { + if (posted.Contains(child)) continue; + if (await post(child)) posted.Add(child); // fail-open: leave un-posted to retry } - // On failure, leave out of `posted` so the next scan retries (fail-open). } } + /// + /// Live subagent nesting (AI-1218): links a subagent from its parent transcript's + /// INVOKE_SUBAGENT steps — the spawn-time signal — rather than waiting for the child to + /// report back. are the lines the watcher just drained from + /// the parent transcript this tick. Each newly-seen child is POSTed to + /// /hooks/antigravity/subagent-link and only added to on + /// success, so a failed POST retries on the next scan (fail-open — never breaks the drain + /// loop). + /// + static Task ScanAntigravitySubagentLinks( + string baseUrl, string sessionId, IReadOnlyList drainedLines, + HashSet posted, CancellationToken ct) => + ExtractAndPostSubagentLinks(drainedLines, posted, + child => PostAntigravitySubagentLinkAsync(baseUrl, sessionId, child, ct)); + static async Task PostAntigravitySubagentLinkAsync( string baseUrl, string sessionId, string childId, CancellationToken ct ) { diff --git a/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentLinkScanTests.cs b/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentLinkScanTests.cs index 77687e7df7..19fe0c087b 100644 --- a/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentLinkScanTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentLinkScanTests.cs @@ -1,84 +1,36 @@ -using System.Text.Json.Nodes; -using Capacitor.Cli.Core.Antigravity; +using Capacitor.Cli.Commands; namespace Capacitor.Cli.Tests.Unit; /// -/// Unit tests for (AI-1218): the live-watcher -/// scoped scan of a single parent's messages/*.json dir, used to detect subagents that -/// have reported back while the parent conversation is still running (as opposed to -/// , which covers the full-history import scan). +/// Unit tests for (AI-1218 redesign): the +/// live-watcher scan that links Antigravity subagents from the parent transcript's +/// INVOKE_SUBAGENT steps (the spawn-time signal) instead of the child-reports-back +/// messages/*.json scan. Drives the extraction+POST composition directly since +/// ScanAntigravitySubagentLinks itself is now a thin wrapper over drained lines. /// public class AntigravitySubagentLinkScanTests { - static string NewHome() { - var home = Path.Combine(Path.GetTempPath(), "kcap-agsublink-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(home); - return home; - } - - // Writes brain//.system_generated/messages/.json with sender→recipient. - static void WriteMessage(string home, string owner, string name, string sender, string recipient) { - var dir = Path.Combine(home, ".gemini", "antigravity", "brain", owner, ".system_generated", "messages"); - Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, name + ".json"), - new JsonObject { ["sender"] = sender, ["recipient"] = recipient }.ToJsonString()); - } - [Test] - public async Task ChildrenOf_returns_only_children_reporting_to_the_parent_deduped() { - var home = NewHome(); - try { - // Two children report back to the parent. - WriteMessage(home, owner: "P", name: "m1", sender: "child1", recipient: "P"); - WriteMessage(home, owner: "P", name: "m2", sender: "child2", recipient: "P"); - // A duplicate message from child1 (e.g. re-sent) must not double the result. - WriteMessage(home, owner: "P", name: "m3", sender: "child1", recipient: "P"); - // Unrelated: a message in the parent's dir addressed to someone else must be excluded. - WriteMessage(home, owner: "P", name: "m4", sender: "child3", recipient: "someone-else"); - // Malformed file must be skipped, not throw. - var dir = Path.Combine(home, ".gemini", "antigravity", "brain", "P", ".system_generated", "messages"); - File.WriteAllText(Path.Combine(dir, "bad.json"), "{ not json"); - - var children = AntigravitySubagents.ChildrenOf("P", home: home, geminiCliHome: ""); - - await Assert.That(children.Count).IsEqualTo(2); - await Assert.That(children.OrderBy(x => x).ToList()).IsEquivalentTo(new List { "child1", "child2" }); - } finally { Directory.Delete(home, recursive: true); } + public async Task Scan_posts_each_invoke_child_once_and_dedupes() { + var posted = new HashSet(StringComparer.Ordinal); + var sent = new List(); + var lines = new[] { + """{"type":"PLANNER_RESPONSE","content":"thinking"}""", + """{"type":"INVOKE_SUBAGENT","content":"Created the following subagents:\n{\"conversationId\":\"6111e615-3caa-4fe8-9d55-b85c43f2cf1f\"}"}""", + }; + await WatchCommand.ExtractAndPostSubagentLinks(lines, posted, child => { sent.Add(child); return Task.FromResult(true); }); + await WatchCommand.ExtractAndPostSubagentLinks(lines, posted, child => { sent.Add(child); return Task.FromResult(true); }); // re-scan + await Assert.That(sent).IsEquivalentTo(new List { "6111e615-3caa-4fe8-9d55-b85c43f2cf1f" }); // once } [Test] - public async Task ChildrenOf_excludes_self_messages() { - var home = NewHome(); - try { - WriteMessage(home, owner: "P", name: "self", sender: "P", recipient: "P"); - - var children = AntigravitySubagents.ChildrenOf("P", home: home, geminiCliHome: ""); - await Assert.That(children.Count).IsEqualTo(0); - } finally { Directory.Delete(home, recursive: true); } - } - - [Test] - public async Task ChildrenOf_is_empty_when_no_messages_dir() { - var home = NewHome(); - try { - var children = AntigravitySubagents.ChildrenOf("P", home: home, geminiCliHome: ""); - await Assert.That(children.Count).IsEqualTo(0); - } finally { Directory.Delete(home, recursive: true); } - } - - [Test] - public async Task ChildrenOf_honors_cancellation() { - var home = NewHome(); - try { - // A handful of message files so the per-file loop actually runs. - for (var i = 0; i < 5; i++) - WriteMessage(home, owner: "P", name: $"m{i}", sender: $"child{i}", recipient: "P"); - - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - await Assert.That(() => AntigravitySubagents.ChildrenOf("P", home: home, geminiCliHome: "", ct: cts.Token)) - .Throws(); - } finally { Directory.Delete(home, recursive: true); } + public async Task Scan_failed_post_is_retried_next_scan() { + var posted = new HashSet(StringComparer.Ordinal); + var attempts = 0; + var lines = new[] { """{"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"6111e615-3caa-4fe8-9d55-b85c43f2cf1f\"}"}""" }; + await WatchCommand.ExtractAndPostSubagentLinks(lines, posted, _ => { attempts++; return Task.FromResult(false); }); // fail + await WatchCommand.ExtractAndPostSubagentLinks(lines, posted, _ => { attempts++; return Task.FromResult(true); }); // retry, succeed + await Assert.That(attempts).IsEqualTo(2); + await Assert.That(posted).Contains("6111e615-3caa-4fe8-9d55-b85c43f2cf1f"); } } From bb3063a497e9047a182c76feac5a1449894fc653 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:26:25 -0400 Subject: [PATCH 5/7] [AI-1218] Import: build parent map from INVOKE_SUBAGENT + drift counters BuildParentMap now derives child->parent linkage from each conversation's transcript_full.jsonl INVOKE_SUBAGENT steps (the spawn-time signal) instead of scanning brain//.system_generated/messages/*.json, which missed children that never reported back and had an inverted sender/recipient direction in some captures. DiscoverAsync logs invoke-edge/dangling-child/ messages-without-invoke drift counters now that messages/ is no longer the detection source. --- .../Antigravity/AntigravitySubagents.cs | 73 +++++---- .../Commands/AntigravityImportSource.cs | 11 ++ .../AntigravityImportSourceTests.cs | 65 ++++---- .../AntigravitySubagentsTests.cs | 143 +++++++++++------- 4 files changed, 177 insertions(+), 115 deletions(-) diff --git a/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs b/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs index ffaa8c27f3..de768e946d 100644 --- a/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs +++ b/src/Capacitor.Cli.Core/Antigravity/AntigravitySubagents.cs @@ -3,21 +3,23 @@ namespace Capacitor.Cli.Core.Antigravity; /// -/// Resolves Antigravity's subagent → parent conversation linkage for historical import -/// (AI-1160). A subagent is a SEPARATE conversation; the parent's brain dir records the -/// linkage as inter-agent messages under -/// brain/<parent>/.system_generated/messages/*.json, each -/// { "sender": "<child>", "recipient": "<parent>" }. This linkage is only -/// written when a child reports back, so it is unreliable at live child-start (nesting is -/// deferred there) — but at IMPORT time every message file is on disk, so the full map is -/// resolvable. Roots are conversations that are never a sender. +/// Resolves Antigravity's subagent → parent conversation linkage for historical import. A +/// subagent is a SEPARATE conversation; AI-1218 redesign: the linkage is derived from the +/// parent transcript's INVOKE_SUBAGENT steps (the spawn-time signal — same source the +/// live watcher now uses, see ), which is available +/// even for a child that never reports back. This replaces the pre-AI-1218 (AI-1160) +/// child-reports-back scan of brain/<parent>/.system_generated/messages/*.json, +/// which silently dropped errored/never-reporting children (see d9956b89) and had the +/// sender/recipient direction inverted in some captures (see 7f8d9d93 → a1204f98). Roots are +/// conversations that never appear as an invoked child. /// public static class AntigravitySubagents { /// /// Builds child-conversation-id → parent-conversation-id by scanning every brain dir's - /// messages. Best-effort: unreadable / malformed message files are skipped. If a child maps - /// to MULTIPLE parents (pathological / non-tree data), the lexicographically-smallest parent - /// wins so the map is deterministic regardless of directory enumeration order. + /// transcript_full.jsonl for INVOKE_SUBAGENT steps. Best-effort: unreadable / + /// truncated transcripts are skipped. If a child is invoked by MULTIPLE parents + /// (pathological / non-tree data), the lexicographically-smallest parent wins so the map is + /// deterministic regardless of directory enumeration order. /// public static IReadOnlyDictionary BuildParentMap( string? home = null, string? geminiCliHome = null, CancellationToken ct = default) { @@ -26,36 +28,20 @@ public static IReadOnlyDictionary BuildParentMap( var brainRoot = Path.Combine(AntigravityPaths.Root(home, geminiCliHome), "brain"); if (!Directory.Exists(brainRoot)) return map; - // Building the full map requires scanning every brain dir's messages (a child records its - // parent, so finding any root's descendants means reading all links). That's O(history) IO, - // so honour cancellation between dirs and files — a targeted `--session` import or a Ctrl+C - // must be able to interrupt it (AI-1160 review). foreach (var brainDir in Directory.EnumerateDirectories(brainRoot)) { ct.ThrowIfCancellationRequested(); - var messages = Path.Combine(brainDir, ".system_generated", "messages"); - if (!Directory.Exists(messages)) continue; + var parent = Path.GetFileName(brainDir); + var transcript = Path.Combine(brainDir, ".system_generated", "logs", "transcript_full.jsonl"); + if (!File.Exists(transcript)) continue; - foreach (var file in Directory.EnumerateFiles(messages, "*.json")) { + foreach (var line in EnumerateLinesSafe(transcript)) { ct.ThrowIfCancellationRequested(); - try { - using var doc = JsonDocument.Parse(File.ReadAllText(file)); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) continue; - - var sender = root.TryGetProperty("sender", out var s) ? s.GetString() : null; - var recipient = root.TryGetProperty("recipient", out var r) ? r.GetString() : null; - - if (string.IsNullOrEmpty(sender) - || string.IsNullOrEmpty(recipient) - || string.Equals(sender, recipient, StringComparison.Ordinal)) - continue; - - // Deterministic on conflict: keep the smallest parent (Ordinal). - if (!map.TryGetValue(sender!, out var existing) - || string.CompareOrdinal(recipient!, existing) < 0) - map[sender!] = recipient!; - } catch { - // Skip a malformed / unreadable message file — never abort the whole map. + foreach (var child in ChildConversationIdsFromLine(line)) { + if (string.Equals(child, parent, StringComparison.Ordinal)) continue; // self-guard + // First-wins on conflict, deterministic: keep the smallest parent (Ordinal). + if (!map.TryGetValue(child, out var existing) + || string.CompareOrdinal(parent, existing) < 0) + map[child] = parent; } } } @@ -63,6 +49,19 @@ public static IReadOnlyDictionary BuildParentMap( return map; } + static IEnumerable EnumerateLinesSafe(string path) { + IEnumerator e; + try { e = File.ReadLines(path).GetEnumerator(); } + catch { yield break; } // unreadable transcript — skip the whole file + using (e) { + while (true) { + try { if (!e.MoveNext()) yield break; } + catch { yield break; } // truncated/locked mid-read — stop, keep what we have + yield return e.Current; + } + } + } + /// Parent conversation id for a child, or null when it is a root / unlinked. public static string? ResolveParent(string childConversationId, string? home = null, string? geminiCliHome = null) => BuildParentMap(home, geminiCliHome).TryGetValue(childConversationId, out var parent) ? parent : null; diff --git a/src/Capacitor.Cli/Commands/AntigravityImportSource.cs b/src/Capacitor.Cli/Commands/AntigravityImportSource.cs index bf5ec12b34..c2264b6c32 100644 --- a/src/Capacitor.Cli/Commands/AntigravityImportSource.cs +++ b/src/Capacitor.Cli/Commands/AntigravityImportSource.cs @@ -48,6 +48,8 @@ public AntigravityImportSource(string? home = null, string? geminiCliHome = null public bool IsAvailable => Directory.Exists(BrainRoot); public bool SupportsTitleGeneration => false; // server computes a fallback title at session-end + static void Log(string message) => Console.Error.WriteLine($"[{DateTimeOffset.Now:HH:mm:ss.fff}] [antigravity-import] {message}"); + public Task> DiscoverAsync(DiscoveryFilters filters, CancellationToken ct) { var result = new List(); if (!Directory.Exists(BrainRoot)) return Task.FromResult>(result); @@ -60,6 +62,15 @@ public Task> DiscoverAsync(DiscoveryFilters fil var convIds = Directory.EnumerateDirectories(BrainRoot).Select(Path.GetFileName).OfType().ToList(); var byRoot = AntigravitySubagents.BuildRootDescendants(convIds, parentMap); + // AI-1218 drift observability: surface format drift without a messages fallback. + var allConversationIds = convIds; + var invokeEdges = parentMap.Count; + var danglingChild = parentMap.Keys.Count(c => !allConversationIds.Contains(c)); + var msgButNoInvoke = allConversationIds.Count(id => + Directory.Exists(AntigravityPaths.MessagesDir(id, _home, _geminiCliHome)) + && !parentMap.ContainsKey(id) && !parentMap.Values.Contains(id)); + Log($"Antigravity import: {invokeEdges} invoke edge(s); {danglingChild} invoked child id(s) with no conversation dir; {msgButNoInvoke} conversation(s) with messages/ but no invoke edge"); + var sinceUtc = filters.Since is { } since ? new DateTimeOffset(since.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc), TimeSpan.Zero) : (DateTimeOffset?)null; diff --git a/test/Capacitor.Cli.Tests.Unit/AntigravityImportSourceTests.cs b/test/Capacitor.Cli.Tests.Unit/AntigravityImportSourceTests.cs index b45986e520..f3fb1636eb 100644 --- a/test/Capacitor.Cli.Tests.Unit/AntigravityImportSourceTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/AntigravityImportSourceTests.cs @@ -1,15 +1,22 @@ -using System.Text.Json.Nodes; using Capacitor.Cli.Commands; namespace Capacitor.Cli.Tests.Unit; /// -/// Unit tests for discovery (AI-1160): roots are -/// conversations that are not a subagent of another (per the messages linkage), children -/// are attached for subagent import, and IsImportRelevantLine matches the normalizer's -/// event-producing step types. +/// Unit tests for discovery. AI-1218 redesign: roots are +/// conversations that are never invoked as a child (per the parent transcript's +/// INVOKE_SUBAGENT steps — see AntigravitySubagents.BuildParentMap), children are +/// attached for subagent import, and IsImportRelevantLine matches the normalizer's +/// event-producing step types. Conversation ids here are GUID-shaped because +/// ChildConversationIdsFromLine only recognizes GUID-shaped ids (matching real brain-dir names). /// public class AntigravityImportSourceTests { + const string Root = "aaaaaaaa-0000-0000-0000-00000000a001"; + const string Child = "bbbbbbbb-0000-0000-0000-00000000b001"; + const string Grand = "cccccccc-0000-0000-0000-00000000c001"; + const string SessA = "aaaaaaaa-0000-0000-0000-00000000a00a"; + const string SessB = "bbbbbbbb-0000-0000-0000-00000000b00b"; + static string NewHome() { var home = Path.Combine(Path.GetTempPath(), "kcap-agimp-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(home); @@ -25,11 +32,15 @@ static void WriteTranscript(string home, string convId, params string[] lines) { File.WriteAllLines(Path.Combine(dir, "transcript_full.jsonl"), lines); } - static void WriteMessage(string home, string owner, string sender, string recipient) { - var dir = Path.Combine(BrainDir(home, owner), ".system_generated", "messages"); + // Appends an INVOKE_SUBAGENT step to convId's transcript naming childId as the spawned + // conversation — the AI-1218 spawn-time linkage BuildParentMap now reads instead of + // messages/*.json. + static void AppendInvoke(string home, string convId, string childId) { + var dir = Path.Combine(BrainDir(home, convId), ".system_generated", "logs"); Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, sender + ".json"), - new JsonObject { ["sender"] = sender, ["recipient"] = recipient }.ToJsonString()); + File.AppendAllLines(Path.Combine(dir, "transcript_full.jsonl"), [ + $$"""{"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"{{childId}}\"}"}""" + ]); } static string UserLine(string ts) => @@ -39,9 +50,9 @@ static string UserLine(string ts) => public async Task Discover_returns_roots_only_and_attaches_children() { var home = NewHome(); try { - WriteTranscript(home, "ROOT", UserLine("2026-07-02T19:00:00Z")); - WriteTranscript(home, "CHILD", UserLine("2026-07-02T19:01:00Z")); - WriteMessage(home, owner: "ROOT", sender: "CHILD", recipient: "ROOT"); // CHILD is a subagent of ROOT + WriteTranscript(home, Root, UserLine("2026-07-02T19:00:00Z")); + WriteTranscript(home, Child, UserLine("2026-07-02T19:01:00Z")); + AppendInvoke(home, Root, Child); // Root invokes Child as a subagent var source = new AntigravityImportSource(home: home, geminiCliHome: ""); await Assert.That(source.IsAvailable).IsTrue(); @@ -50,15 +61,15 @@ public async Task Discover_returns_roots_only_and_attaches_children() { new DiscoveryFilters(FilterCwd: null, FilterSession: null, Since: null, MinLines: 0), CancellationToken.None); - // Only ROOT is a top-level session; CHILD is imported under it. + // Only Root is a top-level session; Child is imported under it. await Assert.That(discovered.Count).IsEqualTo(1); var root = discovered[0]; - await Assert.That(root.SessionId).IsEqualTo("ROOT"); + await Assert.That(root.SessionId).IsEqualTo(Root); await Assert.That(root.Vendor).IsEqualTo("antigravity"); await Assert.That(root.FirstTimestamp).IsEqualTo(DateTimeOffset.Parse("2026-07-02T19:00:00Z")); var children = (List)root.SourceMeta!["Children"]!; - await Assert.That(children).Contains("CHILD"); + await Assert.That(children).Contains(Child); } finally { Directory.Delete(home, recursive: true); } } @@ -66,12 +77,12 @@ public async Task Discover_returns_roots_only_and_attaches_children() { public async Task Discover_nests_transitive_descendants_under_the_top_level_root() { var home = NewHome(); try { - // Chain ROOT ← CHILD ← GRAND. Only ROOT is a session; CHILD *and* GRAND import under it. - WriteTranscript(home, "ROOT", UserLine("2026-07-02T19:00:00Z")); - WriteTranscript(home, "CHILD", UserLine("2026-07-02T19:01:00Z")); - WriteTranscript(home, "GRAND", UserLine("2026-07-02T19:02:00Z")); - WriteMessage(home, owner: "ROOT", sender: "CHILD", recipient: "ROOT"); - WriteMessage(home, owner: "CHILD", sender: "GRAND", recipient: "CHILD"); + // Chain Root ← Child ← Grand. Only Root is a session; Child *and* Grand import under it. + WriteTranscript(home, Root, UserLine("2026-07-02T19:00:00Z")); + WriteTranscript(home, Child, UserLine("2026-07-02T19:01:00Z")); + WriteTranscript(home, Grand, UserLine("2026-07-02T19:02:00Z")); + AppendInvoke(home, Root, Child); + AppendInvoke(home, Child, Grand); var source = new AntigravityImportSource(home: home, geminiCliHome: ""); var discovered = await source.DiscoverAsync( @@ -79,9 +90,9 @@ public async Task Discover_nests_transitive_descendants_under_the_top_level_root CancellationToken.None); await Assert.That(discovered.Count).IsEqualTo(1); - await Assert.That(discovered[0].SessionId).IsEqualTo("ROOT"); + await Assert.That(discovered[0].SessionId).IsEqualTo(Root); var children = (List)discovered[0].SourceMeta!["Children"]!; - await Assert.That(children.OrderBy(x => x).ToList()).IsEquivalentTo(new List { "CHILD", "GRAND" }); + await Assert.That(children.OrderBy(x => x).ToList()).IsEquivalentTo(new List { Child, Grand }.OrderBy(x => x).ToList()); } finally { Directory.Delete(home, recursive: true); } } @@ -89,16 +100,16 @@ public async Task Discover_nests_transitive_descendants_under_the_top_level_root public async Task Discover_honors_the_session_filter() { var home = NewHome(); try { - WriteTranscript(home, "A", UserLine("2026-07-02T19:00:00Z")); - WriteTranscript(home, "B", UserLine("2026-07-02T19:00:00Z")); + WriteTranscript(home, SessA, UserLine("2026-07-02T19:00:00Z")); + WriteTranscript(home, SessB, UserLine("2026-07-02T19:00:00Z")); var source = new AntigravityImportSource(home: home, geminiCliHome: ""); var discovered = await source.DiscoverAsync( - new DiscoveryFilters(FilterCwd: null, FilterSession: "B", Since: null, MinLines: 0), + new DiscoveryFilters(FilterCwd: null, FilterSession: SessB, Since: null, MinLines: 0), CancellationToken.None); await Assert.That(discovered.Count).IsEqualTo(1); - await Assert.That(discovered[0].SessionId).IsEqualTo("B"); + await Assert.That(discovered[0].SessionId).IsEqualTo(SessB); } finally { Directory.Delete(home, recursive: true); } } diff --git a/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentsTests.cs b/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentsTests.cs index 7f973ecbda..73ac251e31 100644 --- a/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentsTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/AntigravitySubagentsTests.cs @@ -1,12 +1,12 @@ -using System.Text.Json.Nodes; using Capacitor.Cli.Core.Antigravity; namespace Capacitor.Cli.Tests.Unit; /// -/// Unit tests for (AI-1160): the child→parent map built -/// from the parent brain dir's messages/*.json linkage used to nest subagents at -/// import time. +/// Unit tests for . AI-1218 redesign: the child→parent map is +/// built from the parent transcript's INVOKE_SUBAGENT steps (the spawn-time signal) rather +/// than the child-reports-back messages/*.json scan used pre-AI-1218 (AI-1160), which +/// missed children that never reported back. /// public class AntigravitySubagentsTests { static string NewHome() { @@ -15,18 +15,28 @@ static string NewHome() { return home; } - // Writes brain//.system_generated/messages/.json with sender→recipient. - static void WriteMessage(string home, string owner, string name, string sender, string recipient) { - var dir = Path.Combine(home, ".gemini", "antigravity", "brain", owner, ".system_generated", "messages"); - Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, name + ".json"), - new JsonObject { ["sender"] = sender, ["recipient"] = recipient }.ToJsonString()); - } - static void MakeBrainDir(string home, string convId) => Directory.CreateDirectory(Path.Combine(home, ".gemini", "antigravity", "brain", convId, ".system_generated", "logs")); + // AI-1218: on-disk brain layout for the INVOKE_SUBAGENT-based BuildParentMap, one temp + // home per test, auto-cleaned on dispose (mirrors the NewHome/MakeBrainDir pattern above, + // but for transcript-based fixtures rather than messages/*.json). + sealed class TempBrain : IDisposable { + public string Home { get; } = NewHome(); + + public void WriteTranscript(string convId, params string[] lines) { + var dir = Path.Combine(Home, ".gemini", "antigravity", "brain", convId, ".system_generated", "logs"); + Directory.CreateDirectory(dir); + File.WriteAllLines(Path.Combine(dir, "transcript_full.jsonl"), lines); + } + + public void Dispose() { + try { Directory.Delete(Home, recursive: true); } catch { /* best effort */ } + } + } + // AI-1160 review (finding 3): the discovery scan is O(history) IO and must be interruptible. + // Still valid under the AI-1218 transcript-based rewrite (scan is still O(history) IO). [Test] public async Task BuildParentMap_honors_cancellation() { var home = NewHome(); @@ -39,41 +49,61 @@ await Assert.That(() => AntigravitySubagents.BuildParentMap(home: home, geminiCl } finally { Directory.Delete(home, recursive: true); } } + // AI-1218: BuildParentMap now derives child→parent from the parent transcript's + // INVOKE_SUBAGENT steps (spawn-time signal) instead of the child-reports-back + // messages/*.json scan (the old signal was unreliable for children that never reported back — + // see AI-1218's d9956b89 case below). [Test] - public async Task BuildParentMap_links_children_to_the_parent_that_owns_the_message() { - var home = NewHome(); - try { - // Parent P owns messages from two children C1, C2 (child = sender, parent = recipient). - MakeBrainDir(home, "P"); MakeBrainDir(home, "C1"); MakeBrainDir(home, "C2"); - WriteMessage(home, owner: "P", name: "m1", sender: "C1", recipient: "P"); - WriteMessage(home, owner: "P", name: "m2", sender: "C2", recipient: "P"); + public async Task BuildParentMap_from_invoke_maps_children_to_the_invoking_parent() { + using var tmp = new TempBrain(); + tmp.WriteTranscript("aaaaaaaa-0000-0000-0000-000000000001" /* parent1 */, + """{"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"bbbbbbbb-0000-0000-0000-000000000001\"}"}"""); + tmp.WriteTranscript("bbbbbbbb-0000-0000-0000-000000000001" /* child1 */, + """{"type":"USER_INPUT","content":"hi"}"""); - var map = AntigravitySubagents.BuildParentMap(home: home, geminiCliHome: ""); + var map = AntigravitySubagents.BuildParentMap(home: tmp.Home, geminiCliHome: ""); + await Assert.That(map["bbbbbbbb-0000-0000-0000-000000000001"]).IsEqualTo("aaaaaaaa-0000-0000-0000-000000000001"); + } - await Assert.That(map.Count).IsEqualTo(2); - await Assert.That(map["C1"]).IsEqualTo("P"); - await Assert.That(map["C2"]).IsEqualTo("P"); - // The root is never a sender, so it isn't a key. - await Assert.That(map.ContainsKey("P")).IsFalse(); + [Test] + public async Task BuildParentMap_direction_is_from_the_invoker_not_the_message_sender() { + // Regression for the messages-scan inversion (7f8d9d93 → a1204f98): the ROOT invokes the + // child, even though a "Message from Root Agent" would have inverted it under the old + // heuristic. + using var tmp = new TempBrain(); + tmp.WriteTranscript("cccccccc-0000-0000-0000-000000000ccc" /* root */, + """{"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"deadbeef-0000-0000-0000-00000000dead\"}"}"""); + tmp.WriteTranscript("deadbeef-0000-0000-0000-00000000dead" /* leaf */, + """{"type":"USER_INPUT","content":"x"}"""); - await Assert.That(AntigravitySubagents.ResolveParent("C1", home: home, geminiCliHome: "")).IsEqualTo("P"); - await Assert.That(AntigravitySubagents.ResolveParent("P", home: home, geminiCliHome: "")).IsNull(); - } finally { Directory.Delete(home, recursive: true); } + var map = AntigravitySubagents.BuildParentMap(home: tmp.Home, geminiCliHome: ""); + await Assert.That(map["deadbeef-0000-0000-0000-00000000dead"]).IsEqualTo("cccccccc-0000-0000-0000-000000000ccc"); + await Assert.That(map.ContainsKey("cccccccc-0000-0000-0000-000000000ccc")).IsFalse(); // root has no parent } [Test] - public async Task BuildParentMap_ignores_self_messages_and_malformed_files() { - var home = NewHome(); - try { - MakeBrainDir(home, "P"); - WriteMessage(home, owner: "P", name: "self", sender: "P", recipient: "P"); // self → ignored - // malformed file - var dir = Path.Combine(home, ".gemini", "antigravity", "brain", "P", ".system_generated", "messages"); - File.WriteAllText(Path.Combine(dir, "bad.json"), "{ not json"); - - var map = AntigravitySubagents.BuildParentMap(home: home, geminiCliHome: ""); - await Assert.That(map.Count).IsEqualTo(0); - } finally { Directory.Delete(home, recursive: true); } + public async Task BuildParentMap_captures_children_that_never_reported_back() { + // d9956b89 case: a child was invoked but only errored (no clean report). INVOKE still + // maps it — this is precisely the gap the messages-scan heuristic couldn't cover. + using var tmp = new TempBrain(); + tmp.WriteTranscript("eeeeeeee-0000-0000-0000-00000000000e" /* p */, + """{"type":"INVOKE_SUBAGENT","content":"[{\"conversationId\":\"e4404400-0000-0000-0000-0000000e4404\"}]"}"""); + // no transcript for the errored child dir on disk beyond the invoke — still mapped + var map = AntigravitySubagents.BuildParentMap(home: tmp.Home, geminiCliHome: ""); + await Assert.That(map["e4404400-0000-0000-0000-0000000e4404"]).IsEqualTo("eeeeeeee-0000-0000-0000-00000000000e"); + } + + [Test] + public async Task BuildParentMap_ignores_self_invocations_and_unreadable_transcripts() { + using var tmp = new TempBrain(); + // Self-guard: a conversation "inviting" itself must not create a self-edge. + tmp.WriteTranscript("11111111-0000-0000-0000-000000000001", + """{"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"11111111-0000-0000-0000-000000000001\"}"}"""); + // A brain dir with no transcript_full.jsonl at all is skipped, not an error. + MakeBrainDir(tmp.Home, "22222222-0000-0000-0000-000000000002"); + + var map = AntigravitySubagents.BuildParentMap(home: tmp.Home, geminiCliHome: ""); + await Assert.That(map.Count).IsEqualTo(0); } [Test] @@ -86,17 +116,28 @@ public async Task BuildParentMap_is_empty_when_no_antigravity_data() { [Test] public async Task BuildParentMap_is_deterministic_when_a_child_maps_to_multiple_parents() { - var home = NewHome(); - try { - // Pathological non-tree data: C reports to both "Pz" and "Pa". The lexicographically - // smallest recipient wins regardless of which message file is enumerated first. - MakeBrainDir(home, "Pa"); MakeBrainDir(home, "Pz"); MakeBrainDir(home, "C"); - WriteMessage(home, owner: "Pz", name: "m1", sender: "C", recipient: "Pz"); - WriteMessage(home, owner: "Pa", name: "m2", sender: "C", recipient: "Pa"); - - var map = AntigravitySubagents.BuildParentMap(home: home, geminiCliHome: ""); - await Assert.That(map["C"]).IsEqualTo("Pa"); - } finally { Directory.Delete(home, recursive: true); } + using var tmp = new TempBrain(); + // Pathological non-tree data: C is invoked by both "Pz" and "Pa". The + // lexicographically-smallest parent wins regardless of directory enumeration order. + tmp.WriteTranscript("6a000000-0000-0000-0000-00000000000a" /* Pa */, + """{"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"cc000000-0000-0000-0000-0000000000cc\"}"}"""); + tmp.WriteTranscript("6a000000-0000-0000-0000-00000000000b" /* Pz, sorts after Pa */, + """{"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"cc000000-0000-0000-0000-0000000000cc\"}"}"""); + + var map = AntigravitySubagents.BuildParentMap(home: tmp.Home, geminiCliHome: ""); + await Assert.That(map["cc000000-0000-0000-0000-0000000000cc"]).IsEqualTo("6a000000-0000-0000-0000-00000000000a"); + } + + [Test] + public async Task ResolveParent_reads_through_to_the_invoke_based_map() { + using var tmp = new TempBrain(); + tmp.WriteTranscript("3a000000-0000-0000-0000-00000000003a" /* parent */, + """{"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"3b000000-0000-0000-0000-00000000003b\"}"}"""); + + await Assert.That(AntigravitySubagents.ResolveParent( + "3b000000-0000-0000-0000-00000000003b", home: tmp.Home, geminiCliHome: "")).IsEqualTo("3a000000-0000-0000-0000-00000000003a"); + await Assert.That(AntigravitySubagents.ResolveParent( + "3a000000-0000-0000-0000-00000000003a", home: tmp.Home, geminiCliHome: "")).IsNull(); } [Test] From 8c5c5c7b4e708f0d0797496e0fbf277977b6fa6d Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:48:20 -0400 Subject: [PATCH 6/7] [AI-1218] Fix stale Antigravity import integration test: INVOKE_SUBAGENT linkage Co-Authored-By: Claude Opus 4.8 --- .../Commands/AntigravityImportSource.cs | 7 +++--- .../AntigravityImportTests.cs | 25 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/Capacitor.Cli/Commands/AntigravityImportSource.cs b/src/Capacitor.Cli/Commands/AntigravityImportSource.cs index c2264b6c32..06c9813a9e 100644 --- a/src/Capacitor.Cli/Commands/AntigravityImportSource.cs +++ b/src/Capacitor.Cli/Commands/AntigravityImportSource.cs @@ -18,9 +18,10 @@ namespace Capacitor.Cli.Commands; /// The conversation id is the brain dir name and is used VERBATIM as the session id /// (matching live capture, which uses the dashed conversationId), so a session captured /// live and later re-imported dedupes to one stream. Subagents are separate conversations -/// linked via messages/*.json (see ); roots are -/// conversations that are never a child, and their children are imported as subagents under -/// them. Antigravity records no machine-readable workspace path in the transcript, so import +/// linked via INVOKE_SUBAGENT steps in the parent's transcript (the spawn-time signal; +/// see ); roots are conversations that are never a child, +/// and their children are imported as subagents under them. Antigravity records no +/// machine-readable workspace path in the transcript, so import /// leaves cwd null (no repo enrichment / exclusion on historical import — a v1 /// limitation shared with Gemini; live capture gets cwd from the hook payload). /// diff --git a/test/Capacitor.Cli.Tests.Integration/AntigravityImportTests.cs b/test/Capacitor.Cli.Tests.Integration/AntigravityImportTests.cs index a75c84b199..38dd84725f 100644 --- a/test/Capacitor.Cli.Tests.Integration/AntigravityImportTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/AntigravityImportTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Nodes; using Capacitor.Cli.Commands; using WireMock.RequestBuilders; using WireMock.ResponseBuilders; @@ -9,10 +8,11 @@ namespace Capacitor.Cli.Tests.Integration; /// /// Wire-contract test for Antigravity historical import (AI-1160). Drives the full /// discover → classify → import path over an on-disk brain tree (a root conversation plus -/// a subagent conversation linked via the parent's messages/*.json) and asserts the -/// lifecycle the server expects: session-start/antigravity → parent transcript → -/// subagent-start → child transcript (routed by agent_id) → subagent-stop → -/// session-end/antigravity, all tagged vendor=antigravity. +/// a subagent conversation linked via an INVOKE_SUBAGENT step in the parent's +/// transcript_full.jsonl — the AI-1218 spawn-time signal, see +/// AntigravitySubagents.BuildParentMap) and asserts the lifecycle the server expects: +/// session-start/antigravity → parent transcript → subagent-start → child transcript (routed +/// by agent_id) → subagent-stop → session-end/antigravity, all tagged vendor=antigravity. /// public class AntigravityImportTests : IDisposable { readonly WireMockServer _server = WireMockServer.Start(); @@ -37,11 +37,16 @@ void WriteTranscript(string convId, string firstUserText) { }); } + // Appends an INVOKE_SUBAGENT step to Root's transcript naming Child as the spawned + // conversation — the AI-1218 spawn-time linkage BuildParentMap reads (messages/*.json is + // no longer consulted). Root's transcript must already exist (WriteTranscript(Root, ...) + // is called before this at every call site). void WriteLinkage() { - var dir = Path.Combine(BrainDir(Root), ".system_generated", "messages"); + var dir = Path.Combine(BrainDir(Root), ".system_generated", "logs"); Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, Child + ".json"), - new JsonObject { ["sender"] = Child, ["recipient"] = Root }.ToJsonString()); + File.AppendAllLines(Path.Combine(dir, "transcript_full.jsonl"), new[] { + $$"""{"type":"INVOKE_SUBAGENT","content":"{\"conversationId\":\"{{Child}}\"}"}""" + }); } [Test] @@ -95,10 +100,12 @@ await Assert.That(posts).IsEquivalentTo(new[] { await Assert.That(startBody).Contains($"\"agent_id\":\"{Child}\""); // The child transcript batch is tagged vendor=antigravity and routed by the agent_id. + // Matched via "agent_id":"" rather than a bare Contains(Child), because the root's + // own transcript batch also contains the Child id embedded in its INVOKE_SUBAGENT step. var subTranscript = _server.LogEntries .Where(e => e.RequestMessage.Path == "/hooks/transcript") .Select(e => e.RequestMessage.Body!) - .Single(b => b.Contains(Child)); + .Single(b => b.Contains($"\"agent_id\":\"{Child}\"")); await Assert.That(subTranscript).Contains("\"vendor\":\"antigravity\""); } From 52cabfc14d0f4053e9ee8eddb331a58b773eb363 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:01:23 -0400 Subject: [PATCH 7/7] [AI-1218] Address qodo review: README live-nesting note + O(n) drift counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: Antigravity subagents now nest in BOTH live capture and historical import (derived from INVOKE_SUBAGENT), correcting the stale "live nesting is a follow-up". - AntigravityImportSource drift counters: HashSet membership (Ordinal) so they stay O(n), not O(n²) List.Contains / parentMap.Values.Contains on large brain trees. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- src/Capacitor.Cli/Commands/AntigravityImportSource.cs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cb79f153e3..763bba0763 100644 --- a/README.md +++ b/README.md @@ -588,7 +588,7 @@ kcap plugin install --antigravity # install the kcap plugin to ~/.gemi kcap plugin remove --antigravity # remove the kcap plugin ``` -Antigravity fires a distinct control hook per lifecycle/tool event; kcap acts on the first `PreInvocation` of a conversation (POSTs lifecycle + spawns a watcher tailing that conversation's `transcript_full.jsonl`, `vendor=antigravity`) — so kcap must be on `PATH`. Antigravity is a GUI whose process outlives any one conversation (like the Codex desktop app), so there is no per-conversation exit signal: the watcher ends a session after it goes idle (default 60 min; override with `KCAP_ANTIGRAVITY_IDLE_MINUTES`), and a later turn reactivates it. Token/model usage lives in each conversation's sibling SQLite db (`conversations/.db`), not the JSONL, so the watcher decodes it and streams the per-generation cost (priced on read; cost is never stored). **Subagents** (Antigravity's nested agents) are separate conversations; in *live* capture they're recorded as their own sessions (nesting them under the parent is a follow-up), while historical `kcap import --antigravity` nests them under the parent (the parent↔child linkage is complete on disk at import time). Historical import reads `~/.gemini/antigravity/brain/*/…/transcript_full.jsonl` and backfills sessions from before the hooks were installed; it's watermark-idempotent (safe to re-run) and leaves the working dir empty (Antigravity records no machine-readable cwd in the transcript — live capture gets it from the hook payload). Imported sessions currently carry content but not cost (cost injection on import is a follow-up). +Antigravity fires a distinct control hook per lifecycle/tool event; kcap acts on the first `PreInvocation` of a conversation (POSTs lifecycle + spawns a watcher tailing that conversation's `transcript_full.jsonl`, `vendor=antigravity`) — so kcap must be on `PATH`. Antigravity is a GUI whose process outlives any one conversation (like the Codex desktop app), so there is no per-conversation exit signal: the watcher ends a session after it goes idle (default 60 min; override with `KCAP_ANTIGRAVITY_IDLE_MINUTES`), and a later turn reactivates it. Token/model usage lives in each conversation's sibling SQLite db (`conversations/.db`), not the JSONL, so the watcher decodes it and streams the per-generation cost (priced on read; cost is never stored). **Subagents** (Antigravity's nested agents) are separate conversations; both *live* capture and historical `kcap import --antigravity` nest them under the parent, derived from the `INVOKE_SUBAGENT` step in the parent's `transcript_full.jsonl` (the spawn-time linkage signal). Live capture POSTs a subagent-link as each child is spawned; import reads the same `INVOKE_SUBAGENT` steps across all conversations on disk. Historical import reads `~/.gemini/antigravity/brain/*/…/transcript_full.jsonl` and backfills sessions from before the hooks were installed; it's watermark-idempotent (safe to re-run) and leaves the working dir empty (Antigravity records no machine-readable cwd in the transcript — live capture gets it from the hook payload). Imported sessions currently carry content but not cost (cost injection on import is a follow-up). Cursor uses a single user-scope `hooks.json`; there is no project-scope variant. diff --git a/src/Capacitor.Cli/Commands/AntigravityImportSource.cs b/src/Capacitor.Cli/Commands/AntigravityImportSource.cs index 06c9813a9e..6fb7186416 100644 --- a/src/Capacitor.Cli/Commands/AntigravityImportSource.cs +++ b/src/Capacitor.Cli/Commands/AntigravityImportSource.cs @@ -64,12 +64,14 @@ public Task> DiscoverAsync(DiscoveryFilters fil var byRoot = AntigravitySubagents.BuildRootDescendants(convIds, parentMap); // AI-1218 drift observability: surface format drift without a messages fallback. - var allConversationIds = convIds; + // HashSet membership so the counters stay O(n), not O(n²), on large brain trees (qodo #285). + var allConversationIds = convIds.ToHashSet(StringComparer.Ordinal); + var linkedChildIds = parentMap.Values.ToHashSet(StringComparer.Ordinal); var invokeEdges = parentMap.Count; var danglingChild = parentMap.Keys.Count(c => !allConversationIds.Contains(c)); - var msgButNoInvoke = allConversationIds.Count(id => + var msgButNoInvoke = convIds.Count(id => Directory.Exists(AntigravityPaths.MessagesDir(id, _home, _geminiCliHome)) - && !parentMap.ContainsKey(id) && !parentMap.Values.Contains(id)); + && !parentMap.ContainsKey(id) && !linkedChildIds.Contains(id)); Log($"Antigravity import: {invokeEdges} invoke edge(s); {danglingChild} invoked child id(s) with no conversation dir; {msgButNoInvoke} conversation(s) with messages/ but no invoke edge"); var sinceUtc = filters.Since is { } since