diff --git a/ProwlCLIContracts/Resources/cli-output-schema.json b/ProwlCLIContracts/Resources/cli-output-schema.json index fca2d663..b2e9c5ba 100644 --- a/ProwlCLIContracts/Resources/cli-output-schema.json +++ b/ProwlCLIContracts/Resources/cli-output-schema.json @@ -1212,6 +1212,7 @@ "event", "source", "confidence", + "binding", "at" ], "properties": { @@ -1280,6 +1281,29 @@ "$ref": "#/$defs/agentSignalWarning" } } + }, + "if": { + "properties": { + "signal": { + "properties": { + "binding": { + "const": "unbound" + } + } + } + } + }, + "then": { + "required": [ + "warnings" + ] + }, + "else": { + "not": { + "required": [ + "warnings" + ] + } } }, "agentsDispatchCompleteData": { diff --git a/ProwlCLITests/AgentSignalSchemaTests.swift b/ProwlCLITests/AgentSignalSchemaTests.swift index 433e494f..8f6e6894 100644 --- a/ProwlCLITests/AgentSignalSchemaTests.swift +++ b/ProwlCLITests/AgentSignalSchemaTests.swift @@ -22,6 +22,20 @@ final class AgentSignalSchemaTests: XCTestCase { try assertValidity(emptyWarnings, expected: false) } + func testAgentSignalSchemaRequiresBindingAndPairsUnboundWithItsWarning() throws { + let bound = #"{"ok":true,"command":"agents.signal","schema_version":"prowl.cli.agents.signal.v1","data":{"pane":{"id":"D2719F02-5F27-4D46-A62F-0FAF49410D4D","worktree_id":"wt"},"signal":{"event":"turn-ended","source":"cooperative_cli","confidence":"exact","binding":"current","at":"2026-08-24T00:00:00.000Z"}}}"# + let missingBinding = bound.replacingOccurrences(of: #""binding":"current","#, with: "") + let unboundWithoutWarning = bound.replacingOccurrences(of: #""binding":"current""#, with: #""binding":"unbound""#) + let currentWithWarning = bound.replacingOccurrences( + of: #"}}}"#, + with: #"},"warnings":[{"code":"signal_unbound","message":"Recorded as diagnostic only."}]}}"# + ) + + try assertValidity(missingBinding, expected: false) + try assertValidity(unboundWithoutWarning, expected: false) + try assertValidity(currentWithWarning, expected: false) + } + func testSignalPayloadOmitsEmptyWarningsAndRoundTripsBinding() throws { let signal = AgentSignalPayload( event: .needsInput, diff --git a/ProwlCLITests/LifecycleWarningSchemaTests.swift b/ProwlCLITests/LifecycleWarningSchemaTests.swift index cdc0d9c9..46c97b31 100644 --- a/ProwlCLITests/LifecycleWarningSchemaTests.swift +++ b/ProwlCLITests/LifecycleWarningSchemaTests.swift @@ -14,7 +14,7 @@ final class LifecycleWarningSchemaTests: XCTestCase { } func testAgentSignalSchemaAcceptsManagedHookSourcesWithoutTokenField() throws { - let response = #"{"ok":true,"command":"agents.signal","schema_version":"prowl.cli.agents.signal.v1","data":{"pane":{"id":"D2719F02-5F27-4D46-A62F-0FAF49410D4D","worktree_id":"wt"},"signal":{"event":"turn-ended","source":"hook_codex","confidence":"exact","at":"2026-08-24T00:00:00.000Z","session_id":"thread-1"}}}"# + let response = #"{"ok":true,"command":"agents.signal","schema_version":"prowl.cli.agents.signal.v1","data":{"pane":{"id":"D2719F02-5F27-4D46-A62F-0FAF49410D4D","worktree_id":"wt"},"signal":{"event":"turn-ended","source":"hook_codex","confidence":"exact","binding":"current","at":"2026-08-24T00:00:00.000Z","session_id":"thread-1"}}}"# try assertValidity(response, expected: true) XCTAssertFalse(response.contains("token")) } diff --git a/docs-ai/013-prowl-cli/contracts/agents-read.md b/docs-ai/013-prowl-cli/contracts/agents-read.md index abe78171..6045e2a5 100644 --- a/docs-ai/013-prowl-cli/contracts/agents-read.md +++ b/docs-ai/013-prowl-cli/contracts/agents-read.md @@ -78,7 +78,7 @@ present only when the transcript result used an eligible session; it contains `i | --- | --- | --- | | `complete` | required | A final answer from a complete, attributable native transcript turn. | | `pending` | absent | The agent is working or blocked; the transcript is not consulted, so an earlier completed turn is never surfaced as the current result. | -| `unavailable` | absent | No exact/high transcript session is available. | +| `unavailable` | absent | Idle/done agent without an exact/high transcript session. | | `missing` | absent | Idle/done agent has no completed final answer in its trusted transcript. | | `incomplete` | absent | The candidate turn is max-token-limited, malformed, unsupported, or not closed. | | `too_large` | absent | A complete answer exceeds the requested `max_bytes`. | diff --git a/docs-ai/013-prowl-cli/contracts/agents-signal.md b/docs-ai/013-prowl-cli/contracts/agents-signal.md index 66a34d23..577af7b9 100644 --- a/docs-ai/013-prowl-cli/contracts/agents-signal.md +++ b/docs-ai/013-prowl-cli/contracts/agents-signal.md @@ -108,9 +108,11 @@ JSON: } ``` -An unbound receipt adds `"binding": "unbound"` and -`"warnings": [{"code": "signal_unbound", "message": "…"}]` under `data`. Text mode prints -`warning: [signal_unbound] …` on stderr after the receipt line. +`binding` is required. An unbound receipt carries `"binding": "unbound"` together with +exactly one `"warnings": [{"code": "signal_unbound", "message": "…"}]` entry under `data`; a +`current` receipt never carries `warnings`, and the schema enforces that pairing. Managed +native-hook receipts are always `current`. Text mode prints `warning: [signal_unbound] …` on +stderr after the receipt line. Optional fields are omitted rather than encoded as `null`. The executable schema is `#/$defs/agentsSignalResponse` in diff --git a/supacode/CLIService/AgentNativeHookCommandHandler.swift b/supacode/CLIService/AgentNativeHookCommandHandler.swift index 18409e23..817c541d 100644 --- a/supacode/CLIService/AgentNativeHookCommandHandler.swift +++ b/supacode/CLIService/AgentNativeHookCommandHandler.swift @@ -53,6 +53,7 @@ final class AgentNativeHookCommandHandler: CommandHandler { progress: nil, source: "hook_\(input.runtime.rawValue)", confidence: AgentSignal.Confidence.exact.rawValue, + binding: .current, timestamp: dateFormatter.string(from: now()), sessionID: input.signal.sessionID, detail: input.signal.detail, diff --git a/supacode/CLIService/AgentReadCommandHandler.swift b/supacode/CLIService/AgentReadCommandHandler.swift index ba000dca..fcc30402 100644 --- a/supacode/CLIService/AgentReadCommandHandler.swift +++ b/supacode/CLIService/AgentReadCommandHandler.swift @@ -91,17 +91,18 @@ final class AgentReadCommandHandler: CommandHandler { } private func makeResult(from snapshot: AgentReadRuntimeSnapshot, maxBytes: Int) async -> AgentReadResult { - guard let session = snapshot.transcriptSession, let path = session.transcriptPath else { - return failedResult(.unavailable) - } // A live turn owns the result slot: the transcript's last complete answer belongs to an - // earlier turn and must not be mistaken for this one. + // earlier turn and must not be mistaken for this one, and an unresolved session is not + // yet a defect while the agent is still working. switch snapshot.status { case .working, .blocked: return AgentReadResult(state: .pending) case .idle, .done: break } + guard let session = snapshot.transcriptSession, let path = session.transcriptPath else { + return failedResult(.unavailable) + } let transcript = await resultProvider(snapshot.agent, path, maxBytes) switch transcript.state { diff --git a/supacodeTests/AgentReadCommandHandlerTests.swift b/supacodeTests/AgentReadCommandHandlerTests.swift index 7d144be6..423ef7fc 100644 --- a/supacodeTests/AgentReadCommandHandlerTests.swift +++ b/supacodeTests/AgentReadCommandHandlerTests.swift @@ -5,7 +5,7 @@ import Testing @MainActor struct AgentReadCommandHandlerTests { - @Test func blockedSnapshotPreservesBlockerWhenTranscriptIsUnavailable() async throws { + @Test func blockedSnapshotWithoutSessionPreservesBlockerAndReportsPending() async throws { let handler = AgentReadCommandHandler( snapshotProvider: { _ in .success(self.makeSnapshot(status: .blocked, blockerText: "Do you want to proceed?\n❯ 1. Yes\n 2. No")) @@ -24,10 +24,49 @@ struct AgentReadCommandHandlerTests { let payload = try #require(try response.data?.decode(as: AgentReadCommandPayload.self)) #expect(payload.agent.status == .blocked) #expect(payload.blocker?.text.contains("1. Yes") == true) + #expect(payload.result.state == .pending) + #expect(payload.result.error == nil) + } + + @Test func idleSnapshotWithoutSessionReportsUnavailable() async throws { + let handler = AgentReadCommandHandler( + snapshotProvider: { _ in .success(self.makeSnapshot(status: .idle)) }, + resultProvider: { _, _, _ in + Issue.record("Unexpected transcript read") + return .failure(.incomplete) + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope(output: .json, command: .agentsRead(AgentReadInput(pane: "p7"))) + ) + + #expect(response.ok) + let payload = try #require(try response.data?.decode(as: AgentReadCommandPayload.self)) #expect(payload.result.state == .unavailable) #expect(payload.result.error?.code == CLIErrorCode.sessionUnresolved) } + @Test func resultOnlyFailsWithResultNotFoundForLiveAgentWithoutSession() async { + let handler = AgentReadCommandHandler( + snapshotProvider: { _ in .success(self.makeSnapshot(status: .working)) }, + resultProvider: { _, _, _ in + Issue.record("Unexpected transcript read") + return .failure(.incomplete) + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .text, + command: .agentsRead(AgentReadInput(pane: "p7", resultOnly: true)) + ) + ) + + #expect(response.ok == false) + #expect(response.error?.code == CLIErrorCode.resultNotFound) + } + @Test func workingSnapshotMapsMissingTrustedHistoryToPending() async throws { let handler = AgentReadCommandHandler( snapshotProvider: { _ in .success(self.makeSnapshot(status: .working, session: self.makeSession())) }, diff --git a/supacodeTests/CLIAgentNativeHookCommandHandlerTests.swift b/supacodeTests/CLIAgentNativeHookCommandHandlerTests.swift index b550eb39..76e07e7a 100644 --- a/supacodeTests/CLIAgentNativeHookCommandHandlerTests.swift +++ b/supacodeTests/CLIAgentNativeHookCommandHandlerTests.swift @@ -29,6 +29,8 @@ struct CLIAgentNativeHookCommandHandlerTests { let payload = try #require(try response.data?.decode(as: AgentSignalCommandPayload.self)) #expect(payload.signal.source == "hook_claude") #expect(payload.signal.confidence == "exact") + #expect(payload.signal.binding == .current) + #expect(payload.warnings == nil) let encoded = try JSONEncoder().encode(response) let encodedText = try #require(String(bytes: encoded, encoding: .utf8)) #expect(!encodedText.contains(input.token))