native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690import Foundationimport GhosttyKitimport Testing
@testable import Prowl
/// The terminal owner's half of OSC 7501 (docs-ai 079 slice 2): the record store per/// surface, the push path through `publishDecision`, the interruptible detection loop,/// the delegated tick, the command-finished release, and the undo-close feed. The/// process probe is replaced by a fake foreground job built around this test process so/// the generation fence sees a real start time.@MainActor@Suite(.serialized)struct WorktreeTerminalStateProgramStatusTests { private struct Fixture { let state: WorktreeTerminalState let tabID: TerminalTabID let surface: GhosttySurfaceView var surfaceID: UUID { surface.id } }
private final class Probe { var job: ForegroundJob? var calls: [Bool] = [] /// When set, the next probe call suspends here until resumed. var gate: CheckedContinuation<Void, Never>? var entered: AsyncStream<Void>.Continuation? }
private let epoch = Date(timeIntervalSince1970: 1_000)
/// A second real process (its start time is readable, unlike launchd's, which is /// the test host's parent) to stand in for an engine child or a successor launch. private func spawnSleeper() throws -> Process { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sleep") process.arguments = ["30"] try process.run() return process }
/// A real process that is not a descendant of the test host (double fork), so the /// launcher-ancestry check sees a genuinely different launch. private func spawnDetachedSleeper() throws -> pid_t { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") process.arguments = ["-c", "sleep 30 >/dev/null 2>&1 & echo $!"] let output = Pipe() process.standardOutput = output try process.run() process.waitUntilExit() let text = try #require(String(bytes: output.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)) return try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))) }
private func claudeJob(pid: pid_t) -> ForegroundJob { ForegroundJob( processGroupID: pid, processes: [ForegroundProcess(pid: pid, parentProcessID: 1, name: "claude", argv0: "claude", cmdline: "claude")]) }
private func report( _ state: GhosttyProgramStatusReport.State, id: String = "", app: String? = "claude-code", kind: GhosttyProgramStatusReport.Kind? = nil ) -> GhosttyProgramStatusReport { GhosttyProgramStatusReport(state: state, kind: kind, id: id, app: app) }
/// A foreground job whose only member is this test process, named like the agent. private func claudeJob(launchPID: pid_t? = nil) -> ForegroundJob { let pid = getpid() var processes = [ ForegroundProcess( pid: pid, parentProcessID: launchPID ?? getppid(), name: "claude", argv0: "claude", cmdline: "claude") ] if let launchPID { processes.insert( ForegroundProcess(pid: launchPID, parentProcessID: 1, name: "node", argv0: "node", cmdline: "node"), at: 0) } return ForegroundJob(processGroupID: pid, processes: processes) }
/// `startsLoop: false` parks a placeholder task so a wake never starts the real /// detection loop; the tests drive `detectAgentState` themselves. private func makeFixture( support: ProgramStatusSupport = .verified, probe: Probe, startsLoop: Bool = false ) -> Fixture { let state = WorktreeTerminalState( runtime: GhosttyRuntime(), worktree: Worktree( id: "/tmp/repo/worktree", name: "worktree", detail: "", workingDirectory: URL(fileURLWithPath: "/tmp/repo/worktree"), repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") ), skipsSurfaceCreationForTesting: true ) state.programStatusSupportForTesting = { _ in support } state.agentProcessProbeForTesting = { _, _, fresh in probe.calls.append(fresh) if let entered = probe.entered { probe.entered = nil await withCheckedContinuation { continuation in probe.gate = continuation entered.yield(()) } } return probe.job } state.lastWindowIsKey = false state.lastWindowIsVisible = false let tabID = state.tabManager.createTab(title: "worktree 1", icon: "terminal") let surface = GhosttySurfaceView( runtime: state.runtime, workingDirectory: URL(fileURLWithPath: "/tmp/repo/worktree", isDirectory: true), fontSize: nil, context: GHOSTTY_SURFACE_CONTEXT_TAB, skipsSurfaceCreationForTesting: true ) state.configureBridgeCallbacks(for: surface, tabId: tabID) state.configureSurfaceCallbacks(for: surface, tabId: tabID) state.surfaces[surface.id] = surface state.trees[tabID] = SplitTree<GhosttySurfaceView>(view: surface) state.focusedSurfaceIdByTab[tabID] = surface.id // `wakeAgentDetection` seeds this before the loop's first poll in production. state.surfaceAgentStates[surface.id] = PaneAgentState(lastChangedAt: epoch) if !startsLoop { state.agentDetectionTasks[surface.id] = Task {} } return Fixture(state: state, tabID: tabID, surface: surface) }
private func noSession( _: IdentifiedAgentProcess?, _: PaneAgentState, _: URL?, _: String, _: (surfaceID: UUID, configRoot: URL?) ) -> (session: AgentSession?, missStreak: Int) { (nil, 0) }
/// One poll with the fake probe; seeds an idle screen scan so the legacy decision is definite. private func detect(_ fixture: Fixture) async -> Bool { await fixture.state.detectAgentState(for: fixture.surface, tabId: fixture.tabID, resolveSession: noSession) }
private func bind(_ fixture: Fixture, probe: Probe) async { probe.job = claudeJob() fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] = WorktreeTerminalState.AgentScreenScan( agent: .claude, text: "", detection: AgentScreenDetection(state: .idle, reason: .legacyDetector)) #expect(await detect(fixture)) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) }
// MARK: Store ownership and the push path
@Test func reportBeforeAnyAgentIsStoredAndWakesDetection() { let probe = Probe() let fixture = makeFixture(probe: probe, startsLoop: true) #expect(fixture.state.agentDetectionTasks[fixture.surfaceID] == nil)
fixture.surface.bridge.onProgramStatus?(report(.idle, app: "pi"))
#expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID]?.root?.state == .idle) #expect(fixture.state.agentDetectionTasks[fixture.surfaceID] != nil) #expect(fixture.state.agentDetectionSchedules[fixture.surfaceID]?.nextInterval(now: Date()) != nil) fixture.state.cleanupAllAgentDetectionState() }
@Test func verifiedPushPublishesInTheSameTurn() async { let probe = Probe() let fixture = makeFixture(probe: probe) var emitted: [ActiveAgentEntry] = [] fixture.state.onAgentEntryChanged = { emitted.append($0) } await bind(fixture, probe: probe) emitted.removeAll()
fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID)
let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] #expect(pane?.state == .working) #expect(pane?.decision?.reason.identifier == "osc.working") #expect(pane?.decision?.screenReason == .delegated) #expect(fixture.state.tabAgentBusyById[fixture.tabID] == true) #expect(emitted.map(\.displayState) == [.working]) // Pid, session, and launch metadata are merged, not replaced. #expect(pane?.agentProcessID == getpid()) }
@Test func doneAndErrorEarnTheBadgeButIdleDoesNot() async { for (state, badge) in [(GhosttyProgramStatusReport.State.done, true), (.error, true), (.idle, false)] { let probe = Probe() let fixture = makeFixture(probe: probe) await bind(fixture, probe: probe) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID)
fixture.state.handleProgramStatus(report(state), surfaceID: fixture.surfaceID)
let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] #expect(pane?.state == .idle) #expect(pane?.displayState == (badge ? .done : .idle), "\(state)") } }
@Test func unverifiedPushChangesNothingPublished() async { let probe = Probe() let fixture = makeFixture(support: .unverified, probe: probe) await bind(fixture, probe: probe) let before = fixture.state.surfaceAgentStates[fixture.surfaceID]
fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID)
#expect(fixture.state.surfaceAgentStates[fixture.surfaceID] == before) #expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID]?.root?.state == .working) }
@Test func withdrawalPublishesNothingUntilAnObserveThatStartedAfterIt() async { let probe = Probe() let fixture = makeFixture(probe: probe) await bind(fixture, probe: probe) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID)
fixture.state.handleProgramStatus(report(.clear), surfaceID: fixture.surfaceID)
// The pane still shows the last published decision; the probe was woken instead. #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.identifier == "osc.working") #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.decision?.reason.isProgramStatus == false) #expect(fixture.state.agentDetectionWakeRequests.contains(fixture.surfaceID))
#expect(await detect(fixture)) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.isProgramStatus == false) }
private let seededScan = WorktreeTerminalState.AgentScreenScan( agent: .claude, text: "seeded frame", detection: AgentScreenDetection(state: .idle, reason: .legacyDetector))
/// Binds, takes OSC authority, and puts the loop on the delegated schedule as the /// loop's tick would have. private func delegate(_ fixture: Fixture, probe: Probe) async { await bind(fixture, probe: probe) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.isDelegated == true) fixture.state.agentDetectionSchedules[fixture.surfaceID] = .delegated fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] = seededScan }
@Test func delegatedTickSkipsTheScreenRead() async { let probe = Probe() let fixture = makeFixture(probe: probe) await delegate(fixture, probe: probe)
#expect(await detect(fixture))
// An active tick would have replaced the memo with the (empty) live frame. #expect(fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] == seededScan) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.identifier == "osc.working") #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.fallbackState == .idle) #expect(fixture.state.nextScheduleAfterTick(surfaceID: fixture.surfaceID, hasAgent: true) == .delegated) }
@Test func keyPressGivesOneFullTickBeforeDelegatingAgain() async { let probe = Probe() let fixture = makeFixture(probe: probe) await delegate(fixture, probe: probe)
fixture.state.wakeAgentDetection(for: fixture.surface, tabId: fixture.tabID) #expect(fixture.state.agentDetectionSchedules[fixture.surfaceID] == .active) #expect(await detect(fixture))
// The screen was read again (the memo now holds the live frame), the decision // still follows the root record, and the next tick delegates again. #expect(fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID]?.text == "") #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.identifier == "osc.working") #expect(fixture.state.nextScheduleAfterTick(surfaceID: fixture.surfaceID, hasAgent: true) == .delegated) }
@Test func keyPressDuringADelegatedTicksSuspensionStillGetsItsFullTick() async { let probe = Probe() let fixture = makeFixture(probe: probe) await delegate(fixture, probe: probe) fixture.state.agentDetectionFullTickRequests.remove(fixture.surfaceID) // Suspend the delegated tick inside the session resolver, after it chose to skip // the screen; a key press lands while it is suspended. let gate = AsyncStream<CheckedContinuation<Void, Never>>.makeStream() let tick = Task { await fixture.state.detectAgentState( for: fixture.surface, tabId: fixture.tabID, resolveSession: { _, _, _, _, _ in await withCheckedContinuation { gate.continuation.yield($0) } return (nil, 0) }) } var iterator = gate.stream.makeAsyncIterator() let resume = await iterator.next() fixture.state.wakeAgentDetection(for: fixture.surface, tabId: fixture.tabID) resume?.resume() #expect(await tick.value)
// That tick stayed delegated, so the request is still open: the loop stays on the // active schedule and the next tick reads the screen before delegating again. #expect(fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] == seededScan) #expect(fixture.state.nextScheduleAfterTick(surfaceID: fixture.surfaceID, hasAgent: true) == .active) fixture.state.agentDetectionSchedules[fixture.surfaceID] = .active #expect(await detect(fixture)) #expect(fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID]?.text == "") #expect(fixture.state.nextScheduleAfterTick(surfaceID: fixture.surfaceID, hasAgent: true) == .delegated) }
@Test func probeMissLeavesDelegationForTheActiveCadence() async { let probe = Probe() let fixture = makeFixture(probe: probe) await delegate(fixture, probe: probe)
probe.job = nil #expect(await detect(fixture))
// Presence holds the agent and the authority, but the remaining misses must run // on the 300 ms cadence, not every 2 s. #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) #expect(fixture.state.nextScheduleAfterTick(surfaceID: fixture.surfaceID, hasAgent: true) == .active) }
@Test func engineChangeOnADelegatedTickRunsAFullAcquisition() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) // Spawned before the report so the root record postdates the engine's start. let engine = try spawnSleeper() defer { engine.terminate() } await delegate(fixture, probe: probe)
// The identified process moved to another (real) pid: the bound generation is gone. probe.job = claudeJob(pid: engine.processIdentifier) #expect(await detect(fixture))
// The screen was read (the memo holds the live frame) and the rebound coordinator // re-derived the evidence against the new generation. #expect(fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID]?.text == "") #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcessID == engine.processIdentifier) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.identifier == "osc.working") #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.boundProcess?.pid == engine.processIdentifier) }
@Test func nextScheduleFollowsDelegation() { let now = Date(timeIntervalSince1970: 100) let active = AgentDetectionSchedule.cold.warmed(now: now).observedAgent(now: now) #expect(WorktreeTerminalState.nextSchedule(active, hasAgent: true, delegated: true, now: now) == .delegated) #expect(WorktreeTerminalState.nextSchedule(.delegated, hasAgent: true, delegated: false, now: now) == .active) #expect( WorktreeTerminalState.nextSchedule(.delegated, hasAgent: false, delegated: false, now: now).nextInterval(now: now) == .seconds(2)) }
// MARK: Release on command finished
@Test func commandFinishedReleasesOnASingleFreshMiss() async { let probe = Probe() let fixture = makeFixture(support: .unverified, probe: probe) var removed: [UUID] = [] fixture.state.onAgentEntryRemoved = { removed.append($0) } await bind(fixture, probe: probe) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) fixture.state.handleProgramStatus(report(.done, id: "built"), surfaceID: fixture.surfaceID) probe.calls.removeAll()
fixture.state.noteCommandFinishedForAgentDetection(surfaceID: fixture.surfaceID) #expect(fixture.state.agentDetectionWakeRequests.contains(fixture.surfaceID)) probe.job = nil #expect(await detect(fixture) == false)
// The wake's probe bypassed the cache and its miss released the entry at once. #expect(probe.calls == [true]) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) #expect(removed == [fixture.surfaceID]) let store = fixture.state.programStatusStoresBySurface[fixture.surfaceID] #expect(store?.root == nil) #expect(store?.record(id: "built")?.state == .done) }
@Test func secondCommandFinishedDuringTheFreshProbeKeepsItsOwnMark() async { let probe = Probe() let fixture = makeFixture(support: .unverified, probe: probe) await bind(fixture, probe: probe) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) fixture.state.noteCommandFinishedForAgentDetection(surfaceID: fixture.surfaceID) let firstMark = fixture.state.pendingCommandFinishedBySurface[fixture.surfaceID]
let entered = AsyncStream<Void>.makeStream() probe.entered = entered.continuation let tick = Task { await self.detect(fixture) } var iterator = entered.stream.makeAsyncIterator() _ = await iterator.next() // A successor reported and its shell prompt returned while the first fresh // sample was still in flight. fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) fixture.state.noteCommandFinishedForAgentDetection(surfaceID: fixture.surfaceID) let secondMark = fixture.state.pendingCommandFinishedBySurface[fixture.surfaceID] #expect(secondMark != firstMark) probe.gate?.resume() #expect(await tick.value)
// The first sample saw the agent (a nested shell's prompt): nothing was released, // and the second D still owns its mark and its fresh sample. #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) #expect(fixture.state.pendingCommandFinishedBySurface[fixture.surfaceID] == secondMark) #expect(fixture.state.agentDetectionFreshProbeRequests.contains(fixture.surfaceID)) #expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID]?.root?.state == .working) }
@Test func successorAfterTheFinishedOwnerRetiresTheLaunchContextAndKeepsItsOwnRecords() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) var removed: [UUID] = [] var emitted: [ActiveAgentEntry] = [] fixture.state.onAgentEntryRemoved = { removed.append($0) } fixture.state.onAgentEntryChanged = { emitted.append($0) } let successor = try spawnDetachedSleeper() defer { kill(successor, SIGTERM) } await bind(fixture, probe: probe) fixture.state.launchProfilesBySurface[fixture.surfaceID] = WorktreeTerminalState.SurfaceLaunchProfile( profileID: UUID(), name: "Claude · Bound", runtime: .claude, dedicatedHome: nil) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID)
// The owner's shell prompt returned; a successor launched (another real pid) and // reported before the probe ran. fixture.state.noteCommandFinishedForAgentDetection(surfaceID: fixture.surfaceID) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) probe.job = claudeJob(pid: successor) emitted.removeAll() #expect(await detect(fixture))
#expect(removed == [fixture.surfaceID]) #expect(fixture.state.launchProfilesBySurface[fixture.surfaceID] == nil) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcessID == successor) #expect(emitted.last?.launchProfileName == nil) // The successor's root arrived after the D and survived the scoped cleanup, so it // drives the successor's decision without another report. #expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID]?.root?.state == .working) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.identifier == "osc.working") }
@Test func agentWithoutReportsIsReleasedOnTheFreshMissToo() async { let probe = Probe() let fixture = makeFixture(support: .unverified, probe: probe) var removed: [UUID] = [] fixture.state.onAgentEntryRemoved = { removed.append($0) } probe.job = ForegroundJob( processGroupID: getpid(), processes: [ ForegroundProcess(pid: getpid(), parentProcessID: getppid(), name: "codex", argv0: "codex", cmdline: "codex") ]) fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] = WorktreeTerminalState.AgentScreenScan( agent: .codex, text: "", detection: AgentScreenDetection(state: .idle, reason: .legacyDetector)) #expect(await detect(fixture)) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .codex)
fixture.state.noteCommandFinishedForAgentDetection(surfaceID: fixture.surfaceID) probe.job = nil #expect(await detect(fixture) == false)
#expect(removed == [fixture.surfaceID]) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) }
@Test func ordinaryMissStillNeedsSixSamples() async { let probe = Probe() let fixture = makeFixture(support: .unverified, probe: probe) await bind(fixture, probe: probe) probe.job = nil probe.calls.removeAll()
#expect(await detect(fixture))
#expect(probe.calls == [false]) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) }
@Test func nestedShellPromptDoesNotReleaseOrDropRecords() async { let probe = Probe() let fixture = makeFixture(probe: probe) await bind(fixture, probe: probe) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID)
fixture.state.noteCommandFinishedForAgentDetection(surfaceID: fixture.surfaceID) #expect(await detect(fixture))
#expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) #expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID]?.root?.state == .working) #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.isDelegated == true) #expect(fixture.state.pendingCommandFinishedBySurface[fixture.surfaceID] == nil) }
@Test func commandFinishedOutcomeSeparatesOwnerFromEngine() { let launcher = IdentifiedAgentProcess( agent: .claude, name: "claude", process: ForegroundProcess(pid: 101, parentProcessID: 100, name: "claude", argv0: "claude", cmdline: nil), launchProcessID: 100) let sameOwner = PaneAgentState(detectedAgent: .claude, agentProcessID: 101, launchProcessID: 100) #expect( WorktreeTerminalState.commandFinishedOutcome( identified: launcher, previous: sameOwner, isLiveAncestor: { _, _ in false }) == .sameOwner) // The engine child was replaced while the launcher stayed a live ancestor. let replaced = IdentifiedAgentProcess( agent: .claude, name: "claude", process: ForegroundProcess(pid: 102, parentProcessID: 102, name: "claude", argv0: "claude", cmdline: nil), launchProcessID: 102) #expect( WorktreeTerminalState.commandFinishedOutcome( identified: replaced, previous: sameOwner, isLiveAncestor: { ancestor, descendant in ancestor == 100 && descendant == 102 }) == .sameOwner) // A successor launch: nothing links it to the finished owner. #expect( WorktreeTerminalState.commandFinishedOutcome( identified: replaced, previous: sameOwner, isLiveAncestor: { _, _ in false }) == .successor) #expect( WorktreeTerminalState.commandFinishedOutcome( identified: nil, previous: sameOwner, isLiveAncestor: { _, _ in false }) == .released) // A first agent appearing right after the prompt returned owns nothing yet. #expect( WorktreeTerminalState.commandFinishedOutcome( identified: replaced, previous: PaneAgentState(), isLiveAncestor: { _, _ in false }) == .successor) }
@Test func commandFinishedWithoutAnAgentDropsLiveRecordsDirectly() { let probe = Probe() let fixture = makeFixture(probe: probe) fixture.state.handleProgramStatus(report(.working, app: "cargo"), surfaceID: fixture.surfaceID) fixture.state.handleProgramStatus(report(.done, id: "built", app: "cargo"), surfaceID: fixture.surfaceID)
fixture.state.noteCommandFinishedForAgentDetection(surfaceID: fixture.surfaceID)
let store = fixture.state.programStatusStoresBySurface[fixture.surfaceID] #expect(store?.root == nil) #expect(store?.record(id: "built")?.state == .done) #expect(fixture.state.pendingCommandFinishedBySurface[fixture.surfaceID] == nil) }
// MARK: Wake
@Test func wakeInterruptsASleepingLoop() async { let probe = Probe() let fixture = makeFixture(probe: probe, startsLoop: true) let calls = AsyncStream<Void>.makeStream() fixture.state.agentProcessProbeForTesting = { _, _, _ in calls.continuation.yield(()) return nil } var iterator = calls.stream.makeAsyncIterator() fixture.state.wakeAgentDetection(for: fixture.surface, tabId: fixture.tabID) _ = await iterator.next() // The loop is now asleep on the warm interval (2 s); a wake must end that sleep. let clock = ContinuousClock() let start = clock.now fixture.state.wakeAgentDetection(for: fixture.surface, tabId: fixture.tabID) _ = await iterator.next() #expect(clock.now - start < .seconds(1)) fixture.state.cleanupAllAgentDetectionState() }
@Test func aLateSleeperContinuationDoesNotUnregisterANewerLoopsSleeper() async { let probe = Probe() let fixture = makeFixture(probe: probe) let id = fixture.surfaceID let old = Task { await fixture.state.sleepAgentDetection(forSurfaceID: id, token: 1, interval: .seconds(30)) } while fixture.state.agentDetectionSleepersBySurface[id] == nil { await Task.yield() } // The surface was closed and restored: a new loop owns the slot now. let newer = Task<Void, any Error> { try await Task.sleep(for: .seconds(30)) } fixture.state.agentDetectionSleepersBySurface[id] = WorktreeTerminalState.AgentDetectionSleeper( token: 2, task: newer)
old.cancel() await old.value
#expect(fixture.state.agentDetectionSleepersBySurface[id]?.token == 2) newer.cancel() }
@Test func aStaleFreshProbeFromBeforeACloseCannotReleaseTheRestoredAgent() async { let probe = Probe() let fixture = makeFixture(support: .unverified, probe: probe) var removed: [UUID] = [] fixture.state.onAgentEntryRemoved = { removed.append($0) } await bind(fixture, probe: probe) fixture.state.noteCommandFinishedForAgentDetection(surfaceID: fixture.surfaceID) // The old poll consumed the mark and is suspended in its fresh probe. let entered = AsyncStream<Void>.makeStream() probe.entered = entered.continuation let oldTick = Task { await self.detect(fixture) } var iterator = entered.stream.makeAsyncIterator() _ = await iterator.next()
// Close and undo: the same surface id comes back with fresh detection state, and // the restored pane binds its agent again. fixture.state.detachAndForgetSurface(fixture.surface) removed.removeAll() fixture.state.configureBridgeCallbacks(for: fixture.surface, tabId: fixture.tabID) fixture.state.surfaces[fixture.surfaceID] = fixture.surface fixture.state.agentDetectionTasks[fixture.surfaceID] = Task {} fixture.state.surfaceAgentStates[fixture.surfaceID] = PaneAgentState(lastChangedAt: epoch) fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] = WorktreeTerminalState.AgentScreenScan( agent: .claude, text: "", detection: AgentScreenDetection(state: .idle, reason: .legacyDetector)) #expect(await detect(fixture)) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) let restoredCoordinator = fixture.state.agentDetectionCoordinators[fixture.surfaceID]
// The old probe lands with a miss from before the close. probe.job = nil probe.gate?.resume() #expect(await oldTick.value == false)
#expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID] === restoredCoordinator) #expect(removed.isEmpty) #expect(fixture.state.agentDetectionPresenceBySurface[fixture.surfaceID]?.currentAgent == .claude) }
// MARK: Undo-close
@Test func retainedSurfaceKeepsFeedingTheStoreAndAppliesCommandFinishedDirectly() { let probe = Probe() let fixture = makeFixture(probe: probe) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID)
fixture.state.detachAndForgetSurface(fixture.surface)
#expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID]?.root?.state == .working) fixture.surface.bridge.onProgramStatus?(report(.blocked, kind: .permission)) #expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID]?.root?.state == .blocked) #expect(fixture.state.agentDetectionTasks[fixture.surfaceID] == nil)
fixture.surface.bridge.onCommandFinished?(0, 0) #expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID]?.root == nil) }
@Test func freeingASurfaceDropsItsStore() { let probe = Probe() let fixture = makeFixture(probe: probe) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) fixture.state.detachAndForgetSurface(fixture.surface) #expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID] != nil)
fixture.state.dropProgramStatusStores(forSurfaceIDs: [fixture.surfaceID]) #expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID] == nil) }
@Test func plainCloseDropsTheStore() { let probe = Probe() let fixture = makeFixture(probe: probe) fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID)
fixture.state.forgetSurface(fixture.surfaceID)
#expect(fixture.state.programStatusStoresBySurface[fixture.surfaceID] == nil) }
@Test func unmappedAppIsLoggedOncePerSurface() async { let probe = Probe() let fixture = makeFixture(probe: probe) var lines: [String] = [] fixture.state.programStatusLogForTesting = { lines.append($0) } await bind(fixture, probe: probe)
fixture.state.handleProgramStatus(report(.working, app: "cargo"), surfaceID: fixture.surfaceID) #expect(await detect(fixture)) fixture.state.handleProgramStatus(report(.done, app: "cargo"), surfaceID: fixture.surfaceID) #expect(await detect(fixture))
#expect(lines.filter { $0.contains("app=cargo unmapped") }.count == 1) }
@Test func knownProducerRecordOnAPaneWithoutAnAgentIsNotUnmapped() async { // Pi's `done` survives the command-finished cleanup; after the release the pane has // no agent, and that leftover is not a new producer. let probe = Probe() let fixture = makeFixture(probe: probe) var lines: [String] = [] fixture.state.programStatusLogForTesting = { lines.append($0) } fixture.state.handleProgramStatus(report(.done, app: "pi"), surfaceID: fixture.surfaceID) #expect(await detect(fixture) == false) #expect(lines.isEmpty) fixture.state.handleProgramStatus(report(.done, app: "cargo"), surfaceID: fixture.surfaceID) #expect(await detect(fixture) == false) #expect(lines.filter { $0.contains("app=cargo unmapped") }.count == 1) }}