native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
36 kB · 1040 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041import Clocksimport Foundationimport ProwlCLISharedimport Testing
@testable import supacode
@MainActorstruct AgentWaitCommandHandlerTests { @Test func succeededDispatchReturnsReceiptAndFailedReceiptIsStructuredError() async throws { let succeeded = snapshot( .completed( id: "d1", outcome: .succeeded, summary: "Done", createdAt: Self.start, completedAt: Self.start)) let successHandler = handler(dispatchSnapshot: succeeded) let success = await successHandler.handle(envelope: waitDispatch("d1")) #expect(success.ok) let payload = try #require(success.data).decode(as: AgentWaitCommandPayload.self) guard case .dispatch(let dispatch) = payload else { Issue.record("Expected dispatch payload") return } #expect(dispatch.receipt.summary == "Done") #expect(dispatch.target == succeeded.binding?.target)
let failed = snapshot( .completed( id: "d2", outcome: .failed, summary: "Tests failed", createdAt: Self.start, completedAt: Self.start)) let failure = await handler(dispatchSnapshot: failed).handle(envelope: waitDispatch("d2")) #expect(failure.error?.code == CLIErrorCode.dispatchFailed) let details = try #require(failure.error?.details).decode(as: AgentWaitErrorDetails.self) guard case .dispatch(let dispatchDetails) = details else { Issue.record("Expected dispatch error details") return } #expect(dispatchDetails.record.state == .completed) }
@Test func dispatchNeedsInputAndIncompleteNeverBecomeSuccess() async { let pending = snapshot(.pending(id: "d1", createdAt: Self.start)) for (event, code) in [ (AgentDispatchObservation.needsInput(pending), CLIErrorCode.dispatchNeedsInput), (.incomplete(pending), CLIErrorCode.dispatchIncomplete), ] { let handler = AgentWaitCommandHandler( observeDispatch: { _ in .success( AgentDispatchObservationStream { continuation in continuation.yield(event) }) } ) let response = await handler.handle(envelope: waitDispatch("d1")) #expect(!response.ok) #expect(response.error?.code == code) } }
@Test func exactConditionSignalCanResolveInitialSnapshotButChangedCannot() async throws { let target = resolvedTarget() let entry = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .idle) let signal = AgentSignal( kind: .turnEnded, source: .cooperativeCLI, confidence: .exact, timestamp: Self.start, sessionID: nil, detail: nil, claimedOrigin: nil ) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: entry, signal: signal, revision: 2, isLive: true, signals: .empty) } ) let response = await handler.handle( envelope: CommandEnvelope( output: .json, command: .agentsWait( AgentWaitInput(mode: .condition, pane: target.paneID, condition: .idle, timeoutSeconds: 1) ) ) ) #expect(response.ok) let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) guard case .condition(let condition) = payload else { Issue.record("Expected condition payload") return } #expect(condition.observation.confidence == "exact") #expect(condition.observation.source == "cooperative_cli") }
@Test func changedExactRejectsHeuristicRevisionWithoutANewSignal() async { let clock = TestClock() let target = resolvedTarget() let surfaceID = UUID(uuidString: target.paneID)! let idle = agentEntry(surfaceID: surfaceID, status: .idle) let working = agentEntry(surfaceID: surfaceID, status: .working) let staleSignal = AgentSignal( kind: .turnEnded, source: .cooperativeCLI, confidence: .exact, timestamp: Self.start, sessionID: nil, detail: nil, claimedOrigin: nil ) var snapshotReads = 0 let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in snapshotReads += 1 let didChangeHeuristically = snapshotReads > 2 return .init( agent: didChangeHeuristically ? working : idle, signal: staleSignal, revision: didChangeHeuristically ? 2 : 1, isLive: true, signals: .empty ) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle( envelope: CommandEnvelope( output: .json, command: .agentsWait( .init( mode: .condition, pane: target.paneID, condition: .changed, timeoutSeconds: 1, minimumConfidence: .exact ) ) )) }
for _ in 0..<5 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.waitTimeout) }
@Test func heuristicIdleRequiresTwoSecondsOfUnchangedState() async throws { let clock = TestClock() let target = resolvedTarget() let entry = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .idle) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: entry, signal: nil, revision: 1, isLive: true, signals: .empty) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle( envelope: CommandEnvelope( output: .json, command: .agentsWait( .init(mode: .condition, pane: target.paneID, condition: .idle, timeoutSeconds: 3) ) )) } for _ in 0..<9 { await Task.yield() await clock.advance(by: .milliseconds(200)) } #expect(!task.isCancelled) await Task.yield() await clock.advance(by: .milliseconds(200)) let response = await task.value let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) guard case .condition(let condition) = payload else { Issue.record("Expected condition payload") return } #expect(condition.waitedMilliseconds == 2_000) #expect(condition.observation.confidence == "heuristic") }
@Test func requestedScreenWaitsForEightHundredMillisecondsOfStability() async throws { let clock = TestClock() let completed = snapshot( .completed( id: "d1", outcome: .succeeded, summary: "Done", createdAt: Self.start, completedAt: Self.start)) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .success( AgentDispatchObservationStream { continuation in continuation.yield(.snapshot(completed)) continuation.finish() }) }, screenProvider: { _ in "one\ntwo\nthree" }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle( envelope: CommandEnvelope( output: .json, command: .agentsWait( .init(mode: .dispatch, dispatchID: "d1", timeoutSeconds: 1, includeScreenLines: 2) ) )) } for _ in 0..<4 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) guard case .dispatch(let dispatch) = payload else { Issue.record("Expected dispatch payload") return } let screenPayload = try #require(dispatch.screen) guard case .captured(let screen) = screenPayload else { Issue.record("Expected captured screen") return } #expect(screen.waitedMilliseconds == 800) #expect(screen.text == "two\nthree") #expect(screen.stabilized) }
@Test func requestedScreenIsIncludedWithFailedDispatch() async throws { let clock = TestClock() let failed = snapshot( .completed( id: "d1", outcome: .failed, summary: "Tests failed", createdAt: Self.start, completedAt: Self.start )) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .success( AgentDispatchObservationStream { continuation in continuation.yield(.snapshot(failed)) continuation.finish() }) }, screenProvider: { _ in "failed evidence" }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle( envelope: CommandEnvelope( output: .json, command: .agentsWait( .init(mode: .dispatch, dispatchID: "d1", timeoutSeconds: 1, includeScreenLines: 2) ) )) }
for _ in 0..<4 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value let details = try #require(response.error?.details) let object = try #require( JSONSerialization.jsonObject(with: details.bytes) as? [String: Any] ) let screen = try #require(object["screen"] as? [String: Any]) #expect(screen["status"] as? String == "captured") #expect(screen["text"] as? String == "failed evidence") }
@Test func requestedScreenIsIncludedWithConditionTimeout() async throws { let clock = TestClock() let target = resolvedTarget() let entry = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .working) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: entry, signal: nil, revision: 1, isLive: true, signals: .empty) }, screenProvider: { _ in "timeout evidence" }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle( envelope: CommandEnvelope( output: .json, command: .agentsWait( .init( mode: .condition, pane: target.paneID, condition: .blocked, timeoutSeconds: 1, minimumConfidence: .exact, includeScreenLines: 2 ) ) )) }
for _ in 0..<9 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value let details = try #require(response.error?.details) let object = try #require( JSONSerialization.jsonObject(with: details.bytes) as? [String: Any] ) let screen = try #require(object["screen"] as? [String: Any]) #expect(screen["status"] as? String == "captured") #expect(screen["text"] as? String == "timeout evidence") }
@Test func cancellingGenericWaitRemovesObservationSubscriber() async { let clock = TestClock() let target = resolvedTarget() let surfaceID = UUID(uuidString: target.paneID)! let store = AgentObservationStore(bufferCapacity: 8) let entry = agentEntry(surfaceID: surfaceID, status: .working) store.publishAgentChanged(entry) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, observeCondition: { store.observe(surfaceID: $0, isLive: true) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init( agent: entry, signal: nil, revision: store.snapshot(surfaceID: surfaceID)?.revision ?? 0, isLive: true, signals: .empty ) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle( envelope: CommandEnvelope( output: .json, command: .agentsWait( .init(mode: .condition, pane: target.paneID, condition: .blocked, timeoutSeconds: 600) ) )) }
for _ in 0..<10 where store.subscriberCount(surfaceID: surfaceID) == 0 { await Task.yield() } #expect(store.subscriberCount(surfaceID: surfaceID) == 1) task.cancel() _ = await task.value for _ in 0..<10 where store.subscriberCount(surfaceID: surfaceID) != 0 { await Task.yield() } #expect(store.subscriberCount(surfaceID: surfaceID) == 0) }
@Test func genericNonExitWaitReturnsAgentGoneWhenSurfaceCloses() async throws { let clock = TestClock() let target = resolvedTarget() let entry = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .working) var snapshotReads = 0 let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in snapshotReads += 1 let isLive = snapshotReads <= 2 return .init( agent: isLive ? entry : nil, signal: nil, revision: isLive ? 1 : 0, isLive: isLive, signals: .empty ) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle( envelope: CommandEnvelope( output: .json, command: .agentsWait( .init( mode: .condition, pane: target.paneID, condition: .blocked, timeoutSeconds: 1, minimumConfidence: .exact ) ) )) }
for _ in 0..<5 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.agentGone) let details = try #require(response.error?.details).decode(as: AgentWaitErrorDetails.self) guard case .condition(let condition) = details else { Issue.record("Expected condition error details") return } #expect(condition.observation?.source == "surface") #expect(condition.observation?.confidence == "exact") }
@Test func genericWaitResubscribesAfterObservationOverflow() async { let clock = TestClock() let target = resolvedTarget() let entry = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .working) var subscriptionCount = 0 let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, observeCondition: { _ in subscriptionCount += 1 if subscriptionCount == 1 { return AgentObservationStream { continuation in continuation.finish(throwing: AgentObservationError.bufferOverflow) } } return AgentObservationStream { _ in } }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: entry, signal: nil, revision: 1, isLive: true, signals: .empty) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle( envelope: CommandEnvelope( output: .json, command: .agentsWait( .init(mode: .condition, pane: target.paneID, condition: .blocked, timeoutSeconds: 600) ) )) }
for _ in 0..<10 where subscriptionCount < 2 { await Task.yield() } #expect(subscriptionCount == 2) task.cancel() _ = await task.value }
@Test func preArmNeedsInputNeedsBlockedDetectorBeforeSatisfyingBlocked() async { let clock = TestClock() let target = resolvedTarget() let working = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .working) let staleNeedsInput = signal(.needsInput, at: Self.start) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: working, signal: staleNeedsInput, revision: 3, isLive: true, signals: .empty) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .blocked, timeout: 1, minimumConfidence: .exact)) }
for _ in 0..<6 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.waitTimeout) }
@Test func preArmNeedsInputSatisfiesBlockedWhenDetectorAgrees() async throws { let target = resolvedTarget() let blocked = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .blocked) let needsInput = signal(.needsInput, at: Self.start) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: blocked, signal: needsInput, revision: 3, isLive: true, signals: .empty) } ) let response = await handler.handle( envelope: conditionWait(target, .blocked, timeout: 1, minimumConfidence: .exact) ) #expect(response.ok) let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) guard case .condition(let condition) = payload else { Issue.record("Expected condition payload") return } #expect(condition.observation.source == "cooperative_cli") #expect(condition.observation.status == .blocked) }
@Test func needsInputArrivingAfterArmSatisfiesBlockedWithoutDetectorCorroboration() async throws { let clock = TestClock() let target = resolvedTarget() let working = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .working) let preArm = signal(.turnEnded, at: Self.start) let fresh = signal(.needsInput, at: Self.start.addingTimeInterval(5)) var snapshotReads = 0 let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in snapshotReads += 1 return .init( agent: working, signal: snapshotReads > 3 ? fresh : preArm, revision: snapshotReads > 3 ? 4 : 3, isLive: true, signals: .empty ) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .blocked, timeout: 5, minimumConfidence: .exact)) }
for _ in 0..<4 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.ok) let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) guard case .condition(let condition) = payload else { Issue.record("Expected condition payload") return } #expect(condition.observation.source == "cooperative_cli") #expect(condition.observation.status == .working) #expect(condition.waitedMilliseconds == 400) }
@Test func preArmTurnEndedNeedsIdleDetectorBeforeSatisfyingIdle() async { let clock = TestClock() let target = resolvedTarget() let working = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .working) let staleTurnEnded = signal(.turnEnded, at: Self.start) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: working, signal: staleTurnEnded, revision: 3, isLive: true, signals: .empty) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .idle, timeout: 1, minimumConfidence: .exact)) }
for _ in 0..<6 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.waitTimeout) }
@Test func agentAppearingWithinGraceStartsTheConditionWait() async throws { let clock = TestClock() let target = resolvedTarget() let idle = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .idle) let turnEnded = signal(.turnEnded, at: Self.start.addingTimeInterval(1)) var snapshotReads = 0 let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in snapshotReads += 1 let appeared = snapshotReads > 3 return .init( agent: appeared ? idle : nil, signal: appeared ? turnEnded : nil, revision: appeared ? 1 : 0, isLive: true, signals: .empty ) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .idle, timeout: 60, minimumConfidence: .exact)) }
for _ in 0..<4 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.ok) let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) guard case .condition(let condition) = payload else { Issue.record("Expected condition payload") return } #expect(condition.observation.source == "cooperative_cli") #expect(condition.waitedMilliseconds == 600) }
@Test func agentNeverAppearingFailsWithAgentNotFoundAfterGrace() async throws { let clock = TestClock() let target = resolvedTarget() let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: nil, signal: nil, revision: 0, isLive: true, signals: .empty) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .blocked, timeout: 600)) }
let graceTicks = AgentWaitCommandHandler.agentAppearanceGraceMilliseconds / 200 for _ in 0..<graceTicks { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.agentNotFound) let details = try #require(response.error?.details).decode(as: AgentWaitErrorDetails.self) guard case .condition(let condition) = details else { Issue.record("Expected condition error details") return } #expect(condition.waitedMilliseconds == AgentWaitCommandHandler.agentAppearanceGraceMilliseconds) #expect(condition.target?.pane.id == target.paneID) }
@Test func shortTimeoutBoundsTheAgentAppearanceGrace() async throws { let clock = TestClock() let target = resolvedTarget() let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: nil, signal: nil, revision: 0, isLive: true, signals: .empty) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .idle, timeout: 1)) }
for _ in 0..<5 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.agentNotFound) let details = try #require(response.error?.details).decode(as: AgentWaitErrorDetails.self) guard case .condition(let condition) = details else { Issue.record("Expected condition error details") return } #expect(condition.waitedMilliseconds == 1_000) }
@Test func autoIdleFallsBackToDetectorWhenLiveChannelHoldsNoTerminalSignal() async throws { // A freshly launched Profile has a verified_live channel that has only reported // `session-start`; the channel cannot describe the current level, so the stabilized // detector view resolves the wait instead of a timeout. let clock = TestClock() let target = resolvedTarget() let idle = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .idle) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: idle, signal: nil, revision: 2, isLive: true, signals: Self.liveClaudeSignals) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .idle, timeout: 3)) }
// Advance past the timeout so a regression surfaces as WAIT_TIMEOUT instead of a hang. for _ in 0..<16 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.ok, "\(String(describing: response.error))") let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) guard case .condition(let condition) = payload else { Issue.record("Expected condition payload") return } #expect(condition.waitedMilliseconds == 2_000) #expect(condition.observation.confidence == "heuristic") #expect(condition.observation.source == "detection") }
@Test func autoIdleDoesNotOverridePreArmNeedsInputWhileLiveChannelIsPresent() async { // The channel's active terminal level is an exact `needs-input`: the runtime disagrees with // the screen, so the stabilized detector view must not end the wait on its own. let clock = TestClock() let target = resolvedTarget() let idle = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .idle) let needsInput = signal(.needsInput, at: Self.start) let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: idle, signal: needsInput, revision: 2, isLive: true, signals: Self.liveClaudeSignals) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .idle, timeout: 3)) }
for _ in 0..<16 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.waitTimeout) }
@Test func autoIdleDoesNotOverrideFreshNeedsInputDuringStabilization() async { // The detector has shown idle for 1.8 s when an exact `needs-input` lands before the screen // catches up: exact evidence wins, so the stabilizer resets instead of resolving idle at the // two-second mark. let clock = TestClock() let target = resolvedTarget() let idle = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .idle) let fresh = signal(.needsInput, at: Self.start.addingTimeInterval(1.8)) var snapshotReads = 0 let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in snapshotReads += 1 // Read 1 is the arm-time baseline and poll read k happens at (k - 2) * 200 ms, so the // signal lands at 1.8 s — inside the stabilizer's window. let arrived = snapshotReads > 10 return .init( agent: idle, signal: arrived ? fresh : nil, revision: arrived ? 3 : 2, isLive: true, signals: Self.liveClaudeSignals ) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .idle, timeout: 3)) }
for _ in 0..<16 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.waitTimeout) #expect(snapshotReads > 10) }
@Test func autoExitWaitsForSessionEndWhileLiveChannelIsPresent() async { // A verified_live channel reports its own `session-end`; the detector losing sight of the // agent on a still-live surface is not an exit under `auto`. let clock = TestClock() let target = resolvedTarget() let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: nil, signal: nil, revision: 2, isLive: true, signals: Self.liveClaudeSignals) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .exit, timeout: 3)) }
for _ in 0..<16 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.waitTimeout) }
@Test func autoChangedIgnoresDetectorChangesWhileLiveChannelIsPresent() async { // `changed` stays an edge wait: with a verified_live channel the runtime reports the next // edge, so a detector-only state change never resolves it under `auto`. let clock = TestClock() let target = resolvedTarget() let surfaceID = UUID(uuidString: target.paneID)! let idle = agentEntry(surfaceID: surfaceID, status: .idle) let working = agentEntry(surfaceID: surfaceID, status: .working) let staleSignal = signal(.turnEnded, at: Self.start) var snapshotReads = 0 let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in snapshotReads += 1 let didChangeHeuristically = snapshotReads > 2 return .init( agent: didChangeHeuristically ? working : idle, signal: staleSignal, revision: didChangeHeuristically ? 2 : 1, isLive: true, signals: Self.liveClaudeSignals ) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .changed, timeout: 3)) }
for _ in 0..<16 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.error?.code == CLIErrorCode.waitTimeout) }
@Test func autoExitFallsBackToDetectorWhenChannelCannotReportSessionEnd() async throws { // Codex's notifier reports only `turn-ended`, so after `/quit` the detector losing the agent // on a still-live surface is the only exit evidence and must keep resolving under `auto`. let clock = TestClock() let target = resolvedTarget() let handler = AgentWaitCommandHandler( observeDispatch: { _ in .failure(.notFound) }, resolveConditionTarget: { _ in .success(target) }, conditionSnapshot: { _ in .init(agent: nil, signal: nil, revision: 2, isLive: true, signals: Self.liveCodexSignals) }, clock: clock, now: { Self.start } ) let task = Task { await handler.handle(envelope: conditionWait(target, .exit, timeout: 3)) }
for _ in 0..<16 { await Task.yield() await clock.advance(by: .milliseconds(200)) } let response = await task.value #expect(response.ok, "\(String(describing: response.error))") let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) guard case .condition(let condition) = payload else { Issue.record("Expected condition payload") return } #expect(condition.waitedMilliseconds == 2_000) #expect(condition.observation.confidence == "heuristic") #expect(condition.observation.rawState == "absent") }
private static let liveCodexSignals = AgentSignalsPayload( channels: [ AgentSignalChannelPayload( source: "hook_codex", state: .verifiedLive, confidence: "exact", events: [.turnEnded], lastSeenAt: "2026-08-28T00:00:00Z" ) ], last: nil, lastBinding: nil )
private static let liveClaudeSignals = AgentSignalsPayload( channels: [ AgentSignalChannelPayload( source: "hook_claude", state: .verifiedLive, confidence: "exact", events: [.needsInput, .sessionEnd, .sessionStart, .turnEnded], lastSeenAt: "2026-08-28T00:00:00Z" ) ], last: nil, lastBinding: nil )
private func conditionWait( _ target: TabResolvedTarget, _ condition: AgentWaitCondition, timeout: Int, minimumConfidence: AgentWaitMinimumConfidence? = nil ) -> CommandEnvelope { CommandEnvelope( output: .json, command: .agentsWait( AgentWaitInput( mode: .condition, pane: target.paneID, condition: condition, timeoutSeconds: timeout, minimumConfidence: minimumConfidence ) ) ) }
private func signal(_ event: AgentSignal.Kind, at timestamp: Date) -> AgentSignal { AgentSignal( kind: event, source: .cooperativeCLI, confidence: .exact, timestamp: timestamp, sessionID: nil, detail: nil, claimedOrigin: nil ) }
private static let start = Date(timeIntervalSince1970: 1_000)
private func handler(dispatchSnapshot: AgentDispatchSnapshot) -> AgentWaitCommandHandler { AgentWaitCommandHandler( observeDispatch: { _ in .success( AgentDispatchObservationStream { continuation in continuation.yield(.snapshot(dispatchSnapshot)) continuation.finish() }) }, clock: TestClock(), now: { Self.start } ) }
private func waitDispatch(_ id: String) -> CommandEnvelope { CommandEnvelope( output: .json, command: .agentsWait(AgentWaitInput(mode: .dispatch, dispatchID: id, timeoutSeconds: 1)) ) }
private func snapshot(_ record: AgentDispatchRecord) -> AgentDispatchSnapshot { AgentDispatchSnapshot( record: record, binding: AgentDispatchBinding( surfaceID: UUID(), target: target(), evidenceEpoch: UUID() ) ) }
private func target() -> TabTarget { TabTarget( worktree: .init(id: "w1", name: "App", path: "/App", rootPath: "/App", kind: "worktree"), tab: .init(id: "t1", title: "Agent", selected: true), pane: .init(id: UUID().uuidString, title: "Agent", cwd: "/App", focused: true) ) }
private func resolvedTarget() -> TabResolvedTarget { let value = target() return TabResolvedTarget( worktreeID: value.worktree.id, worktreeName: value.worktree.name, worktreePath: value.worktree.path, worktreeRootPath: value.worktree.rootPath, worktreeKind: value.worktree.kind, tabID: value.tab.id, tabTitle: value.tab.title, tabSelected: value.tab.selected, paneID: value.pane.id, paneTitle: value.pane.title, paneCWD: value.pane.cwd, paneFocused: value.pane.focused ) }
private func agentEntry(surfaceID: UUID, status: AgentDisplayState) -> ActiveAgentEntry { ActiveAgentEntry( id: surfaceID, worktreeID: "w1", worktreeName: "App", workingDirectory: URL(fileURLWithPath: "/App"), tabID: TerminalTabID(rawValue: UUID()), paneTitle: "Agent", surfaceID: surfaceID, paneIndex: 0, iconLookupToken: "codex", agent: .codex, rawState: Self.rawState(for: status), displayState: status, lastChangedAt: Self.start ) }
private static func rawState(for status: AgentDisplayState) -> AgentRawState { switch status { case .working: .working case .blocked: .blocked case .idle, .done: .idle } }}