From a0276fc64421016ededde19a71ea9e5651c290be Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 30 Oct 2026 13:23:47 +0000 Subject: [PATCH 1/6] feat(acp): migrate subagent observation to ACP sessions Replace private transcript polling with bounded ACP child-session streams in the bundled TUI and desktop client. Retain generation-checked Kit steering without advertising unsupported child cancellation. --- Cargo.lock | 49 +- Cargo.toml | 15 +- macos/KitDesktop/Models/AppState.swift | 12 + .../Models/ConversationController.swift | 26 +- .../Models/FocusedSubagentController.swift | 179 +-- macos/KitDesktop/Services/ACPClient.swift | 31 +- .../Services/ACPKitExtensions.swift | 70 ++ .../FocusedSubagentTests.swift | 372 +++--- src/acp_child.rs | 143 ++- src/acp_child/protocol.rs | 17 +- src/acp_child/stream.rs | 1102 +++++++++++++++++ src/acp_child/transcript.rs | 117 +- src/protocols/acp.rs | 237 ++-- src/protocols/acp/subagents.rs | 254 ++++ src/protocols/acp/v2.rs | 128 +- src/tools/subagent.rs | 32 +- src/tools/subagent/tests.rs | 10 +- src/tui/app.rs | 38 +- src/tui/app/focus.rs | 975 +-------------- src/tui/app/recovery.rs | 1 + src/tui/app/subagent_sessions.rs | 736 +++++++++++ src/tui/mod.rs | 122 +- src/tui/recovery.rs | 66 +- src/tui/subagent_sessions.rs | 21 + 24 files changed, 3141 insertions(+), 1612 deletions(-) create mode 100644 src/acp_child/stream.rs create mode 100644 src/protocols/acp/subagents.rs create mode 100644 src/tui/app/subagent_sessions.rs create mode 100644 src/tui/subagent_sessions.rs diff --git a/Cargo.lock b/Cargo.lock index 84daa4dd..e7f2374f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,7 +83,7 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" version = "2.2.0" -source = "git+https://github.com/danielkov/rust-sdk?rev=03eab519aa5ecfc97a43bf17f9721c38c065c47b#03eab519aa5ecfc97a43bf17f9721c38c065c47b" +source = "git+https://github.com/danielkov/rust-sdk?rev=05d219b5cebc6980fec81ee64f62a4a655a47b84#05d219b5cebc6980fec81ee64f62a4a655a47b84" dependencies = [ "quote", "syn 3.0.4", @@ -109,8 +109,7 @@ dependencies = [ [[package]] name = "agentkit-acp" version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdfeb02858b4c7defc804c6fda75854ae6682919f3bac7fd25de832517f903ea" +source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" dependencies = [ "agentkit-acp-sdk", "agentkit-core", @@ -127,7 +126,7 @@ dependencies = [ [[package]] name = "agentkit-acp-http" version = "2.2.0" -source = "git+https://github.com/danielkov/rust-sdk?rev=03eab519aa5ecfc97a43bf17f9721c38c065c47b#03eab519aa5ecfc97a43bf17f9721c38c065c47b" +source = "git+https://github.com/danielkov/rust-sdk?rev=05d219b5cebc6980fec81ee64f62a4a655a47b84#05d219b5cebc6980fec81ee64f62a4a655a47b84" dependencies = [ "agentkit-acp-sdk", "async-stream", @@ -148,8 +147,7 @@ dependencies = [ [[package]] name = "agentkit-acp-schema" version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcb839ccca8f4f43bfc9aa26473396f16afbd32071efd00d36801daed62f9d7" +source = "git+https://github.com/danielkov/agent-client-protocol?rev=66d51e46f0835a0d0848e9e2746ef8672c75e343#66d51e46f0835a0d0848e9e2746ef8672c75e343" dependencies = [ "anyhow", "derive_more", @@ -164,7 +162,7 @@ dependencies = [ [[package]] name = "agentkit-acp-sdk" version = "2.2.0" -source = "git+https://github.com/danielkov/rust-sdk?rev=03eab519aa5ecfc97a43bf17f9721c38c065c47b#03eab519aa5ecfc97a43bf17f9721c38c065c47b" +source = "git+https://github.com/danielkov/rust-sdk?rev=05d219b5cebc6980fec81ee64f62a4a655a47b84#05d219b5cebc6980fec81ee64f62a4a655a47b84" dependencies = [ "agent-client-protocol-derive", "agentkit-acp-schema", @@ -206,8 +204,7 @@ dependencies = [ [[package]] name = "agentkit-capabilities" version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193f4a771e30b61fbabecd90dba9a9bc16bdafc2cf717d086727871e9d0e3cf" +source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" dependencies = [ "agentkit-core", "async-trait", @@ -245,8 +242,7 @@ dependencies = [ [[package]] name = "agentkit-core" version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2110778950cdd87cc8ea3f0697c0551eb515ef23841b9c5d255a01974be17798" +source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" dependencies = [ "futures-timer", "serde", @@ -277,8 +273,7 @@ dependencies = [ [[package]] name = "agentkit-loop" version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e048d2be22aa27bf635b65b073cb2a396efca232200a4ba79b4f75d1513dcd" +source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" dependencies = [ "agentkit-core", "agentkit-task-manager", @@ -387,8 +382,7 @@ dependencies = [ [[package]] name = "agentkit-task-manager" version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f893be1f3d14f060e141e6b50baa09338778f90764cc030295105e101d29c06" +source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" dependencies = [ "agentkit-core", "agentkit-tools-core", @@ -431,8 +425,7 @@ dependencies = [ [[package]] name = "agentkit-tools-core" version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b9a8af3f09c79fc0707ea87dc377edd40bdf65db11e6ebfb50783f4e8e8ff3" +source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" dependencies = [ "agentkit-capabilities", "agentkit-core", @@ -550,7 +543,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -561,7 +554,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1991,7 +1984,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2606,7 +2599,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -4233,7 +4226,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4709,7 +4702,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4768,7 +4761,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5278,7 +5271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5482,7 +5475,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5988,7 +5981,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6294,7 +6287,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6df87636..b326f7fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ a2a-protocol-client = { version = "=0.14.1", default-features = false, features a2a-protocol-server = { version = "=0.14.1", default-features = false } a2a-protocol-types = "=0.14.1" agenthooksprotocol = { version = "=0.10.1", default-features = false, features = ["reqwest", "tokio-process"] } -agent-client-protocol = { package = "agentkit-acp-sdk", version = "=2.2.0", features = ["unstable_session_fork", "unstable_session_inject"] } +agent-client-protocol = { package = "agentkit-acp-sdk", version = "=2.2.0", features = ["unstable_session_fork", "unstable_session_inject", "unstable_subagents"] } agent-client-protocol-schema = { package = "agentkit-acp-schema", version = "=1.9.1", features = ["unstable_session_notices", "unstable_session_compaction"] } agent-client-protocol-http = { package = "agentkit-acp-http", version = "=2.2.0", default-features = false, features = ["server", "client"] } agentkit-acp = { version = "=0.11.0", features = ["unstable-inject"] } @@ -143,7 +143,14 @@ unimplemented = "deny" disallowed_methods = "deny" disallowed_macros = "deny" -# Bounded SSE replay fairness; keep HTTP and core channel types on one revision. +# Keep the ACP schema, SDK, HTTP transport, and agentkit types unified. [patch.crates-io] -agentkit-acp-http = { git = "https://github.com/danielkov/rust-sdk", rev = "03eab519aa5ecfc97a43bf17f9721c38c065c47b" } -agentkit-acp-sdk = { git = "https://github.com/danielkov/rust-sdk", rev = "03eab519aa5ecfc97a43bf17f9721c38c065c47b" } +agentkit-acp-http = { git = "https://github.com/danielkov/rust-sdk", rev = "05d219b5cebc6980fec81ee64f62a4a655a47b84" } +agentkit-acp-sdk = { git = "https://github.com/danielkov/rust-sdk", rev = "05d219b5cebc6980fec81ee64f62a4a655a47b84" } +agentkit-acp-schema = { git = "https://github.com/danielkov/agent-client-protocol", rev = "66d51e46f0835a0d0848e9e2746ef8672c75e343" } +agentkit-acp = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } +agentkit-core = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } +agentkit-loop = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } +agentkit-tools-core = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } +agentkit-capabilities = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } +agentkit-task-manager = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } diff --git a/macos/KitDesktop/Models/AppState.swift b/macos/KitDesktop/Models/AppState.swift index 43cd1cb0..927b2a0c 100644 --- a/macos/KitDesktop/Models/AppState.swift +++ b/macos/KitDesktop/Models/AppState.swift @@ -324,6 +324,18 @@ struct AgentRoster: Equatable { private(set) var usageByID: [String: DesktopUsageUpdate] = [:] private var steerableGenerations: [String: UInt64] = [:] + mutating func syncStreamed(_ source: AgentRoster) { + rowsByID = source.rowsByID + steerableGenerations = source.steerableGenerations + usageByID = usageByID.filter { rowsByID[$0.key] != nil } + } + + mutating func applyStreamed(row: AgentRosterRow, canSteer: Bool) { + rowsByID[row.id] = row + if canSteer { steerableGenerations[row.id] = row.generation } + else { steerableGenerations.removeValue(forKey: row.id) } + } + func canSteer(id: String, generation: UInt64) -> Bool { steerableGenerations[id] == generation } diff --git a/macos/KitDesktop/Models/ConversationController.swift b/macos/KitDesktop/Models/ConversationController.swift index 1da33afc..904b1ccd 100644 --- a/macos/KitDesktop/Models/ConversationController.swift +++ b/macos/KitDesktop/Models/ConversationController.swift @@ -94,7 +94,7 @@ final class ConversationController: ObservableObject { } var shouldPresentAgentRoster: Bool { - runtimeTransportAvailable != false && Self.shouldPresentAgentRoster( + !client.childSessions.roster.rowsByID.isEmpty || runtimeTransportAvailable != false && Self.shouldPresentAgentRoster( expectedSessionID: expectedRuntimeSessionID, runtimeSessionID: runtimeSessionID, transcriptIsEmpty: entries.isEmpty ) @@ -145,7 +145,7 @@ final class ConversationController: ObservableObject { guard isReady, !shuttingDown, shouldPresentAgentRoster, let row = agentRoster.rowsByID[id], row.generation == generation else { return false } return row.status == .working && row.parentID == nil - && agentRoster.canSteer(id: id, generation: generation) + && client.childSessions.roster.canSteer(id: id, generation: generation) } var onSessionReady: ((String, [ConfigOption]) -> Void)? @@ -206,6 +206,10 @@ final class ConversationController: ObservableObject { if self.startupUpdates != nil { self.startupUpdates?.append(update) } else { self.apply(update) } } + activeClient.onChildUpdate = { [weak self, weak activeClient] in + guard let self, let activeClient, self.isCurrentClient(activeClient, generation: generation), !self.shuttingDown else { return } + self.agentRoster.syncStreamed(activeClient.childSessions.roster) + } activeClient.onRuntimeEvent = { [weak self, weak activeClient] event in guard let self, let activeClient, self.isCurrentClient(activeClient, generation: generation), !self.shuttingDown else { return } self.applyRuntime(event) @@ -424,6 +428,19 @@ final class ConversationController: ObservableObject { private func apply(_ update: DesktopUpdate) { switch update { + case .sessionMessage(let message): + let identity = MessageIdentity(role: "session:" + message.sessionID, id: message.messageID) + if let entryID = messageEntryIDs[identity], let index = transcriptIndex(for: entryID) { + changedTranscriptIndices.insert(index) + entries[index].title = message.title + entries[index].text = message.text + entries[index].formatted = nil + transcriptRevision += 1 + } else { + let entry = TranscriptEntry(role: .status, title: message.title, text: message.text) + appendEntry(entry) + messageEntryIDs[identity] = entry.id + } case .userMessage(let message): finishActiveThought() applyMessage(message, role: .user) @@ -629,6 +646,8 @@ final class ConversationController: ObservableObject { return } guard runtimeTransportAvailable != false, runtimeSessionID == expectedRuntimeSessionID else { return } + if ["subagent_state_changed", "subagent_capabilities", "subagent_descendants_removed"].contains(kind), + !client.childSessions.roster.rowsByID.isEmpty { return } var roster = agentRoster if roster.apply(event: event, nowMS: Self.nowMilliseconds()) { agentRoster = roster @@ -1210,7 +1229,8 @@ final class ConversationController: ObservableObject { if !available { runtimeLeaseWorkItem?.cancel() runtimeLeaseWorkItem = nil - agentRoster.invalidateLiveState() + if client.childSessions.roster.rowsByID.isEmpty { agentRoster.invalidateLiveState() } + else { agentRoster.syncStreamed(client.childSessions.roster) } rosterPruneWorkItem?.cancel() // Tool status itself is ACP-authoritative. Only nested runtime child // observations become unknown when the diagnostic stream is lost. diff --git a/macos/KitDesktop/Models/FocusedSubagentController.swift b/macos/KitDesktop/Models/FocusedSubagentController.swift index ca5868f3..de6b2006 100644 --- a/macos/KitDesktop/Models/FocusedSubagentController.swift +++ b/macos/KitDesktop/Models/FocusedSubagentController.swift @@ -1,22 +1,10 @@ import Foundation +import CoreFoundation import Combine -struct SubagentTranscriptPage: Decodable { - let updates: [JSONValue] - let nextCursor: UInt64 - let generation: UInt64 - let caughtUp: Bool - enum CodingKeys: String, CodingKey { - case updates, generation - case nextCursor = "next_cursor" - case caughtUp = "caught_up" - } -} - -// The same boundary serves the live ACP connection and protocol fixtures. +// Child observations are connection-local ACP notifications, never transcript RPCs. protocol FocusedSubagentTransport: AnyObject { - func readSubagentTranscript(id: String, generation: UInt64, cursor: UInt64, - completion: @escaping (Result) -> Void) + var childSessions: DesktopChildSessions { get } func steerSubagent(id: String, generation: UInt64, prompt: String, completion: @escaping (Result<[String: Any], Error>) -> Void) } @@ -39,7 +27,6 @@ final class FocusedSubagentController: ObservableObject { @Published private(set) var steering = false @Published private(set) var canSteer = false @Published var draft = "" - private(set) var cursor: UInt64 = 0 private(set) var generation: UInt64 = 0 private let transport: FocusedSubagentTransport private var childID = "" @@ -47,8 +34,8 @@ final class FocusedSubagentController: ObservableObject { private var active = false private var capability = false private var readEnabled = false - private var pendingRead = false - private var poll: Task? + private var sessionMessages = DesktopSessionMessages() + private var observation: AnyCancellable? init(transport: FocusedSubagentTransport) { self.transport = transport } @@ -56,20 +43,19 @@ final class FocusedSubagentController: ObservableObject { stop() childID = row.id generation = row.generation - cursor = 0 entries = [] - partial = false + sessionMessages = DesktopSessionMessages() + partial = true // ACP reconnects can replay only a bounded tail. active = row.status == .working capability = canSteer - readEnabled = row.parentID == nil - self.canSteer = active && capability && readEnabled - guard readEnabled else { - notice = "Descendant transcript inspection is not supported." - return + readEnabled = true + self.canSteer = active && capability && row.parentID == nil + for value in transport.childSessions.history[row.id] ?? [] { apply(value) } + observation = transport.childSessions.updates.sink { [weak self] id, value in + guard let self, self.readEnabled, id == self.childID else { return } + self.apply(value) } - loading = true - notice = "Loading child transcript…" - readNext() + refreshNotice() } func update(_ row: AgentRosterRow?, canSteer: Bool) { @@ -80,25 +66,16 @@ final class FocusedSubagentController: ObservableObject { } guard row.generation >= generation else { return } if row.generation != generation { focus(row, canSteer: canSteer); return } - let wasActive = active active = row.status == .working capability = canSteer - self.canSteer = active && capability && readEnabled + self.canSteer = active && capability && readEnabled && row.parentID == nil if !loading { refreshNotice() } - if active && !wasActive && readEnabled { - // A caught-up startup page is not the end of this generation. - poll?.cancel() - poll = nil - readNext() - } } func stop() { epoch = UUID() - poll?.cancel() - poll = nil + observation = nil readEnabled = false - pendingRead = false loading = false steering = false canSteer = false @@ -128,44 +105,6 @@ final class FocusedSubagentController: ObservableObject { } } - private func readNext() { - guard readEnabled, !pendingRead else { return } - pendingRead = true - let token = epoch - let requestedCursor = cursor - let requestedGeneration = generation - transport.readSubagentTranscript(id: childID, generation: generation, cursor: cursor) { [weak self] result in - guard let self, self.epoch == token, self.cursor == requestedCursor, - self.generation == requestedGeneration, self.readEnabled else { return } - self.pendingRead = false - switch result { - case .failure(let error): - self.stop() - self.notice = "Transcript unavailable: \(error.localizedDescription). Reopen to retry." - case .success(let page): - guard page.generation == requestedGeneration, page.nextCursor >= requestedCursor, - page.updates.isEmpty || page.nextCursor > requestedCursor else { - self.stop() - self.notice = "Transcript generation or cursor changed. Reopen to resync." - return - } - for value in page.updates.prefix(2048) { self.apply(value) } - if page.updates.count > 2048 { self.partial = true } - self.cursor = page.nextCursor - if page.caughtUp { self.loading = false } - self.refreshNotice() - // Drain idle transcripts once, then stop polling. Empty writer pages back off too. - if !self.active && page.caughtUp { return } - let delay: UInt64 = page.caughtUp || page.nextCursor == requestedCursor ? 500_000_000 : 10_000_000 - self.poll = Task { [weak self] in - do { try await Task.sleep(nanoseconds: delay) } catch { return } - guard let self, self.epoch == token else { return } - self.readNext() - } - } - } - } - private func refreshNotice() { if steering { notice = "Sending steer to child…" } else if loading { notice = "Loading child transcript…" } @@ -176,6 +115,11 @@ final class FocusedSubagentController: ObservableObject { private func apply(_ value: JSONValue) { do { + if let message = sessionMessages.apply(value, session: childID) { + partial = partial || message.partial + put(id: "session:" + message.messageID, title: message.title, text: message.text) + return + } let update = try DesktopUpdate(wire: value) switch update { case .userMessage(let message): appendMessage(message, role: "You") @@ -243,3 +187,84 @@ final class FocusedSubagentController: ObservableObject { return text } } + +// Main-queue confined, ephemeral state. Ownership must be announced parent-first. +final class DesktopChildSessions { + let updates = PassthroughSubject<(String, JSONValue), Never>() + private(set) var roster = AgentRoster() + private(set) var history: [String: [JSONValue]] = [:] + private var owners: [String: String] = [:] + private var snapshots: [String: [String: Any]] = [:] + private var retained: [(String, Int)] = [] + private var bytes = 0 + + func reset() { + roster.reset() + history.removeAll() + owners.removeAll() + snapshots.removeAll() + retained.removeAll() + bytes = 0 + } + + @discardableResult + func receive(root: String, session: String, update: [String: Any]) -> Bool { + guard session == root || owners[session] != nil, + let data = try? JSONSerialization.data(withJSONObject: update), data.count <= 64 * 1024 else { return false } + if update["sessionUpdate"] as? String == "subagent_update" { + guard let id = update["sessionId"] as? String, !id.isEmpty, id != root, id != session, + owners[id] == nil || owners[id] == session, + owners[id] != nil || owners.count < 1024 else { return false } + let old = roster.rowsByID[id] + let meta = update["_meta"] as? [String: Any] + let grant = meta?["kit/subagent/steer"] as? [String: Any] + let generation = (grant?["generation"] as? NSNumber).flatMap { number -> UInt64? in + guard CFGetTypeID(number) != CFBooleanGetTypeID(), number.doubleValue >= 0, + number.stringValue == String(number.uint64Value) else { return nil } + return number.uint64Value + } + if let generation, let old, generation < old.generation { return false } + owners[id] = session + var snapshot = snapshots[id] ?? [:] + for key in ["title", "description", "state"] where update[key] != nil { snapshot[key] = update[key] } + snapshots[id] = snapshot + let state = (snapshot["state"] as? [String: Any])?["state"] as? String + let status: SubagentStatus = state == "running" || state == "requires_action" ? .working : (state == nil ? .starting : .idle) + let currentGeneration = generation ?? old?.generation ?? 0 + var canSteer = roster.canSteer(id: id, generation: currentGeneration) + if old?.generation != currentGeneration || status != .working { canSteer = false } + if update["_meta"] is NSNull || meta?["kit/subagent/steer"] != nil { + let permission = grant?["canSteer"] as? NSNumber + canSteer = generation != nil && permission.map { + CFGetTypeID($0) == CFBooleanGetTypeID() && $0.boolValue + } == true + } + canSteer = canSteer && session == root && status == .working + let row = AgentRosterRow(id: id, name: snapshot["title"] as? String ?? id, status: status, + outcome: nil, generation: currentGeneration, task: snapshot["description"] as? String ?? "", + parentID: session == root ? nil : session, parentName: nil, harness: "", model: nil, + createdAtMS: 0, generationStartedAtMS: 0, generationFinishedAtMS: nil) + roster.applyStreamed(row: row, canSteer: canSteer) + return true + } + guard session != root, let wire = try? JSONDecoder().decode(JSONValue.self, from: data) else { return false } + if update["sessionUpdate"] as? String == "state_update", let owner = owners[session] { + var state = update + state.removeValue(forKey: "sessionUpdate") + // State changes revoke grants; running alone cannot restore them. + _ = receive(root: root, session: owner, update: [ + "sessionUpdate": "subagent_update", "sessionId": session, "state": state + ]) + } + history[session, default: []].append(wire) + retained.append((session, data.count)) + bytes += data.count + while retained.count > 512 || bytes > 4 * 1024 * 1024 { + let (owner, size) = retained.removeFirst() + history[owner]?.removeFirst() + bytes -= size + } + updates.send((session, wire)) + return true + } +} diff --git a/macos/KitDesktop/Services/ACPClient.swift b/macos/KitDesktop/Services/ACPClient.swift index aa763d01..c6b90feb 100644 --- a/macos/KitDesktop/Services/ACPClient.swift +++ b/macos/KitDesktop/Services/ACPClient.swift @@ -33,6 +33,8 @@ final class ACPClient { var environment: [String: String] = [:] } + let childSessions = DesktopChildSessions() + var onChildUpdate: (() -> Void)? var onUpdate: ((DesktopUpdate) -> Void)? var onRuntimeEvent: ((Dictionary) -> Void)? var onDiagnostic: ((String) -> Void)? @@ -64,6 +66,7 @@ final class ACPClient { private var exitStatus: Int32? private var stdoutClosed = false private var stderrClosed = false + private var sessionMessages = DesktopSessionMessages() private var pendingChunk: DesktopUpdate? private var closeCompletions: [() -> Void] = [] private var chunkFlush: DispatchWorkItem? @@ -163,16 +166,6 @@ final class ACPClient { } } - func readSubagentTranscript(id: String, generation: UInt64, cursor: UInt64, - completion: @escaping (Result) -> Void) { - subagentRequest(method: "kit/subagent/transcript/read", id: id, generation: generation, - fields: ["cursor": cursor]) { result in - completion(result.flatMap { payload in - Result { try Self.decode(SubagentTranscriptPage.self, from: payload) } - }) - } - } - func steerSubagent(id: String, generation: UInt64, prompt: String, completion: @escaping (Result) -> Void) { subagentRequest(method: "kit/subagent/steer", id: id, generation: generation, @@ -240,6 +233,8 @@ final class ACPClient { ) } catch { exited = true + sessionMessages = DesktopSessionMessages() + DispatchQueue.main.async { self.childSessions.reset(); self.onChildUpdate?() } complete(completion, with: .failure(error)) return } @@ -499,14 +494,23 @@ final class ACPClient { private func handleLocked(_ message: RPCEnvelope) { if let method = message.method { if method == "session/update", let params = message.params, let update = params["update"] as? Dictionary { - guard let routed = params["sessionId"] as? String, routed == sessionID else { - DispatchQueue.main.async { self.onDiagnostic?("Ignored ACP update for another session") } + guard let routed = params["sessionId"] as? String, let root = sessionID else { return } + if routed != root || update["sessionUpdate"] as? String == "subagent_update" { + DispatchQueue.main.async { + if self.childSessions.receive(root: root, session: routed, update: update) { + self.onChildUpdate?() + } + } return } do { let data = try JSONSerialization.data(withJSONObject: update) + if ["session_message", "session_message_chunk"].contains(update["sessionUpdate"] as? String ?? ""), + data.count > 64 * 1024 { return } let wire = try JSONDecoder().decode(JSONValue.self, from: data) - let decoded = try DesktopUpdate(wire: wire) + let decoded: DesktopUpdate + if let message = sessionMessages.apply(wire, session: routed) { decoded = .sessionMessage(message) } + else { decoded = try DesktopUpdate(wire: wire) } if case .unknown(let kind) = decoded { DispatchQueue.main.async { self.onDiagnostic?("Ignored unsupported desktop update: \(kind)") } } else { enqueueUpdateLocked(decoded) } @@ -702,6 +706,7 @@ final class ACPClient { private func finalizeExitLocked(_ status: Int32) { guard !exited, stdoutClosed, stderrClosed else { return } exited = true + DispatchQueue.main.async { self.childSessions.reset(); self.onChildUpdate?() } exitSource?.cancel(); exitSource = nil flushChunkLocked() failPendingLocked(ACPClientError.process("Kit exited with status \(status)")) diff --git a/macos/KitDesktop/Services/ACPKitExtensions.swift b/macos/KitDesktop/Services/ACPKitExtensions.swift index 4199ca72..3fc276f3 100644 --- a/macos/KitDesktop/Services/ACPKitExtensions.swift +++ b/macos/KitDesktop/Services/ACPKitExtensions.swift @@ -126,6 +126,75 @@ enum DesktopContentBlock: Codable, Equatable { } } +struct DesktopDirectedMessage: Equatable { + let sessionID: String + let messageID: String + let title: String + let text: String + let partial: Bool +} + +// Ephemeral ACP patch state, bounded independently of either transcript view. +// The enclosing notification supplies scope; payload IDs cannot change it. +struct DesktopSessionMessages { + private struct Key: Hashable { + let session: String + let message: String + } + private struct Message { + var sender: String? + var recipient: String? + var text = "" + var partial = false + } + private var messages: [Key: Message] = [:] + private var order: [Key] = [] + static let maximumMessages = 500 + static let maximumTextCharacters = 16_384 + + mutating func apply(_ value: JSONValue, session: String) -> DesktopDirectedMessage? { + guard case .object(let fields) = value, + case .string(let kind) = fields["sessionUpdate"], + kind == "session_message" || kind == "session_message_chunk", + case .string(let id) = fields["messageId"] else { return nil } + let key = Key(session: session, message: id) + if messages[key] == nil { order.append(key) } + var message = messages[key] ?? Message() + if kind == "session_message" { + // Omitted fields preserve state; explicit null clears it. + if let sender = fields["senderSessionId"] { + if case .string(let id) = sender { message.sender = id } + else if sender == .null { message.sender = nil } + } + if let recipient = fields["recipientSessionId"] { + if case .string(let id) = recipient { message.recipient = id } + else if recipient == .null { message.recipient = nil } + } + } + func text(_ block: JSONValue) -> String { + if case .object(let content) = block, case .string(let text) = content["text"] { return text } + return "[Non-text session content]" + } + if let content = fields["content"] { + if kind == "session_message_chunk" { message.text += text(content) } + else if case .array(let blocks) = content { message.text = blocks.map(text).joined() } + else if content == .null { message.text = "" } + } + if message.text.count > Self.maximumTextCharacters { + message.partial = true + message.text = String(message.text.prefix(Self.maximumTextCharacters)) + } + messages[key] = message + if order.count > Self.maximumMessages { messages.removeValue(forKey: order.removeFirst()) } + let title: String + if message.sender == session { title = "To " + (message.recipient ?? "session") } + else if let sender = message.sender { title = "From " + sender } + else { title = "Session message" } + return DesktopDirectedMessage(sessionID: session, messageID: id, title: String(title.prefix(300)), + text: message.text, partial: message.partial) + } +} + struct DesktopMessageUpdate: Equatable { var messageId: String var content: [DesktopContentBlock] @@ -206,6 +275,7 @@ struct DesktopTurnState: Codable, Equatable { } enum DesktopUpdate: Equatable { + case sessionMessage(DesktopDirectedMessage) case userMessage(DesktopMessageUpdate), agentMessage(DesktopMessageUpdate), agentThought(DesktopMessageUpdate) case toolCall(DesktopToolUpdate), toolCallUpdate(DesktopToolUpdate), toolCallContent(id: String, content: JSONValue) case plan(DesktopPlanContent), planRemoved(id: String), usage(DesktopUsageUpdate), tokenUsage(DesktopTokenUsage) diff --git a/macos/KitDesktopTests/FocusedSubagentTests.swift b/macos/KitDesktopTests/FocusedSubagentTests.swift index 00e935e2..5d7f43e1 100644 --- a/macos/KitDesktopTests/FocusedSubagentTests.swift +++ b/macos/KitDesktopTests/FocusedSubagentTests.swift @@ -1,206 +1,254 @@ import XCTest +import Combine @testable import Kit final class FocusedSubagentTests: XCTestCase { - @MainActor - func testReplayRejectsStaleFocusGenerationAndInvalidCursor() throws { - let transport = ChildTransport() - let controller = FocusedSubagentController(transport: transport) - controller.focus(row(), canSteer: true) - let stale = transport.reads[0].completion - controller.focus(row(generation: 2), canSteer: false) - stale(.success(page(generation: 1, cursor: 10, text: "stale"))) - XCTAssertTrue(controller.entries.isEmpty) - transport.reads[1].completion(.success(page(generation: 1, cursor: 10, text: "wrong generation"))) - XCTAssertTrue(controller.entries.isEmpty) - XCTAssertFalse(controller.canSteer) - XCTAssertTrue(controller.notice.contains("generation or cursor")) - controller.focus(row(), canSteer: true) - transport.reads.last!.completion(.success(page(generation: 1, cursor: 0, text: "invalid cursor"))) - XCTAssertTrue(controller.entries.isEmpty) - XCTAssertFalse(controller.canSteer) + func testModelSelectionUsesCurrentAdvertisement() { + let option = ConfigOption(id: "model", name: "Model", currentValue: "a", groups: [ConfigGroup(id: "provider", name: "Provider", choices: [ConfigChoice(value: "a", name: "A"), ConfigChoice(value: "b", name: "B")])]) + XCTAssertNotNil(ModelSelection.option(in: [option], id: "model", value: "b", disabled: false)) + XCTAssertNil(ModelSelection.option(in: [option], id: "model", value: "b", disabled: true)) + XCTAssertNil(ModelSelection.option(in: [option], id: "model", value: "a", disabled: false)) + XCTAssertNil(ModelSelection.option(in: [option], id: "model", value: "removed", disabled: false)) + XCTAssertNil(ModelSelection.option(in: [], id: "model", value: "b", disabled: false)) } + @MainActor - func testCaughtUpStartingChildResumesPollingWhenWorking() { + func testToolContentChunksAppendAndStatusPatchPreservesOutput() { let transport = ChildTransport() let controller = FocusedSubagentController(transport: transport) - controller.focus(row(status: .starting), canSteer: true) - transport.reads[0].completion(.success(SubagentTranscriptPage(updates: [], nextCursor: 0, generation: 1, caughtUp: true))) - XCTAssertFalse(controller.loading) - XCTAssertFalse(controller.canSteer) - controller.update(row(status: .working), canSteer: true) - XCTAssertEqual(transport.reads.count, 2) - controller.update(row(status: .working), canSteer: true) - XCTAssertEqual(transport.reads.count, 2) - transport.reads[1].completion(.success(page(generation: 1, cursor: 10, text: "Child output after startup"))) - XCTAssertEqual(controller.entries.first?.text, "Child output after startup") - XCTAssertEqual(controller.cursor, 10) - XCTAssertTrue(controller.canSteer) - controller.stop() + transport.childSessions.receive(root: "root", session: "root", update: announcement(state: "idle")) + controller.focus(transport.childSessions.roster.rowsByID["child"]!, canSteer: false) + let updates: [JSONValue] = [ + .object(["sessionUpdate": .string("tool_call"), "toolCallId": .string("call"), "title": .string("Shell"), "status": .string("in_progress")]), + .object(["sessionUpdate": .string("tool_call_content_chunk"), "toolCallId": .string("call"), "content": .string("first chunk")]), + .object(["sessionUpdate": .string("tool_call_content_chunk"), "toolCallId": .string("call"), "content": .string("second chunk")]), + .object(["sessionUpdate": .string("tool_call_update"), "toolCallId": .string("call"), "status": .string("completed")]), + ] + for value in updates { + let data = try! JSONEncoder().encode(value) + let update = try! JSONSerialization.jsonObject(with: data) as! [String: Any] + transport.childSessions.receive(root: "root", session: "child", update: update) + } + XCTAssertEqual(controller.entries.count, 1) + XCTAssertEqual(controller.entries[0].text, "\"first chunk\"\n\"second chunk\"") + XCTAssertTrue(controller.entries[0].title.contains("completed")) + XCTAssertTrue(controller.partial) } - @MainActor - func testSteeringCapabilityLifecycleAndStaleAcknowledgement() { - let transport = ChildTransport() - let controller = FocusedSubagentController(transport: transport) - controller.focus(row(), canSteer: false) - controller.draft = "A child instruction" - controller.send() - XCTAssertTrue(transport.steers.isEmpty) - controller.update(row(), canSteer: true) - controller.send() - XCTAssertEqual(transport.steers.first?.prompt, "A child instruction") - XCTAssertTrue(controller.steering) - controller.send() - XCTAssertEqual(transport.steers.count, 1) - controller.focus(row(generation: 2), canSteer: true) - controller.draft = "New generation draft" - transport.steers[0].completion(.success(["receipt": [:]])) - XCTAssertEqual(controller.draft, "New generation draft") - controller.update(row(generation: 2, status: .idle), canSteer: true) - XCTAssertFalse(controller.canSteer) - controller.stop() + + private func announcement(_ id: String = "child", generation: Int = 1, steer: Bool = true, state: String = "running") -> [String: Any] { + ["sessionUpdate": "subagent_update", "sessionId": id, "title": "Child", "state": ["state": state], + "_meta": ["kit/subagent/steer": ["generation": generation, "canSteer": steer]]] + } + private func message(_ text: String) -> [String: Any] { + ["sessionUpdate": "agent_message_chunk", "messageId": "m", "content": ["type": "text", "text": text]] } @MainActor - func testReplayIsBoundedAndDescendantsAreReadOnly() { - let transport = ChildTransport() - let controller = FocusedSubagentController(transport: transport) - controller.focus(row(status: .idle), canSteer: true) - let updates = (0..<505).map { index in message(id: String(index), text: String(repeating: "x", count: 17_000)) } - transport.reads[0].completion(.success(SubagentTranscriptPage(updates: updates, nextCursor: 20, generation: 1, caughtUp: true))) - XCTAssertEqual(controller.entries.count, FocusedSubagentController.maximumEntries) - XCTAssertTrue(controller.entries.allSatisfy { $0.text.count <= FocusedSubagentController.maximumTextCharacters }) - XCTAssertTrue(controller.partial) - XCTAssertFalse(controller.canSteer) - XCTAssertEqual(controller.cursor, 20) - controller.focus(row(parent: "ancestor"), canSteer: true) - XCTAssertEqual(transport.reads.count, 1) - XCTAssertFalse(controller.canSteer) - XCTAssertTrue(controller.notice.contains("Descendant")) + func testRoutedNotificationsAndImmutableParentFirstOwnership() { + let store = DesktopChildSessions() + XCTAssertFalse(store.receive(root: "root", session: "unknown", update: announcement())) + XCTAssertFalse(store.receive(root: "root", session: "child", update: message("early"))) + XCTAssertTrue(store.receive(root: "root", session: "root", update: announcement())) + XCTAssertTrue(store.receive(root: "root", session: "child", update: announcement("nested"))) + XCTAssertFalse(store.receive(root: "root", session: "root", update: announcement("nested"))) + XCTAssertFalse(store.receive(root: "root", session: "nested", update: announcement("root"))) + XCTAssertFalse(store.receive(root: "root", session: "nested", update: announcement())) + XCTAssertFalse(store.roster.canSteer(id: "nested", generation: 1)) + XCTAssertTrue(store.receive(root: "root", session: "nested", update: message("nested output"))) + XCTAssertEqual(store.history["nested"]?.count, 1) + XCTAssertNil(store.history["root"]) } @MainActor - func testClosingRejectsReplayAndPreservesFailedSteerDraft() { - let transport = ChildTransport() - let controller = FocusedSubagentController(transport: transport) - controller.focus(row(), canSteer: true) - controller.draft = "Keep this draft" - controller.send() - transport.steers[0].completion(.failure(ACPClientError.protocolError("child stopped"))) - XCTAssertEqual(controller.draft, "Keep this draft") - XCTAssertFalse(controller.steering) - controller.stop() - transport.reads[0].completion(.success(page(generation: 1, cursor: 20, text: "late"))) - XCTAssertTrue(controller.entries.isEmpty) + func testSteeringRequiresFreshExactGenerationGrant() { + let store = DesktopChildSessions() + store.receive(root: "root", session: "root", update: announcement(generation: 2)) + XCTAssertTrue(store.roster.canSteer(id: "child", generation: 2)) + store.receive(root: "root", session: "root", update: announcement(generation: 1)) + XCTAssertEqual(store.roster.rowsByID["child"]?.generation, 2) + store.receive(root: "root", session: "root", update: ["sessionUpdate": "subagent_update", "sessionId": "child", "state": ["state": "idle"]]) + store.receive(root: "root", session: "root", update: ["sessionUpdate": "subagent_update", "sessionId": "child", "state": ["state": "running"], "capabilities": ["cancel": [:]]]) + XCTAssertFalse(store.roster.canSteer(id: "child", generation: 2)) + store.receive(root: "root", session: "root", update: announcement(generation: 2)) + store.receive(root: "root", session: "root", update: ["sessionUpdate": "subagent_update", "sessionId": "child", "_meta": NSNull()]) + XCTAssertFalse(store.roster.canSteer(id: "child", generation: 2)) + store.receive(root: "root", session: "root", update: announcement(generation: 2)) + store.receive(root: "root", session: "child", update: ["sessionUpdate": "state_update", "state": "idle"]) + XCTAssertFalse(store.roster.canSteer(id: "child", generation: 2)) + store.receive(root: "root", session: "root", update: ["sessionUpdate": "subagent_update", "sessionId": "child", "state": ["state": "running"], "_meta": ["kit/subagent/steer": ["generation": 2, "canSteer": 1]]]) + XCTAssertFalse(store.roster.canSteer(id: "child", generation: 2)) + store.reset() + XCTAssertFalse(store.receive(root: "root", session: "child", update: message("late"))) + store.receive(root: "root", session: "root", update: announcement(steer: false)) + XCTAssertFalse(store.roster.canSteer(id: "child", generation: 1)) } @MainActor - func testToolContentChunksAppendAndStatusPatchPreservesOutput() { + func testLiveAndRetainedHistorySessionMessagesAndSteerFailure() { let transport = ChildTransport() + let store = transport.childSessions + store.receive(root: "root", session: "root", update: announcement()) + store.receive(root: "root", session: "child", update: message("retained")) let controller = FocusedSubagentController(transport: transport) - controller.focus(row(status: .idle), canSteer: false) - let updates: [JSONValue] = [ - .object(["sessionUpdate": .string("tool_call"), "toolCallId": .string("call"), "title": .string("Shell"), "status": .string("in_progress")]), - .object(["sessionUpdate": .string("tool_call_content_chunk"), "toolCallId": .string("call"), "content": .string("first chunk")]), - .object(["sessionUpdate": .string("tool_call_content_chunk"), "toolCallId": .string("call"), "content": .string("second chunk")]), - .object(["sessionUpdate": .string("tool_call_update"), "toolCallId": .string("call"), "status": .string("completed")]), - ] - transport.reads[0].completion(.success(SubagentTranscriptPage(updates: updates, nextCursor: 30, generation: 1, caughtUp: true))) - XCTAssertEqual(controller.entries.count, 1) - XCTAssertEqual(controller.entries[0].text, "\"first chunk\"\n\"second chunk\"") - XCTAssertTrue(controller.entries[0].title.contains("completed")) - XCTAssertFalse(controller.partial) + controller.focus(store.roster.rowsByID["child"]!, canSteer: true) + store.receive(root: "root", session: "child", update: message(" live")) + XCTAssertEqual(controller.entries.first?.text, "retained live") + store.receive(root: "root", session: "child", update: ["sessionUpdate": "session_message", "messageId": "directed", "senderSessionId": "root", "recipientSessionId": "child", "content": [["type": "text", "text": "instruction"]]]) + XCTAssertEqual(controller.entries.last?.title, "From root") + controller.draft = "Keep draft" + controller.send() + transport.completion?(.failure(ACPClientError.protocolError("stopped"))) + XCTAssertEqual(controller.draft, "Keep draft") + controller.disconnected() + XCTAssertFalse(controller.canSteer) + store.receive(root: "root", session: "child", update: message("late")) + XCTAssertEqual(controller.entries.first?.text, "retained live") } - func testModelSelectionUsesCurrentAdvertisement() { - let option = ConfigOption(id: "model", name: "Model", currentValue: "a", groups: [ConfigGroup(id: "provider", name: "Provider", choices: [ConfigChoice(value: "a", name: "A"), ConfigChoice(value: "b", name: "B")])]) - XCTAssertNotNil(ModelSelection.option(in: [option], id: "model", value: "b", disabled: false)) - XCTAssertNil(ModelSelection.option(in: [option], id: "model", value: "b", disabled: true)) - XCTAssertNil(ModelSelection.option(in: [option], id: "model", value: "a", disabled: false)) - XCTAssertNil(ModelSelection.option(in: [option], id: "model", value: "removed", disabled: false)) - XCTAssertNil(ModelSelection.option(in: [], id: "model", value: "b", disabled: false)) - } + @MainActor + func testACPClientRoutesChildNotificationsWithoutTranscriptRPC() async { + let script = #""" +import json, sys - func testChildProtocolFixtureUsesSeparateReplayAndGenerationCheckedSteering() { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() - let client = ACPClient(launchOverride: .init(executable: URL(fileURLWithPath: "/usr/bin/python3"), prefixArguments: [root.appendingPathComponent("fixtures/mock-acp-v2.py").path, "--models"])) - var parentMessages = 0 - client.onUpdate = { update in - if case .agentMessage = update { parentMessages += 1 } - } +def send(value): + print(json.dumps(value), flush=True) +def update(session, value): + send({"jsonrpc":"2.0", "method":"session/update", "params":{"sessionId":session, "update":value}}) +for line in sys.stdin: + request = json.loads(line) + method = request.get("method") + if "id" not in request: continue + result = {} + if method == "initialize": + result = {"protocolVersion":2,"info":{"name":"child-fixture","version":"1"},"capabilities":{}} + elif method == "session/new": result = {"sessionId":"root"} + elif method == "kit/subagent/steer" and request["params"]["generation"] != 1: + send({"jsonrpc":"2.0","id":request["id"],"error":{"code":-32602,"message":"stale generation"}}) + continue + elif method == "kit/subagent/transcript/read": + raise RuntimeError("Removed RPC must never be called") + send({"jsonrpc":"2.0","id":request["id"],"result":result}) + if method == "session/new": + update("root", {"sessionUpdate":"subagent_update","sessionId":"child","state":{"state":"running"},"_meta":{"kit/subagent/steer":{"generation":1,"canSteer":True}}}) + update("unrelated", {"sessionUpdate":"subagent_update","sessionId":"intruder"}) + update("unrelated", {"sessionUpdate":"agent_message_chunk","messageId":"bad","content":{"type":"text","text":"bad"}}) + update("child", {"sessionUpdate":"agent_message_chunk","messageId":"m","content":{"type":"text","text":"child output"}}) + update("root", {"sessionUpdate":"session_message","messageId":"directed","senderSessionId":"root","recipientSessionId":"child","content":[{"type":"text","text":"Delegate"}]}) + update("root", {"sessionUpdate":"session_message_chunk","messageId":"directed","content":{"type":"text","text":" now"}}) + update("child", {"sessionUpdate":"session_message","messageId":"directed","senderSessionId":"child","recipientSessionId":"root","content":[{"type":"text","text":"Child reply"}]}) + update("unrelated", {"sessionUpdate":"session_message","messageId":"intruder","senderSessionId":"root","content":[{"type":"text","text":"bad"}]}) + update("root", {"sessionUpdate":"session_message","messageId":"reply","senderSessionId":"child","recipientSessionId":"root","content":[{"type":"text","text":"Reply"}]}) + update("root", {"sessionUpdate":"session_message_chunk","messageId":"reply","content":{"type":"text","text":" done"}}) +"""# + let client = ACPClient(launchOverride: .init(executable: URL(fileURLWithPath: "/usr/bin/python3"), prefixArguments: ["-c", script])) + let parent = ConversationController(conversation: Conversation(workspaceID: UUID()), workspacePath: NSTemporaryDirectory(), client: client) let ready = expectation(description: "ready") - client.start(options: ACPLaunchOptions(root: root.path, sessionID: "child-test", resume: false, provider: nil, model: nil, reasoningEffort: nil), loading: false) { result in - if case .failure(let error) = result { XCTFail(error.localizedDescription) } - ready.fulfill() - } - wait(for: [ready], timeout: 3) - let read = expectation(description: "child replay") - client.readSubagentTranscript(id: "fixture-child", generation: 1, cursor: 0) { result in - switch result { - case .success(let page): - XCTAssertEqual(page.generation, 1) - XCTAssertEqual(page.nextCursor, 128) - XCTAssertEqual(page.updates.count, 1) - XCTAssertTrue(page.caughtUp) - case .failure(let error): XCTFail(error.localizedDescription) + let received = expectation(description: "root directed reply") + parent.onSessionReady = { _, _ in ready.fulfill() } + var delivered = false + let observation = parent.$transcriptRevision.sink { _ in + if !delivered, parent.entries.contains(where: { $0.text == "Reply done" }) { + delivered = true + received.fulfill() } - read.fulfill() } - let steer = expectation(description: "child steer") - client.steerSubagent(id: "fixture-child", generation: 1, prompt: "Continue") { result in + parent.start() + await fulfillment(of: [ready, received], timeout: 5) + observation.cancel() + XCTAssertEqual(parent.entries.map(\.text), ["Delegate now", "Reply done"]) + XCTAssertEqual(parent.entries.map(\.title), ["To child", "From child"]) + XCTAssertTrue(parent.entries.allSatisfy { $0.role == .status }) + let child = FocusedSubagentController(transport: client) + child.focus(client.childSessions.roster.rowsByID["child"]!, canSteer: true) + XCTAssertEqual(child.entries.map(\.text), ["child output", "Child reply"]) + XCTAssertEqual(child.entries.last?.title, "To root") + XCTAssertNil(client.childSessions.roster.rowsByID["intruder"]) + let accepted = expectation(description: "steer accepted") + client.steerSubagent(id: "child", generation: 1, prompt: "Continue") { result in if case .failure(let error) = result { XCTFail(error.localizedDescription) } - steer.fulfill() + accepted.fulfill() } - let stale = expectation(description: "stale generation") - client.steerSubagent(id: "fixture-child", generation: 2, prompt: "Stale") { result in - if case .success = result { XCTFail("Stale generation must fail") } + let stale = expectation(description: "stale steer") + client.steerSubagent(id: "child", generation: 2, prompt: "Stale") { result in + if case .success = result { XCTFail("Stale generation accepted") } stale.fulfill() } - wait(for: [read, steer, stale], timeout: 3) - XCTAssertEqual(parentMessages, 0, "Child replay must not enter the parent update stream") - let caughtUp = expectation(description: "caught up cursor") - client.readSubagentTranscript(id: "fixture-child", generation: 1, cursor: 128) { result in - switch result { - case .success(let page): - XCTAssertTrue(page.updates.isEmpty) - XCTAssertEqual(page.nextCursor, 128) - case .failure(let error): XCTFail(error.localizedDescription) - } - caughtUp.fulfill() - } - wait(for: [caughtUp], timeout: 3) + await fulfillment(of: [accepted, stale], timeout: 5) let closed = expectation(description: "closed") - client.close(activeTurn: false) { closed.fulfill() } - wait(for: [closed], timeout: 3) + parent.close { closed.fulfill() } + await fulfillment(of: [closed], timeout: 5) + XCTAssertTrue(client.childSessions.history.isEmpty) } - private func row(generation: UInt64 = 1, status: SubagentStatus = .working, parent: String? = nil) -> AgentRosterRow { - AgentRosterRow(id: "child", name: "Child", status: status, outcome: nil, generation: generation, task: "Work", parentID: parent, parentName: nil, harness: "acp.kit", model: nil, createdAtMS: 1, generationStartedAtMS: 1, generationFinishedAtMS: nil) + @MainActor + func testDirectedMessagePatchesRetainAndExplicitlyClearIdentity() { + let transport = ChildTransport() + transport.childSessions.receive(root: "root", session: "root", update: announcement()) + let child = FocusedSubagentController(transport: transport) + child.focus(transport.childSessions.roster.rowsByID["child"]!, canSteer: false) + func patch(_ fields: [String: Any]) { + var update = fields + update["sessionUpdate"] = "session_message" + update["messageId"] = "m" + transport.childSessions.receive(root: "root", session: "child", update: update) + } + patch(["senderSessionId": "child", "recipientSessionId": "root", "content": [["type": "text", "text": "Retained"]]]) + patch(["recipientSessionId": "sibling"]) + XCTAssertEqual(child.entries.last?.title, "To sibling") + XCTAssertEqual(child.entries.last?.text, "Retained") + patch(["recipientSessionId": NSNull()]) + XCTAssertEqual(child.entries.last?.title, "To session") + patch(["senderSessionId": NSNull()]) + XCTAssertEqual(child.entries.last?.title, "Session message") + patch(["senderSessionId": "root"]) + XCTAssertEqual(child.entries.last?.title, "From root") + patch(["senderSessionId": NSNull(), "content": NSNull()]) + XCTAssertEqual(child.entries.last?.title, "Session message") + XCTAssertEqual(child.entries.last?.text, "") } - private func message(id: String = "message", text: String) -> JSONValue { - .object(["sessionUpdate": .string("agent_message_chunk"), "messageId": .string(id), "content": .object(["type": .string("text"), "text": .string(text)])]) + + func testDirectedReducerScopesIDsAndBoundsRetainedPatchState() { + var messages = DesktopSessionMessages() + func wire(_ id: String, sender: String? = nil) -> JSONValue { + var fields: [String: JSONValue] = ["sessionUpdate": .string("session_message"), "messageId": .string(id)] + if let sender { fields["senderSessionId"] = .string(sender) } + return .object(fields) + } + _ = messages.apply(wire("same", sender: "root"), session: "root") + _ = messages.apply(wire("same", sender: "other"), session: "child") + XCTAssertEqual(messages.apply(wire("same"), session: "root")?.title, "To session") + XCTAssertEqual(messages.apply(wire("same"), session: "child")?.title, "From other") + for index in 0.. SubagentTranscriptPage { - SubagentTranscriptPage(updates: [message(text: text)], nextCursor: cursor, generation: generation, caughtUp: true) + + @MainActor + func testHistoryAndFocusedEntriesAreBounded() { + let transport = ChildTransport() + let store = transport.childSessions + store.receive(root: "root", session: "root", update: announcement()) + for index in 0..<600 { + var update = message(String(repeating: "x", count: 20_000)) + update["messageId"] = String(index) + store.receive(root: "root", session: "child", update: update) + } + XCTAssertLessThanOrEqual(store.history["child"]?.count ?? 0, 512) + let controller = FocusedSubagentController(transport: transport) + controller.focus(store.roster.rowsByID["child"]!, canSteer: false) + XCTAssertLessThanOrEqual(controller.entries.count, FocusedSubagentController.maximumEntries) + XCTAssertTrue(controller.entries.allSatisfy { $0.text.count <= FocusedSubagentController.maximumTextCharacters }) + XCTAssertTrue(controller.partial) } } private final class ChildTransport: FocusedSubagentTransport { - struct Read { - let completion: (Result) -> Void - } - struct Steer { - let prompt: String - let completion: (Result<[String: Any], Error>) -> Void - } - var reads: [Read] = [] - var steers: [Steer] = [] - func readSubagentTranscript(id: String, generation: UInt64, cursor: UInt64, completion: @escaping (Result) -> Void) { - reads.append(Read(completion: completion)) - } + let childSessions = DesktopChildSessions() + var completion: ((Result<[String: Any], Error>) -> Void)? func steerSubagent(id: String, generation: UInt64, prompt: String, completion: @escaping (Result<[String: Any], Error>) -> Void) { - steers.append(Steer(prompt: prompt, completion: completion)) + self.completion = completion } } diff --git a/src/acp_child.rs b/src/acp_child.rs index c2b99284..6487ce90 100644 --- a/src/acp_child.rs +++ b/src/acp_child.rs @@ -16,6 +16,7 @@ use agent_client_protocol::{ByteStreams, UntypedMessage}; mod messages; mod protocol; +mod stream; pub(crate) mod transcript; use agentkit_acp::{ CloseSessionRequest, ConfigOptionUpdate, ContentBlock, DeleteSessionRequest, @@ -1493,6 +1494,8 @@ async fn run( let notification_routes = Arc::clone(&routes); let config_snapshots = Arc::new(ConfigSnapshots::default()); let notification_configs = Arc::clone(&config_snapshots); + let stream_routes = Arc::new(stream::Routes::default()); + let notification_stream_routes = Arc::clone(&stream_routes); let root = config.root.clone(); let additional_directories = config.additional_directories.clone(); let startup_complete = Arc::new(AtomicBool::new(false)); @@ -1510,6 +1513,7 @@ async fn run( && let SessionUpdate::ConfigOptionUpdate(update) = ¬ification.update { notification_configs.set(notification.session_id.clone(), update.config_options.clone())?; } + notification_stream_routes.forward(&session_id.to_string(), params["update"].clone()); let route = notification_routes .lock() .ok() @@ -1518,6 +1522,7 @@ async fn run( return Ok(()); }; if let Some(state) = protocol::foreground(¶ms)? { + route.record_inspection(params["update"].clone()); // Startup/replayed idle cannot settle a newly submitted turn. route.idle.send_if_modified(|current| current.advance(state)); return Ok(()); @@ -1624,6 +1629,7 @@ async fn run( let (idle, _) = watch::channel(protocol::Foreground::Waiting); routes.lock().map_err(|_| agent_client_protocol::Error::internal_error())? .insert(id.clone(), Route { transcript: None, inspection_segments: Arc::new(Mutex::new(InspectionSegments::default())), owner: String::new(), generation: 0, output: Arc::clone(&replay_output), idle }); + stream_routes.begin_resume(id.to_string()); let result = protocol::resume(&connection, version, id.clone(), root.clone(), additional_directories.clone()).await; routes.lock().map_err(|_| agent_client_protocol::Error::internal_error())?.remove(&id); result @@ -1710,7 +1716,7 @@ async fn run( // Routes are installed/removed only by prompt tasks. Read under // their lock, then release it before any protocol I/O. Injection // never owns or changes the foreground output, gate, or idle state. - let request = { + let (request, submitted) = { let routes = match routes.lock() { Ok(routes) => routes, Err(_) => { @@ -1733,7 +1739,8 @@ async fn run( // The SDK enqueue is synchronous and does not invoke route callbacks. // Route removal/replacement cannot interleave admission and enqueue. // Only receipt waiting is spawned, with no route guard alive. - protocol::steer(&connection, steer.session_id, steer.content) + let submitted = route.and_then(|route| route.transcript.clone().map(|transcript| (transcript, route.owner.clone(), route.generation, steer.content.clone()))); + (protocol::steer(&connection, steer.session_id, steer.content), submitted) }; let auth_methods = auth_methods.clone(); tasks.spawn(async move { @@ -1744,6 +1751,9 @@ async fn run( }, Err(error) => Err(ChildError::Failed(child_request_error(error, &auth_methods))), }; + if result.is_ok() && let Some((transcript, owner, generation, content)) = submitted { + transcript.record_submitted_prompt(&owner, generation, &content); + } let _ = steer.reply.send(result); }); } @@ -1949,6 +1959,7 @@ async fn run( }); } Request::Prompt(prompt) => { + let stream_routes = Arc::clone(&stream_routes); let auth_methods = auth_methods.clone(); let connection = connection.clone(); let routes = Arc::clone(&routes); @@ -1959,6 +1970,9 @@ async fn run( let session_id = prompt.session_id.clone(); let output = Arc::new(Mutex::new(ChildOutput::default())); let (idle_tx, mut idle_rx) = watch::channel(protocol::Foreground::Waiting); + if let Some(transcript) = &prompt.transcript { + stream_routes.register(&session_id.to_string(), &prompt.owner, prompt.generation, transcript.clone()); + } if let Ok(mut routes) = routes.lock() { routes.insert(session_id.clone(), Route { transcript: prompt.transcript.clone(), inspection_segments: Arc::new(Mutex::new(InspectionSegments::default())), owner: prompt.owner.clone(), generation: prompt.generation, output: Arc::clone(&output), idle: idle_tx }); } @@ -1967,6 +1981,7 @@ async fn run( id: prompt.owner.clone(), generation: prompt.generation, can_steer: supports_steer, }); if let Some(transcript) = &prompt.transcript { + transcript.stream_capabilities(&prompt.owner, prompt.generation, supports_steer); transcript.record_submitted_prompt(&prompt.owner, prompt.generation, &prompt.content); } } @@ -4254,6 +4269,130 @@ for line in sys.stdin: } } + #[tokio::test] + async fn resumed_nested_stream_survives_until_prompt_without_reannouncement() { + let script = r#" +import json,sys +version=int(sys.argv[1]) +def send(value): + print(json.dumps(value),flush=True) +def update(session, value): + send({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':session,'update':value}}) +for line in sys.stdin: + request=json.loads(line) + if 'id' not in request: + continue + method=request['method'] + result={} + if method == 'initialize': + if version == 1: + result={'protocolVersion':1,'agentCapabilities':{'loadSession':True,'sessionCapabilities':{'close':{}}}} + else: + result={'protocolVersion':2,'info':{'name':'resume-stream-test','version':'1'},'capabilities':{'session':{}}} + elif method in ('session/load','session/resume'): + update('saved',{'sessionUpdate':'subagent_update','sessionId':'nested','title':'retained child'}) + update('nested',{'sessionUpdate':'agent_thought_chunk','content':{'type':'text','text':'resume thought'}}) + elif method == 'session/prompt': + # Deliberately no second ownership announcement. + update('nested',{'sessionUpdate':'agent_thought_chunk','content':{'type':'text','text':'live thought'}}) + if version == 1: + result={'stopReason':'end_turn'} + else: + result={'messageId':'accepted-prompt'} + send({'jsonrpc':'2.0','id':request['id'],'result':result}) + if method == 'session/prompt' and version == 2: + update('saved',{'sessionUpdate':'state_update','state':'running'}) + update('saved',{'sessionUpdate':'state_update','state':'idle','stopReason':'end_turn'}) +"#; + for version in [1, 2] { + let root = tempfile::tempdir().unwrap(); + let profiles = BTreeMap::from([( + "resume".into(), + AcpHarnessProfile { + command: "python3".into(), + args: vec!["-c".into(), script.into(), version.to_string()], + permissions: AcpPermissionPolicy::Deny, + }, + )]); + let config = ChildConfig { + root: root.path().into(), + additional_directories: Vec::new(), + model: "unused".into(), + provider: Default::default(), + reasoning_effort: None, + openrouter_api_key: None, + configured_mcp_config: None, + configured_mcp_config_inherited: false, + legacy_mcp_config: false, + mcp_config: None, + credential_storage: Default::default(), + telemetry: Default::default(), + harnesses: AcpHarnesses::new(profiles).unwrap(), + default_harness: "acp.resume".into(), + parent_id: None, + parent_name: None, + tree_slots: None, + prompt_cache_key: None, + }; + let (child, _) = tokio::time::timeout( + Duration::from_secs(5), + ChildSession::start_with_output( + config, + "acp.resume".into(), + Some(("saved".into(), true)), + None, + 1, + TurnCancellation::default(), + ), + ) + .await + .unwrap() + .unwrap(); + // The owning runtime only attaches its transcript at prompt time. + let transcripts = transcript::Transcripts::default(); + transcripts.stream.announce( + "child", + "worker", + "task", + crate::events::SubagentStatus::Working, + 1, + ); + transcripts.start("child", 1); + let mut rx = transcripts.stream.subscribe("parent".into(), false); + tokio::time::timeout( + Duration::from_secs(5), + child.prompt_generation( + "child".into(), + 1, + "continue".into(), + TurnCancellation::default(), + Some(transcripts.get("child", 1).unwrap()), + ), + ) + .await + .unwrap() + .unwrap(); + let mut nested = None; + let mut thought_texts = Vec::new(); + while let Ok((owner, event)) = rx.try_recv() { + if owner == "child" && event["sessionUpdate"] == "subagent_update" { + assert!( + nested.is_none(), + "nested association must be announced once" + ); + nested = event["sessionId"].as_str().map(str::to_owned); + } + if event["sessionUpdate"] == "agent_thought_chunk" { + assert_eq!(Some(owner.as_str()), nested.as_deref()); + thought_texts.push(event["content"]["text"].as_str().unwrap().to_owned()); + } + } + assert!(nested.is_some()); + assert_eq!(thought_texts, ["resume thought", "live thought"]); + child.close().await.unwrap(); + } + } + #[tokio::test] async fn mock_v2_child_waits_for_idle_and_supports_forks() { let root = tempfile::tempdir().unwrap(); diff --git a/src/acp_child/protocol.rs b/src/acp_child/protocol.rs index 4ceb323d..fda4afbc 100644 --- a/src/acp_child/protocol.rs +++ b/src/acp_child/protocol.rs @@ -68,10 +68,12 @@ fn initialize_params() -> Result { v1::InitializeRequest::new(ProtocolVersion::V2) .client_info(v1::Implementation::new("kit", env!("CARGO_PKG_VERSION"))) .client_capabilities( - v1::ClientCapabilities::new().session( - v1::ClientSessionCapabilities::new() - .compaction(v1::CompactionCapabilities::default()), - ), + v1::ClientCapabilities::new() + .subagents(v1::SubagentCapabilities::default()) + .session( + v1::ClientSessionCapabilities::new() + .compaction(v1::CompactionCapabilities::default()), + ), ), )?; let v2_params = serde_json::to_value(v2::InitializeRequest::new( @@ -498,7 +500,12 @@ pub(super) fn completion(reason: Option) -> Result v1::StopReason::MaxTurnRequests, Some(v2::StopReason::Refusal) => v1::StopReason::Refusal, Some(v2::StopReason::Cancelled) => v1::StopReason::Cancelled, - Some(v2::StopReason::Other(reason)) if reason == "_error" => { + Some(v2::StopReason::Error(_)) => { + return Err(Error::into_internal_error(std::io::Error::other( + "nested agent turn failed", + ))); + } + Some(v2::StopReason::Other(reason)) if reason.stop_reason == "_error" => { return Err(Error::into_internal_error(std::io::Error::other( "nested agent turn failed", ))); diff --git a/src/acp_child/stream.rs b/src/acp_child/stream.rs new file mode 100644 index 00000000..a462d6fc --- /dev/null +++ b/src/acp_child/stream.rs @@ -0,0 +1,1102 @@ +//! Best-effort, bounded ACP child multiplexing. Disk inspection is independent. +//! `State` has one lock: subscribe, announce, capabilities and forward write it. +//! They publish complete owned JSON values and never await under the guard. +//! Broadcast wakeups occur only after a complete state commit; a panic poisons +//! and isolates streaming instead of recovering uncertain +//! parent-before-child ordering. IDs and ownership survive prompt generations. +use agent_client_protocol::schema::v1::{self as acp, SessionUpdate}; +use serde_json::Value; +use std::{ + collections::{HashMap, VecDeque}, + sync::Mutex, +}; +use tokio::sync::broadcast; + +const QUEUE: usize = 2048; +const FRAME_BYTES: usize = 64 * 1024; +const NODES: usize = 1024; +const HISTORY: usize = 512; +const HISTORY_BYTES: usize = 4 * 1024 * 1024; +const SUBSCRIBERS: usize = 16; +type Event = (String, Value); + +#[derive(Default)] +pub(crate) struct Stream { + state: Mutex, +} +#[derive(Default)] +struct State { + parent: Option, + // In insertion order, so reconnect snapshots always announce ancestors first. + nodes: Vec, + positions: HashMap, + subscribers: Vec>, + history: VecDeque<(Event, usize)>, + history_bytes: usize, + steering: HashMap, +} +impl Stream { + pub(crate) fn subscribe( + &self, + parent: String, + replay_history: bool, + ) -> broadcast::Receiver { + let (sender, receiver) = broadcast::channel(QUEUE); + let Ok(mut state) = self.state.lock() else { + return receiver; + }; + if state + .parent + .as_ref() + .is_some_and(|current| current != &parent) + { + return receiver; + } + state + .subscribers + .retain(|subscriber| subscriber.receiver_count() != 0); + if state.subscribers.len() == SUBSCRIBERS { + return receiver; + } + state.parent = Some(parent.clone()); + // A private queue avoids replaying append-only chunks to existing live + // subscribers. Ancestors precede all retained content; the bounded tail + // preserves its original cross-session order. Snapshot + tail fits QUEUE. + // This is a partial tail: ACP allows chunks before identity, including + // when the original message upsert has fallen outside the replay budget. + for (owner, update) in &state.nodes { + // Ownership is needed even for no-history resumes. Cached grants + // are not fresh authorizations on a new subscription. + let mut update = update.clone(); + revoke_grants(&mut update); + let _ = sender.send(( + if owner.is_empty() { + parent.clone() + } else { + owner.clone() + }, + update.clone(), + )); + } + if replay_history { + for ((owner, update), _) in &state.history { + let _ = sender.send(( + if owner.is_empty() { + parent.clone() + } else { + owner.clone() + }, + update.clone(), + )); + } + } + state.subscribers.push(sender); + receiver + } + + pub(crate) fn announce( + &self, + id: &str, + name: &str, + task: &str, + status: crate::events::SubagentStatus, + generation: u64, + ) { + let activity = match status { + crate::events::SubagentStatus::Starting | crate::events::SubagentStatus::Working => { + acp::StateUpdate::Running(acp::RunningStateUpdate::new()) + } + crate::events::SubagentStatus::Idle => { + acp::StateUpdate::Idle(acp::IdleStateUpdate::new()) + } + crate::events::SubagentStatus::Removed => { + acp::StateUpdate::Unknown(acp::UnknownStateUpdate::new()) + } + }; + let update = SessionUpdate::SubagentUpdate( + acp::SubagentUpdate::new(id.to_owned()) + .title(name.to_owned()) + .description(task.chars().take(4096).collect::()) + .state(activity), + ); + let Ok(mut update) = serde_json::to_value(update) else { + return; + }; + let Ok(mut state) = self.state.lock() else { + return; + }; + if state + .steering + .get(id) + .is_some_and(|prior| prior.generation > generation) + { + return; + } + if !state.positions.contains_key(id) && state.nodes.len() == NODES { + return; + } + let supported = state + .steering + .get(id) + .is_some_and(|prior| prior.generation == generation && prior.supported); + let steering = Steering { + generation, + supported, + active: matches!(status, crate::events::SubagentStatus::Working), + }; + update["_meta"] = steering.meta(); + state.steering.insert(id.to_owned(), steering); + Self::publish(&mut state, "", update); + } + + pub(crate) fn capabilities(&self, id: &str, generation: u64, supported: bool) { + let Ok(mut state) = self.state.lock() else { + return; + }; + let Some(steering) = state.steering.get_mut(id) else { + return; + }; + if steering.generation != generation { + return; + } + steering.supported = supported; + let mut update = match serde_json::to_value(SessionUpdate::SubagentUpdate( + acp::SubagentUpdate::new(id.to_owned()), + )) { + Ok(update) => update, + Err(_) => return, + }; + update["_meta"] = steering.meta(); + Self::publish(&mut state, "", update); + } + + pub(crate) fn parent(&self, owner: &str) -> Option { + let state = self.state.lock().ok()?; + let position = *state.positions.get(owner)?; + let parent = &state.nodes[position].0; + if parent.is_empty() { + state.parent.clone() + } else { + Some(parent.clone()) + } + } + + pub(crate) fn directed(&self, owner: &str, update: Value) { + self.forward(owner, update.clone()); + let parent = { + let Ok(state) = self.state.lock() else { return }; + let Some(&position) = state.positions.get(owner) else { + return; + }; + state.nodes[position].0.clone() + }; + self.forward(&parent, update); + } + + pub(crate) fn forward(&self, owner: &str, update: Value) { + let Ok(mut state) = self.state.lock() else { + return; + }; + Self::publish(&mut state, owner, update); + } + + fn publish(state: &mut State, owner: &str, update: Value) { + // Bound frame admission without allocating a serialized duplicate. + let mut limit = Limit(FRAME_BYTES); + if serde_json::to_writer(&mut limit, &update).is_err() { + return; + } + let size = FRAME_BYTES - limit.0 + owner.len(); + if !owner.is_empty() && !state.positions.contains_key(owner) { + return; + } + if update["sessionUpdate"] == "subagent_update" { + let Some(id) = update["sessionId"].as_str() else { + return; + }; + if let Some(&position) = state.positions.get(id) { + if state.nodes[position].0 != owner { + return; + } + if let (Some(current), Some(patch)) = + (state.nodes[position].1.as_object_mut(), update.as_object()) + { + let mut merged = current.clone(); + patch_update(&mut merged, patch); + if serde_json::to_writer(Limit(FRAME_BYTES), &merged).is_err() { + return; + } + *current = merged; + } + } else { + if state.nodes.len() == NODES { + return; + } + let position = state.nodes.len(); + state.positions.insert(id.to_owned(), position); + state.nodes.push((owner.to_owned(), update.clone())); + } + } + if update["sessionUpdate"] == "state_update" + && let Some(&position) = state.positions.get(owner) + && let Some(object) = update.as_object() + { + let mut activity = object.clone(); + activity.remove("sessionUpdate"); + let mut snapshot = state.nodes[position].1.clone(); + snapshot["state"] = Value::Object(activity); + if let Some(steering) = state.steering.get_mut(owner) { + if update["state"] != "running" { + steering.active = false; + } + if let Some(snapshot) = snapshot.as_object_mut() { + patch_update( + snapshot, + &serde_json::Map::from_iter([("_meta".into(), steering.meta())]), + ); + } + } + if serde_json::to_writer(Limit(FRAME_BYTES), &snapshot).is_ok() { + state.nodes[position].1 = snapshot; + } + } + // State snapshots are replayed from `nodes`; replaying an older + // state_update after that snapshot would resurrect completed work. + if !matches!( + update["sessionUpdate"].as_str(), + Some("subagent_update" | "state_update") + ) { + while state.history.len() >= HISTORY || state.history_bytes + size > HISTORY_BYTES { + let Some((_, removed)) = state.history.pop_front() else { + break; + }; + state.history_bytes -= removed; + } + state + .history + .push_back(((owner.to_owned(), update.clone()), size)); + state.history_bytes += size; + } + let Some(parent) = &state.parent else { return }; + let event = ( + if owner.is_empty() { + parent.clone() + } else { + owner.to_owned() + }, + update, + ); + state + .subscribers + .retain(|subscriber| subscriber.send(event.clone()).is_ok()); + } +} +/// `_meta` patches keys, not the whole object. Null values remove individual +/// keys; a null `_meta` clears the object. Other update fields replace wholesale. +fn patch_update( + current: &mut serde_json::Map, + patch: &serde_json::Map, +) { + for (key, value) in patch { + if key != "_meta" || value.is_null() { + current.insert(key.clone(), value.clone()); + } else if let Some(keys) = value.as_object() { + let mut meta = current + .get("_meta") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + for (key, value) in keys { + if value.is_null() { + meta.remove(key); + } else { + meta.insert(key.clone(), value.clone()); + } + } + current.insert("_meta".into(), Value::Object(meta)); + } + } +} + +/// Replayed metadata and descendants observed through another harness do not +/// authorize this server's direct-child mutation endpoints. Live local grants +/// come only from the active generation's capability report. +fn revoke_grants(update: &mut Value) { + if update.get("capabilities").is_some() { + update["capabilities"] = Value::Null; + } + if let Some(grant) = update + .get_mut("_meta") + .and_then(Value::as_object_mut) + .and_then(|meta| meta.get_mut("kit/subagent/steer")) + { + if let Some(grant) = grant.as_object_mut() { + grant.insert("canSteer".into(), Value::Bool(false)); + } else { + *grant = Value::Null; + } + } +} + +struct Steering { + generation: u64, + supported: bool, + active: bool, +} +impl Steering { + fn meta(&self) -> Value { + Value::Object(serde_json::Map::from_iter([( + "kit/subagent/steer".into(), + Value::Object(serde_json::Map::from_iter([ + ("generation".into(), Value::from(self.generation)), + ( + "canSteer".into(), + Value::Bool(self.active && self.supported), + ), + ])), + )])) + } +} + +struct Limit(usize); +impl std::io::Write for Limit { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > self.0 { + return Err(std::io::Error::other("stream frame exceeds budget")); + } + self.0 -= bytes.len(); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Connection-local remote IDs are never exposed directly: independent harnesses +/// can reuse them. Resume staging, registration and notifications are the writers. +/// Lock order is Routes -> Stream; there is no reverse edge or await. Poison +/// isolates this connection's stream. A retained route carries no execution gate. +#[derive(Default)] +pub(crate) struct Routes(Mutex); +#[derive(Default)] +struct RoutesState { + live: HashMap, + resume: Option, +} +/// Resume has no local transcript owner yet. Keep associations independently of +/// the bounded content tail so eviction cannot orphan future descendant traffic. +struct Resume { + root: String, + associations: Vec, + positions: HashMap, + history: VecDeque<(Event, usize)>, + bytes: usize, +} +impl Resume { + fn record(&mut self, remote: &str, update: Value) { + if remote != self.root && !self.positions.contains_key(remote) { + return; + } + // The root's new prompt lifecycle is authoritative at attachment time. + // A replayed idle must not revoke that generation's fresh capability grant. + if remote == self.root && update["sessionUpdate"] == "state_update" { + return; + } + let mut limit = Limit(FRAME_BYTES); + if serde_json::to_writer(&mut limit, &(remote, &update)).is_err() { + return; + } + let size = FRAME_BYTES - limit.0; + if update["sessionUpdate"] == "subagent_update" { + let Some(id) = update["sessionId"].as_str() else { + return; + }; + if let Some(&position) = self.positions.get(id) { + if self.associations[position].0 != remote { + return; + } + if let (Some(current), Some(patch)) = ( + self.associations[position].1.as_object(), + update.as_object(), + ) { + let mut merged = current.clone(); + patch_update(&mut merged, patch); + if serde_json::to_writer(Limit(FRAME_BYTES), &merged).is_ok() { + self.associations[position].1 = Value::Object(merged); + } + } + } else if id != self.root && self.associations.len() < NODES - 1 { + self.positions + .insert(id.to_owned(), self.associations.len()); + self.associations.push((remote.to_owned(), update)); + } + return; + } + if update["sessionUpdate"] == "state_update" { + if let Some(&position) = self.positions.get(remote) + && let Some(object) = update.as_object() + { + let mut activity = object.clone(); + activity.remove("sessionUpdate"); + let mut snapshot = self.associations[position].1.clone(); + snapshot["state"] = Value::Object(activity); + if serde_json::to_writer(Limit(FRAME_BYTES), &snapshot).is_ok() { + self.associations[position].1 = snapshot; + } + } + // Current state belongs in the association snapshot, never the FIFO + // tail: eviction must not lose idle, nor replay an older transition + // after a newer parent-provided state snapshot. + return; + } + while self.history.len() >= HISTORY || self.bytes + size > HISTORY_BYTES { + let Some((_, removed)) = self.history.pop_front() else { + break; + }; + self.bytes -= removed; + } + self.history.push_back(((remote.to_owned(), update), size)); + self.bytes += size; + } +} +#[derive(Clone)] +struct Route { + owner: String, + generation: u64, + transcript: super::transcript::Transcript, + segment: Option, + sequence: u64, + announced_message: Option, +} +impl Routes { + pub(crate) fn begin_resume(&self, remote: String) { + let Ok(mut state) = self.0.lock() else { return }; + if state.resume.is_none() && !state.live.contains_key(&remote) { + state.resume = Some(Resume { + root: remote, + associations: Vec::new(), + positions: HashMap::new(), + history: VecDeque::new(), + bytes: 0, + }); + } + } + + pub(crate) fn register( + &self, + remote: &str, + owner: &str, + generation: u64, + transcript: super::transcript::Transcript, + ) { + let Ok(mut state) = self.0.lock() else { + return; + }; + let routes = &mut state.live; + if routes.len() < NODES || routes.contains_key(remote) { + routes.insert( + remote.to_owned(), + Route { + owner: owner.to_owned(), + generation, + transcript, + segment: None, + sequence: 0, + announced_message: None, + }, + ); + } + if state + .resume + .as_ref() + .is_some_and(|resume| resume.root == remote) + && let Some(resume) = state.resume.take() + { + for (remote, update) in resume.associations { + Self::forward_registered(&mut state.live, &remote, update); + } + for ((remote, update), _) in resume.history { + Self::forward_registered(&mut state.live, &remote, update); + } + } + } + pub(crate) fn forward(&self, remote: &str, update: Value) { + let Ok(mut state) = self.0.lock() else { return }; + if !state.live.contains_key(remote) + && let Some(resume) = &mut state.resume + { + resume.record(remote, update); + return; + } + Self::forward_registered(&mut state.live, remote, update); + } + fn forward_registered(routes: &mut HashMap, remote: &str, mut update: Value) { + let Some(route) = routes.get(remote).cloned() else { + return; + }; + if update["sessionUpdate"] == "subagent_update" { + let Some(child) = update["sessionId"].as_str().map(str::to_owned) else { + return; + }; + if !routes.contains_key(&child) { + if routes.len() == NODES { + return; + } + routes.insert( + child.clone(), + Route { + owner: format!("{}:{}:{}", route.owner.len(), route.owner, child), + generation: route.generation, + transcript: route.transcript.clone(), + segment: None, + sequence: 0, + announced_message: None, + }, + ); + } + update["sessionId"] = Value::String(routes[&child].owner.clone()); + revoke_grants(&mut update); + } + for key in ["senderSessionId", "recipientSessionId"] { + if let Some(id) = update[key].as_str().map(str::to_owned) { + // Do not leak unannounced identities from an unrelated session. + update[key] = routes + .get(&id) + .map(|route| Value::String(route.owner.clone())) + .unwrap_or(Value::Null); + } + } + // Assistant output is addressed to the delegating session, not the human. + // Keep native content and IDs, but express this as an inter-session view. + match update["sessionUpdate"].as_str() { + Some( + "agent_message" | "agent_message_chunk" | "user_message" | "user_message_chunk", + ) => { + let incoming = update["sessionUpdate"] + .as_str() + .is_some_and(|kind| kind.starts_with("user_")); + let chunk = update["sessionUpdate"] + .as_str() + .is_some_and(|kind| kind.ends_with("_chunk")); + let Some(current) = routes.get_mut(remote) else { + return; + }; + let kind = update["sessionUpdate"].as_str().unwrap_or_default(); + if current.segment.as_deref() != Some(kind) { + let Some(sequence) = current.sequence.checked_add(1) else { + return; + }; + current.sequence = sequence; + current.segment = Some(kind.to_owned()); + } + let native = update["messageId"] + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| format!("{}-{}", route.generation, current.sequence)); + // Message IDs are transcript-local upstream. Namespace both views + // so sibling replies cannot overwrite each other on the parent. + let id = format!("{}:{}:{}", route.owner.len(), route.owner, native); + let announce = !chunk || current.announced_message.as_deref() != Some(&id); + current.announced_message = Some(id.clone()); + let mut message = acp::SessionMessage::new(id.clone()) + .sender_message_id(acp::MessageId::from(id.clone())) + .recipient_message_id(acp::MessageId::from(id.clone())); + let own = acp::SessionId::from(route.owner.clone()); + let parent = route.transcript.stream_parent(&route.owner); + if incoming { + message = message.recipient_session_id(own); + if let Some(parent) = parent { + message = message.sender_session_id(acp::SessionId::from(parent)); + } + } else { + message = message.sender_session_id(own); + if let Some(parent) = parent { + message = message.recipient_session_id(acp::SessionId::from(parent)); + } + } + if !chunk + && let Ok(content) = + serde_json::from_value::>(update["content"].clone()) + { + message = message.content(content); + } + if announce + && let Ok(message) = + serde_json::to_value(SessionUpdate::SessionMessage(message)) + { + route.transcript.directed(&route.owner, message); + } + if chunk + && let Ok(content) = + serde_json::from_value::(update["content"].clone()) + && let Ok(chunk) = serde_json::to_value(SessionUpdate::SessionMessageChunk( + acp::SessionMessageChunk::new(id, content), + )) + { + route.transcript.directed(&route.owner, chunk); + } + } + _ => { + if let Some(current) = routes.get_mut(remote) { + current.segment = None; + } + route.transcript.forward(&route.owner, update); + } + } + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::disallowed_methods, + clippy::disallowed_macros +)] +mod tests { + use super::*; + use crate::events::SubagentStatus; + use serde_json::json; + + #[test] + fn announcement_precedes_messages_and_survives_generations() { + let stream = Stream::default(); + let mut rx = stream.subscribe("parent".into(), true); + stream.forward("child", json!({"sessionUpdate":"agent_thought_chunk"})); + assert!(rx.try_recv().is_err()); + stream.announce("child", "worker", "task", SubagentStatus::Starting, 1); + stream.directed( + "child", + json!({"sessionUpdate":"session_message","messageId":"m"}), + ); + let announcement = rx.try_recv().unwrap(); + assert_eq!(announcement.0, "parent"); + assert_eq!(announcement.1["sessionId"], "child"); + assert_eq!(rx.try_recv().unwrap().0, "child"); + assert_eq!(rx.try_recv().unwrap().0, "parent"); + stream.announce("child", "worker", "again", SubagentStatus::Working, 1); + assert_eq!(rx.try_recv().unwrap().1["sessionId"], "child"); + let mut replay = stream.subscribe("parent".into(), true); + assert_eq!(replay.try_recv().unwrap().1["description"], "again"); + assert_eq!(replay.try_recv().unwrap().0, "child"); + assert_eq!(replay.try_recv().unwrap().0, "parent"); + assert!(replay.try_recv().is_err()); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn ownership_is_immutable_and_replay_is_parent_first() { + let stream = Stream::default(); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + stream.forward( + "child", + json!({"sessionUpdate":"subagent_update","sessionId":"nested","title":"nested"}), + ); + stream.forward( + "", + json!({"sessionUpdate":"subagent_update","sessionId":"nested","title":"stolen"}), + ); + let mut rx = stream.subscribe("parent".into(), true); + assert_eq!(rx.try_recv().unwrap().1["sessionId"], "child"); + let nested = rx.try_recv().unwrap(); + assert_eq!(nested.0, "child"); + assert_eq!(nested.1["title"], "nested"); + assert_eq!(stream.parent("nested").as_deref(), Some("child")); + } + + #[test] + fn oversized_updates_are_rejected_without_poisoning_stream() { + let stream = Stream::default(); + let mut rx = stream.subscribe("parent".into(), true); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + rx.try_recv().unwrap(); + stream.forward("child", json!({"content":"x".repeat(FRAME_BYTES)})); + assert!(rx.try_recv().is_err()); + stream.forward("child", json!({"sessionUpdate":"plan","entries":[]})); + assert_eq!(rx.try_recv().unwrap().1["sessionUpdate"], "plan"); + } + + #[test] + fn full_snapshot_fits_before_receiver_is_returned() { + let stream = Stream::default(); + for index in 0..NODES { + stream.announce( + &format!("child-{index}"), + "worker", + "task", + SubagentStatus::Idle, + 1, + ); + } + let mut rx = stream.subscribe("parent".into(), true); + for index in 0..NODES { + assert_eq!( + rx.try_recv().unwrap().1["sessionId"], + format!("child-{index}") + ); + } + assert!(rx.try_recv().is_err()); + } + + #[test] + fn nested_routes_and_directed_message_ids_are_stable_and_distinct() { + let transcripts = super::super::transcript::Transcripts::default(); + let mut rx = transcripts.stream.subscribe("parent".into(), true); + let routes = Routes::default(); + for child in ["a", "b"] { + transcripts + .stream + .announce(child, child, "task", SubagentStatus::Working, 1); + transcripts.start(child, 1); + routes.register(child, child, 1, transcripts.get(child, 1).unwrap()); + } + rx.try_recv().unwrap(); + rx.try_recv().unwrap(); + let chunk = json!({"sessionUpdate":"agent_message_chunk", "content":{"type":"text","text":"hello"}}); + routes.forward("a", chunk.clone()); + let first = rx.try_recv().unwrap(); + assert_eq!(first.1["sessionUpdate"], "session_message"); + assert_eq!(first.1["senderSessionId"], "a"); + assert_eq!(first.1["recipientSessionId"], "parent"); + for _ in 0..3 { + rx.try_recv().unwrap(); + } + routes.forward("a", chunk.clone()); + assert_eq!(rx.try_recv().unwrap().1["messageId"], first.1["messageId"]); + rx.try_recv().unwrap(); + assert!(rx.try_recv().is_err()); + routes.forward("b", chunk); + assert_ne!(rx.try_recv().unwrap().1["messageId"], first.1["messageId"]); + for _ in 0..3 { + rx.try_recv().unwrap(); + } + routes.forward( + "a", + json!({"sessionUpdate":"subagent_update", "sessionId":"remote-nested"}), + ); + let child = rx.try_recv().unwrap(); + assert_eq!(child.0, "a"); + routes.forward( + "remote-nested", + json!({"sessionUpdate":"plan","entries":[]}), + ); + assert_eq!( + rx.try_recv().unwrap().0, + child.1["sessionId"].as_str().unwrap() + ); + } + + #[test] + fn steering_requires_current_generation_support_and_live_work() { + let stream = Stream::default(); + let mut rx = stream.subscribe("parent".into(), true); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + assert_eq!( + rx.try_recv().unwrap().1["_meta"]["kit/subagent/steer"], + json!({"generation":1,"canSteer":false}) + ); + stream.capabilities("child", 1, true); + assert_eq!( + rx.try_recv().unwrap().1["_meta"]["kit/subagent/steer"]["canSteer"], + true + ); + stream.announce("child", "worker", "task", SubagentStatus::Idle, 1); + assert_eq!( + rx.try_recv().unwrap().1["_meta"]["kit/subagent/steer"]["canSteer"], + false + ); + stream.announce("child", "worker", "task", SubagentStatus::Working, 2); + assert_eq!( + rx.try_recv().unwrap().1["_meta"]["kit/subagent/steer"], + json!({"generation":2,"canSteer":false}) + ); + stream.capabilities("child", 1, true); + stream.announce("child", "worker", "old", SubagentStatus::Idle, 1); + assert!(rx.try_recv().is_err()); + stream.capabilities("child", 2, true); + assert_eq!( + rx.try_recv().unwrap().1["_meta"]["kit/subagent/steer"]["canSteer"], + true + ); + let mut replay = stream.subscribe("parent".into(), true); + assert_eq!( + replay.try_recv().unwrap().1["_meta"]["kit/subagent/steer"], + json!({"generation":2,"canSteer":false}) + ); + } + + #[test] + fn reconnect_replays_bounded_ordered_history_without_duplicating_live_chunks() { + let stream = Stream::default(); + let mut live = stream.subscribe("parent".into(), true); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + live.try_recv().unwrap(); + for sequence in 0..HISTORY + 3 { + stream.forward("child", json!({"sessionUpdate":"session_message_chunk", "messageId":"m", "content":{"type":"text","text":sequence.to_string()}})); + live.try_recv().unwrap(); + } + let mut replay = stream.subscribe("parent".into(), true); + assert_eq!( + replay.try_recv().unwrap().1["sessionUpdate"], + "subagent_update" + ); + for sequence in 3..HISTORY + 3 { + assert_eq!( + replay.try_recv().unwrap().1["content"]["text"], + sequence.to_string() + ); + } + assert!(replay.try_recv().is_err()); + assert!(live.try_recv().is_err()); + stream.forward("child", json!({"sessionUpdate":"plan","entries":[]})); + assert_eq!(replay.try_recv().unwrap(), live.try_recv().unwrap()); + } + + #[test] + fn history_byte_budget_evicts_old_frames_without_regressing_current_state() { + let stream = Stream::default(); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + let text = "x".repeat(FRAME_BYTES / 2); + for sequence in 0..HISTORY { + stream.forward("child", json!({"sessionUpdate":"session_message_chunk","messageId":sequence.to_string(),"content":{"type":"text","text":text}})); + } + stream.forward( + "child", + json!({"sessionUpdate":"state_update","state":"running"}), + ); + stream.announce("child", "worker", "task", SubagentStatus::Idle, 1); + let mut replay = stream.subscribe("parent".into(), true); + let announcement = replay.try_recv().unwrap(); + assert_eq!( + announcement.1["_meta"]["kit/subagent/steer"]["canSteer"], + false + ); + let mut retained_bytes = 0; + let mut count = 0; + while let Ok((_, event)) = replay.try_recv() { + assert_eq!(event["sessionUpdate"], "session_message_chunk"); + retained_bytes += serde_json::to_vec(&event).unwrap().len(); + count += 1; + } + assert!(count > 0 && count < HISTORY); + assert!(retained_bytes <= HISTORY_BYTES); + } + + #[test] + fn nested_current_state_is_replayed_in_ownership_snapshot() { + let stream = Stream::default(); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + stream.forward( + "child", + json!({"sessionUpdate":"subagent_update","sessionId":"nested"}), + ); + stream.forward( + "nested", + json!({"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}), + ); + let mut replay = stream.subscribe("parent".into(), true); + replay.try_recv().unwrap(); + assert_eq!( + replay.try_recv().unwrap().1["state"], + json!({"state":"idle","stopReason":"end_turn"}) + ); + assert!(replay.try_recv().is_err()); + } + + #[test] + fn nested_kit_steering_grants_do_not_authorize_the_direct_child_rpc() { + let transcripts = super::super::transcript::Transcripts::default(); + let mut rx = transcripts.stream.subscribe("parent".into(), true); + transcripts + .stream + .announce("child", "worker", "task", SubagentStatus::Working, 1); + transcripts.start("child", 1); + let routes = Routes::default(); + routes.register("remote", "child", 1, transcripts.get("child", 1).unwrap()); + rx.try_recv().unwrap(); + routes.forward("remote", json!({"sessionUpdate":"subagent_update", "sessionId":"nested", "_meta":{"kit/subagent/steer":{"generation":1,"canSteer":true}}})); + assert_eq!( + rx.try_recv().unwrap().1["_meta"]["kit/subagent/steer"]["canSteer"], + false + ); + } + + #[test] + fn no_history_resume_announces_ownership_without_old_content_or_grants() { + let stream = Stream::default(); + let mut live = stream.subscribe("parent".into(), false); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + stream.capabilities("child", 1, true); + stream.forward("child", json!({"sessionUpdate":"session_message_chunk", "messageId":"m", "content":{"type":"text","text":"old"}})); + for _ in 0..3 { + live.try_recv().unwrap(); + } + let mut resumed = stream.subscribe("parent".into(), false); + let association = resumed.try_recv().unwrap(); + assert_eq!(association.1["sessionId"], "child"); + assert_eq!( + association.1["_meta"]["kit/subagent/steer"], + json!({"generation":1,"canSteer":false}) + ); + assert!(resumed.try_recv().is_err()); + assert!(live.try_recv().is_err()); + // A new capability report, not a cached replay, can grant steering. + stream.capabilities("child", 1, true); + assert_eq!( + resumed.try_recv().unwrap().1["_meta"]["kit/subagent/steer"]["canSteer"], + true + ); + stream.forward("child", json!({"sessionUpdate":"plan","entries":[]})); + assert_eq!(resumed.try_recv().unwrap().1["sessionUpdate"], "plan"); + } + + #[test] + fn replay_revokes_cached_standard_and_kit_mutation_grants() { + let stream = Stream::default(); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + stream.forward("child", json!({"sessionUpdate":"subagent_update", "sessionId":"nested", "capabilities":{"cancel":{}}, "_meta":{"kit/subagent/steer":{"generation":3,"canSteer":true}}})); + for replay_history in [false, true] { + let mut rx = stream.subscribe("parent".into(), replay_history); + rx.try_recv().unwrap(); + let nested = rx.try_recv().unwrap(); + assert_eq!(nested.1["capabilities"], Value::Null); + assert_eq!( + nested.1["_meta"]["kit/subagent/steer"], + json!({"generation":3,"canSteer":false}) + ); + } + } + + #[test] + fn metadata_key_patches_match_live_and_reconnect_snapshots() { + let stream = Stream::default(); + let mut live = stream.subscribe("parent".into(), false); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + live.try_recv().unwrap(); + let mut observed = serde_json::Map::new(); + for patch in [ + json!({"sessionUpdate":"subagent_update","sessionId":"child","_meta":{"one":1,"two":{"a":1}}}), + json!({"sessionUpdate":"subagent_update","sessionId":"child","_meta":{"one":null,"three":3}}), + json!({"sessionUpdate":"subagent_update","sessionId":"child","description":"omitted meta"}), + json!({"sessionUpdate":"subagent_update","sessionId":"child","_meta":{"two":{"b":2}}}), + ] { + stream.forward("", patch); + let event = live.try_recv().unwrap(); + patch_update(&mut observed, event.1.as_object().unwrap()); + } + assert_eq!(observed["_meta"], json!({"two":{"b":2},"three":3})); + let mut replay = stream.subscribe("parent".into(), false); + let snapshot = replay.try_recv().unwrap(); + assert_eq!(snapshot.1["_meta"]["two"], observed["_meta"]["two"]); + assert_eq!(snapshot.1["_meta"]["three"], 3); + assert!(snapshot.1["_meta"].get("one").is_none()); + stream.forward( + "child", + json!({"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}), + ); + let mut after_state = stream.subscribe("parent".into(), false); + assert_eq!(after_state.try_recv().unwrap().1["_meta"]["three"], 3); + stream.forward( + "", + json!({"sessionUpdate":"subagent_update","sessionId":"child","_meta":null}), + ); + let mut cleared = stream.subscribe("parent".into(), false); + assert_eq!(cleared.try_recv().unwrap().1["_meta"], Value::Null); + } + + #[test] + fn resume_tail_eviction_preserves_associations_and_metadata() { + let transcripts = super::super::transcript::Transcripts::default(); + transcripts + .stream + .announce("child", "worker", "task", SubagentStatus::Working, 1); + transcripts.start("child", 1); + let mut rx = transcripts.stream.subscribe("parent".into(), false); + rx.try_recv().unwrap(); + let routes = Routes::default(); + routes.begin_resume("remote".into()); + routes.forward("remote", json!({"sessionUpdate":"subagent_update","sessionId":"nested","_meta":{"one":1,"two":2}})); + routes.forward("remote", json!({"sessionUpdate":"subagent_update","sessionId":"nested","_meta":{"one":null,"three":3}})); + for sequence in 0..HISTORY + 3 { + routes.forward("nested", json!({"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":sequence.to_string()}})); + } + assert!(rx.try_recv().is_err()); + routes.forward( + "remote", + json!({"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}), + ); + routes.register("remote", "child", 1, transcripts.get("child", 1).unwrap()); + let association = rx.try_recv().unwrap(); + assert_eq!(association.1["_meta"], json!({"two":2,"three":3})); + let nested = association.1["sessionId"].as_str().unwrap(); + for sequence in 3..HISTORY + 3 { + let event = rx.try_recv().unwrap(); + assert_eq!(event.0, nested); + assert_eq!(event.1["content"]["text"], sequence.to_string()); + } + routes.forward("nested", json!({"sessionUpdate":"plan","entries":[]})); + assert_eq!(rx.try_recv().unwrap().0, nested); + transcripts.stream.capabilities("child", 1, true); + assert_eq!( + rx.try_recv().unwrap().1["_meta"]["kit/subagent/steer"]["canSteer"], + true + ); + } + + #[test] + fn resume_descendant_state_survives_tail_eviction_and_respects_newer_associations() { + for newer_association in [false, true] { + let transcripts = super::super::transcript::Transcripts::default(); + transcripts + .stream + .announce("child", "worker", "task", SubagentStatus::Working, 1); + transcripts.start("child", 1); + let mut rx = transcripts.stream.subscribe("parent".into(), false); + rx.try_recv().unwrap(); + let routes = Routes::default(); + routes.begin_resume("remote".into()); + routes.forward("remote", json!({"sessionUpdate":"subagent_update","sessionId":"nested","state":{"state":"running"}})); + routes.forward( + "nested", + json!({"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}), + ); + if newer_association { + routes.forward("remote", json!({"sessionUpdate":"subagent_update","sessionId":"nested","state":{"state":"running"}})); + } + let content_count = if newer_association { 2 } else { HISTORY + 1 }; + for sequence in 0..content_count { + routes.forward("nested", json!({"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":sequence.to_string()}})); + } + routes.register("remote", "child", 1, transcripts.get("child", 1).unwrap()); + let association = rx.try_recv().unwrap(); + assert_eq!( + association.1["state"]["state"], + if newer_association { "running" } else { "idle" } + ); + for sequence in content_count.saturating_sub(HISTORY)..content_count { + let event = rx.try_recv().unwrap(); + assert_eq!(event.1["sessionUpdate"], "agent_thought_chunk"); + assert_eq!(event.1["content"]["text"], sequence.to_string()); + } + assert!(rx.try_recv().is_err()); + // The retained stream snapshot must agree, not just the live event. + let mut replay = transcripts.stream.subscribe("parent".into(), false); + replay.try_recv().unwrap(); + assert_eq!( + replay.try_recv().unwrap().1["state"], + association.1["state"] + ); + } + } + + #[test] + fn poisoned_ordering_is_isolated() { + let stream = Stream::default(); + let mut rx = stream.subscribe("parent".into(), true); + let _ = std::panic::catch_unwind(|| { + let _guard = stream.state.lock().unwrap(); + panic!("abandoned transition"); + }); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + assert!(rx.try_recv().is_err()); + } +} diff --git a/src/acp_child/transcript.rs b/src/acp_child/transcript.rs index 3769ffdb..53fd4a20 100644 --- a/src/acp_child/transcript.rs +++ b/src/acp_child/transcript.rs @@ -7,23 +7,30 @@ use serde::{Serialize, Serializer, ser::SerializeMap}; use serde_json::Value; use std::{ collections::HashMap, - fs::File, - io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}, + io::Write, sync::{ Arc, Mutex, OnceLock, atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, mpsc, }, }; +#[cfg(test)] +use std::{ + fs::File, + io::{BufRead, BufReader, Read, Seek, SeekFrom}, +}; const QUEUE: usize = 256; const FRAME_BYTES: usize = 1024 * 1024; const QUEUE_BYTES: usize = 8 * 1024 * 1024; +#[cfg(test)] const PAGE_UPDATES: usize = 64; +#[cfg(test)] const PAGE_BYTES: u64 = 256 * 1024; #[derive(Default)] pub(crate) struct Transcripts { generations: Mutex>, + pub(crate) stream: Arc, } impl Transcripts { pub(crate) fn start(&self, id: &str, generation: u64) { @@ -37,7 +44,8 @@ impl Transcripts { } } // One disk log survives all prompt generations of this child session. - let transcript = Transcript::start(); + let mut transcript = Transcript::start(); + transcript.stream = Some(self.stream.clone()); let Ok(mut generations) = self.generations.lock() else { return; }; @@ -65,6 +73,7 @@ impl Transcripts { pub(crate) struct Transcript { sender: mpsc::SyncSender>, shared: Arc, + stream: Option>, } #[derive(Default)] struct Shared { @@ -94,6 +103,7 @@ impl Drop for Shared { }); } } +#[cfg(test)] #[derive(Debug)] pub(crate) struct Page { pub updates: Vec, @@ -152,8 +162,34 @@ impl Transcript { { shared.failed.store(true, Ordering::Release); } - Self { sender, shared } + Self { + sender, + shared, + stream: None, + } + } + pub(crate) fn stream_capabilities(&self, owner: &str, generation: u64, supported: bool) { + if let Some(stream) = &self.stream { + stream.capabilities(owner, generation, supported); + } + } + + pub(crate) fn stream_parent(&self, owner: &str) -> Option { + self.stream.as_ref()?.parent(owner) + } + + pub(crate) fn directed(&self, owner: &str, update: Value) { + if let Some(stream) = &self.stream { + stream.directed(owner, update); + } + } + + pub(crate) fn forward(&self, owner: &str, update: Value) { + if let Some(stream) = &self.stream { + stream.forward(owner, update); + } } + /// Release the writer thread after all admitted records, retaining only disk /// history for later focus. Repeated terminal roster events are harmless. pub(crate) fn finish(&self) { @@ -169,33 +205,29 @@ impl Transcript { } } - /// ACP prompt acceptance has no native user-message ID, even in v2. Keep - /// submitted input distinct from all reported updates; never guess matches. + /// Delegation is a session-directed message, never a synthetic human turn. pub(crate) fn record_submitted_prompt( &self, owner: &str, generation: u64, content: &[super::ContentBlock], ) { - self.record(&Value::Object(serde_json::Map::from_iter([ - ("sessionUpdate".into(), Value::String("kit_transcript_partial".into())), - ("reason".into(), Value::String("Submitted prompt is shown separately. ACP does not identify its echo; reported user messages are preserved and may repeat that prompt".into())), - ]))); - for content in content { - let Ok(content) = serde_json::to_value(content) else { - self.fail(); - continue; - }; - let update = Value::Object(serde_json::Map::from_iter([ - ( - "sessionUpdate".into(), - Value::String("user_message_chunk".into()), - ), - ("content".into(), content), - ])); - if let Some(update) = super::inspection_event(owner, generation, update) { - self.record(&update); - } + use agent_client_protocol::schema::v1::{SessionMessage, SessionUpdate}; + let mut message = SessionMessage::new(format!( + "kit-delegation-{generation}-{}", + crate::session::new_id() + )) + .recipient_session_id(agent_client_protocol::schema::v1::SessionId::from( + owner.to_owned(), + )) + .content(content.to_vec()); + if let Some(parent) = self.stream.as_ref().and_then(|stream| stream.parent(owner)) { + message = message + .sender_session_id(agent_client_protocol::schema::v1::SessionId::from(parent)); + } + if let Ok(update) = serde_json::to_value(SessionUpdate::SessionMessage(message)) { + self.directed(owner, update.clone()); + self.record(&update); } } @@ -302,6 +334,7 @@ impl Transcript { self.shared.pending.fetch_sub(1, Ordering::Release); } } + #[cfg(test)] pub(crate) async fn read(&self, cursor: u64) -> Result { let shared = self.shared.clone(); tokio::task::spawn_blocking(move || shared.read(cursor)) @@ -368,6 +401,7 @@ impl Write for BoundedRecord { } } +#[cfg(test)] impl Shared { fn read(&self, cursor: u64) -> Result { let unavailable = @@ -674,30 +708,22 @@ mod tests { } route.record_inspection(json!({"sessionUpdate":"user_message", "messageId":"steer-accepted", "content":[{"type":"text","text":"hello"}]})); let history = drain(&transcript, 0).await; - assert_eq!(history.updates.len(), 5); - assert_eq!( - history.updates[0]["sessionUpdate"], - "kit_transcript_partial" - ); - assert!( - history.updates[0]["reason"] - .as_str() - .unwrap() - .contains("echo") - ); - assert_eq!(history.updates[1]["content"]["text"], "hello"); - assert_eq!(history.updates[2]["content"]["text"], "hel"); - assert_eq!(history.updates[3]["content"]["text"], "lo"); + assert_eq!(history.updates.len(), 4); + assert_eq!(history.updates[0]["sessionUpdate"], "session_message"); + assert_eq!(history.updates[0]["recipientSessionId"], "child"); + assert_eq!(history.updates[0]["content"][0]["text"], "hello"); + assert_eq!(history.updates[1]["content"]["text"], "hel"); + assert_eq!(history.updates[2]["content"]["text"], "lo"); assert_eq!( - history.updates[2]["messageId"], - history.updates[3]["messageId"] - ); - assert_ne!( history.updates[1]["messageId"], history.updates[2]["messageId"] ); - assert_eq!(history.updates[4]["messageId"], "steer-accepted"); - assert_eq!(history.updates[4]["content"][0]["text"], "hello"); + assert_ne!( + history.updates[0]["messageId"], + history.updates[1]["messageId"] + ); + assert_eq!(history.updates[3]["messageId"], "steer-accepted"); + assert_eq!(history.updates[3]["content"][0]["text"], "hello"); } #[tokio::test] @@ -771,6 +797,7 @@ mod tests { let transcript = Transcript { sender, shared: Arc::default(), + stream: None, }; if disconnected { drop(receiver); diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index 25fb973b..80c95406 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -58,6 +58,7 @@ mod activity; pub(crate) mod model_switch; mod session_pagination; mod skill_catalog; +mod subagents; pub(crate) mod tool_projection; pub mod v2; @@ -450,25 +451,6 @@ fn hex_value(byte: u8) -> Option { } } -/// Bounded polling of runtime-owned disk history. Cursor zero starts replay; -/// next_cursor is an opaque byte offset. Reuse it to transition into live reads. -#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] -#[request(method = "kit/subagent/transcript/read", response = ReadSubagentTranscriptResponse)] -pub(crate) struct ReadSubagentTranscriptRequest { - pub session_id: agentkit_acp::SessionId, - pub id: String, - pub generation: u64, - pub cursor: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)] -pub(crate) struct ReadSubagentTranscriptResponse { - pub updates: Vec, - pub next_cursor: u64, - pub generation: u64, - pub caught_up: bool, -} - /// Generation-checked steering of one live direct subagent. #[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] #[request(method = "kit/subagent/steer", response = SteerSubagentResponse)] @@ -1238,6 +1220,7 @@ async fn logout_authentication( } struct SessionSetup { + replay_history: bool, cwd: PathBuf, additional_directories: Vec, mcp_servers: Vec, @@ -1463,6 +1446,8 @@ impl Drop for SessionPublicationRollback<'_> { } struct Server { + // Connection-local snapshot. Only initialize writes; a new connection starts disabled. + subagent_updates: AtomicBool, runtime: Arc, integration: Arc, registry: SessionRegistry, @@ -1487,6 +1472,7 @@ impl Server { fn new(runtime: Arc, integration: AcpIntegration, registry: SessionRegistry) -> Self { Self { + subagent_updates: AtomicBool::new(false), runtime, integration: Arc::new(integration), registry, @@ -1595,6 +1581,10 @@ impl Server { } async fn initialize(&self, request: InitializeRequest) -> InitializeResponse { + self.subagent_updates.store( + request.client_capabilities.subagents.is_some(), + Ordering::Release, + ); let logout_authentication = self.runtime.supports_logout_authentication(); InitializeResponse::new(agent_client_protocol::schema::ProtocolVersion::V1) .agent_capabilities(capabilities(logout_authentication)) @@ -1619,6 +1609,7 @@ impl Server { let attached = self .attach_session( SessionSetup { + replay_history: false, cwd: request.cwd, additional_directories: request.additional_directories, mcp_servers: request.mcp_servers, @@ -1706,6 +1697,7 @@ impl Server { let attached = self .attach_session( SessionSetup { + replay_history: true, cwd: request.cwd, additional_directories: request.additional_directories, mcp_servers: request.mcp_servers, @@ -1746,6 +1738,7 @@ impl Server { let attached = self .attach_session( SessionSetup { + replay_history: false, cwd: request.cwd, additional_directories: request.additional_directories, mcp_servers: request.mcp_servers, @@ -1773,6 +1766,7 @@ impl Server { commit: impl FnOnce(crate::runtime::SessionClaim) -> Result + Send, ) -> Result, AcpRuntimeError> { let SessionSetup { + replay_history, cwd, additional_directories, mcp_servers, @@ -1907,13 +1901,28 @@ impl Server { token, completed, }; + let subagent_updates = self.subagent_updates.load(Ordering::Acquire).then(|| { + driver + .subagents + .subscribe_updates(session_id.to_string(), replay_history) + }); let actor_task = tokio::spawn(async move { let _shutdown_bridge = shutdown_bridge; let _guard = guard; if activated.await.is_ok() { let runtime = Arc::clone(&actor.runtime); let session_id = actor.session_id.clone(); - let actor = std::pin::pin!(session_actor(actor)); + let actor = std::pin::pin!(subagents::with_updates( + session_actor(actor), + subagent_updates, + |session_id, update| { + if let Some(update) = subagents::v1_update(update) { + connection + .send_notification(SessionNotification::new(session_id, update))?; + } + Ok(()) + }, + )); let polling = std::pin::pin!(skill_catalog::poll_commands(runtime, |commands| { let _ = connection .send_notification(available_commands_update(session_id.clone(), commands)); @@ -2084,29 +2093,6 @@ impl Server { .ok_or_else(|| AcpRuntimeError::SessionNotFound(session_id.to_string())) } - async fn read_subagent_transcript( - &self, - request: ReadSubagentTranscriptRequest, - ) -> Result { - let subagents = self - .sessions - .lock() - .map_err(|_| AcpRuntimeError::ClientClosed)? - .get(&request.session_id.to_string().into()) - .and_then(|session| session.subagents.clone()) - .ok_or_else(|| AcpRuntimeError::SessionNotFound(request.session_id.to_string()))?; - let page = subagents - .read_transcript(&request.id, request.generation, request.cursor) - .await - .map_err(|error| AcpRuntimeError::Loop(error.to_string()))?; - Ok(ReadSubagentTranscriptResponse { - updates: page.updates, - next_cursor: page.next_cursor, - generation: request.generation, - caught_up: page.caught_up, - }) - } - async fn steer_subagent( &self, request: SteerSubagentRequest, @@ -3195,24 +3181,6 @@ fn component( }, agent_client_protocol::on_receive_request!(), ) - .on_receive_request( - { - let state = Arc::clone(&state); - async move |request: ReadSubagentTranscriptRequest, responder, cx| { - let state = Arc::clone(&state); - cx.spawn(async move { - responder.respond_with_result( - state - .read_subagent_transcript(request) - .await - .map_err(sdk_error), - ) - })?; - Ok(()) - } - }, - agent_client_protocol::on_receive_request!(), - ) .on_receive_request( { let state = Arc::clone(&state); @@ -3315,6 +3283,10 @@ fn fork_parent_context( fn capabilities(logout_authentication: bool) -> agentkit_acp::AgentCapabilities { let capabilities = agentkit_acp::AgentCapabilities::new() + .meta(Map::from_iter([( + "kit/subagent/steer".into(), + serde_json::json!({}), + )])) .load_session(true) .prompt_capabilities( PromptCapabilities::new() @@ -3377,97 +3349,54 @@ async fn drain_client_messages( )] pub(crate) mod test_support { use super::*; - use agentkit_task_manager::TaskManager as _; - /// Exercises the real request type and handler over ACP, with an actual - /// child's runtime-owned manager supplied by the subprocess regression test. + /// Exercises the replacement notification envelope over a real ACP connection + /// with the child's actual runtime-owned transcript supplied by its regression test. pub(crate) async fn assert_transcript_route( - root: &std::path::Path, + _root: &std::path::Path, manager: crate::tools::Subagents, child: String, generation: u64, ) { - let server = Arc::new(Server::new( - Runtime::new(root, "test-model").unwrap(), - AcpIntegration::builder() - .name("transcript-route-test") - .approval_resolver(AutoDenyResolver) - .build() - .unwrap(), - SessionRegistry::new(), - )); - for (id, subagents) in [("owner", manager.clone()), ("other", manager.fresh())] { - server.sessions.lock().unwrap().insert( - agentkit_acp::SessionId::new(id), - SessionHandle { - subagents: Some(subagents), - voice_state: Default::default(), - token: 1, - commands: mpsc::channel(1).0, - background_jobs: BackgroundJobs::default(), - structured_completion: false, - tasks: agentkit_task_manager::AsyncTaskManager::new().handle(), + let page = manager + .read_transcript(&child, generation, 0) + .await + .unwrap(); + assert!(!page.updates.is_empty()); + let (agent_transport, client_transport) = agent_client_protocol::Channel::duplex(); + let (received, mut notifications) = mpsc::unbounded_channel(); + let client = agent_client_protocol::Client + .builder() + .on_receive_notification( + async move |update: SessionNotification, _cx| { + received.send(update).unwrap(); + Ok(()) }, + agent_client_protocol::on_receive_notification!(), ); - } - let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); - let service = agent_client_protocol::Agent.builder().on_receive_request( - { - let server = server.clone(); - async move |request: ReadSubagentTranscriptRequest, responder, _cx| { - responder.respond_with_result( - server - .read_subagent_transcript(request) - .await - .map_err(sdk_error), - ) - } - }, - agent_client_protocol::on_receive_request!(), - ); - let actor = tokio::spawn(service.connect_to(agent_transport)); - agent_client_protocol::Client + let client_task = tokio::spawn(client.connect_to(client_transport)); + agent_client_protocol::Agent .builder() - .connect_with(client_transport, async move |connection| { - let request = ReadSubagentTranscriptRequest { - session_id: agentkit_acp::SessionId::new("owner"), - id: child, - generation, - cursor: 0, - }; - let page = connection - .send_request(request.clone()) - .block_task() - .await?; - assert_eq!(page.generation, generation); - assert!(!page.updates.is_empty()); - assert!(page.next_cursor > 0); - for invalid in [ - ReadSubagentTranscriptRequest { - session_id: agentkit_acp::SessionId::new("other"), - ..request.clone() - }, - ReadSubagentTranscriptRequest { - session_id: agentkit_acp::SessionId::new("missing"), - ..request.clone() - }, - ReadSubagentTranscriptRequest { - generation: generation + 1, - ..request.clone() - }, - ReadSubagentTranscriptRequest { - cursor: u64::MAX, - ..request - }, - ] { - assert!(connection.send_request(invalid).block_task().await.is_err()); + .connect_with(agent_transport, async move |connection| { + connection.send_notification(SessionNotification::new( + "parent", + SessionUpdate::SubagentUpdate(agent_client_protocol::schema::v1::SubagentUpdate::new(child.clone())), + ))?; + for update in page.updates { + if let Some(update) = subagents::v1_update(update) { + connection.send_notification(SessionNotification::new(child.clone(), update))?; + } } + let association = notifications.recv().await.unwrap(); + assert!(matches!(association.update, SessionUpdate::SubagentUpdate(update) if update.session_id.to_string() == child)); + let content = notifications.recv().await.unwrap(); + assert_eq!(content.session_id.to_string(), child); Ok(()) }) .await .unwrap(); - actor.abort(); - let _ = actor.await; + client_task.abort(); + let _ = client_task.await; } impl SessionRegistry { @@ -3763,6 +3692,38 @@ pub(super) mod tests { ); } + #[tokio::test] + async fn v1_subagent_capabilities_reset_on_initialize_and_new_connections() { + let root = tempfile::tempdir().unwrap(); + let create_server = || { + Server::new( + Runtime::new(root.path(), "gpt-5.4").unwrap(), + AcpIntegration::builder() + .name("subagent-test") + .approval_resolver(AutoDenyResolver) + .build() + .unwrap(), + SessionRegistry::new(), + ) + }; + let server = create_server(); + assert!(!server.subagent_updates.load(Ordering::Acquire)); + for (capabilities, supported) in [ + (json!({"subagents": {}}), true), + (json!({"subagents": null}), false), + (json!({"subagents": {}}), true), + (json!({}), false), + ] { + let request = serde_json::from_value(json!({ + "protocolVersion":1, "clientCapabilities": capabilities, + })) + .unwrap(); + server.initialize(request).await; + assert_eq!(server.subagent_updates.load(Ordering::Acquire), supported); + } + assert!(!create_server().subagent_updates.load(Ordering::Acquire)); + } + #[test] fn v1_authentication_errors_and_terminal_login_use_protocol_codes() { let error = sdk_error(AcpRuntimeError::Loop( diff --git a/src/protocols/acp/subagents.rs b/src/protocols/acp/subagents.rs new file mode 100644 index 00000000..863ca2c3 --- /dev/null +++ b/src/protocols/acp/subagents.rs @@ -0,0 +1,254 @@ +//! Connection-local transport for upstream subagent sessions. +use std::future::Future; + +use futures_util::future::{Either, select}; +use serde_json::Value; +use tokio::sync::broadcast; + +/// Project native v2 messages onto the v1 subagent message model. Mapping +/// both upserts and chunks preserves replacement semantics without appending +/// the final message a second time after its streamed chunks. +/// Foreground state is already folded into the parent association by runtime. +/// Other v2-only terminal, plan-operation, and tool-content updates have no +/// lossless v1 representation; omit those rather than terminate forwarding. +pub(super) fn v1_update(mut update: Value) -> Option { + let kind = update + .get("sessionUpdate") + .and_then(Value::as_str) + .unwrap_or_default(); + let replacement = match kind { + "state_update" + | "terminal_update" + | "terminal_output_chunk" + | "plan_update" + | "plan_removed" + | "tool_call_content_chunk" => return None, + "user_message" | "agent_message" | "agent_thought" => Some("session_message"), + "user_message_chunk" | "agent_message_chunk" | "agent_thought_chunk" + if update.get("messageId").is_some() => + { + Some("session_message_chunk") + } + _ => None, + }; + if let Some(replacement) = replacement { + update["sessionUpdate"] = Value::String(replacement.into()); + } + serde_json::from_value(update).ok() +} + +/// Unsupported or malformed child events are isolated to that event. They are +/// not connection failures, and must not suppress subsequent valid updates. +pub(super) fn v2_update(update: Value) -> Option { + serde_json::from_value(update).ok() +} + +/// Poll forwarding in the actor's task: cancellation and attachment rollback +/// cannot leave a detached sender holding the old connection alive. Runtime +/// events must include the enclosing session ID, not only the direct child ID, +/// and must announce associations before any event references those children. +pub(super) async fn with_updates( + actor: impl Future, + updates: Option>, + mut send: impl FnMut(String, Value) -> Result<(), agent_client_protocol::Error>, +) { + let Some(mut updates) = updates else { + actor.await; + return; + }; + tokio::pin!(actor); + loop { + let next = match select(actor.as_mut(), Box::pin(updates.recv())).await { + Either::Left(((), pending_update)) => { + drop(pending_update); + None + } + Either::Right((update, _)) => Some(update), + }; + match next { + None => { + // Closing children can enqueue final updates during actor teardown. + while let Ok((session_id, update)) = updates.try_recv() { + if send(session_id, update).is_err() { + break; + } + } + return; + } + Some(update) => { + match update { + Ok((session_id, update)) => { + if send(session_id, update).is_err() { + actor.await; + return; + } + } + Err(_) => { + // On lag, stop rather than send an event whose association + // may have been dropped. Parent execution remains available. + actor.await; + return; + } + } + } + } + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::panic, + clippy::disallowed_macros, + clippy::disallowed_methods +)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn upstream_types_preserve_association_patches_and_directed_messages() { + use agent_client_protocol::schema::v1::{SessionUpdate, SubagentUpdate}; + let association = + serde_json::to_value(SessionUpdate::SubagentUpdate(SubagentUpdate::new("child"))) + .unwrap(); + assert_eq!( + association, + json!({"sessionUpdate":"subagent_update", "sessionId":"child"}) + ); + for update in [ + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "title":null, "description":null, "capabilities":null, "state":null, "_meta":null}), + json!({"sessionUpdate":"session_message", "messageId":"message", "senderSessionId":"parent", "recipientSessionId":"child"}), + json!({"sessionUpdate":"session_message_chunk", "messageId":"message", "content":{"type":"text", "text":"investigate"}}), + ] { + let v1: SessionUpdate = serde_json::from_value(update.clone()).unwrap(); + assert_eq!(serde_json::to_value(v1).unwrap(), update); + let v2: agentkit_acp::v2::wire::SessionUpdate = + serde_json::from_value(update.clone()).unwrap(); + assert_eq!(serde_json::to_value(v2).unwrap(), update); + } + } + + #[test] + fn v1_projection_preserves_v2_message_replacements_after_state_updates() { + assert!(v1_update(json!({"sessionUpdate":"state_update", "state":"running"})).is_none()); + let updates = [ + json!({"sessionUpdate":"agent_message", "messageId":"answer"}), + json!({"sessionUpdate":"agent_message_chunk", "messageId":"answer", "content":{"type":"text","text":"partial"}}), + json!({"sessionUpdate":"agent_message", "messageId":"answer", "content":[{"type":"text","text":"final"}]}), + ]; + for (update, kind) in updates.into_iter().zip([ + "session_message", + "session_message_chunk", + "session_message", + ]) { + let mut expected = update.clone(); + expected["sessionUpdate"] = kind.into(); + let projected = v1_update(update).unwrap(); + assert_eq!(serde_json::to_value(projected).unwrap(), expected); + } + let legacy = json!({"sessionUpdate":"agent_message_chunk", "content":{"type":"text", "text":"legacy"}}); + assert_eq!( + serde_json::to_value(v1_update(legacy.clone()).unwrap()).unwrap(), + legacy + ); + assert!(v1_update(json!({"sessionUpdate":"session_message_chunk"})).is_none()); + } + + #[tokio::test] + async fn unsupported_events_do_not_stop_either_version_stream() { + let projections: [fn(Value) -> Option; 2] = [ + |update| v1_update(update).map(|value| serde_json::to_value(value).unwrap()), + |update| v2_update(update).map(|value| serde_json::to_value(value).unwrap()), + ]; + for (version, project) in projections.into_iter().enumerate() { + let (tx, rx) = broadcast::channel(8); + let (finished, finish) = tokio::sync::oneshot::channel(); + let mut finished = Some(finished); + let unsupported = if version == 0 { + json!({"sessionUpdate":"state_update", "state":"running"}) + } else { + json!({"sessionUpdate":"current_mode_update", "currentModeId":"default"}) + }; + let valid = json!({"sessionUpdate":"session_message", "messageId":"survives"}); + let future = json!({"sessionUpdate":"future_update"}); + // V2's official Other variant preserves unknown and v1-only updates. + // An invalid discriminator is genuinely unrepresentable in either schema. + let expected = if version == 0 { + vec![("child".into(), valid.clone())] + } else { + vec![ + ("child".into(), future.clone()), + ("child".into(), unsupported.clone()), + ("child".into(), valid.clone()), + ] + }; + for update in [ + future, + unsupported, + json!({"sessionUpdate": 12}), + valid.clone(), + ] { + tx.send(("child".into(), update)).unwrap(); + } + let mut sent = Vec::new(); + with_updates( + async { + finish.await.unwrap(); + }, + Some(rx), + |id, update| { + if let Some(update) = project(update) { + let complete = + update.get("messageId").and_then(Value::as_str) == Some("survives"); + sent.push((id, update)); + if complete { + finished.take().unwrap().send(()).unwrap(); + } + } + Ok(()) + }, + ) + .await; + assert_eq!(sent, expected); + } + } + + #[tokio::test] + async fn forwarding_preserves_nested_order_and_directed_messages() { + let (tx, rx) = broadcast::channel(8); + let updates = [ + ( + "parent", + json!({"sessionUpdate":"subagent_update","sessionId":"child"}), + ), + ( + "child", + json!({"sessionUpdate":"subagent_update","sessionId":"nested"}), + ), + ( + "child", + json!({"sessionUpdate":"session_message","messageId":"message","senderSessionId":"child","recipientSessionId":"nested"}), + ), + ( + "nested", + json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}), + ), + ]; + for (id, update) in &updates { + assert!(tx.send(((*id).to_owned(), update.clone())).is_ok()); + } + let mut sent = Vec::new(); + with_updates(async {}, Some(rx), |session_id, update| { + sent.push((session_id, update)); + Ok(()) + }) + .await; + assert_eq!(sent, updates.map(|(id, update)| (id.to_owned(), update))); + } + + #[tokio::test] + async fn unsupported_clients_receive_no_subagent_updates() { + with_updates(async {}, None, |_, _| panic!("unexpected update")).await; + } +} diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index b0667702..45282490 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -745,6 +745,13 @@ struct PendingSessionPublication { session: SessionHandle, } +struct SessionSetup { + cwd: PathBuf, + additional_directories: Vec, + mcp_servers: Vec, + replay_history: bool, +} + struct Server { // Connection-local negotiated snapshot; only initialize writes it. form_elicitation: AtomicBool, @@ -909,6 +916,10 @@ impl Server { .meta .get_or_insert_with(Map::new) .insert("kit/session/status".into(), serde_json::json!({})); + capabilities + .meta + .get_or_insert_with(Map::new) + .insert("kit/subagent/steer".into(), serde_json::json!({})); capabilities .session .get_or_insert_with(wire::SessionCapabilities::new) @@ -950,13 +961,16 @@ impl Server { let claim = self.runtime.claim_session()?; let attached = self .attach_session( - request.cwd.0, - request - .additional_directories - .into_iter() - .map(|path| path.0) - .collect(), - request.mcp_servers, + SessionSetup { + cwd: request.cwd.0, + additional_directories: request + .additional_directories + .into_iter() + .map(|path| path.0) + .collect(), + mcp_servers: request.mcp_servers, + replay_history: false, + }, connection, claim, ) @@ -1008,13 +1022,16 @@ impl Server { } let attached = self .attach_session( - request.cwd.0, - request - .additional_directories - .into_iter() - .map(|path| path.0) - .collect(), - request.mcp_servers, + SessionSetup { + cwd: request.cwd.0, + additional_directories: request + .additional_directories + .into_iter() + .map(|path| path.0) + .collect(), + mcp_servers: request.mcp_servers, + replay_history: replay, + }, connection, claim, ) @@ -1089,12 +1106,16 @@ impl Server { async fn attach_session( self: &Arc, - cwd: PathBuf, - additional_directories: Vec, - mcp_servers: Vec, + setup: SessionSetup, connection: V2ConnectionTo, mut claim: crate::runtime::SessionClaim, ) -> Result { + let SessionSetup { + cwd, + additional_directories, + mcp_servers, + replay_history, + } = setup; let runtime = self.runtime.for_workspace(&cwd).await?; let cwd = runtime.root().to_path_buf(); let additional_directories = runtime.additional_directories(&additional_directories)?; @@ -1114,7 +1135,7 @@ impl Server { let ask_user = self.form_elicitation.load(Ordering::Acquire).then(|| { crate::tools::ask_user::AskUserTool::new(connection.clone(), session_id.clone()) }); - let sink = ResponseReplacementSink::new(ConnectionSink(connection)); + let sink = ResponseReplacementSink::new(ConnectionSink(connection.clone())); let activity = native_activity(session_id.clone(), sink.clone()); let binding = AcpSessionBinding::new(session_id.clone(), SessionId::new(claim.id()), sink.clone()) @@ -1188,13 +1209,27 @@ impl Server { token, completed, }; + let subagent_updates = driver + .subagents + .subscribe_updates(session_id.to_string(), replay_history); let actor_task = tokio::spawn(async move { let _guard = guard; if activated.await.is_ok() { let sink = actor.sink.clone(); let runtime = Arc::clone(&actor.runtime); let session_id = actor.session_id.clone(); - let actor = std::pin::pin!(session_actor(actor)); + let actor = std::pin::pin!(super::subagents::with_updates( + session_actor(actor), + Some(subagent_updates), + |session_id, update| { + if let Some(update) = super::subagents::v2_update(update) { + connection.send_notification(wire::UpdateSessionNotification::new( + session_id, update, + ))?; + } + Ok(()) + }, + )); let polling = std::pin::pin!(skill_catalog::poll_commands(runtime, |commands| { let _ = sink.update(available_commands_update(session_id.clone(), commands)); })); @@ -1448,29 +1483,6 @@ impl Server { .map_err(|_| AcpRuntimeError::ClientClosed)? } - async fn read_subagent_transcript( - &self, - request: super::ReadSubagentTranscriptRequest, - ) -> Result { - let subagents = self - .sessions - .lock() - .map_err(|_| AcpRuntimeError::ClientClosed)? - .get(&request.session_id.to_string().into()) - .and_then(|session| session.subagents.clone()) - .ok_or_else(|| AcpRuntimeError::SessionNotFound(request.session_id.to_string()))?; - let page = subagents - .read_transcript(&request.id, request.generation, request.cursor) - .await - .map_err(|error| AcpRuntimeError::Loop(error.to_string()))?; - Ok(super::ReadSubagentTranscriptResponse { - updates: page.updates, - next_cursor: page.next_cursor, - generation: request.generation, - caught_up: page.caught_up, - }) - } - async fn steer_subagent( &self, request: super::SteerSubagentRequest, @@ -2356,12 +2368,15 @@ fn finish_reason_to_stop_reason(reason: &FinishReason) -> wire::StopReason { FinishReason::Cancelled => wire::StopReason::Cancelled, FinishReason::Blocked => wire::StopReason::Refusal, FinishReason::Error => error_stop_reason(), - FinishReason::Other(reason) => wire::StopReason::Other(reason.clone()), + FinishReason::Other(reason) => wire::StopReason::Other(wire::OtherStopReason::new( + reason.clone(), + Default::default(), + )), } } fn error_stop_reason() -> wire::StopReason { - wire::StopReason::Other("_error".into()) + wire::StopReason::Error(wire::ErrorStopReason::new()) } fn v2_config_options( @@ -2940,24 +2955,6 @@ pub(crate) fn component( }, agent_client_protocol::on_receive_request!(), ) - .on_receive_request( - { - let state = Arc::clone(&state); - async move |request: super::ReadSubagentTranscriptRequest, responder, cx| { - let state = Arc::clone(&state); - cx.spawn(async move { - responder.respond_with_result( - state - .read_subagent_transcript(request) - .await - .map_err(sdk_error), - ) - })?; - Ok(()) - } - }, - agent_client_protocol::on_receive_request!(), - ) .on_receive_request( { let state = Arc::clone(&state); @@ -7547,11 +7544,14 @@ for line in sys.stdin: ); assert_eq!( finish_reason_to_stop_reason(&FinishReason::Error), - wire::StopReason::Other("_error".into()) + wire::StopReason::Error(wire::ErrorStopReason::new()) ); assert_eq!( finish_reason_to_stop_reason(&FinishReason::Other("provider-stop".into())), - wire::StopReason::Other("provider-stop".into()) + wire::StopReason::Other(wire::OtherStopReason::new( + "provider-stop", + Default::default() + )) ); } diff --git a/src/tools/subagent.rs b/src/tools/subagent.rs index 7fbdaddc..5b5d5921 100644 --- a/src/tools/subagent.rs +++ b/src/tools/subagent.rs @@ -510,6 +510,18 @@ impl Subagents { Self::new(config) } + /// Subscribe before starting work. Always announces retained ownership without + /// granting mutations from cached capabilities. `replay_history` additionally + /// emits a bounded event tail; false is suitable for no-replay resumes. + pub fn subscribe_updates( + &self, + parent_id: String, + replay_history: bool, + ) -> tokio::sync::broadcast::Receiver<(String, Value)> { + self.transcripts.stream.subscribe(parent_id, replay_history) + } + + #[cfg(test)] pub(crate) async fn read_transcript( &self, id: &str, @@ -529,9 +541,14 @@ impl Subagents { id, generation, status, + name, + task, .. } = &event { + self.transcripts + .stream + .announce(id, name, task, *status, *generation); self.transcripts.start(id, *generation); if matches!(status, SubagentStatus::Removed) && let Ok(transcript) = self.transcripts.get(id, *generation) @@ -3171,13 +3188,18 @@ mod steer_tests { } tokio::task::yield_now().await; } - let user_texts: Vec<_> = history + let directed_texts: Vec<_> = history .iter() - .filter(|update| update["sessionUpdate"] == "user_message_chunk") - .filter_map(|update| update["content"]["text"].as_str()) + .filter(|update| { + update["sessionUpdate"] == "session_message" + && update["recipientSessionId"] == id.as_str() + }) + .filter_map(|update| update["content"].as_array()) + .flatten() + .filter_map(|content| content["text"].as_str()) .collect(); - assert!(user_texts.contains(&"original turn")); - assert!(user_texts.contains(&"next turn")); + assert!(directed_texts.contains(&"original turn")); + assert!(directed_texts.contains(&"next turn")); assert!( history .iter() diff --git a/src/tools/subagent/tests.rs b/src/tools/subagent/tests.rs index fd3a66ae..3958a864 100644 --- a/src/tools/subagent/tests.rs +++ b/src/tools/subagent/tests.rs @@ -576,8 +576,14 @@ fn close_input_accepts_a_handle_subagent_id_or_background_call_id() { #[test] fn live_session_limit_is_120_per_manager() { let directory = tempfile::tempdir().unwrap(); - let (manager, _, prior) = manager_with_disconnected_session(directory.path()); - manager.sessions.lock().unwrap().remove(&prior.id); + let manager = manager_with_generic_harness(directory.path(), Vec::new()); + // This tests a manager's local limit, not the invoking Kit process's shared + // delegation tree. Use a private real slot directory without changing env. + manager + .tree_slots + .path + .set(directory.path().join("slots")) + .unwrap(); let permits = (0..MAX_LIVE_SUBAGENTS) .map(|_| manager.reserve().unwrap()) .collect::>(); diff --git a/src/tui/app.rs b/src/tui/app.rs index ab6a889a..b7112486 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -44,11 +44,16 @@ use super::{ mod focus; mod recovery; +mod subagent_sessions; pub use focus::ChildView; /// Everything the client learns from the agent or its own runtime channel. #[derive(Debug)] pub enum Update { + SubagentSession { + root: String, + notification: Box, + }, Elicitation(super::elicitation_requests::Pending), GatewayRecovery(crate::tui::recovery::Event), ChildSteerFinished { @@ -859,7 +864,8 @@ pub struct App { pub(super) gateway_status: Option, gateway_candidate: Option, pub child_focus: Option, - child_ui: HashMap, + session_messages: HashMap, + child_sessions: HashMap, pub child_views: HashMap, pub agents_selected: Option, pub agents_keyboard_focus: bool, @@ -1172,7 +1178,8 @@ impl App { gateway_status: None, gateway_candidate: None, child_focus: None, - child_ui: HashMap::new(), + session_messages: HashMap::new(), + child_sessions: HashMap::new(), child_views: HashMap::new(), agents_selected: None, agents_keyboard_focus: false, @@ -2115,10 +2122,13 @@ impl App { Some("turn stopped: maximum turn-request limit reached".into()), ), Some(StopReason::Refusal) => (false, Some("turn refused".into())), - Some(StopReason::Other(reason)) if reason == "_error" => { + Some(StopReason::Other(reason)) if reason.stop_reason == "_error" => { (false, Some("turn failed".into())) } - Some(StopReason::Other(reason)) => (false, Some(format!("turn stopped: {reason}"))), + Some(StopReason::Error(_)) => (false, Some("turn failed".into())), + Some(StopReason::Other(reason)) => { + (false, Some(format!("turn stopped: {}", reason.stop_reason))) + } Some(_) => (false, Some("turn stopped for an unknown reason".into())), None => (false, Some("turn stopped without a reason".into())), }; @@ -2298,6 +2308,9 @@ impl App { fn apply_observed(&mut self, update: Update) { let at = self.observed_at(); match update { + Update::SubagentSession { root, notification } => { + self.apply_subagent_session(&root, *notification) + } Update::Elicitation(pending) => { self.elicitations .retain(|pending| !pending.reply.is_closed()); @@ -2701,6 +2714,7 @@ impl App { return; } self.runtime_status_unavailable = true; + self.child_sessions.clear(); for view in self.child_views.values_mut() { view.disable("Child connection unavailable; retained transcript is read-only"); } @@ -3220,8 +3234,9 @@ impl App { self.follow = true; self.focused_call_id = None; self.child_focus = None; + self.session_messages.clear(); + self.child_sessions.clear(); self.child_views.clear(); - self.child_ui.clear(); self.agents_selected = None; self.agents_keyboard_focus = false; self.child_back_area = Rect::default(); @@ -6688,8 +6703,17 @@ mod tests { "turn stopped: maximum turn-request limit reached", ), (StopReason::Refusal, "turn refused"), - (StopReason::Other("_error".into()), "turn failed"), - (StopReason::Other("custom".into()), "turn stopped: custom"), + ( + StopReason::Error(agent_client_protocol::schema::v2::ErrorStopReason::new()), + "turn failed", + ), + ( + StopReason::Other(agent_client_protocol::schema::v2::OtherStopReason::new( + "custom", + Default::default(), + )), + "turn stopped: custom", + ), ] { let mut app = app(); compose(&mut app, "a = shell({ command: \"ls\" })\nreturn a"); diff --git a/src/tui/app/focus.rs b/src/tui/app/focus.rs index b601fef7..547e6b44 100644 --- a/src/tui/app/focus.rs +++ b/src/tui/app/focus.rs @@ -1,67 +1,13 @@ -//! Only the focused child owns a transcript. Other children retain small UI state. +//! Child views consume their automatically delivered ACP session streams. use super::*; -use crate::protocols::acp::ReadSubagentTranscriptResponse; const MAX_CHILD_DRAFT: usize = 16 * 1024; -const MAX_PARTIAL_REASONS: usize = 4; -const MAX_PARTIAL_REASON_BYTES: usize = 512; - -pub(super) struct ChildUiState { - editor: Editor, - generation: u64, - pending_text: Option, - steer_notice: Option, - scroll: usize, - follow: bool, -} - -/// One wall/monotonic anchor per view keeps replay pages and live updates on -/// the same clock. These are observation times, not exact remote start times. -struct ReplayClock { - unix_ms: u64, - instant: Instant, -} - -impl ReplayClock { - fn now() -> Self { - let unix_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - Self { - unix_ms: u64::try_from(unix_ms).unwrap_or(u64::MAX), - instant: Instant::now(), - } - } - - fn observed_at(&self, value: &serde_json::Value) -> Option { - let unix_ms = value["kitObservedAtUnixMs"].as_u64()?; - if unix_ms <= self.unix_ms { - self.instant - .checked_sub(Duration::from_millis(self.unix_ms - unix_ms)) - } else { - self.instant - .checked_add(Duration::from_millis(unix_ms - self.unix_ms)) - } - } -} - pub struct ChildView { - clock: ReplayClock, pub app: Box, pub notice: String, pub generation: u64, pub can_steer: bool, - pub cursor: u64, - /// Delay reads when caught up or waiting for the spool writer to commit. - pub read_backoff: bool, - pub read_enabled: bool, active: bool, - loading: bool, - restore_scroll: Option<(usize, bool)>, - partial: bool, - partial_reasons: Vec, - partial_reasons_omitted: bool, pending_text: Option, steer_notice: Option, } @@ -70,7 +16,6 @@ impl ChildView { pub(super) fn disable(&mut self, notice: &str) { self.active = false; self.can_steer = false; - self.read_enabled = false; self.pending_text = None; self.steer_notice = None; self.app.finish_replay_incomplete(); @@ -78,32 +23,16 @@ impl ChildView { } fn refresh_notice(&mut self) { - if !self.read_enabled { - return; - } self.notice = if self.pending_text.is_some() { "Sending steer to child…" - } else if self.loading { - "Loading child transcript…" } else if !self.active { - "Child idle or closed; transcript is read-only" + "Child not confirmed working; transcript is read-only" } else if !self.can_steer { "Read-only: compatible steering is not available for this child" } else { "Text steering available · Enter to send · Esc back to main" } .into(); - if self.partial { - let mut reason = if self.partial_reasons.is_empty() { - "child sent an unsupported update".to_owned() - } else { - self.partial_reasons.join("; ") - }; - if self.partial_reasons_omitted { - reason.push_str("; additional limitations omitted"); - } - self.notice = format!("Partial transcript: {reason} · {}", self.notice); - } if let Some(notice) = &self.steer_notice { self.notice = format!("{notice} · {}", self.notice); } @@ -112,19 +41,6 @@ impl ChildView { impl App { pub fn leave_child(&mut self) { - for (id, mut view) in self.child_views.drain() { - self.child_ui.insert( - id, - ChildUiState { - editor: std::mem::take(&mut view.app.editor), - generation: view.generation, - pending_text: view.pending_text, - steer_notice: view.steer_notice, - scroll: view.restore_scroll.map_or(view.app.scroll, |state| state.0), - follow: view.restore_scroll.map_or(view.app.follow, |state| state.1), - }, - ); - } self.child_focus = None; self.agents_keyboard_focus = false; self.agents_selected = None; @@ -133,14 +49,29 @@ impl App { } pub fn focus_child(&mut self, id: String) { - let Some(row) = self.agents.get(&id) else { + self.leave_child(); + self.ensure_child_view(&id); + if !self.child_views.contains_key(&id) { + return; + } + self.agents_selected = Some(id.clone()); + self.child_focus = Some(id); + self.agents_keyboard_focus = false; + self.selection = None; + self.press = None; + } + + pub(super) fn ensure_child_view(&mut self, id: &str) { + if self.child_views.contains_key(id) { + return; + } + let Some(row) = self.agents.get(id) else { return; }; let generation = row.generation; let active = row.status == SubagentStatus::Working; let direct = row.parent_id.is_none(); let can_steer = active && direct && row.focus_can_steer; - self.leave_child(); let mut app = App::new( self.root.clone(), String::new(), @@ -149,48 +80,25 @@ impl App { ); app.show_thoughts = self.show_thoughts; app.phase = if active { Phase::Working } else { Phase::Idle }; - let mut pending_text = None; - let mut steer_notice = None; - let mut restore_scroll = None; - if let Some(ui) = self.child_ui.remove(&id) { - if ui.generation == generation { - pending_text = ui.pending_text; - steer_notice = ui.steer_notice; - } - app.editor = ui.editor; - restore_scroll = Some((ui.scroll, ui.follow)); - app.scroll = ui.scroll; - app.follow = ui.follow; + if let Some(state) = self + .child_sessions + .get(id) + .and_then(|association| association.snapshot.state.value()) + .cloned() + { + app.apply(Update::State(state)); } let mut view = ChildView { - clock: ReplayClock::now(), app: Box::new(app), generation, can_steer, active, - notice: if direct { - String::new() - } else { - "Transcript unavailable: descendant inspection is not supported".into() - }, - cursor: 0, - read_backoff: false, - read_enabled: direct, - loading: true, - partial: false, - partial_reasons: Vec::new(), - partial_reasons_omitted: false, - pending_text, - steer_notice, - restore_scroll, + notice: String::new(), + pending_text: None, + steer_notice: None, }; view.refresh_notice(); - self.child_views.insert(id.clone(), view); - self.agents_selected = Some(id.clone()); - self.child_focus = Some(id); - self.agents_keyboard_focus = false; - self.selection = None; - self.press = None; + self.child_views.insert(id.to_owned(), view); } pub(super) fn child_capabilities(&mut self, id: &str, generation: u64, can_steer: bool) { @@ -210,162 +118,23 @@ impl App { } } - // Epoch also rejects responses after switching away and back to the same child. - pub fn child_read_target(&self) -> Option<(String, u64, u64, u64)> { - let id = self.child_focus.as_ref()?; - let view = self.child_views.get(id)?; - view.read_enabled.then(|| { - ( - id.clone(), - view.generation, - self.clipboard_route_epoch, - view.cursor, - ) - }) - } - - pub fn child_read_finished( - &mut self, - target: &(String, u64, u64, u64), - result: Result, - ) { - if self.child_read_target().as_ref() != Some(target) { - return; - } - let (id, generation, _, cursor) = target; - let Some(view) = self.child_views.get_mut(id) else { - return; - }; - let response = match result { - Ok(response) => response, - Err(error) => { - view.read_enabled = false; - view.notice = format!("Transcript unavailable: {error}. Reopen child to retry."); - return; - } - }; - if response.generation != *generation { - view.read_enabled = false; - view.notice = "Transcript generation changed; reopen child to resync".into(); - return; - } - if response.next_cursor < *cursor - || (!response.updates.is_empty() && response.next_cursor == *cursor) - { - view.read_enabled = false; - view.notice = "Invalid transcript cursor; reopen child to resync".into(); - return; - } - // Empty pending-writer pages are valid even before the first record. - // Do not spin on them while the disk writer is starting or catching up. - view.read_backoff = response.caught_up || response.next_cursor == *cursor; - for value in response.updates { - if matches!( - value["sessionUpdate"].as_str(), - Some("kit_transcript_partial" | "kit_transcript_truncated") - ) { - view.partial = true; - let reason = value["reason"] - .as_str() - .map(str::trim) - .filter(|reason| !reason.is_empty()) - .unwrap_or("backend reported incomplete transcript history"); - let end = reason.floor_char_boundary(reason.len().min(MAX_PARTIAL_REASON_BYTES)); - let mut reason_text: String = reason[..end] - .chars() - .map(|character| { - if character.is_control() { - ' ' - } else { - character - } - }) - .collect(); - if end < reason.len() { - reason_text.push('…'); - } - if !view.partial_reasons.contains(&reason_text) { - if view.partial_reasons.len() < MAX_PARTIAL_REASONS { - view.partial_reasons.push(reason_text); - } else { - view.partial_reasons_omitted = true; - } - } - continue; - } - let observed_at = view.clock.observed_at(&value); - let Ok(update) = serde_json::from_value(value) else { - view.partial = true; - continue; - }; - // Session metadata is intentionally not transcript content. Other - // accepted wire variants that translate to nothing must not vanish - // silently (notably v2 terminal output and terminal lifecycle). - use agent_client_protocol::schema::v2::SessionUpdate; - let metadata = matches!( - &update, - SessionUpdate::SessionInfoUpdate(_) - | SessionUpdate::AvailableCommandsUpdate(_) - | SessionUpdate::ConfigOptionUpdate(_) - | SessionUpdate::UsageUpdate(_) - | SessionUpdate::StateUpdate(_) - ); - let (_, updates) = super::super::translate( - agent_client_protocol::schema::v2::UpdateSessionNotification::new( - id.clone(), - update, - ), - ); - if updates.is_empty() && !metadata { - view.partial = true; - } - for update in updates { - view.app.apply_at(update, observed_at); - } - } - view.cursor = response.next_cursor; - if response.caught_up { - view.loading = false; - if !view.active { - view.app.finish_replay_incomplete(); - } - if let Some((scroll, follow)) = view.restore_scroll.take() { - view.app.scroll = scroll; - view.app.follow = follow; - } - } - view.refresh_notice(); - } - pub(super) fn child_lifecycle(&mut self, id: &str, generation: u64, status: SubagentStatus) { let Some(view) = self.child_views.get(id) else { - if let Some(ui) = self.child_ui.get_mut(id) - && (generation > ui.generation - || (generation == ui.generation && status != SubagentStatus::Working)) - { - ui.steer_notice = None; - } return; }; if generation < view.generation { return; } - if generation != view.generation { - if self.agents.contains_key(id) { - self.focus_child(id.to_owned()); - } else { - if let Some(view) = self.child_views.get_mut(id) { - view.disable("Child removed; transcript is read-only"); - } - return; - } - } let Some(view) = self.child_views.get_mut(id) else { return; }; + if generation != view.generation { + view.generation = generation; + view.pending_text = None; + view.steer_notice = None; + view.can_steer = false; + } if view.active != (status == SubagentStatus::Working) { - // Reject an in-flight read sampled before this lifecycle boundary. - // A fresh read must drain the terminal tail before closing clocks. self.clipboard_route_epoch = self.clipboard_route_epoch.wrapping_add(1); view.steer_notice = None; } @@ -374,9 +143,9 @@ impl App { view.app.phase = Phase::Working; } else { view.can_steer = false; - // Lifecycle can precede the final spool page. Keep timing boundaries - // open until a subsequent caught-up read consumes that tail. - view.read_backoff = false; + if status == SubagentStatus::Removed { + view.app.finish_replay_incomplete(); + } } view.refresh_notice(); } @@ -392,15 +161,6 @@ impl App { Err(error) => format!("Steer result: {error}"), }; let Some(view) = self.child_views.get_mut(id) else { - if let Some(ui) = self.child_ui.get_mut(id) - && ui.generation == generation - && let Some(text) = ui.pending_text.take() - { - if result.is_ok() && ui.editor.text() == text { - ui.editor.clear(); - } - ui.steer_notice = Some(notice); - } return; }; if view.generation != generation { @@ -638,308 +398,10 @@ mod tests { })); } - fn page( - generation: u64, - next_cursor: u64, - caught_up: bool, - text: &str, - ) -> ReadSubagentTranscriptResponse { - ReadSubagentTranscriptResponse { - generation, - next_cursor, - caught_up, - updates: vec![ - serde_json::json!({"sessionUpdate": "agent_message_chunk", "messageId": "message", "content": {"type": "text", "text": text}}), - ], - } - } - - fn read(app: &mut App, text: &str, caught_up: bool) { - let target = app.child_read_target().unwrap(); - app.child_read_finished(&target, Ok(page(target.1, target.3 + 1, caught_up, text))); - } - fn key(app: &mut App, code: KeyCode) -> Action { app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) } - #[test] - fn replay_clock_preserves_offsets_and_unknown_observations() { - let instant = Instant::now(); - let clock = ReplayClock { - unix_ms: 10_000, - instant, - }; - let value = |ms| serde_json::json!({"kitObservedAtUnixMs": ms}); - assert_eq!( - clock.observed_at(&value(8_000)), - instant.checked_sub(Duration::from_secs(2)) - ); - assert_eq!( - clock.observed_at(&value(12_000)), - instant.checked_add(Duration::from_secs(2)) - ); - assert_eq!(clock.observed_at(&serde_json::json!({})), None); - assert_eq!( - clock.observed_at(&serde_json::json!({"kitObservedAtUnixMs": null})), - None - ); - } - - #[test] - fn completed_tool_duration_is_stable_across_late_load_and_reopen() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - let instant = Instant::now(); - for opened_at in [10_000, 30_000] { - app.focus_child("child".into()); - app.child_views.get_mut("child").unwrap().clock = ReplayClock { - unix_ms: opened_at, - instant, - }; - for (cursor, update) in [ - serde_json::json!({"sessionUpdate": "tool_call_update", "toolCallId": "tool", "title": "shell", "status": "in_progress", "kitObservedAtUnixMs": 2_000}), - serde_json::json!({"sessionUpdate": "tool_call_update", "toolCallId": "tool", "status": "completed", "kitObservedAtUnixMs": 5_000}), - ].into_iter().enumerate() { - let target = app.child_read_target().unwrap(); - app.child_read_finished(&target, Ok(ReadSubagentTranscriptResponse { - generation: 1, next_cursor: cursor as u64 + 1, caught_up: true, updates: vec![update], - })); - if cursor == 0 { - let call = app.child_views["child"].app.tool_call("tool").unwrap(); - assert!(call.running()); - assert_eq!(call.started, instant.checked_sub(Duration::from_millis(opened_at - 2_000))); - } - } - assert_eq!( - app.child_views["child"] - .app - .tool_call("tool") - .unwrap() - .elapsed(), - Some(3_000) - ); - app.leave_child(); - } - } - - #[test] - fn terminal_lifecycle_waits_for_tail_before_closing_reasoning() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.focus_child("child".into()); - app.child_views.get_mut("child").unwrap().clock = ReplayClock { - unix_ms: 10_000, - instant: Instant::now(), - }; - let thought = serde_json::json!({"sessionUpdate": "agent_thought_chunk", "messageId": "thought", "content": {"type": "text", "text": "thinking"}, "kitObservedAtUnixMs": 2_000}); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 1, - caught_up: true, - updates: vec![thought], - }), - ); - let stale_target = app.child_read_target().unwrap(); - lifecycle(&mut app, "child", 1, SubagentStatus::Idle); - assert_ne!(app.child_read_target().unwrap(), stale_target); - app.child_read_finished( - &stale_target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 1, - caught_up: true, - updates: vec![], - }), - ); - let target = app.child_read_target().unwrap(); - app.child_read_finished(&target, Ok(ReadSubagentTranscriptResponse { generation: 1, next_cursor: 2, caught_up: true, updates: vec![serde_json::json!({"sessionUpdate": "agent_message_chunk", "messageId": "answer", "content": {"type": "text", "text": "done"}, "kitObservedAtUnixMs": 5_000})] })); - assert!( - app.child_views["child"] - .app - .blocks - .iter() - .any(|block| matches!( - block, - Block::Thought { - closed: true, - millis: Some(3_000), - .. - } - )) - ); - } - - #[test] - fn inactive_child_without_terminal_records_does_not_keep_aging() { - for initially_active in [false, true] { - let mut app = app(); - lifecycle( - &mut app, - "child", - 1, - if initially_active { - SubagentStatus::Working - } else { - SubagentStatus::Idle - }, - ); - app.focus_child("child".into()); - app.child_views.get_mut("child").unwrap().clock = ReplayClock { - unix_ms: 10_000, - instant: Instant::now(), - }; - let target = app.child_read_target().unwrap(); - app.child_read_finished(&target, Ok(ReadSubagentTranscriptResponse { - generation: 1, next_cursor: 2, caught_up: true, updates: vec![ - serde_json::json!({"sessionUpdate": "tool_call_update", "toolCallId": "tool", "title": "shell", "status": "in_progress", "kitObservedAtUnixMs": 2_000}), - serde_json::json!({"sessionUpdate": "agent_thought_chunk", "messageId": "thought", "content": {"type": "text", "text": "thinking"}, "kitObservedAtUnixMs": 3_000}), - ], - })); - if initially_active { - lifecycle(&mut app, "child", 1, SubagentStatus::Idle); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 2, - caught_up: true, - updates: vec![], - }), - ); - } - let child = &app.child_views["child"].app; - let call = child.tool_call("tool").unwrap(); - assert!(call.running(), "missing outcome must not become success"); - assert_eq!(call.elapsed(), None); - assert!(child.blocks.iter().any(|block| matches!( - block, - Block::Thought { - closed: true, - millis: None, - .. - } - ))); - } - } - - #[test] - fn focus_navigation_preserves_root_roster_draft_and_scroll() { - let mut app = app(); - lifecycle(&mut app, "alpha", 1, SubagentStatus::Working); - lifecycle(&mut app, "beta", 1, SubagentStatus::Working); - assert!(app.child_views.is_empty()); - assert!(app.child_read_target().is_none()); - app.editor.insert_str("root draft"); - app.follow = false; - app.scroll = 7; - app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)); - key(&mut app, KeyCode::Down); - assert_eq!(app.agents_selected.as_deref(), Some("beta")); - key(&mut app, KeyCode::Up); - key(&mut app, KeyCode::Enter); - assert_eq!(app.child_focus.as_deref(), Some("alpha")); - assert!(app.child_views["alpha"].notice.contains("Loading")); - read(&mut app, "snapshot", false); - assert!(app.child_views["alpha"].notice.contains("Loading")); - read(&mut app, " live", true); - assert!(!app.child_views["alpha"].notice.contains("Loading")); - assert_eq!(app.child_read_target().unwrap().3, 2); - read(&mut app, " continued live", true); - assert_eq!(app.child_read_target().unwrap().3, 3); - assert_eq!(app.child_views["alpha"].app.blocks.len(), 1); - app.apply(Update::AgentMessage { - id: "root".into(), - text: "root runs".into(), - append: true, - }); - assert_eq!(app.blocks.len(), 1); - assert_eq!(app.agent_tree_rows().len(), 2); - key(&mut app, KeyCode::Esc); - assert!(app.child_views.is_empty()); - assert!(app.child_read_target().is_none()); - assert_eq!(app.editor.text(), "root draft"); - assert_eq!(app.scroll, 7); - assert!(!app.follow); - } - - #[test] - fn only_focus_retains_transcript_and_reopening_replays_without_size_cutoff() { - let mut app = app(); - for i in 0..32 { - let id = format!("child-{i}"); - lifecycle(&mut app, &id, 1, SubagentStatus::Working); - app.child_capabilities(&id, 1, true); - } - assert!(app.child_views.is_empty()); - app.focus_child("child-0".into()); - app.paste("small draft"); - read(&mut app, &"x".repeat(2 * 1024 * 1024 + 1), true); - assert!(!app.child_views["child-0"].app.blocks.is_empty()); - app.child_views.get_mut("child-0").unwrap().app.scroll = 3; - app.child_views.get_mut("child-0").unwrap().app.follow = false; - for i in 1..32 { - app.focus_child(format!("child-{i}")); - read(&mut app, "snapshot", true); - assert_eq!(app.child_views.len(), 1); - assert!(!app.child_views.contains_key("child-0")); - } - app.focus_child("child-0".into()); - assert!(app.child_views["child-0"].app.blocks.is_empty()); - assert_eq!(app.child_read_target().unwrap().3, 0); - assert_eq!(app.child_views["child-0"].app.editor.text(), "small draft"); - read(&mut app, "replayed", true); - assert_eq!(app.child_views["child-0"].app.scroll, 3); - assert!(!app.child_views["child-0"].app.follow); - } - - #[test] - fn stale_pages_are_rejected_across_focus_generation_and_session_changes() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.focus_child("child".into()); - let first = app.child_read_target().unwrap(); - app.leave_child(); - app.focus_child("child".into()); - app.child_read_finished(&first, Ok(page(1, 1, true, "stale"))); - assert!(app.child_views["child"].app.blocks.is_empty()); - let second = app.child_read_target().unwrap(); - lifecycle(&mut app, "child", 2, SubagentStatus::Working); - assert_eq!(app.child_read_target().unwrap().1, 2); - app.child_read_finished(&second, Ok(page(1, 1, true, "stale"))); - assert!(app.child_views["child"].app.blocks.is_empty()); - let third = app.child_read_target().unwrap(); - app.start_session("new-root".into()); - app.child_read_finished(&third, Ok(page(2, 1, true, "stale"))); - assert!(app.child_views.is_empty()); - assert!(app.child_ui.is_empty()); - } - - #[test] - fn errors_and_invalid_cursors_are_explicit_and_reopening_resyncs() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.focus_child("child".into()); - let target = app.child_read_target().unwrap(); - app.child_read_finished(&target, Err("disk read failed".into())); - assert!(app.child_views["child"].notice.contains("disk read failed")); - assert!(app.child_read_target().is_none()); - app.focus_child("child".into()); - let target = app.child_read_target().unwrap(); - app.child_read_finished(&target, Ok(page(1, 0, false, "invalid"))); - assert!(app.child_views["child"].notice.contains("cursor")); - assert!(app.child_views["child"].app.blocks.is_empty()); - app.focus_child("child".into()); - let target = app.child_read_target().unwrap(); - app.child_read_finished(&target, Ok(page(2, 1, true, "wrong generation"))); - assert!(app.child_views["child"].notice.contains("generation")); - assert!(app.child_views["child"].app.blocks.is_empty()); - } - #[test] fn steering_stays_generation_safe_even_when_focus_moves() { let mut app = app(); @@ -967,240 +429,6 @@ mod tests { assert!(app.blocks.is_empty()); } - #[test] - fn descendants_have_clear_unsupported_notice_and_no_reads() { - let mut app = app(); - lifecycle(&mut app, "nested", 1, SubagentStatus::Working); - app.agents.get_mut("nested").unwrap().parent_id = Some("parent".into()); - app.child_capabilities("nested", 1, true); - app.focus_child("nested".into()); - assert!(app.child_read_target().is_none()); - assert!(!app.child_views["nested"].can_steer); - assert!(app.child_views["nested"].notice.contains("descendant")); - } - - #[test] - fn roster_escape_and_back_button_release_the_focused_transcript() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.focus_child("child".into()); - read(&mut app, "snapshot", true); - app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)); - key(&mut app, KeyCode::Esc); - assert!(app.child_focus.is_none()); - assert!(app.child_views.is_empty()); - assert!(!app.agents_keyboard_focus); - assert!(app.agents_selected.is_none()); - app.focus_child("child".into()); - read(&mut app, "snapshot", true); - // Back must release roster focus even while keyboard navigation owns it. - app.agents_keyboard_focus = true; - app.child_back_area = Rect::new(0, 0, 20, 1); - app.handle_mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 1, - row: 0, - modifiers: KeyModifiers::NONE, - }); - assert!(app.child_focus.is_none()); - assert!(app.child_views.is_empty()); - assert!(!app.agents_keyboard_focus); - assert!(app.agents_selected.is_none()); - - app.focus_child("child".into()); - key(&mut app, KeyCode::Esc); - assert!(app.child_focus.is_none()); - assert!(!app.agents_keyboard_focus); - assert!(app.agents_selected.is_none()); - } - - #[test] - fn pending_writer_pages_keep_loading_and_back_off_until_records_arrive() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.focus_child("child".into()); - assert!(!app.child_views["child"].read_backoff); - // The spool path may not exist yet; subsequent reads can also wait for - // pending records without advancing the cursor. - for _ in 0..2 { - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 0, - caught_up: false, - updates: vec![], - }), - ); - assert_eq!(app.child_read_target().unwrap(), target); - assert!(app.child_views["child"].read_backoff); - assert!(app.child_views["child"].notice.contains("Loading")); - assert!(app.child_views["child"].app.blocks.is_empty()); - } - read(&mut app, "snapshot", false); - assert!(!app.child_views["child"].read_backoff); - assert!(app.child_views["child"].notice.contains("Loading")); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: target.3, - caught_up: false, - updates: vec![], - }), - ); - assert!(app.child_views["child"].read_backoff); - read(&mut app, " caught up", true); - assert!(app.child_views["child"].read_backoff); - assert!(!app.child_views["child"].notice.contains("Loading")); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: target.3, - caught_up: true, - updates: vec![], - }), - ); - assert!(app.child_views["child"].read_backoff); - read(&mut app, " live", false); - assert!(!app.child_views["child"].read_backoff); - assert_eq!(app.child_views["child"].app.blocks.len(), 1); - } - - #[test] - fn unchanged_nonempty_and_regressing_pages_are_rejected() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - for caught_up in [false, true] { - app.focus_child("child".into()); - let target = app.child_read_target().unwrap(); - app.child_read_finished(&target, Ok(page(1, 0, caught_up, "invalid"))); - assert!(app.child_read_target().is_none()); - assert!(app.child_views["child"].notice.contains("cursor")); - assert!(app.child_views["child"].app.blocks.is_empty()); - } - app.focus_child("child".into()); - read(&mut app, "snapshot", false); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 0, - caught_up: false, - updates: vec![], - }), - ); - assert!(app.child_read_target().is_none()); - assert!(app.child_views["child"].notice.contains("cursor")); - } - - #[test] - fn unsupported_terminal_content_marks_replay_partial_but_metadata_does_not() { - use agent_client_protocol::schema::v2::SessionUpdate; - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.focus_child("child".into()); - let metadata = - serde_json::json!({"sessionUpdate": "session_info_update", "title": "Child title"}); - assert!(matches!( - serde_json::from_value::(metadata.clone()).unwrap(), - SessionUpdate::SessionInfoUpdate(_) - )); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 1, - caught_up: true, - updates: vec![metadata], - }), - ); - assert!(!app.child_views["child"].notice.contains("Partial")); - let output = serde_json::json!({"sessionUpdate": "terminal_output_chunk", "terminalId": "shell", "data": "aGkK"}); - assert!(matches!( - serde_json::from_value::(output.clone()).unwrap(), - SessionUpdate::TerminalOutputChunk(_) - )); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 2, - caught_up: true, - updates: vec![output], - }), - ); - assert_eq!(app.child_read_target().unwrap().3, 2); - assert!( - app.child_views["child"] - .notice - .contains("Partial transcript") - ); - read(&mut app, "later supported text", true); - assert!( - app.child_views["child"] - .notice - .contains("Partial transcript") - ); - } - - #[test] - fn steer_results_survive_empty_pages_and_focus_switches_until_explicit_send() { - for result in [ - Ok(()), - Err("child rejected the steer".to_owned()), - Err("child steering timed out; delivery is unknown".to_owned()), - ] { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.child_capabilities("child", 1, true); - app.focus_child("child".into()); - read(&mut app, "snapshot", true); - app.paste("steer draft"); - key(&mut app, KeyCode::Enter); - app.child_steer_finished("child", 1, result.clone()); - let expected = match &result { - Ok(()) => "Steer accepted", - Err(error) => error.as_str(), - }; - for _ in 0..2 { - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: target.3, - caught_up: true, - updates: vec![], - }), - ); - assert!(app.child_views["child"].notice.contains(expected)); - } - app.leave_child(); - app.focus_child("child".into()); - assert!(app.child_views["child"].notice.contains(expected)); - read(&mut app, "replayed", true); - assert!(app.child_views["child"].notice.contains(expected)); - if result.is_err() { - assert_eq!(app.child_views["child"].app.editor.text(), "steer draft"); - } else { - app.paste("next steer"); - } - assert!(matches!( - key(&mut app, KeyCode::Enter), - Action::SteerChild { .. } - )); - assert!(!app.child_views["child"].notice.contains(expected)); - } - } - #[test] fn parked_steer_result_is_restored_with_draft_and_cleared_on_generation_change() { let mut app = app(); @@ -1226,129 +454,4 @@ mod tests { ); assert_eq!(app.child_views["child"].app.editor.text(), "draft"); } - - #[test] - fn backend_partial_reasons_remain_visible_with_sticky_steer_results_and_live_pages() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.child_capabilities("child", 1, true); - app.focus_child("child".into()); - app.paste("steer"); - key(&mut app, KeyCode::Enter); - app.child_steer_finished("child", 1, Err("delivery is unknown".into())); - let reasons = [ - "ACP does not identify its echo; reported messages may repeat the prompt", - "Inherited transcript before this fork is unavailable", - ]; - let target = app.child_read_target().unwrap(); - app.child_read_finished(&target, Ok(ReadSubagentTranscriptResponse { - generation: 1, next_cursor: 1, caught_up: false, - updates: reasons.iter().map(|reason| serde_json::json!({"sessionUpdate": "kit_transcript_partial", "reason": reason})).collect(), - })); - for reason in reasons { - assert!(app.child_views["child"].notice.contains(reason)); - } - assert!( - app.child_views["child"] - .notice - .contains("delivery is unknown") - ); - assert!(app.child_views["child"].notice.contains("Loading")); - read(&mut app, "supported live text", true); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: target.3, - caught_up: true, - updates: vec![], - }), - ); - for reason in reasons { - assert!(app.child_views["child"].notice.contains(reason)); - } - assert!( - app.child_views["child"] - .notice - .contains("delivery is unknown") - ); - assert!( - !app.child_views["child"] - .notice - .contains("child sent an unsupported update") - ); - } - - #[test] - fn oversized_record_omission_reason_survives_live_and_empty_pages() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.focus_child("child".into()); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 1, - caught_up: false, - updates: vec![serde_json::json!({ - "sessionUpdate": "kit_transcript_truncated", - "reason": "One inspection update exceeded 1 MiB and was omitted; later updates remain available" - })], - }), - ); - read(&mut app, "supported live text", true); - let target = app.child_read_target().unwrap(); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: target.3, - caught_up: true, - updates: vec![], - }), - ); - let notice = &app.child_views["child"].notice; - assert!(notice.contains("exceeded 1 MiB")); - assert!(notice.contains("later updates remain available")); - } - - #[test] - fn backend_partial_reasons_are_bounded_deduplicated_and_utf8_safe() { - let mut app = app(); - lifecycle(&mut app, "child", 1, SubagentStatus::Working); - app.focus_child("child".into()); - let target = app.child_read_target().unwrap(); - let mut updates = vec![ - serde_json::json!({"sessionUpdate": "kit_transcript_partial", "reason": "known limitation"}); - 2 - ]; - updates.extend((0..8).map(|index| serde_json::json!({"sessionUpdate": "kit_transcript_partial", "reason": format!("{index}\n{}", "界".repeat(600))}))); - app.child_read_finished( - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 1, - caught_up: true, - updates, - }), - ); - let view = &app.child_views["child"]; - assert_eq!(view.partial_reasons.len(), MAX_PARTIAL_REASONS); - assert_eq!( - view.partial_reasons - .iter() - .filter(|reason| *reason == "known limitation") - .count(), - 1 - ); - assert!( - view.partial_reasons - .iter() - .all(|reason| reason.len() <= MAX_PARTIAL_REASON_BYTES + '…'.len_utf8()) - ); - assert!(!view.notice.contains('\n')); - assert!(view.notice.contains("additional limitations omitted")); - } } diff --git a/src/tui/app/recovery.rs b/src/tui/app/recovery.rs index c491c8e2..58dca546 100644 --- a/src/tui/app/recovery.rs +++ b/src/tui/app/recovery.rs @@ -60,6 +60,7 @@ impl App { }); self.gateway_epoch = marker.epoch; self.gateway_blocked = true; + self.reset_child_associations(); self.elicitations.clear(); self.model_switch = None; self.model_dialog = None; diff --git a/src/tui/app/subagent_sessions.rs b/src/tui/app/subagent_sessions.rs new file mode 100644 index 00000000..2c3b4ec9 --- /dev/null +++ b/src/tui/app/subagent_sessions.rs @@ -0,0 +1,736 @@ +//! Announcements grant observation, never permission to prompt or steer. +use super::super::{UpdateSessionNotification, wire}; +use super::*; +use agent_client_protocol::schema::MaybeUndefined; + +pub(super) struct Association { + parent: String, + pub(super) snapshot: wire::SubagentUpdate, +} + +impl App { + pub(super) fn reset_child_associations(&mut self) { + self.child_sessions.clear(); + for row in self.agents.values_mut() { + row.focus_can_steer = false; + } + for view in self.child_views.values_mut() { + view.disable("Child connection unavailable; retained transcript is read-only"); + } + } + + pub(super) fn apply_subagent_session( + &mut self, + root: &str, + notification: UpdateSessionNotification, + ) { + let session = notification.session_id.to_string(); + if self.session_id.as_deref().is_some_and(|id| id != root) + || (session != root && !self.child_sessions.contains_key(&session)) + { + return; + } + if let wire::SessionUpdate::SubagentUpdate(update) = notification.update { + self.upsert_child(root, &session, update); + return; + } + if session == root { + self.apply_session_message(&session, notification.update); + return; + } + self.ensure_child_view(&session); + if let wire::SessionUpdate::StateUpdate(state) = ¬ification.update + && let Some(row) = self.agents.get_mut(&session) + { + row.status = work_status(Some(state)); + if row.status != SubagentStatus::Working { + row.focus_can_steer = false; + } + let (generation, status) = (row.generation, row.status); + self.child_lifecycle(&session, generation, status); + } + if let Some(row) = self.agents.get_mut(&session) { + match ¬ification.update { + wire::SessionUpdate::ToolCallUpdate(tool) => { + row.activity.apply(crate::events::SubagentActivity::Tool { + id: tool.tool_call_id.to_string(), + title: match &tool.title { + MaybeUndefined::Value(title) => Some(title.clone()), + MaybeUndefined::Null => Some(String::new()), + MaybeUndefined::Undefined => None, + }, + running: tool.status.value().map(|status| { + matches!( + status, + wire::ToolCallStatus::Pending | wire::ToolCallStatus::InProgress + ) + }), + }); + } + wire::SessionUpdate::PlanUpdate(plan) => { + if let wire::PlanUpdateContent::Items(items) = &plan.plan { + let entry = items + .entries + .iter() + .find(|entry| entry.status == wire::PlanEntryStatus::InProgress) + .or_else(|| { + items + .entries + .iter() + .find(|entry| entry.status == wire::PlanEntryStatus::Pending) + }) + .map(|entry| entry.content.clone()); + row.activity + .apply(crate::events::SubagentActivity::Plan { entry }); + } + } + _ => {} + } + } + let Some(view) = self.child_views.get_mut(&session) else { + return; + }; + match notification.update { + update @ (wire::SessionUpdate::SessionMessage(_) + | wire::SessionUpdate::SessionMessageChunk(_)) => { + view.app.apply_session_message(&session, update); + } + _ => { + for update in super::super::translate(notification).1 { + // Reporting child commands never authorizes child input. + if !matches!(update, Update::AvailableCommands { .. }) { + view.app.apply(update); + } + } + } + } + if let Some(row) = self.agents.get_mut(&session) { + row.usage = view.app.usage; + row.cost = view.app.cost.clone(); + } + } + + fn upsert_child(&mut self, root: &str, parent: &str, update: wire::SubagentUpdate) { + let id = update.session_id.to_string(); + // Prior announcement and immutable ownership prevent cycles. + if id.is_empty() + || id == root + || id == parent + || self + .child_sessions + .get(&id) + .is_some_and(|a| a.parent != parent) + { + return; + } + // Read the incoming patch, not retained metadata: a past grant must + // not revive after idle, reconnect, or a later generation boundary. + let steer_patch = match &update.meta { + MaybeUndefined::Undefined => None, + MaybeUndefined::Null => Some(None), + MaybeUndefined::Value(meta) => meta.get("kit/subagent/steer").map(|value| { + value + .get("generation") + .and_then(serde_json::Value::as_u64) + .map(|generation| { + ( + generation, + value + .get("canSteer") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + ) + }) + }), + }; + if let Some(Some((generation, _))) = steer_patch + && self + .agents + .get(&id) + .is_some_and(|row| generation < row.generation) + { + return; + } + let association = self + .child_sessions + .entry(id.clone()) + .or_insert_with(|| Association { + parent: parent.to_owned(), + snapshot: wire::SubagentUpdate::new(id.clone()), + }); + let state_changed = !update.state.is_undefined(); + let snapshot = &mut association.snapshot; + patch(&mut snapshot.title, update.title); + patch(&mut snapshot.description, update.description); + patch(&mut snapshot.capabilities, update.capabilities); + patch(&mut snapshot.state, update.state); + patch(&mut snapshot.cwd, update.cwd); + patch( + &mut snapshot.additional_directories, + update.additional_directories, + ); + patch_meta(&mut snapshot.meta, update.meta); + let state = snapshot.state.value().cloned(); + let row = self.agents.entry(id.clone()).or_insert_with(|| AgentRow { + id: id.clone(), + name: id.clone(), + status: SubagentStatus::Starting, + outcome: None, + generation: 0, + task: String::new(), + parent_id: (parent != root).then(|| parent.to_owned()), + parent_name: None, + harness: String::new(), + vendor: HarnessVendor::Unknown, + model: None, + created_at_unix_ms: 0, + generation_started_at_unix_ms: 0, + generation_finished_at_unix_ms: None, + usage: None, + cost: None, + activity: AgentActivity::default(), + focus_can_steer: false, + }); + row.name = snapshot + .title + .value() + .cloned() + .unwrap_or_else(|| id.clone()); + row.task = snapshot.description.value().cloned().unwrap_or_default(); + if state_changed { + row.status = work_status(state.as_ref()); + if row.status != SubagentStatus::Working { + row.focus_can_steer = false; + } + } + let mut generation_changed = false; + if let Some(Some((generation, _))) = steer_patch { + generation_changed = generation != row.generation; + if generation_changed { + row.generation = generation; + row.focus_can_steer = false; + } + } + // Standard cancel is never an explicit Kit steering grant. The exact + // Kit metadata key grants only the current direct child's generation. + let (generation, status) = (row.generation, row.status); + if state_changed || generation_changed { + self.child_lifecycle(&id, generation, status); + } + if let Some(grant) = steer_patch { + self.child_capabilities( + &id, + generation, + parent == root && grant.is_some_and(|(_, can_steer)| can_steer), + ); + } + if state_changed + && let Some(state) = state + && let Some(view) = self.child_views.get_mut(&id) + { + view.app.apply(Update::State(state)); + } + self.agents_visible = true; + } + + fn apply_session_message(&mut self, session: &str, update: wire::SessionUpdate) { + let id = match &update { + wire::SessionUpdate::SessionMessage(message) => message.message_id.clone(), + wire::SessionUpdate::SessionMessageChunk(chunk) => chunk.message_id.clone(), + _ => return, + }; + let message = self + .session_messages + .entry(id.to_string()) + .or_insert_with(|| wire::SessionMessage::new(id.clone())); + match update { + wire::SessionUpdate::SessionMessage(update) => { + patch(&mut message.sender_session_id, update.sender_session_id); + patch( + &mut message.recipient_session_id, + update.recipient_session_id, + ); + patch(&mut message.sender_message_id, update.sender_message_id); + patch( + &mut message.recipient_message_id, + update.recipient_message_id, + ); + patch(&mut message.content, update.content); + patch_meta(&mut message.meta, update.meta); + } + wire::SessionUpdate::SessionMessageChunk(chunk) => { + if !matches!(message.content, MaybeUndefined::Value(_)) { + message.content = MaybeUndefined::Value(Vec::new()); + } + if let MaybeUndefined::Value(content) = &mut message.content { + content.push(chunk.content); + } + } + _ => return, + } + let sender = message.sender_session_id.value().map(ToString::to_string); + let recipient = message + .recipient_session_id + .value() + .map(ToString::to_string); + let label = if sender.as_deref() == Some(session) { + format!("To {}", recipient.as_deref().unwrap_or("session")) + } else if let Some(sender) = sender { + format!("From {sender}") + } else { + "Session message".to_owned() + }; + let mut content = vec![wire::ContentBlock::Text(wire::TextContent::new(format!( + "{label}\n" + )))]; + if let Some(blocks) = message.content.value() { + content.extend(blocks.iter().cloned()); + } + let notification = UpdateSessionNotification::new( + session.to_owned(), + wire::SessionUpdate::AgentMessage(wire::AgentMessage::new(id).content(content)), + ); + for update in super::super::translate(notification).1 { + self.apply(update); + } + } +} + +fn patch(stored: &mut MaybeUndefined, incoming: MaybeUndefined) { + if !incoming.is_undefined() { + *stored = incoming; + } +} + +fn patch_meta(stored: &mut MaybeUndefined, incoming: MaybeUndefined) { + match incoming { + MaybeUndefined::Undefined => {} + MaybeUndefined::Null => *stored = MaybeUndefined::Null, + MaybeUndefined::Value(patch) => { + if !matches!(stored, MaybeUndefined::Value(_)) { + *stored = MaybeUndefined::Value(wire::Meta::new()); + } + if let MaybeUndefined::Value(meta) = stored { + for (key, value) in patch { + if value.is_null() { + meta.remove(&key); + } else { + meta.insert(key, value); + } + } + } + } + } +} + +fn work_status(state: Option<&wire::StateUpdate>) -> SubagentStatus { + match state { + Some(wire::StateUpdate::Running(_) | wire::StateUpdate::RequiresAction(_)) => { + SubagentStatus::Working + } + Some(wire::StateUpdate::Idle(_)) => SubagentStatus::Idle, + _ => SubagentStatus::Starting, + } +} +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::disallowed_macros, + clippy::disallowed_methods +)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn app() -> App { + let mut app = App::new( + PathBuf::from("."), + String::new(), + String::new(), + String::new(), + ); + app.start_session("root".into()); + app + } + fn send(app: &mut App, session: &str, update: Value) { + for update in crate::tui::subagent_sessions::translate( + serde_json::from_value(json!({"sessionId": session, "update": update})).unwrap(), + "root", + ) { + app.apply(update); + } + } + fn announce(app: &mut App, parent: &str, child: &str) { + let notification = UpdateSessionNotification::new( + parent.to_owned(), + wire::SessionUpdate::SubagentUpdate( + wire::SubagentUpdate::new(child.to_owned()) + .title("Worker") + .state(wire::StateUpdate::Running(wire::RunningStateUpdate::new())), + ), + ); + for update in crate::tui::subagent_sessions::translate(notification, "root") { + app.apply(update); + } + } + fn chunk(app: &mut App, session: &str, text: &str) { + send( + app, + session, + json!({"sessionUpdate": "agent_message_chunk", "messageId": "answer", "content": {"type": "text", "text": text}}), + ); + } + #[test] + fn announcements_are_required_and_ownership_cannot_change() { + let mut app = app(); + chunk(&mut app, "child", "ignored"); + assert!(app.child_views.is_empty()); + announce(&mut app, "root", "child"); + announce(&mut app, "child", "nested"); + announce(&mut app, "root", "nested"); + announce(&mut app, "nested", "root"); + assert_eq!(app.child_sessions["nested"].parent, "child"); + assert!(!app.child_sessions.contains_key("root")); + chunk(&mut app, "nested", "nested answer"); + assert!(!app.child_views["nested"].app.blocks.is_empty()); + assert!(app.blocks.is_empty()); + assert!(!app.child_views["nested"].can_steer); + } + #[test] + fn interleaved_child_transcripts_survive_focus_switches() { + let mut app = app(); + announce(&mut app, "root", "a"); + announce(&mut app, "root", "b"); + chunk(&mut app, "a", "one"); + app.focus_child("a".into()); + chunk(&mut app, "b", "two"); + assert_eq!(app.child_focus.as_deref(), Some("a")); + app.leave_child(); + chunk(&mut app, "a", "three"); + app.focus_child("a".into()); + assert_eq!(app.child_views.len(), 2); + assert_eq!(app.child_views["a"].app.blocks.len(), 1); + assert_eq!(app.child_views["b"].app.blocks.len(), 1); + assert!(app.blocks.is_empty()); + } + #[test] + fn patches_and_mirrored_state_do_not_recreate_the_child() { + let mut app = app(); + announce(&mut app, "root", "child"); + chunk(&mut app, "child", "answer"); + send( + &mut app, + "root", + json!({"sessionUpdate": "subagent_update", "sessionId": "child", "description": "role"}), + ); + assert_eq!(app.agents["child"].name, "Worker"); + send( + &mut app, + "root", + json!({"sessionUpdate": "subagent_update", "sessionId": "child", "title": null, "state": null}), + ); + assert_eq!(app.agents["child"].name, "child"); + assert_eq!(app.agents["child"].status, SubagentStatus::Starting); + send( + &mut app, + "child", + json!({"sessionUpdate":"state_update", "state":"idle"}), + ); + let blocks_after_idle = app.child_views["child"].app.blocks.len(); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "state":{"state":"idle"}}), + ); + assert_eq!(app.agents["child"].status, SubagentStatus::Idle); + // Ordinary idle rendering may append a stop notice. The mirrored + // association must neither repeat it nor replace the observed answer. + let child = &app.child_views["child"].app; + assert_eq!(child.blocks.len(), blocks_after_idle); + assert!(matches!(&child.blocks[0], Block::Agent(text) if text == "answer")); + } + #[test] + fn mirrored_idle_preserves_terminal_outcomes_in_either_order() { + for mirror_first in [false, true] { + let mut app = app(); + announce(&mut app, "root", "child"); + send( + &mut app, + "child", + json!({"sessionUpdate":"tool_call_update", "toolCallId":"tool", "title":"work", "status":"in_progress"}), + ); + let state = json!({"state":"idle", "stopReason":"cancelled"}); + let mirror = + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "state":state}); + let ordinary = + json!({"sessionUpdate":"state_update", "state":"idle", "stopReason":"cancelled"}); + if mirror_first { + send(&mut app, "root", mirror); + send(&mut app, "child", ordinary); + } else { + send(&mut app, "child", ordinary); + send(&mut app, "root", mirror); + } + let child = &app.child_views["child"].app; + assert!( + matches!(&child.blocks[0], Block::Tool(call) if call.status == ToolCallStatus::Failed) + ); + assert_eq!( + child + .blocks + .iter() + .filter( + |block| matches!(block, Block::Notice(text) if text == "turn interrupted") + ) + .count(), + 1 + ); + } + } + + #[test] + fn messages_allow_chunks_before_identity_and_metadata_only_patches() { + let mut app = app(); + send( + &mut app, + "root", + json!({"sessionUpdate":"session_message_chunk", "messageId":"m", "content":{"type":"text", "text":"hello"}}), + ); + send( + &mut app, + "root", + json!({"sessionUpdate":"session_message", "messageId":"m", "senderSessionId":"root", "recipientSessionId":"child"}), + ); + assert_eq!( + serde_json::to_value(&app.session_messages["m"]).unwrap()["content"][0]["text"], + "hello" + ); + assert_eq!(app.blocks.len(), 1); + send( + &mut app, + "root", + json!({"sessionUpdate":"session_message", "messageId":"m", "content":null}), + ); + assert!(matches!( + app.session_messages["m"].content, + MaybeUndefined::Null + )); + } + #[test] + fn reconnect_revokes_associations_and_live_steering() { + let mut app = app(); + announce(&mut app, "root", "child"); + chunk(&mut app, "child", "before"); + app.child_capabilities("child", 0, true); + assert!(app.child_views["child"].can_steer); + app.reset_child_associations(); + chunk(&mut app, "child", "must not append"); + assert!(app.child_sessions.is_empty()); + assert!(!app.agents["child"].focus_can_steer); + assert!(!app.child_views["child"].can_steer); + app.start_session("new".into()); + assert!(app.child_views.is_empty()); + } + #[test] + fn idle_revokes_steering_before_a_child_view_exists() { + let mut app = app(); + announce(&mut app, "root", "child"); + app.child_capabilities("child", 0, true); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "state":{"state":"idle"}}), + ); + announce(&mut app, "root", "child"); + app.focus_child("child".into()); + assert!(!app.child_views["child"].can_steer); + } + #[test] + fn background_child_creation_preserves_root_navigation() { + let mut app = app(); + announce(&mut app, "root", "child"); + app.agents_keyboard_focus = true; + app.agents_selected = Some("child".into()); + let epoch = app.clipboard_route_epoch; + chunk(&mut app, "child", "hello"); + assert!(app.child_focus.is_none()); + assert!(app.agents_keyboard_focus); + assert_eq!(app.agents_selected.as_deref(), Some("child")); + assert_eq!(app.clipboard_route_epoch, epoch); + } + + #[test] + fn native_association_patches_preserve_omission_and_clear_null() { + let mut app = app(); + announce(&mut app, "root", "child"); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "capabilities":{"cancel":{}}, "_meta":{"one":1,"two":2}}), + ); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "description":"role", "_meta":{"one":null,"three":3}}), + ); + let snapshot = &app.child_sessions["child"].snapshot; + assert!(snapshot.capabilities.value().is_some()); + assert_eq!(snapshot.title.value().map(String::as_str), Some("Worker")); + let meta = snapshot.meta.value().unwrap(); + assert!(!meta.contains_key("one")); + assert_eq!(meta["two"], 2); + assert_eq!(meta["three"], 3); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "capabilities":null, "_meta":null}), + ); + assert!(matches!( + app.child_sessions["child"].snapshot.capabilities, + MaybeUndefined::Null + )); + assert!(matches!( + app.child_sessions["child"].snapshot.meta, + MaybeUndefined::Null + )); + } + + #[test] + fn initial_mirrored_state_is_applied_when_focusing_later() { + let mut app = app(); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "state":{"state":"requires_action"}}), + ); + assert!(!app.child_views.contains_key("child")); + app.focus_child("child".into()); + assert!(app.child_views["child"].app.phase == Phase::Blocked); + } + + #[test] + fn mirrored_requires_action_survives_metadata_only_patches() { + let mut app = app(); + announce(&mut app, "root", "child"); + chunk(&mut app, "child", "hello"); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "state":{"state":"requires_action"}}), + ); + assert!(app.child_views["child"].app.phase == Phase::Blocked); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "title":"Renamed"}), + ); + assert!(app.child_views["child"].app.phase == Phase::Blocked); + } + + fn steer_grant(app: &mut App, parent: &str, child: &str, generation: u64, can_steer: bool) { + send( + app, + parent, + json!({"sessionUpdate":"subagent_update", "sessionId":child, + "_meta":{"kit/subagent/steer":{"generation":generation,"canSteer":can_steer}}}), + ); + } + + #[test] + fn kit_metadata_grants_only_the_current_generation_and_fences_pending_results() { + let mut app = app(); + announce(&mut app, "root", "child"); + app.focus_child("child".into()); + assert!(!app.child_views["child"].can_steer); + steer_grant(&mut app, "root", "child", 2, true); + assert_eq!(app.agents["child"].generation, 2); + assert_eq!(app.child_views["child"].generation, 2); + assert!(app.child_views["child"].can_steer); + steer_grant(&mut app, "root", "child", 1, false); + assert!(app.child_views["child"].can_steer); + app.paste("draft"); + assert!(matches!( + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Action::SteerChild { generation: 2, .. } + )); + steer_grant(&mut app, "root", "child", 3, false); + app.child_steer_finished("child", 2, Ok(())); + assert_eq!(app.child_views["child"].generation, 3); + assert_eq!(app.child_views["child"].app.editor.text(), "draft"); + assert!(!app.child_views["child"].can_steer); + steer_grant(&mut app, "root", "child", 2, true); + assert_eq!(app.agents["child"].generation, 3); + assert!(!app.child_views["child"].can_steer); + } + + #[test] + fn retained_kit_metadata_never_revives_an_idle_or_unknown_child_grant() { + for state in ["idle", "unknown"] { + let mut app = app(); + announce(&mut app, "root", "child"); + steer_grant(&mut app, "root", "child", 2, true); + app.focus_child("child".into()); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "state":{"state":state}}), + ); + assert!(!app.child_views["child"].can_steer); + announce(&mut app, "root", "child"); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "_meta":{"unrelated":true}}), + ); + assert!(!app.child_views["child"].can_steer); + steer_grant(&mut app, "root", "child", 2, true); + assert!(app.child_views["child"].can_steer); + } + } + + #[test] + fn cleared_or_malformed_kit_grants_revoke_and_nested_grants_are_denied() { + let mut app = app(); + announce(&mut app, "root", "child"); + app.focus_child("child".into()); + for meta in [ + json!(null), + json!({"kit/subagent/steer":null}), + json!({"kit/subagent/steer":{"generation":2}}), + json!({"kit/subagent/steer":{"generation":"2","canSteer":true}}), + ] { + steer_grant(&mut app, "root", "child", 2, true); + assert!(app.child_views["child"].can_steer); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "_meta":meta}), + ); + assert!(!app.child_views["child"].can_steer); + } + announce(&mut app, "child", "nested"); + steer_grant(&mut app, "child", "nested", 2, true); + app.focus_child("nested".into()); + assert!(!app.child_views["nested"].can_steer); + steer_grant(&mut app, "root", "child", 2, true); + app.reset_child_associations(); + announce(&mut app, "root", "child"); + app.focus_child("child".into()); + assert!(!app.child_views["child"].can_steer); + } + + #[test] + fn standard_capabilities_never_enable_kit_steering() { + let mut app = app(); + announce(&mut app, "root", "child"); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "capabilities":{"cancel":{}}}), + ); + app.focus_child("child".into()); + assert!(!app.child_views["child"].can_steer); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f7d3c573..b471b847 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -23,6 +23,7 @@ mod recovery; mod scheduler; mod source; mod startup; +mod subagent_sessions; pub use startup::pick_session; mod theme; @@ -1693,7 +1694,7 @@ async fn run_client( } let current = notification_session.lock().ok().map(|route| route.clone()); for update in current.as_ref().map_or_else(Vec::new, |route| { - translate_for_session(notification, &route.id) + subagent_sessions::translate(notification, &route.id) }) { let queued = if matches!(update, Update::AvailableCommands { .. }) { QueuedUpdate::global(update) @@ -2050,7 +2051,6 @@ async fn run_client( Voice(crate::voice::VoiceEvent), StorageShutdown, - ChildTranscript(Result), Terminal(Option>), ModelSwitch(ModelSwitchCompletion), Background(Option), @@ -2067,7 +2067,6 @@ async fn run_client( let mut pending_update = None; let mut clipboard_pastes = ClipboardPastes::default(); let mut submit_after_paste = false; - let mut child_read: Option = None; let mut usage: Option>> = None; let mut priority = scheduler::Priority::default(); let mut frames = scheduler::Frames::new(tokio::time::Instant::now()); @@ -2080,33 +2079,8 @@ async fn run_client( app.gateway_blocked = true; if let Some(pending) = clipboard_pastes.pending.as_mut() { pending.submit = false; } submit_after_paste = false; - child_read = None; app.child_focus = None; } - let target = app.child_read_target().map(|target| (session_id.to_string(), target)); - if child_read.as_ref().is_some_and(|read| Some(&read.target) != target.as_ref()) { - // Dropping the future cancels the local request and frees its page. - child_read = None; - } - if child_read.is_none() && let Some(target) = target { - let backoff = app.child_views[&target.1.0].read_backoff; - let request = crate::protocols::acp::ReadSubagentTranscriptRequest { - session_id: target.0.clone().into(), - id: target.1.0.clone(), generation: target.1.1, cursor: target.1.3, - }; - let connection = connection.clone(); - child_read = Some(ChildTranscriptRead { - target, - future: Box::pin(async move { - if backoff { tokio::time::sleep(Duration::from_millis(150)).await; } - match tokio::time::timeout(HANDSHAKE, connection.send_request(request).block_task()).await { - Ok(result) => result.map_err(|error| error.message.to_string()), - Err(_) => Err("child transcript read timed out".into()), - } - }), - }); - } - // Reconcile after every prior event, including failures in // result/observe, before accepting any next user input. voice.notify_state(&connection, &session_id); @@ -2143,7 +2117,7 @@ async fn run_client( } // Only background sources rotate. A ready paced frame // is rendered by its branch, after the input check. - let sources = 9; + let sources = 8; for offset in 0..sources { let branch = (next_priority + offset) % sources; let ready = match branch { @@ -2163,12 +2137,9 @@ async fn run_client( 4 if redraw => ticker.poll_tick(cx).map(|_| SessionEvent::Tick), 5 => voice.poll(cx).map(SessionEvent::Voice), - 8 => usage.as_mut().map_or(Poll::Pending, |task| { + 7 => usage.as_mut().map_or(Poll::Pending, |task| { std::pin::Pin::new(task).poll(cx).map(SessionEvent::Usage) }), - 7 => child_read.as_mut().map_or(Poll::Pending, |read| { - read.future.as_mut().poll(cx).map(SessionEvent::ChildTranscript) - }), 6 if frame_deadline.is_some() => frame.as_mut().poll(cx).map(|()| SessionEvent::Frame), _ => Poll::Pending, }; @@ -2198,13 +2169,6 @@ async fn run_client( Err(error) => app.note(format!("usage: {error}")), } } - SessionEvent::ChildTranscript(result) => { - if let Some(read) = child_read.take() - && read.target.0 == session_id.to_string() - && (!is_remote || recovery_available(&recovery_ingress, &transition_session, &app)) { - finish_child_transcript(&mut app, &mut frames, &read.target.1, result); - } - } SessionEvent::Voice(event) => match event { crate::voice::VoiceEvent::Ready => voice.mark_ready(&mut app), crate::voice::VoiceEvent::Transcript { speaker, text } => { @@ -3175,31 +3139,6 @@ fn osc52(text: &str) -> String { format!("\x1b]52;c;{}\x07", STANDARD.encode(text)) } -/// One cancellable page request, scoped to the root session and focus epoch. -struct ChildTranscriptRead { - target: (String, (String, u64, u64, u64)), - future: std::pin::Pin< - Box< - dyn Future< - Output = Result, - > + Send, - >, - >, -} - -/// Apply only the current focus epoch and schedule its visible page or notice. -fn finish_child_transcript( - app: &mut App, - frames: &mut scheduler::Frames, - target: &(String, u64, u64, u64), - result: Result, -) { - if app.child_read_target().as_ref() == Some(target) { - app.child_read_finished(target, result); - frames.invalidate(); - } -} - /// Child steering never enters the root prompt path. Its completion is scoped /// independently to the captured root route and the selected child generation. fn spawn_child_steer( @@ -5568,7 +5507,7 @@ mod tests { for reason in [ wire::StopReason::EndTurn, wire::StopReason::Cancelled, - wire::StopReason::Other("custom".into()), + wire::StopReason::Other(wire::OtherStopReason::new("custom", Default::default())), ] { let idle = UpdateSessionNotification::new( "session", @@ -9171,55 +9110,4 @@ mod scheduler_event_tests { assert!(frames.urgent()); assert!(frames.ready(now)); } - - #[test] - fn current_child_pages_and_errors_invalidate_but_stale_reads_do_not() { - use crate::events::{HarnessVendor, RuntimeEvent, SubagentStatus}; - use crate::protocols::acp::ReadSubagentTranscriptResponse; - let mut app = app(); - app.apply(Update::Runtime(RuntimeEvent::SubagentStateChanged { - id: "child".into(), - name: "child".into(), - status: SubagentStatus::Working, - outcome: None, - generation: 1, - task: "task".into(), - parent_id: None, - parent_name: None, - harness: "acp.kit".into(), - vendor: HarnessVendor::Kit, - model: None, - created_at_unix_ms: 1, - generation_started_at_unix_ms: 1, - generation_finished_at_unix_ms: None, - })); - app.focus_child("child".into()); - let target = app.child_read_target().unwrap(); - let now = tokio::time::Instant::now(); - let mut frames = scheduler::Frames::new(now); - frames.drawn(now); - finish_child_transcript( - &mut app, - &mut frames, - &target, - Ok(ReadSubagentTranscriptResponse { - generation: 1, - next_cursor: 1, - caught_up: true, - updates: vec![ - serde_json::json!({"sessionUpdate": "agent_message_chunk", "messageId": "message", "content": {"type": "text", "text": "child output"}}), - ], - }), - ); - assert!(frames.deadline().is_some()); - assert!(!frames.urgent()); - assert_eq!(app.child_read_target().unwrap().3, 1); - frames.drawn(now); - finish_child_transcript(&mut app, &mut frames, &target, Err("stale".into())); - assert_eq!(frames.deadline(), None); - let target = app.child_read_target().unwrap(); - finish_child_transcript(&mut app, &mut frames, &target, Err("read failed".into())); - assert!(frames.deadline().is_some()); - assert!(app.child_views["child"].notice.contains("read failed")); - } } diff --git a/src/tui/recovery.rs b/src/tui/recovery.rs index ef4fbb55..3f3218ee 100644 --- a/src/tui/recovery.rs +++ b/src/tui/recovery.rs @@ -1,5 +1,6 @@ //! Ephemeral bridge recovery protocol. Admission fences precede FIFO application. -use super::{ActiveSessionRoute, App, QueuedUpdate, Update, UpdateSessionNotification, translate}; +use super::UpdateSessionNotification; +use super::{ActiveSessionRoute, App, QueuedUpdate, Update}; use serde::Deserialize; use std::io::Write; use std::sync::{Arc, Mutex}; @@ -92,14 +93,26 @@ pub(super) fn notification( ingress: &Arc>, route: &Arc>, ) -> Option { - let session = notification.session_id.to_string(); + let addressed_session = notification.session_id.to_string(); let mut size = Size(0); let sized = serde_json::to_writer(&mut size, ¬ification).is_ok(); let marker = notification.meta.as_ref().and_then(|meta| meta.get(META)); let parsed = marker.and_then(|marker| serde_json::from_value::(marker.clone()).ok()); let mut ingress = ingress.lock().ok()?; let mut route = route.lock().ok()?; - if ingress.session.as_ref().is_some_and(|id| id != &session) { + // Recovery is scoped to the attached root, while child events retain + // their original address for event-loop association validation. Only root + // notifications may alter recovery state. + let session = ingress + .session + .clone() + .or_else(|| (!route.id.is_empty()).then(|| route.id.clone())) + .unwrap_or_else(|| addressed_session.clone()); + if addressed_session != session + && !parsed + .as_ref() + .is_some_and(|m| matches!(m.kind, Kind::Live | Kind::Replay)) + { return None; } let mut marker = match parsed.filter(|marker| marker.epoch > 0) { @@ -161,7 +174,7 @@ pub(super) fn notification( drop(route); drop(ingress); let updates = if matches!(marker.kind, Kind::Replay | Kind::Live) { - translate(notification).1 + super::subagent_sessions::translate(notification, &session) } else { Vec::new() }; @@ -417,4 +430,49 @@ mod tests { drop(completions); drop(workers); } + #[test] + fn child_events_share_root_recovery_epoch_without_controlling_it() { + let route = route(); + let ingress = Arc::new(Mutex::new(Ingress::for_session(Some("session".into())))); + let child = |kind, epoch| { + serde_json::json!({ + "sessionId": "child", "update": {"sessionUpdate":"agent_message_chunk", "messageId":"m", "content":{"type":"text", "text":"hello"}}, + "_meta": { META: {"kind": kind, "epoch": epoch} } + }) + }; + assert!( + notification( + serde_json::from_value(child("begin", 2)).unwrap(), + &ingress, + &route + ) + .is_none() + ); + assert!( + notification( + serde_json::from_value(child("live", 1)).unwrap(), + &ingress, + &route + ) + .is_some() + ); + notification(marked("begin", 2, state()), &ingress, &route).unwrap(); + assert!( + notification( + serde_json::from_value(child("live", 1)).unwrap(), + &ingress, + &route + ) + .is_none() + ); + let replay = notification( + serde_json::from_value(child("replay", 2)).unwrap(), + &ingress, + &route, + ) + .unwrap(); + assert!( + matches!(replay.update, Update::GatewayRecovery(Event { session, updates, .. }) if session == "session" && matches!(updates.as_slice(), [Update::SubagentSession { .. }])) + ); + } } diff --git a/src/tui/subagent_sessions.rs b/src/tui/subagent_sessions.rs new file mode 100644 index 00000000..deaf09e1 --- /dev/null +++ b/src/tui/subagent_sessions.rs @@ -0,0 +1,21 @@ +//! Native ACP session routing. Association ownership is event-loop local. +//! The callback clones the existing route under its mutex and never holds a +//! guard across send or await. +use super::{SessionUpdate, Update, UpdateSessionNotification}; + +pub(super) fn translate(notification: UpdateSessionNotification, root: &str) -> Vec { + if notification.session_id.to_string() != root + || matches!( + notification.update, + SessionUpdate::SubagentUpdate(_) + | SessionUpdate::SessionMessage(_) + | SessionUpdate::SessionMessageChunk(_) + ) + { + return vec![Update::SubagentSession { + root: root.to_owned(), + notification: Box::new(notification), + }]; + } + super::translate_for_session(notification, root) +} From 26185adf16012c82cfee95029aab48258e10a543 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 30 Oct 2026 15:55:48 +0000 Subject: [PATCH 2/6] fix(acp): preserve child lifecycle and message patch semantics --- macos/KitDesktop/Models/AppState.swift | 40 +++- .../Models/ConversationController.swift | 4 +- macos/KitDesktopTests/AppModelTests.swift | 93 +++++++++ src/acp_child/stream.rs | 152 +++++++++++++- src/protocols/acp/subagents.rs | 45 ++++- src/tui/app/subagent_sessions.rs | 190 +++++++++++++++++- 6 files changed, 500 insertions(+), 24 deletions(-) diff --git a/macos/KitDesktop/Models/AppState.swift b/macos/KitDesktop/Models/AppState.swift index 927b2a0c..315c620b 100644 --- a/macos/KitDesktop/Models/AppState.swift +++ b/macos/KitDesktop/Models/AppState.swift @@ -325,8 +325,44 @@ struct AgentRoster: Equatable { private var steerableGenerations: [String: UInt64] = [:] mutating func syncStreamed(_ source: AgentRoster) { - rowsByID = source.rowsByID - steerableGenerations = source.steerableGenerations + // The ACP stream owns associations and steering grants. Runtime events + // retain richer lifecycle metadata, including removal tombstones that + // must survive later ACP "unknown" state snapshots and content updates. + guard !source.rowsByID.isEmpty else { + rowsByID.removeAll() + steerableGenerations.removeAll() + usageByID.removeAll() + return + } + steerableGenerations.removeAll() + for (id, streamed) in source.rowsByID { + guard !cleanedIDs.contains(id) else { continue } + if let version = versions[id], + version.generation > streamed.generation + || (version.generation == streamed.generation && version.statusRank == SubagentStatus.removed.rank) { + continue + } + let runtime = rowsByID[id] + let sameGeneration = runtime?.generation == streamed.generation + let runtimeStatus = versions[id].flatMap { version -> SubagentStatus? in + guard version.generation == streamed.generation, version.statusRank > streamed.status.rank else { return nil } + return runtime?.status + } + let status = runtimeStatus ?? streamed.status + rowsByID[id] = AgentRosterRow( + id: id, name: streamed.name, status: status, + outcome: sameGeneration ? runtime?.outcome : nil, + generation: streamed.generation, task: streamed.task, + parentID: streamed.parentID, parentName: runtime?.parentID == streamed.parentID ? runtime?.parentName : nil, + harness: runtime?.harness ?? streamed.harness, model: runtime?.model ?? streamed.model, + createdAtMS: runtime?.createdAtMS ?? streamed.createdAtMS, + generationStartedAtMS: sameGeneration ? (runtime?.generationStartedAtMS ?? streamed.generationStartedAtMS) : streamed.generationStartedAtMS, + generationFinishedAtMS: sameGeneration ? runtime?.generationFinishedAtMS : streamed.generationFinishedAtMS + ) + if status == .working, streamed.parentID == nil, source.canSteer(id: id, generation: streamed.generation) { + steerableGenerations[id] = streamed.generation + } + } usageByID = usageByID.filter { rowsByID[$0.key] != nil } } diff --git a/macos/KitDesktop/Models/ConversationController.swift b/macos/KitDesktop/Models/ConversationController.swift index 904b1ccd..3fac1e80 100644 --- a/macos/KitDesktop/Models/ConversationController.swift +++ b/macos/KitDesktop/Models/ConversationController.swift @@ -145,6 +145,7 @@ final class ConversationController: ObservableObject { guard isReady, !shuttingDown, shouldPresentAgentRoster, let row = agentRoster.rowsByID[id], row.generation == generation else { return false } return row.status == .working && row.parentID == nil + && agentRoster.canSteer(id: id, generation: generation) && client.childSessions.roster.canSteer(id: id, generation: generation) } @@ -646,10 +647,9 @@ final class ConversationController: ObservableObject { return } guard runtimeTransportAvailable != false, runtimeSessionID == expectedRuntimeSessionID else { return } - if ["subagent_state_changed", "subagent_capabilities", "subagent_descendants_removed"].contains(kind), - !client.childSessions.roster.rowsByID.isEmpty { return } var roster = agentRoster if roster.apply(event: event, nowMS: Self.nowMilliseconds()) { + if !client.childSessions.roster.rowsByID.isEmpty { roster.syncStreamed(client.childSessions.roster) } agentRoster = roster scheduleRosterPrune() return diff --git a/macos/KitDesktopTests/AppModelTests.swift b/macos/KitDesktopTests/AppModelTests.swift index 74127aa6..1219ce53 100644 --- a/macos/KitDesktopTests/AppModelTests.swift +++ b/macos/KitDesktopTests/AppModelTests.swift @@ -328,6 +328,99 @@ final class AppModelTests: XCTestCase { } final class AgentRosterTests: XCTestCase { + @MainActor + func testStreamedSyncPreservesRuntimeMetadataAndSteeringAuthority() throws { + let stream = DesktopChildSessions() + stream.receive(root: "root", session: "root", update: streamed("child")) + var roster = AgentRoster() + roster.syncStreamed(stream.roster) + var working = event(id: "child", name: "Child", status: "working", created: 10, started: 20) + working["harness"] = "acp.claude" + working["model"] = "model-name" + XCTAssertTrue(roster.apply(event: working, nowMS: 30)) + roster.syncStreamed(stream.roster) + XCTAssertEqual(roster.rowsByID["child"]?.harness, "acp.claude") + XCTAssertEqual(roster.rowsByID["child"]?.model, "model-name") + XCTAssertEqual(roster.rowsByID["child"]?.createdAtMS, 10) + XCTAssertEqual(roster.rowsByID["child"]?.generationStartedAtMS, 20) + XCTAssertTrue(roster.canSteer(id: "child", generation: 1)) + + stream.receive(root: "root", session: "root", update: streamed("child", steer: false)) + _ = roster.apply(event: ["event": "subagent_capabilities", "id": "child", "generation": 1, "can_steer": true], nowMS: 40) + roster.syncStreamed(stream.roster) + XCTAssertFalse(roster.canSteer(id: "child", generation: 1)) + XCTAssertTrue(roster.apply(event: event(id: "child", name: "Child", status: "idle", outcome: "failed", finished: 50), nowMS: 50)) + roster.syncStreamed(stream.roster) + XCTAssertEqual(roster.rowsByID["child"]?.status, .idle) + XCTAssertEqual(roster.rowsByID["child"]?.outcome, .failed) + XCTAssertEqual(roster.rowsByID["child"]?.generationFinishedAtMS, 50) + XCTAssertFalse(roster.canSteer(id: "child", generation: 1)) + } + + @MainActor + func testStreamedSyncDoesNotResurrectRemovedOrExpiredFailedChildren() { + let stream = DesktopChildSessions() + stream.receive(root: "root", session: "root", update: streamed("done")) + stream.receive(root: "root", session: "root", update: streamed("failed")) + var roster = AgentRoster() + roster.syncStreamed(stream.roster) + _ = roster.apply(event: event(id: "done", name: "Done", status: "removed", outcome: "success", finished: 100), nowMS: 100) + _ = roster.apply(event: event(id: "failed", name: "Failed", status: "removed", outcome: "failed", finished: 100), nowMS: 100) + stream.receive(root: "root", session: "root", update: streamed("done", state: "unknown")) + stream.receive(root: "root", session: "root", update: streamed("failed", state: "unknown")) + roster.syncStreamed(stream.roster) + XCTAssertNil(roster.rowsByID["done"]) + XCTAssertEqual(roster.rowsByID["failed"]?.status, .removed) + XCTAssertEqual(roster.rowsByID["failed"]?.outcome, .failed) + XCTAssertEqual(roster.nextFailedRemovalExpiryMS, 4_100) + XCTAssertEqual(roster.counts.total, 0) + XCTAssertTrue(roster.pruneExpired(at: 4_100)) + roster.syncStreamed(stream.roster) + XCTAssertTrue(roster.rowsByID.isEmpty) + } + + @MainActor + func testStreamedDescendantsStayRemovedAfterSynchronization() { + let stream = DesktopChildSessions() + stream.receive(root: "root", session: "root", update: streamed("parent")) + stream.receive(root: "root", session: "parent", update: streamed("child")) + stream.receive(root: "root", session: "child", update: streamed("nested")) + var roster = AgentRoster() + roster.syncStreamed(stream.roster) + XCTAssertTrue(roster.apply(event: ["event": "subagent_descendants_removed", "ancestor_id": "parent"], nowMS: 100)) + roster.syncStreamed(stream.roster) + XCTAssertEqual(Set(roster.rowsByID.keys), ["parent"]) + XCTAssertFalse(roster.canSteer(id: "child", generation: 1)) + XCTAssertFalse(roster.apply(event: event(id: "nested", name: "Late", status: "working", generation: 99), nowMS: 101)) + roster.syncStreamed(stream.roster) + XCTAssertEqual(Set(roster.rowsByID.keys), ["parent"]) + } + + @MainActor + func testControllerMergesRuntimeLifecycleAfterStreamedAssociation() { + let client = ACPClient() + let controller = ConversationController(conversation: Conversation(workspaceID: UUID()), workspacePath: "/tmp", client: client) + controller.prepareRuntimeSession("root") + controller.applyRuntime(["event": "session_started", "session_id": "root"]) + client.childSessions.receive(root: "root", session: "root", update: streamed("child")) + var working = event(id: "child", name: "Child", status: "working") + working["model"] = "runtime-model" + controller.applyRuntime(working) + XCTAssertEqual(controller.agentRoster.rowsByID["child"]?.model, "runtime-model") + controller.applyRuntime(event(id: "child", name: "Child", status: "removed", outcome: "success", finished: 100)) + XCTAssertNil(controller.agentRoster.rowsByID["child"]) + client.childSessions.receive(root: "root", session: "root", update: streamed("child", state: "unknown")) + // Usage also synchronizes streamed observations without losing tombstones. + controller.applyRuntime(["event": "subagent_usage", "id": "child", "used": 1, "size": 100]) + XCTAssertNil(controller.agentRoster.rowsByID["child"]) + XCTAssertEqual(controller.agentRoster.counts.total, 0) + } + + private func streamed(_ id: String, state: String = "running", steer: Bool = true) -> [String: Any] { + ["sessionUpdate": "subagent_update", "sessionId": id, "title": id, "state": ["state": state], + "_meta": ["kit/subagent/steer": ["generation": 1, "canSteer": steer]]] + } + func testGenerationAwareLifecycleRejectsStaleAndDuplicateEvents() throws { var roster = AgentRoster() XCTAssertTrue(roster.apply(event: event(id: "a", name: "Scout", status: "starting", generation: 1), nowMS: 1_100)) diff --git a/src/acp_child/stream.rs b/src/acp_child/stream.rs index a462d6fc..a5fd3ac3 100644 --- a/src/acp_child/stream.rs +++ b/src/acp_child/stream.rs @@ -257,7 +257,22 @@ impl Stream { } } if serde_json::to_writer(Limit(FRAME_BYTES), &snapshot).is_ok() { + // V1 has no child-addressed state_update. Publish a minimal + // parent-addressed association patch as well as retaining it, + // before the raw v2 update. This is a known ownership edge. + let association_owner = state.nodes[position].0.clone(); + let mut patch = match serde_json::to_value(SessionUpdate::SubagentUpdate( + acp::SubagentUpdate::new(owner.to_owned()), + )) { + Ok(patch) => patch, + Err(_) => return, + }; + patch["state"] = snapshot["state"].clone(); + if let Some(steering) = state.steering.get(owner) { + patch["_meta"] = steering.meta(); + } state.nodes[position].1 = snapshot; + Self::publish(state, &association_owner, patch); } } // State snapshots are replayed from `nodes`; replaying an older @@ -614,11 +629,20 @@ impl Routes { message = message.recipient_session_id(acp::SessionId::from(parent)); } } - if !chunk - && let Ok(content) = - serde_json::from_value::>(update["content"].clone()) - { - message = message.content(content); + if !chunk { + match update.get("content") { + None => {} + Some(Value::Null) => { + message.content = agent_client_protocol::schema::MaybeUndefined::Null; + } + Some(content) => { + if let Ok(content) = + serde_json::from_value::>(content.clone()) + { + message = message.content(content); + } + } + } } if announce && let Ok(message) = @@ -1088,6 +1112,124 @@ mod tests { } } + #[test] + fn directed_message_views_preserve_omission_null_and_replacement_content() { + for kind in ["agent_message", "user_message"] { + let transcripts = super::super::transcript::Transcripts::default(); + transcripts + .stream + .announce("child", "worker", "task", SubagentStatus::Working, 1); + transcripts.start("child", 1); + let mut rx = transcripts.stream.subscribe("parent".into(), false); + rx.try_recv().unwrap(); + let routes = Routes::default(); + routes.register("remote", "child", 1, transcripts.get("child", 1).unwrap()); + let mut views: HashMap = HashMap::new(); + let updates = [ + ( + json!({"sessionUpdate":kind,"messageId":"m","content":[{"type":"text","text":"old"}]}), + "old", + ), + (json!({"sessionUpdate":kind,"messageId":"m"}), "old"), + ( + json!({"sessionUpdate":kind,"messageId":"m","content":null}), + "", + ), + ( + json!({"sessionUpdate":format!("{kind}_chunk"),"messageId":"m","content":{"type":"text","text":"new"}}), + "new", + ), + ( + json!({"sessionUpdate":kind,"messageId":"m","content":[{"type":"text","text":"replacement"}]}), + "replacement", + ), + ]; + for (native, expected) in updates { + let content = native.get("content").cloned(); + let chunk = native["sessionUpdate"] + .as_str() + .unwrap() + .ends_with("_chunk"); + routes.forward("remote", native); + while let Ok((owner, mut event)) = rx.try_recv() { + let decoded: acp::SessionUpdate = + serde_json::from_value(event.clone()).unwrap(); + match decoded { + acp::SessionUpdate::SessionMessage(_) => { + if !chunk { + assert_eq!(event.get("content"), content.as_ref()); + } + event["sessionUpdate"] = Value::String("agent_message".into()); + } + acp::SessionUpdate::SessionMessageChunk(_) => { + event["sessionUpdate"] = Value::String("agent_message_chunk".into()); + } + _ => panic!("expected a directed message"), + } + // Exercise the actual native text reducer for each view; + // only the envelope discriminator differs from its API. + let update = super::super::messages::parse(&event).unwrap().unwrap(); + views.entry(owner).or_default().record(update); + } + assert_eq!(views["parent"].clone().finish(), expected); + assert_eq!(views["child"].clone().finish(), expected); + } + } + } + + #[test] + fn live_descendant_state_has_parent_addressed_v1_association_patch() { + let stream = Stream::default(); + let mut rx = stream.subscribe("root".into(), false); + stream.announce("child", "worker", "task", SubagentStatus::Working, 1); + stream.forward("child", json!({"sessionUpdate":"subagent_update","sessionId":"grandchild","state":{"state":"running"}})); + stream.forward( + "grandchild", + json!({"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}), + ); + let mut associations = Vec::new(); + let mut raw_states = Vec::new(); + while let Ok((owner, update)) = rx.try_recv() { + if update["sessionUpdate"] == "state_update" { + assert_eq!( + associations.len(), + 3, + "ownership and state patch precede raw state" + ); + raw_states.push((owner, update)); + continue; + } + // Decode with the actual v1 SDK API. The server's private projection + // passes subagent_update through unchanged and drops raw state_update. + let notification: acp::SessionNotification = serde_json::from_value(json!({ + "sessionId":owner, "update":update, + })) + .unwrap(); + if let acp::SessionUpdate::SubagentUpdate(association) = notification.update { + associations.push((notification.session_id.to_string(), association)); + } + } + assert_eq!(associations.len(), 3); + assert_eq!(associations[0].0, "root"); + assert_eq!(associations[0].1.session_id.to_string(), "child"); + assert_eq!(associations[1].0, "child"); + assert_eq!(associations[1].1.session_id.to_string(), "grandchild"); + assert_eq!(associations[2].0, "child"); + assert_eq!(associations[2].1.session_id.to_string(), "grandchild"); + assert!(matches!( + associations[2].1.state, + agent_client_protocol::schema::MaybeUndefined::Value(acp::StateUpdate::Idle(_)) + )); + assert_eq!(raw_states.len(), 1); + assert_eq!(raw_states[0].0, "grandchild"); + let mut replay = stream.subscribe("root".into(), false); + replay.try_recv().unwrap(); + assert_eq!( + replay.try_recv().unwrap().1["state"], + json!({"state":"idle","stopReason":"end_turn"}) + ); + } + #[test] fn poisoned_ordering_is_isolated() { let stream = Stream::default(); diff --git a/src/protocols/acp/subagents.rs b/src/protocols/acp/subagents.rs index 863ca2c3..c911a1c7 100644 --- a/src/protocols/acp/subagents.rs +++ b/src/protocols/acp/subagents.rs @@ -11,6 +11,9 @@ use tokio::sync::broadcast; /// Foreground state is already folded into the parent association by runtime. /// Other v2-only terminal, plan-operation, and tool-content updates have no /// lossless v1 representation; omit those rather than terminate forwarding. +/// Reasoning chunks retain their native category. Whole-thought replacement +/// upserts cannot be represented losslessly in v1, so omit them instead of +/// exposing reasoning as an ordinary answer or duplicating streamed thoughts. pub(super) fn v1_update(mut update: Value) -> Option { let kind = update .get("sessionUpdate") @@ -22,11 +25,10 @@ pub(super) fn v1_update(mut update: Value) -> Option return None, - "user_message" | "agent_message" | "agent_thought" => Some("session_message"), - "user_message_chunk" | "agent_message_chunk" | "agent_thought_chunk" - if update.get("messageId").is_some() => - { + | "tool_call_content_chunk" + | "agent_thought" => return None, + "user_message" | "agent_message" => Some("session_message"), + "user_message_chunk" | "agent_message_chunk" if update.get("messageId").is_some() => { Some("session_message_chunk") } _ => None, @@ -155,6 +157,39 @@ mod tests { assert!(v1_update(json!({"sessionUpdate":"session_message_chunk"})).is_none()); } + #[test] + fn v1_projection_preserves_reasoning_chunks_and_omits_whole_thoughts() { + for message_id in [None, Some("thought-1")] { + let mut thought = json!({ + "sessionUpdate": "agent_thought_chunk", + "content": {"type": "text", "text": "reasoning"}, + }); + if let Some(message_id) = message_id { + thought["messageId"] = message_id.into(); + } + let projected = v1_update(thought.clone()).unwrap(); + assert!(matches!( + projected, + agentkit_acp::SessionUpdate::AgentThoughtChunk(_) + )); + assert_eq!(serde_json::to_value(projected).unwrap(), thought); + } + for content in [ + json!([{"type":"text", "text":"complete reasoning"}]), + Value::Null, + ] { + assert!( + v1_update(json!({ + "sessionUpdate": "agent_thought", "messageId": "thought-1", "content": content, + })) + .is_none() + ); + } + assert!( + v1_update(json!({"sessionUpdate":"agent_thought", "messageId":"thought-1"})).is_none() + ); + } + #[tokio::test] async fn unsupported_events_do_not_stop_either_version_stream() { let projections: [fn(Value) -> Option; 2] = [ diff --git a/src/tui/app/subagent_sessions.rs b/src/tui/app/subagent_sessions.rs index 2c3b4ec9..c352d658 100644 --- a/src/tui/app/subagent_sessions.rs +++ b/src/tui/app/subagent_sessions.rs @@ -39,7 +39,9 @@ impl App { return; } self.ensure_child_view(&session); - if let wire::SessionUpdate::StateUpdate(state) = ¬ification.update + let retired = self.child_roster_retired(&session); + if !retired + && let wire::SessionUpdate::StateUpdate(state) = ¬ification.update && let Some(row) = self.agents.get_mut(&session) { row.status = work_status(Some(state)); @@ -49,7 +51,7 @@ impl App { let (generation, status) = (row.generation, row.status); self.child_lifecycle(&session, generation, status); } - if let Some(row) = self.agents.get_mut(&session) { + if !retired && let Some(row) = self.agents.get_mut(&session) { match ¬ification.update { wire::SessionUpdate::ToolCallUpdate(tool) => { row.activity.apply(crate::events::SubagentActivity::Tool { @@ -98,18 +100,51 @@ impl App { _ => { for update in super::super::translate(notification).1 { // Reporting child commands never authorizes child input. - if !matches!(update, Update::AvailableCommands { .. }) { + if !matches!(update, Update::AvailableCommands { .. }) + && !(retired && matches!(update, Update::State(_))) + { view.app.apply(update); } } } } - if let Some(row) = self.agents.get_mut(&session) { + if !retired && let Some(row) = self.agents.get_mut(&session) { row.usage = view.app.usage; row.cost = view.app.cost.clone(); } } + /// Runtime retirement wins over independently delivered ACP activity. + /// Associations remain available for retained history, not roster revival. + fn child_roster_retired(&self, id: &str) -> bool { + if self.cleaned_agent_ids.contains(id) + || self + .agent_versions + .get(id) + .is_some_and(|(_, rank)| *rank == agent_status_rank(SubagentStatus::Removed)) + { + return true; + } + // Ownership is immutable and cycle-free. Walk retained associations so + // cleanup also covers descendants announced after the cleanup event. + let mut ancestor = self + .child_sessions + .get(id) + .map(|association| association.parent.as_str()); + while let Some(parent) = ancestor { + if self.cleaned_agent_ids.contains(parent) + || self.cleaned_agent_ancestors.contains(parent) + { + return true; + } + ancestor = self + .child_sessions + .get(parent) + .map(|association| association.parent.as_str()); + } + false + } + fn upsert_child(&mut self, root: &str, parent: &str, update: wire::SubagentUpdate) { let id = update.session_id.to_string(); // Prior announcement and immutable ownership prevent cycles. @@ -171,6 +206,14 @@ impl App { ); patch_meta(&mut snapshot.meta, update.meta); let state = snapshot.state.value().cloned(); + let title = snapshot.title.value().cloned(); + let description = snapshot.description.value().cloned(); + if self.child_roster_retired(&id) { + if let Some(view) = self.child_views.get_mut(&id) { + view.disable("Child removed; retained transcript is read-only"); + } + return; + } let row = self.agents.entry(id.clone()).or_insert_with(|| AgentRow { id: id.clone(), name: id.clone(), @@ -191,12 +234,8 @@ impl App { activity: AgentActivity::default(), focus_can_steer: false, }); - row.name = snapshot - .title - .value() - .cloned() - .unwrap_or_else(|| id.clone()); - row.task = snapshot.description.value().cloned().unwrap_or_default(); + row.name = title.unwrap_or_else(|| id.clone()); + row.task = description.unwrap_or_default(); if state_changed { row.status = work_status(state.as_ref()); if row.status != SubagentStatus::Working { @@ -721,6 +760,137 @@ mod tests { assert!(!app.child_views["child"].can_steer); } + fn runtime_state( + app: &mut App, + id: &str, + generation: u64, + status: SubagentStatus, + outcome: Option, + ) { + app.apply(Update::Runtime(RuntimeEvent::SessionStarted { + session_id: "root".into(), + })); + app.apply_runtime_at( + RuntimeEvent::SubagentStateChanged { + id: id.into(), + name: id.into(), + status, + outcome, + generation, + task: "task".into(), + parent_id: None, + parent_name: None, + harness: "acp.kit".into(), + vendor: HarnessVendor::Kit, + model: None, + created_at_unix_ms: 100, + generation_started_at_unix_ms: 100, + generation_finished_at_unix_ms: (status == SubagentStatus::Removed) + .then_some(10_000), + }, + 10_000, + ); + } + + #[test] + fn retirement_wins_in_either_stream_order_without_dropping_retained_history() { + for runtime_first in [false, true] { + let mut app = app(); + announce(&mut app, "root", "child"); + runtime_state(&mut app, "child", 1, SubagentStatus::Working, None); + chunk(&mut app, "child", "retained answer"); + let unknown = json!({"sessionUpdate":"subagent_update", "sessionId":"child", "state":{"state":"unknown"}}); + if runtime_first { + runtime_state(&mut app, "child", 1, SubagentStatus::Removed, None); + send(&mut app, "root", unknown); + } else { + send(&mut app, "root", unknown); + runtime_state(&mut app, "child", 1, SubagentStatus::Removed, None); + } + // Repeated removal is already fenced by the recorded terminal rank. + runtime_state(&mut app, "child", 1, SubagentStatus::Removed, None); + assert!(!app.agents.contains_key("child")); + assert!(app.child_sessions.contains_key("child")); + app.focus_child("child".into()); + assert_eq!(app.child_focus.as_deref(), Some("child")); + assert!( + matches!(&app.child_views["child"].app.blocks[0], Block::Agent(text) if text == "retained answer") + ); + assert!(!app.child_views["child"].can_steer); + // Late transcript content remains observable, without a roster row. + chunk(&mut app, "child", " tail"); + assert!(!app.agents.contains_key("child")); + assert!( + matches!(&app.child_views["child"].app.blocks[0], Block::Agent(text) if text == "retained answer tail") + ); + } + } + + #[test] + fn streamed_updates_do_not_overwrite_a_retained_failed_runtime_row() { + let mut app = app(); + announce(&mut app, "root", "child"); + runtime_state(&mut app, "child", 1, SubagentStatus::Working, None); + chunk(&mut app, "child", "answer"); + runtime_state( + &mut app, + "child", + 1, + SubagentStatus::Removed, + Some(GenerationOutcome::Failed), + ); + let failed = app.agents["child"].clone(); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "title":"late", "state":{"state":"unknown"}}), + ); + send( + &mut app, + "child", + json!({"sessionUpdate":"state_update", "state":"running"}), + ); + assert_eq!(app.agents["child"], failed); + assert!(app.child_views["child"].app.phase == Phase::Idle); + // Only a newer runtime lifecycle can clear the terminal version fence. + runtime_state(&mut app, "child", 2, SubagentStatus::Working, None); + announce(&mut app, "root", "child"); + assert_eq!(app.agents["child"].generation, 2); + assert_eq!(app.agents["child"].status, SubagentStatus::Working); + } + + #[test] + fn descendant_cleanup_fences_known_and_late_announced_descendants() { + for cleanup_first in [false, true] { + let mut app = app(); + announce(&mut app, "root", "parent"); + announce(&mut app, "parent", "nested"); + chunk(&mut app, "nested", "history"); + app.apply(Update::Runtime(RuntimeEvent::SessionStarted { + session_id: "root".into(), + })); + if cleanup_first { + app.apply(Update::Runtime(RuntimeEvent::SubagentDescendantsRemoved { + ancestor_id: "parent".into(), + })); + announce(&mut app, "parent", "nested"); + } else { + announce(&mut app, "parent", "nested"); + app.apply(Update::Runtime(RuntimeEvent::SubagentDescendantsRemoved { + ancestor_id: "parent".into(), + })); + } + announce(&mut app, "nested", "late-grandchild"); + announce(&mut app, "parent", "late-child"); + assert!(!app.agents.contains_key("nested")); + assert!(!app.agents.contains_key("late-grandchild")); + assert!(!app.agents.contains_key("late-child")); + assert!(app.agents.contains_key("parent")); + assert!(app.child_views.contains_key("nested")); + assert!(!app.child_views["nested"].can_steer); + } + } + #[test] fn standard_capabilities_never_enable_kit_steering() { let mut app = app(); From d9498948af7b7e1046c94719eda77caf36a29b2e Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 30 Oct 2026 16:17:15 +0000 Subject: [PATCH 3/6] fix(macos): fence late descendants after roster cleanup --- macos/KitDesktop/Models/AppState.swift | 44 ++++++++++++++++------- macos/KitDesktopTests/AppModelTests.swift | 38 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/macos/KitDesktop/Models/AppState.swift b/macos/KitDesktop/Models/AppState.swift index 315c620b..0614185e 100644 --- a/macos/KitDesktop/Models/AppState.swift +++ b/macos/KitDesktop/Models/AppState.swift @@ -334,6 +334,8 @@ struct AgentRoster: Equatable { usageByID.removeAll() return } + for (id, parentID) in source.parentsByID { parentsByID[id] = parentID } + removeCleanedDescendants() steerableGenerations.removeAll() for (id, streamed) in source.rowsByID { guard !cleanedIDs.contains(id) else { continue } @@ -367,6 +369,9 @@ struct AgentRoster: Equatable { } mutating func applyStreamed(row: AgentRosterRow, canSteer: Bool) { + if let parentID = row.parentID { parentsByID[row.id] = parentID } + removeCleanedDescendants() + guard !cleanedIDs.contains(row.id) else { return } rowsByID[row.id] = row if canSteer { steerableGenerations[row.id] = row.generation } else { steerableGenerations.removeValue(forKey: row.id) } @@ -386,6 +391,26 @@ struct AgentRoster: Equatable { } private var versions: [String: Version] = [:] private var cleanedIDs: Set = [] + // Keep ownership independently of visible rows: cleanup can precede the + // first announcement, and a removed parent can acquire late descendants. + private var parentsByID: [String: String] = [:] + private var cleanedAncestors: Set = [] + + private mutating func removeCleanedDescendants() { + guard !cleanedAncestors.isEmpty else { return } + while true { + let before = cleanedIDs.count + for (id, parentID) in parentsByID { + if cleanedAncestors.contains(parentID) || cleanedIDs.contains(parentID) { + cleanedIDs.insert(id) + } + } + if cleanedIDs.count == before { break } + } + rowsByID = rowsByID.filter { !cleanedIDs.contains($0.key) } + usageByID = usageByID.filter { !cleanedIDs.contains($0.key) } + steerableGenerations = steerableGenerations.filter { !cleanedIDs.contains($0.key) } + } var nextFailedRemovalExpiryMS: UInt64? { rowsByID.values.compactMap { row in @@ -443,7 +468,10 @@ struct AgentRoster: Equatable { mutating func apply(event: [String: Any], nowMS: UInt64) -> Bool { switch event["event"] as? String { case "subagent_state_changed": - guard let row = Self.parseRow(event), !cleanedIDs.contains(row.id) else { return false } + guard let row = Self.parseRow(event) else { return false } + if let parentID = row.parentID { parentsByID[row.id] = parentID } + removeCleanedDescendants() + guard !cleanedIDs.contains(row.id) else { return false } let incoming = Version(generation: row.generation, statusRank: row.status.rank) if let current = versions[row.id], incoming <= current { return false } versions[row.id] = incoming @@ -481,18 +509,8 @@ struct AgentRoster: Equatable { return true case "subagent_descendants_removed": guard let ancestorID = event["ancestor_id"] as? String else { return false } - var removed: Set = [] - while true { - let before = removed.count - for row in rowsByID.values where row.id != ancestorID { - if row.parentID == ancestorID || row.parentID.map(removed.contains) == true { removed.insert(row.id) } - } - if removed.count == before { break } - } - rowsByID = rowsByID.filter { !removed.contains($0.key) } - usageByID = usageByID.filter { !removed.contains($0.key) } - steerableGenerations = steerableGenerations.filter { !removed.contains($0.key) } - cleanedIDs.formUnion(removed) + cleanedAncestors.insert(ancestorID) + removeCleanedDescendants() return true default: return false } diff --git a/macos/KitDesktopTests/AppModelTests.swift b/macos/KitDesktopTests/AppModelTests.swift index 1219ce53..e0197520 100644 --- a/macos/KitDesktopTests/AppModelTests.swift +++ b/macos/KitDesktopTests/AppModelTests.swift @@ -392,8 +392,46 @@ final class AgentRosterTests: XCTestCase { XCTAssertEqual(Set(roster.rowsByID.keys), ["parent"]) XCTAssertFalse(roster.canSteer(id: "child", generation: 1)) XCTAssertFalse(roster.apply(event: event(id: "nested", name: "Late", status: "working", generation: 99), nowMS: 101)) + stream.receive(root: "root", session: "nested", update: streamed("unseen")) roster.syncStreamed(stream.roster) XCTAssertEqual(Set(roster.rowsByID.keys), ["parent"]) + XCTAssertFalse(roster.apply(event: event(id: "unseen", name: "Late", status: "working", generation: 99), nowMS: 102)) + } + + @MainActor + func testCleanupBeforeFirstAnnouncementFencesUnseenDescendants() { + let stream = DesktopChildSessions() + var roster = AgentRoster() + XCTAssertTrue(roster.apply(event: ["event": "subagent_descendants_removed", "ancestor_id": "parent"], nowMS: 100)) + stream.receive(root: "root", session: "root", update: streamed("parent")) + stream.receive(root: "root", session: "parent", update: streamed("child")) + stream.receive(root: "root", session: "child", update: streamed("grandchild")) + stream.receive(root: "root", session: "root", update: streamed("peer")) + roster.syncStreamed(stream.roster) + XCTAssertEqual(Set(roster.rowsByID.keys), ["parent", "peer"]) + XCTAssertFalse(roster.canSteer(id: "child", generation: 1)) + XCTAssertFalse(roster.apply(event: event(id: "grandchild", name: "Late", status: "working", generation: 99), nowMS: 101)) + + // Live roster cleanup must not discard retained child transcripts. + XCTAssertTrue(stream.receive(root: "root", session: "grandchild", update: [ + "sessionUpdate": "agent_message_chunk", "content": ["type": "text", "text": "late output"] + ])) + XCTAssertEqual(stream.history["grandchild"]?.count, 1) + roster.syncStreamed(stream.roster) + XCTAssertEqual(Set(roster.rowsByID.keys), ["parent", "peer"]) + } + + func testCleanupRetainsOwnershipAfterParentRowDisappears() { + var roster = AgentRoster() + _ = roster.apply(event: event(id: "child", name: "Child", status: "removed", outcome: "success", parentID: "parent", finished: 100), nowMS: 100) + XCTAssertNil(roster.rowsByID["child"]) + _ = roster.apply(event: ["event": "subagent_descendants_removed", "ancestor_id": "parent"], nowMS: 101) + XCTAssertFalse(roster.apply(event: event(id: "unseen", name: "Unseen", status: "working", parentID: "child"), nowMS: 102)) + roster.invalidateLiveState() + XCTAssertFalse(roster.apply(event: event(id: "later", name: "Later", status: "working", parentID: "unseen"), nowMS: 103)) + XCTAssertTrue(roster.rowsByID.isEmpty) + roster.reset() + XCTAssertTrue(roster.apply(event: event(id: "child", name: "Child", status: "working", parentID: "parent"), nowMS: 104)) } @MainActor From a77241eaa0c4c3ca9b563c199d11200e883b8c9b Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 30 Oct 2026 16:21:07 +0000 Subject: [PATCH 4/6] fix: preserve child exit status and roster visibility --- macos/KitDesktop/Models/AppState.swift | 8 ++----- macos/KitDesktopTests/AppModelTests.swift | 25 ++++++++++++++++++++ src/tui/app/subagent_sessions.rs | 28 ++++++++++++++++++++++- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/macos/KitDesktop/Models/AppState.swift b/macos/KitDesktop/Models/AppState.swift index 0614185e..e01bf400 100644 --- a/macos/KitDesktop/Models/AppState.swift +++ b/macos/KitDesktop/Models/AppState.swift @@ -328,12 +328,8 @@ struct AgentRoster: Equatable { // The ACP stream owns associations and steering grants. Runtime events // retain richer lifecycle metadata, including removal tombstones that // must survive later ACP "unknown" state snapshots and content updates. - guard !source.rowsByID.isEmpty else { - rowsByID.removeAll() - steerableGenerations.removeAll() - usageByID.removeAll() - return - } + // An empty stream can arrive before helper-exit retirement. Revoke + // grants below, but preserve lifecycle rows for failed-removal display. for (id, parentID) in source.parentsByID { parentsByID[id] = parentID } removeCleanedDescendants() steerableGenerations.removeAll() diff --git a/macos/KitDesktopTests/AppModelTests.swift b/macos/KitDesktopTests/AppModelTests.swift index e0197520..d139343a 100644 --- a/macos/KitDesktopTests/AppModelTests.swift +++ b/macos/KitDesktopTests/AppModelTests.swift @@ -501,6 +501,31 @@ final class AgentRosterTests: XCTestCase { XCTAssertNil(roster.rowsByID["grandchild"]) } + @MainActor + func testEmptyStreamBeforeHelperExitPreservesFailedRetirement() throws { + let stream = DesktopChildSessions() + stream.receive(root: "root", session: "root", update: streamed("child")) + var roster = AgentRoster() + roster.syncStreamed(stream.roster) + _ = roster.apply(event: event(id: "child", name: "Child", status: "working"), nowMS: 100) + XCTAssertTrue(roster.canSteer(id: "child", generation: 1)) + + // ACP exit clears streamed observations before retiring runtime rows. + stream.reset() + roster.syncStreamed(stream.roster) + XCTAssertFalse(roster.canSteer(id: "child", generation: 1)) + XCTAssertEqual(roster.rowsByID["child"]?.status, .working) + roster.retireActive(at: 200) + let retired = try XCTUnwrap(roster.rowsByID["child"]) + XCTAssertEqual(retired.status, .removed) + XCTAssertEqual(retired.outcome, .failed) + XCTAssertEqual(retired.generationFinishedAtMS, 200) + roster.syncStreamed(stream.roster) + XCTAssertEqual(roster.rowsByID["child"], retired) + roster.reset() + XCTAssertTrue(roster.rowsByID.isEmpty) + } + func testRemovalOutcomeAndRetirementMatchRosterSemantics() throws { var roster = AgentRoster() _ = roster.apply(event: event(id: "success", name: "Done", status: "idle", outcome: "success"), nowMS: 1_000) diff --git a/src/tui/app/subagent_sessions.rs b/src/tui/app/subagent_sessions.rs index c352d658..05fa8f39 100644 --- a/src/tui/app/subagent_sessions.rs +++ b/src/tui/app/subagent_sessions.rs @@ -269,7 +269,10 @@ impl App { { view.app.apply(Update::State(state)); } - self.agents_visible = true; + if !self.agents_auto_opened { + self.agents_visible = true; + self.agents_auto_opened = true; + } } fn apply_session_message(&mut self, session: &str, update: wire::SessionUpdate) { @@ -421,6 +424,29 @@ mod tests { json!({"sessionUpdate": "agent_message_chunk", "messageId": "answer", "content": {"type": "text", "text": text}}), ); } + #[test] + fn associations_auto_open_agents_only_once() { + let mut app = app(); + assert!(!app.show_agents()); + + announce(&mut app, "root", "child"); + assert!(app.show_agents()); + assert!(app.agents_auto_opened); + + app.toggle_agents(); + assert!(!app.show_agents()); + announce(&mut app, "root", "child"); + assert!(!app.show_agents()); + send( + &mut app, + "root", + json!({"sessionUpdate":"subagent_update", "sessionId":"child", "state":{"state":"idle"}}), + ); + assert!(!app.show_agents()); + announce(&mut app, "root", "another-child"); + assert!(!app.show_agents()); + } + #[test] fn announcements_are_required_and_ownership_cannot_change() { let mut app = app(); From 1a711565b0d700268f8b7c5a9606598a4c2beed2 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 30 Oct 2026 16:40:21 +0000 Subject: [PATCH 5/6] fix(macos): retain rejected child cleanup ancestry --- .../Models/ConversationController.swift | 5 ++++- macos/KitDesktopTests/AppModelTests.swift | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/macos/KitDesktop/Models/ConversationController.swift b/macos/KitDesktop/Models/ConversationController.swift index 3fac1e80..721aff0a 100644 --- a/macos/KitDesktop/Models/ConversationController.swift +++ b/macos/KitDesktop/Models/ConversationController.swift @@ -648,7 +648,10 @@ final class ConversationController: ObservableObject { } guard runtimeTransportAvailable != false, runtimeSessionID == expectedRuntimeSessionID else { return } var roster = agentRoster - if roster.apply(event: event, nowMS: Self.nowMilliseconds()) { + let applied = roster.apply(event: event, nowMS: Self.nowMilliseconds()) + // Rejected rows can still teach us ownership needed to fence later + // descendants. Persist that metadata even when no row was admitted. + if applied || roster != agentRoster { if !client.childSessions.roster.rowsByID.isEmpty { roster.syncStreamed(client.childSessions.roster) } agentRoster = roster scheduleRosterPrune() diff --git a/macos/KitDesktopTests/AppModelTests.swift b/macos/KitDesktopTests/AppModelTests.swift index d139343a..2dff86b2 100644 --- a/macos/KitDesktopTests/AppModelTests.swift +++ b/macos/KitDesktopTests/AppModelTests.swift @@ -434,6 +434,24 @@ final class AgentRosterTests: XCTestCase { XCTAssertTrue(roster.apply(event: event(id: "child", name: "Child", status: "working", parentID: "parent"), nowMS: 104)) } + @MainActor + func testControllerRetainsRejectedChildOwnershipForLateGrandchildren() { + let client = ACPClient() + let controller = ConversationController(conversation: Conversation(workspaceID: UUID()), workspacePath: "/tmp", client: client) + controller.prepareRuntimeSession("root") + controller.applyRuntime(["event": "session_started", "session_id": "root"]) + controller.applyRuntime(["event": "subagent_descendants_removed", "ancestor_id": "parent"]) + controller.applyRuntime(event(id: "child", name: "Child", status: "working", parentID: "parent")) + XCTAssertNil(controller.agentRoster.rowsByID["child"]) + controller.applyRuntime(event(id: "grandchild", name: "Grandchild", status: "working", parentID: "child")) + XCTAssertNil(controller.agentRoster.rowsByID["grandchild"]) + controller.applyRuntime(event(id: "great-grandchild", name: "Great-grandchild", status: "working", parentID: "grandchild")) + XCTAssertTrue(controller.agentRoster.rowsByID.isEmpty) + controller.applyRuntime(event(id: "peer", name: "Peer", status: "working")) + XCTAssertEqual(Set(controller.agentRoster.rowsByID.keys), ["peer"]) + XCTAssertTrue(client.childSessions.roster.rowsByID.isEmpty) + } + @MainActor func testControllerMergesRuntimeLifecycleAfterStreamedAssociation() { let client = ACPClient() From 554859a418b795f16a144a752ad9c84cfe525e73 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 30 Oct 2026 17:08:52 +0000 Subject: [PATCH 6/6] build(acp): use published registry dependencies Replace temporary Git patches with SDK/HTTP 3.6.0, schema 1.14.0 and AgentKit ACP 0.12.0. Retain the approved wasm .128 cohort and SDK-required libc 0.2.189 pin. Adapt released protocol APIs without changing bounded gateway or subagent semantics. --- Cargo.lock | 123 ++++++++++++++----------------- Cargo.toml | 22 ++---- src/gateway.rs | 10 +-- src/gateway/http_client.rs | 6 +- src/gateway/http_server.rs | 13 +++- src/protocols/acp.rs | 13 +++- src/protocols/acp/v2.rs | 53 ++++++++----- src/tui/app.rs | 21 +++--- src/tui/app/focus.rs | 2 + src/tui/app/subagent_sessions.rs | 91 ++++++++++++++++++++++- src/tui/mod.rs | 45 +++++++++-- src/tui/ui.rs | 6 +- tests/gateway.rs | 8 +- 13 files changed, 271 insertions(+), 142 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7f2374f..36945e3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,8 +82,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "2.2.0" -source = "git+https://github.com/danielkov/rust-sdk?rev=05d219b5cebc6980fec81ee64f62a4a655a47b84#05d219b5cebc6980fec81ee64f62a4a655a47b84" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2d255cb5d3afa4760084fbcaf87c7d214d316328546cd777d8736a8af6e368" dependencies = [ "quote", "syn 3.0.4", @@ -108,8 +109,9 @@ dependencies = [ [[package]] name = "agentkit-acp" -version = "0.11.0" -source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965127f11f8e852bfd60ef89204fac57f1de01b6510c9fc0fee691ea8bb9a73a" dependencies = [ "agentkit-acp-sdk", "agentkit-core", @@ -125,8 +127,9 @@ dependencies = [ [[package]] name = "agentkit-acp-http" -version = "2.2.0" -source = "git+https://github.com/danielkov/rust-sdk?rev=05d219b5cebc6980fec81ee64f62a4a655a47b84#05d219b5cebc6980fec81ee64f62a4a655a47b84" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e490f2e7a930daee151b41e7e10da01ea5ef9b2b3010dc6221790c4b545d0" dependencies = [ "agentkit-acp-sdk", "async-stream", @@ -146,8 +149,9 @@ dependencies = [ [[package]] name = "agentkit-acp-schema" -version = "1.9.1" -source = "git+https://github.com/danielkov/agent-client-protocol?rev=66d51e46f0835a0d0848e9e2746ef8672c75e343#66d51e46f0835a0d0848e9e2746ef8672c75e343" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fceebccf753866b268d7375a9ef4904ea358019fefb16438b0146c8a56d0f6" dependencies = [ "anyhow", "derive_more", @@ -156,30 +160,26 @@ dependencies = [ "serde_json", "serde_with", "strum", - "tracing", ] [[package]] name = "agentkit-acp-sdk" -version = "2.2.0" -source = "git+https://github.com/danielkov/rust-sdk?rev=05d219b5cebc6980fec81ee64f62a4a655a47b84#05d219b5cebc6980fec81ee64f62a4a655a47b84" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34a98bcf96dbdcae3eec8a1a9f44b1c1862f1739f7f6a0e2a23fa515f2af84d3" dependencies = [ "agent-client-protocol-derive", "agentkit-acp-schema", - "async-io", - "async-process", "blocking", "futures", "futures-concurrency", + "libc", "rustc-hash", - "rustix 1.1.4", - "schemars 1.2.2", "serde", "serde_json", - "shell-words", "tracing", "uuid", - "windows-sys 0.61.2", + "wasm-bindgen", ] [[package]] @@ -204,7 +204,8 @@ dependencies = [ [[package]] name = "agentkit-capabilities" version = "0.10.5" -source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193f4a771e30b61fbabecd90dba9a9bc16bdafc2cf717d086727871e9d0e3cf" dependencies = [ "agentkit-core", "async-trait", @@ -242,7 +243,8 @@ dependencies = [ [[package]] name = "agentkit-core" version = "0.10.5" -source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2110778950cdd87cc8ea3f0697c0551eb515ef23841b9c5d255a01974be17798" dependencies = [ "futures-timer", "serde", @@ -273,7 +275,8 @@ dependencies = [ [[package]] name = "agentkit-loop" version = "0.11.0" -source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e048d2be22aa27bf635b65b073cb2a396efca232200a4ba79b4f75d1513dcd" dependencies = [ "agentkit-core", "agentkit-task-manager", @@ -382,7 +385,8 @@ dependencies = [ [[package]] name = "agentkit-task-manager" version = "0.10.7" -source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f893be1f3d14f060e141e6b50baa09338778f90764cc030295105e101d29c06" dependencies = [ "agentkit-core", "agentkit-tools-core", @@ -425,7 +429,8 @@ dependencies = [ [[package]] name = "agentkit-tools-core" version = "0.10.5" -source = "git+https://github.com/danielkov/agentkit?rev=562b81be3ce37f82682f2172fdc67a5719ffbb2f#562b81be3ce37f82682f2172fdc67a5719ffbb2f" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b9a8af3f09c79fc0707ea87dc377edd40bdf65db11e6ebfb50783f4e8e8ff3" dependencies = [ "agentkit-capabilities", "agentkit-core", @@ -543,7 +548,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -554,7 +559,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -891,7 +896,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", - "axum-macros", "base64 0.22.1", "bytes", "form_urlencoded", @@ -940,17 +944,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -1984,7 +1977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2599,7 +2592,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -2994,9 +2987,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -3205,9 +3198,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.190" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -4226,7 +4219,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4702,7 +4695,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4761,7 +4754,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5166,12 +5159,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "2.0.1" @@ -5271,7 +5258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5475,7 +5462,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5981,7 +5968,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6145,9 +6132,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -6158,9 +6145,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -6168,9 +6155,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6178,22 +6165,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -6213,9 +6200,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -6287,7 +6274,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b326f7fe..44a7effb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,10 +37,10 @@ a2a-protocol-client = { version = "=0.14.1", default-features = false, features a2a-protocol-server = { version = "=0.14.1", default-features = false } a2a-protocol-types = "=0.14.1" agenthooksprotocol = { version = "=0.10.1", default-features = false, features = ["reqwest", "tokio-process"] } -agent-client-protocol = { package = "agentkit-acp-sdk", version = "=2.2.0", features = ["unstable_session_fork", "unstable_session_inject", "unstable_subagents"] } -agent-client-protocol-schema = { package = "agentkit-acp-schema", version = "=1.9.1", features = ["unstable_session_notices", "unstable_session_compaction"] } -agent-client-protocol-http = { package = "agentkit-acp-http", version = "=2.2.0", default-features = false, features = ["server", "client"] } -agentkit-acp = { version = "=0.11.0", features = ["unstable-inject"] } +agent-client-protocol = { package = "agentkit-acp-sdk", version = "=3.6.0", features = ["stdio", "unstable_session_fork", "unstable_session_inject", "unstable_subagents"] } +agent-client-protocol-schema = { package = "agentkit-acp-schema", version = "=1.14.0" } +agent-client-protocol-http = { package = "agentkit-acp-http", version = "=3.6.0", default-features = false, features = ["server", "client"] } +agentkit-acp = { version = "=0.12.0", features = ["unstable-inject"] } agentkit-adapter-completions = "=0.11.0" agentkit-core = "=0.10.5" agentkit-context = "=0.10.5" @@ -117,7 +117,7 @@ cubeb = { version = "=0.38.0", default-features = false, optional = true } cubeb-sys = { version = "=0.38.0", default-features = false, features = ["unittest-build"], optional = true } [target.'cfg(unix)'.dependencies] -libc = "=0.2.190" +libc = "=0.2.189" [target.'cfg(windows)'.dependencies] windows-sys = { version = "=0.61.2", features = [ @@ -142,15 +142,3 @@ todo = "deny" unimplemented = "deny" disallowed_methods = "deny" disallowed_macros = "deny" - -# Keep the ACP schema, SDK, HTTP transport, and agentkit types unified. -[patch.crates-io] -agentkit-acp-http = { git = "https://github.com/danielkov/rust-sdk", rev = "05d219b5cebc6980fec81ee64f62a4a655a47b84" } -agentkit-acp-sdk = { git = "https://github.com/danielkov/rust-sdk", rev = "05d219b5cebc6980fec81ee64f62a4a655a47b84" } -agentkit-acp-schema = { git = "https://github.com/danielkov/agent-client-protocol", rev = "66d51e46f0835a0d0848e9e2746ef8672c75e343" } -agentkit-acp = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } -agentkit-core = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } -agentkit-loop = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } -agentkit-tools-core = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } -agentkit-capabilities = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } -agentkit-task-manager = { git = "https://github.com/danielkov/agentkit", rev = "562b81be3ce37f82682f2172fdc67a5719ffbb2f" } diff --git a/src/gateway.rs b/src/gateway.rs index f87562a0..3011fc34 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -504,11 +504,11 @@ fn router(gateway: Arc) -> Result Result for Connection { async fn connect_to(self, client: impl ConnectTo) -> agent_client_protocol::Result<()> { let (channel, driver) = client.into_bounded_channel_and_future(ChannelLimits::default())?; let failure = channel.tx.failure(); - // Channel's driver can return immediately while its endpoint remains - // live. Drive both to completion, rather than treating that as EOF. - let running = std::pin::pin!(futures_util::future::try_join(self.run(channel), driver)); + // A direct channel has no driver. A transport driver can also finish + // while its endpoint remains live; neither case means channel EOF. + let drive = async move { + if let Some(driver) = driver { + driver.await + } else { + Ok(()) + } + }; + let running = std::pin::pin!(futures_util::future::try_join(self.run(channel), drive)); match select(failure, running).await { Either::Left((error, _)) => Err(error), Either::Right((result, _)) => result.map(|_| ()), diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index 80c95406..f0c9d566 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -3003,10 +3003,7 @@ pub(crate) fn http_router(runtime: Arc, registry: SessionRegistry) -> a .with_v2(v2::component(Arc::clone(&runtime), registry.clone())) })) }) - .with_options(agent_client_protocol_http::ServerOptions { - health_endpoint: false, - ..Default::default() - }) + .with_options(agent_client_protocol_http::ServerOptions::default().with_health_endpoint(false)) .into_router() } @@ -7371,6 +7368,10 @@ pub(super) mod tests { agent_client_protocol::on_receive_notification!(), ) .connect_with(client_transport, async move |connection| { + connection + .send_request(InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await?; connection .send_request(LoadSessionRequest::new( "missing-session", @@ -7784,6 +7785,10 @@ pub(super) mod tests { agent_client_protocol::on_receive_notification!(), ) .connect_with(client_transport, async move |connection| { + connection + .send_request(InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await?; let unsupported = vec![McpServer::Http(McpServerHttp::new("kithub", ""))]; // Missing executables remain discoverable connection failures; they // must not prevent attachment or leak into another session. diff --git a/src/protocols/acp/v2.rs b/src/protocols/acp/v2.rs index 45282490..4239bffa 100644 --- a/src/protocols/acp/v2.rs +++ b/src/protocols/acp/v2.rs @@ -117,7 +117,9 @@ fn available_commands_update( "Optional arguments", )) }); - wire::AvailableCommand::new(name, description).input(input) + wire::AvailableCommand::Command( + wire::Command::new(name, description).input(input), + ) }) .collect(), )), @@ -2751,11 +2753,11 @@ pub(crate) fn http_router(runtime: Arc, registry: SessionRegistry) -> a agent_client_protocol_http::AcpHttpServer::new(move || { v2_router(Arc::clone(&runtime), registry.clone()) }) - .with_options(agent_client_protocol_http::ServerOptions { - path: "/acp/v2".into(), - health_endpoint: false, - ..Default::default() - }) + .with_options( + agent_client_protocol_http::ServerOptions::default() + .with_path("/acp/v2") + .with_health_endpoint(false), + ) .into_router() } @@ -3141,11 +3143,9 @@ mod tests { wire::ProtocolVersion::V2, wire::Implementation::new("test-client", "0"), ) - .capabilities( - wire::ClientCapabilities::new().auth( - wire::AuthCapabilities::new().terminal(wire::TerminalAuthCapabilities::new()), - ), - ) + .capabilities(wire::ClientCapabilities::new().auth( + wire::ClientAuthCapabilities::new().terminal(wire::TerminalAuthCapabilities::new()), + )) } fn terminal_auth_method_ids(methods: &[wire::AuthMethod]) -> Vec<&str> { @@ -3316,7 +3316,8 @@ mod tests { }; assert_eq!(usage.used, 53_000); assert_eq!(usage.size, 272_000); - assert!(usage.cost.is_none()); + assert!(usage.cost.is_undefined()); + assert!(serde_json::to_value(usage).unwrap().get("cost").is_none()); } #[test] @@ -5397,17 +5398,28 @@ mod tests { update .available_commands .iter() - .map(|command| command.name.as_str()) + .map(wire::AvailableCommand::name) .collect::>(), ["compact", "review"] ); - assert!(update.available_commands[0].input.is_none()); - assert_eq!(update.available_commands[1].description, "Review changes"); - let Some(wire::AvailableCommandInput::Text(input)) = &update.available_commands[1].input - else { + let wire::AvailableCommand::Command(compact) = &update.available_commands[0] else { + panic!("expected native compact slash command"); + }; + let wire::AvailableCommand::Command(review) = &update.available_commands[1] else { + panic!("expected native review slash command"); + }; + assert!(compact.input.is_none()); + assert_eq!(review.description, "Review changes"); + let Some(wire::AvailableCommandInput::Text(input)) = &review.input else { panic!("expected text argument hint"); }; assert_eq!(input.hint, "Optional arguments"); + let encoded = serde_json::to_value(&update).unwrap(); + assert_eq!(encoded["availableCommands"][0]["type"], "command"); + assert_eq!(encoded["availableCommands"][0]["name"], "compact"); + assert!(encoded["availableCommands"][0].get("input").is_none()); + assert_eq!(encoded["availableCommands"][1]["type"], "command"); + assert_eq!(encoded["availableCommands"][1]["input"]["type"], "text"); } #[tokio::test] @@ -6591,7 +6603,7 @@ mod tests { .expect_err("replacement must reject an unknown session"); assert_eq!( i32::from(error.code), - i32::from(wire::Error::resource_not_found(None).code) + i32::from(wire::Error::resource_not_found().code) ); assert_eq!(error.data, Some(json!({ "sessionId": "missing-session" }))); Ok(()) @@ -7111,8 +7123,9 @@ for line in sys.stdin: )) .unwrap(); assert!(unsupported.auth_methods.is_empty()); - let terminal_capabilities = wire::ClientCapabilities::new() - .auth(wire::AuthCapabilities::new().terminal(wire::TerminalAuthCapabilities::new())); + let terminal_capabilities = wire::ClientCapabilities::new().auth( + wire::ClientAuthCapabilities::new().terminal(wire::TerminalAuthCapabilities::new()), + ); assert!(terminal_auth_methods(&terminal_capabilities, |_| false).is_empty()); let methods = terminal_auth_methods(&terminal_capabilities, |provider| { provider != ProviderKind::OpenRouter diff --git a/src/tui/app.rs b/src/tui/app.rs index b7112486..38917ce4 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -14,6 +14,7 @@ use std::ffi::OsStr; #[cfg(any(target_os = "macos", target_os = "linux"))] use std::process::{Command, Stdio}; +use agent_client_protocol::schema::MaybeUndefined; use agent_client_protocol::schema::v2::{ AuthMethodTerminal, StateUpdate, StopReason, ToolCallStatus, ToolKind, }; @@ -160,7 +161,7 @@ pub enum Update { Usage { used: u64, size: u64, - cost: Option, + cost: MaybeUndefined, }, /// The authoritative ACP v2 foreground lifecycle, preserved from the wire. State(StateUpdate), @@ -2656,8 +2657,10 @@ impl App { } } Update::Usage { used, size, cost } => { - if let Some(cost) = cost.filter(valid_cost) { - self.cost = Some(cost); + match cost { + MaybeUndefined::Value(cost) if valid_cost(&cost) => self.cost = Some(cost), + MaybeUndefined::Null => self.cost = None, + _ => {} } self.usage = Some(ContextUsage { used, size }); } @@ -10246,22 +10249,22 @@ mod tests { #[test] fn session_cost_reports_replace_totals_and_reset_with_session() { - use agent_client_protocol::schema::v2::Cost; + use agent_client_protocol::schema::{MaybeUndefined, v2::Cost}; let mut app = app(); assert!(app.cost.is_none()); for amount in [0.0, 1.25, 1.25, 2.0] { app.apply(Update::Usage { used: 1, size: 2, - cost: Some(Cost::new(amount, "USD")), + cost: MaybeUndefined::Value(Cost::new(amount, "USD")), }); assert_eq!(app.cost.as_ref().unwrap().amount, amount); } for cost in [ - None, - Some(Cost::new(f64::NAN, "USD")), - Some(Cost::new(-1.0, "USD")), - Some(Cost::new(3.0, "bad")), + MaybeUndefined::Undefined, + MaybeUndefined::Value(Cost::new(f64::NAN, "USD")), + MaybeUndefined::Value(Cost::new(-1.0, "USD")), + MaybeUndefined::Value(Cost::new(3.0, "bad")), ] { app.apply(Update::Usage { used: 2, diff --git a/src/tui/app/focus.rs b/src/tui/app/focus.rs index 547e6b44..4db9c3cc 100644 --- a/src/tui/app/focus.rs +++ b/src/tui/app/focus.rs @@ -79,6 +79,8 @@ impl App { String::new(), ); app.show_thoughts = self.show_thoughts; + app.cost = row.cost.clone(); + app.usage = row.usage; app.phase = if active { Phase::Working } else { Phase::Idle }; if let Some(state) = self .child_sessions diff --git a/src/tui/app/subagent_sessions.rs b/src/tui/app/subagent_sessions.rs index 05fa8f39..16fac1ee 100644 --- a/src/tui/app/subagent_sessions.rs +++ b/src/tui/app/subagent_sessions.rs @@ -39,6 +39,7 @@ impl App { return; } self.ensure_child_view(&session); + let usage_update = matches!(notification.update, wire::SessionUpdate::UsageUpdate(_)); let retired = self.child_roster_retired(&session); if !retired && let wire::SessionUpdate::StateUpdate(state) = ¬ification.update @@ -92,6 +93,11 @@ impl App { let Some(view) = self.child_views.get_mut(&session) else { return; }; + if usage_update && let Some(row) = self.agents.get(&session) { + // Runtime diagnostics may have reported a newer prior cost than + // this view. An omitted native patch must preserve that value too. + view.app.cost = row.cost.clone(); + } match notification.update { update @ (wire::SessionUpdate::SessionMessage(_) | wire::SessionUpdate::SessionMessageChunk(_)) => { @@ -108,9 +114,17 @@ impl App { } } } - if !retired && let Some(row) = self.agents.get_mut(&session) { + if !retired + && usage_update + && let Some(row) = self.agents.get_mut(&session) + { row.usage = view.app.usage; row.cost = view.app.cost.clone(); + if let Some(cost) = &row.cost { + self.agent_costs.insert(session, cost.clone()); + } else { + self.agent_costs.remove(&session); + } } } @@ -917,6 +931,81 @@ mod tests { } } + #[test] + fn native_usage_cost_patches_preserve_replace_and_clear_root_and_child_totals() { + for session in ["root", "child"] { + let mut app = app(); + announce(&mut app, "root", "child"); + for (patch, expected) in [ + (Some(json!({"amount":1.25,"currency":"USD"})), Some(1.25)), + (None, Some(1.25)), + (Some(json!({"amount":2.0,"currency":"USD"})), Some(2.0)), + (Some(json!(null)), None), + (None, None), + (Some(json!({"amount":0.0,"currency":"USD"})), Some(0.0)), + (Some(json!({"amount":-1.0,"currency":"USD"})), Some(0.0)), + ] { + let mut update = json!({"sessionUpdate":"usage_update","used":7,"size":100}); + if let Some(cost) = patch { + update["cost"] = cost; + } + send(&mut app, session, update); + let view = if session == "root" { + &app + } else { + &app.child_views[session].app + }; + assert_eq!(view.cost.as_ref().map(|cost| cost.amount), expected); + assert_eq!(view.usage, Some(ContextUsage { used: 7, size: 100 })); + if session == "child" { + assert_eq!( + app.agents[session].cost.as_ref().map(|cost| cost.amount), + expected + ); + assert_eq!(app.subagent_cost_totals().get("USD").copied(), expected); + } + } + } + } + + #[test] + fn omitted_child_cost_preserves_runtime_report_before_or_after_view_creation() { + for view_first in [false, true] { + let mut app = app(); + announce(&mut app, "root", "child"); + if view_first { + chunk(&mut app, "child", "before"); + } + runtime_state(&mut app, "child", 1, SubagentStatus::Working, None); + app.apply(Update::Runtime(RuntimeEvent::SubagentUsage { + id: "child".into(), + used: 1, + size: 10, + cost: Some(wire::Cost::new(3.0, "USD")), + })); + chunk(&mut app, "child", "after"); + assert_eq!(app.agents["child"].cost.as_ref().unwrap().amount, 3.0); + send( + &mut app, + "child", + json!({"sessionUpdate":"usage_update","used":2,"size":10}), + ); + assert_eq!( + app.child_views["child"].app.cost.as_ref().unwrap().amount, + 3.0 + ); + assert_eq!(app.agents["child"].cost.as_ref().unwrap().amount, 3.0); + send( + &mut app, + "child", + json!({"sessionUpdate":"usage_update","used":3,"size":10,"cost":null}), + ); + assert!(app.agents["child"].cost.is_none()); + assert!(app.child_views["child"].app.cost.is_none()); + assert!(app.subagent_cost_totals().is_empty()); + } + } + #[test] fn standard_capabilities_never_enable_kit_steering() { let mut app = app(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index b471b847..2e5d747d 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -892,8 +892,9 @@ fn client_capabilities(credential_storage: &CredentialStorage) -> wire::ClientCa wire::ElicitationCapabilities::new().form(wire::ElicitationFormCapabilities::new()), ); if credential_storage.is_persistent() { - capabilities - .auth(wire::AuthCapabilities::new().terminal(wire::TerminalAuthCapabilities::new())) + capabilities.auth( + wire::ClientAuthCapabilities::new().terminal(wire::TerminalAuthCapabilities::new()), + ) } else { capabilities } @@ -4206,7 +4207,7 @@ fn translate(notification: UpdateSessionNotification) -> (String, Vec) { commands: update .available_commands .into_iter() - .map(|available| command::Command::new(available.name, available.description)) + .map(|available| command::Command::new(available.name(), available.description())) .collect(), }], SessionUpdate::ConfigOptionUpdate(update) => { @@ -4621,6 +4622,7 @@ mod tests { sync::{Arc, Mutex}, }; + use agent_client_protocol::schema::MaybeUndefined; use agent_client_protocol::schema::v2::{ AgentMessage, AgentThought, AuthMethod, AuthMethodTerminal, AvailableCommand, AvailableCommandsUpdate, ContentBlock, EnvVariable, IdleStateUpdate, RunningStateUpdate, @@ -5437,7 +5439,40 @@ mod tests { )); assert_eq!(session, "session"); assert!( - matches!(updates.as_slice(), [Update::Usage { used: 1, size: 2, cost: Some(actual) }] if actual == &cost) + matches!(updates.as_slice(), [Update::Usage { used: 1, size: 2, cost: MaybeUndefined::Value(actual) }] if actual == &cost) + ); + } + + #[test] + fn translates_usage_cost_omission_and_null_as_distinct_patches() { + use agent_client_protocol::schema::v2::UsageUpdate; + for cost in [MaybeUndefined::Undefined, MaybeUndefined::Null] { + let (_, updates) = translate(UpdateSessionNotification::new( + "session", + SessionUpdate::UsageUpdate(UsageUpdate::new(1, 2).cost(cost.clone())), + )); + assert!( + matches!(updates.as_slice(), [Update::Usage { cost: actual, .. }] if actual == &cost) + ); + } + } + + #[test] + fn translates_future_available_command_metadata_through_native_accessors() { + let (_, updates) = translate(UpdateSessionNotification::new( + "session", + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(vec![ + AvailableCommand::Other(wire::OtherAvailableCommand::new( + "_custom", + "inspect", + "Inspect context", + Default::default(), + )), + ])), + )); + assert!( + matches!(updates.as_slice(), [Update::AvailableCommands { commands, .. }] + if commands.as_slice() == [command::Command::new("inspect", "Inspect context")]) ); } @@ -5446,7 +5481,7 @@ mod tests { let (_, updates) = translate(UpdateSessionNotification::new( "session", SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(vec![ - AvailableCommand::new("compact", "Compact context"), + AvailableCommand::Command(wire::Command::new("compact", "Compact context")), ])), )); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 201496cc..e86db538 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -6532,7 +6532,7 @@ mod tests { app.apply(Update::Usage { used: 1, size: 2, - cost: Some(Cost::new(0.0, "USD")), + cost: agent_client_protocol::schema::MaybeUndefined::Value(Cost::new(0.0, "USD")), }); assert!( render(&mut app, 160, 24) @@ -6544,7 +6544,7 @@ mod tests { app.apply(Update::Usage { used: 1, size: 2, - cost: Some(Cost::new(1.2345, "EUR")), + cost: agent_client_protocol::schema::MaybeUndefined::Value(Cost::new(1.2345, "EUR")), }); assert!( render(&mut app, 160, 24) @@ -6570,7 +6570,7 @@ mod tests { app.apply(Update::Usage { used: 1_360, size: 272_000, - cost: None, + cost: agent_client_protocol::schema::MaybeUndefined::Undefined, }); app.session_id = Some("s-1770000000000-12345-0".into()); diff --git a/tests/gateway.rs b/tests/gateway.rs index 2b6fe993..524e8788 100644 --- a/tests/gateway.rs +++ b/tests/gateway.rs @@ -169,12 +169,11 @@ impl SdkClient { .default_headers(headers) .build() .unwrap(); - let transport = - AcpHttpClient::with_endpoint_and_client(format!("{url}/acp/v2"), http).unwrap(); + let transport = AcpHttpClient::from_http_client(format!("{url}/acp/v2"), http).unwrap(); let (channel, future) = transport.into_channel_and_future(); let mut client = Self { channel, - transport: tokio::spawn(future), + transport: tokio::spawn(future.expect("HTTP transport requires a connection driver")), updates: Vec::new(), }; let response = client.request(1, "initialize", json!({"protocolVersion":2,"info":{"name":"standard-sdk-test","version":"0"},"capabilities":{}})).await; @@ -1627,6 +1626,9 @@ async fn sdk_transport_loss_preserves_running_prompt_and_replays() { .await; assert!(response.get("error").is_none(), "{response}"); assert_replayed_answer(&resumed.updates, "Completed after HTTP transport loss"); + // Durable output can precede the actor's terminal state. Resume preserves + // that still-running work; wait for its lifecycle boundary before prompting. + resumed.wait_idle().await; resumed .start_prompt(3, id, "Healthy after transport loss") .await;