diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index ca365c336..1a4e57bfb 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -2019,7 +2019,9 @@ root/RepoPromptTests.OpenCodeACPLaunchResolverTests/testDefaultProfileResolvesOp root/RepoPromptTests.OpenCodeACPLaunchResolverTests/testFailedProbeDoesNotLeaveSpawnableCacheAndReplacementCanRecover root Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift RepoPromptTests.OpenCodeACPLaunchResolverTests testFailedProbeDoesNotLeaveSpawnableCacheAndReplacementCanRecover AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.314000 unreviewed retain_pending_review 0 initial census source line 178 root/RepoPromptTests.OpenCodeACPLaunchResolverTests/testMakeLaunchConfigurationResolvesExplicitPathWithoutPriorProbe root Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift RepoPromptTests.OpenCodeACPLaunchResolverTests testMakeLaunchConfigurationResolvesExplicitPathWithoutPriorProbe AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001000 unreviewed retain_pending_review 0 initial census source line 6 root/RepoPromptTests.OpenCodeACPLaunchResolverTests/testOpenCodeHomeBinHintDoesNotLeakIntoNativeDefaultsOrOtherProviders root Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift RepoPromptTests.OpenCodeACPLaunchResolverTests testOpenCodeHomeBinHintDoesNotLeakIntoNativeDefaultsOrOtherProviders AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 79 +root/RepoPromptTests.OpenCodeACPLaunchResolverTests/testPromptTimeoutDoesNotUseOpenCodeStartupGuidance root Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift RepoPromptTests.OpenCodeACPLaunchResolverTests testPromptTimeoutDoesNotUseOpenCodeStartupGuidance AgentMode opencode_acp.timeout.prompt_passthrough timeout,provider_error_boundary,opencode protocol_negative root_swiftpm routine 1 A non-startup ACPRequestTimeoutError for session/prompt remains an apiError carrying the original timeout instead of OpenCode startup-recovery guidance. Mid-session model or prompt stalls could be mislabeled as OpenCode installation/configuration failures, sending users toward irrelevant upgrade guidance. test_case retain 0 PR OpenCode ACP startup timeout guidance boundary coverage root/RepoPromptTests.OpenCodeACPLaunchResolverTests/testRepeatedProbeRefreshesCurrentEnvironmentBeforeSpawn root Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift RepoPromptTests.OpenCodeACPLaunchResolverTests testRepeatedProbeRefreshesCurrentEnvironmentBeforeSpawn AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.299000 unreviewed retain_pending_review 0 initial census source line 90 +root/RepoPromptTests.OpenCodeACPLaunchResolverTests/testSessionNewTimeoutNormalizesToOpenCodeRecoveryGuidance root Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift RepoPromptTests.OpenCodeACPLaunchResolverTests testSessionNewTimeoutNormalizesToOpenCodeRecoveryGuidance AgentMode opencode_acp.timeout.startup_guidance timeout,startup_guidance,version_identity,opencode provider_error_mapping root_swiftpm routine 1 A session/new ACPRequestTimeoutError normalizes to invalidConfiguration containing OpenCode startup guidance, upgrade command, verification command, agent version identity, and the original timeout detail. OpenCode ACP startup hangs could surface as a generic provider/API failure without actionable upgrade or version context for users and support. test_case retain 0 PR OpenCode ACP startup timeout guidance regression coverage root/RepoPromptTests.OpenCodeACPLaunchResolverTests/testWorldWritableExecutableDirectoryIsRejectedWithoutExecution root Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift RepoPromptTests.OpenCodeACPLaunchResolverTests testWorldWritableExecutableDirectoryIsRejectedWithoutExecution AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001000 unreviewed retain_pending_review 0 initial census source line 162 root/RepoPromptTests.OracleMessageFinalisationHubTests/testCancellationBeforeRegistrationResumesOnlyMatchingWaiter root Tests/RepoPromptTests/Chat/OracleMessageFinalisationHubTests.swift RepoPromptTests.OracleMessageFinalisationHubTests testCancellationBeforeRegistrationResumesOnlyMatchingWaiter Chat unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 60 root/RepoPromptTests.OracleMessageFinalisationHubTests/testWaiterCancellationIsLocalAndDoesNotCompleteMessage root Tests/RepoPromptTests/Chat/OracleMessageFinalisationHubTests.swift RepoPromptTests.OracleMessageFinalisationHubTests testWaiterCancellationIsLocalAndDoesNotCompleteMessage Chat unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 6 diff --git a/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPAgentSessionController.swift b/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPAgentSessionController.swift index b22f38d74..8a32c54b5 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPAgentSessionController.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPAgentSessionController.swift @@ -48,7 +48,6 @@ actor ACPAgentSessionController { case processNotRunning case protocolViolation(String) case requestFailed(String, code: Int? = nil) - case requestTimedOut(method: String, timeoutSeconds: TimeInterval, launchDescription: String?, diagnosticHint: String?) case transportClosed var errorDescription: String? { @@ -64,26 +63,10 @@ actor ACPAgentSessionController { return "ACP request failed: \(message) (code \(code))" } return "ACP request failed: \(message)" - case let .requestTimedOut(method, timeoutSeconds, launchDescription, diagnosticHint): - var message = "ACP request \(method) timed out after \(Self.formattedTimeout(timeoutSeconds))." - if let launchDescription, !launchDescription.isEmpty { - message += " Launched: `\(launchDescription)`." - } - if let diagnosticHint, !diagnosticHint.isEmpty { - message += " \(diagnosticHint)" - } - return message case .transportClosed: return "ACP transport closed unexpectedly." } } - - private static func formattedTimeout(_ seconds: TimeInterval) -> String { - if seconds.rounded(.towardZero) == seconds { - return "\(Int(seconds))s" - } - return String(format: "%.1fs", seconds) - } } private enum JSONRPCID: Hashable { @@ -242,6 +225,7 @@ actor ACPAgentSessionController { private var stdoutFramer = LineFramer() private var stderrFramer = LineFramer() private var launchDescription: String? + private var agentIdentityDescription: String? private var stdoutByteCount = 0 private var stdoutLineCount = 0 private var invalidACPLineCount = 0 @@ -470,6 +454,10 @@ actor ACPAgentSessionController { ] ) diagnose(.phaseCompleted("initialize")) + agentIdentityDescription = Self.agentIdentityDescription(from: initializeResponse) + if let agentIdentityDescription { + diagnose(.info("ACP agent identity: \(agentIdentityDescription)")) + } state = .initialized let authMethods = initializeResponse["authMethods"] as? [[String: Any]] ?? [] @@ -589,7 +577,7 @@ actor ACPAgentSessionController { throw error } let message = displayText(for: provider.normalizeError(error)) - if case ControllerError.requestTimedOut = error { + if error is ACPRequestTimeoutError { emit(.stream(AIStreamResult(type: "error", text: message))) log("ACP prompt request timed out; cancelling prompt") } else { @@ -910,14 +898,19 @@ actor ACPAgentSessionController { guard let self else { throw ControllerError.transportClosed } try await waitForPromptSettlement(turnID: turnID) } - group.addTask { [launchDescription, diagnosticHint = timeoutDiagnosticHint()] in + group.addTask { [ + launchDescription, + diagnosticHint = timeoutDiagnosticHint(), + agentIdentityDescription + ] in let duration = UInt64((timeoutSeconds * 1_000_000_000).rounded()) try await Task.sleep(nanoseconds: duration) - throw ControllerError.requestTimedOut( + throw ACPRequestTimeoutError( method: "session/cancel", timeoutSeconds: timeoutSeconds, launchDescription: launchDescription, - diagnosticHint: diagnosticHint + diagnosticHint: diagnosticHint, + agentIdentity: agentIdentityDescription ) } defer { group.cancelAll() } @@ -1577,11 +1570,12 @@ actor ACPAgentSessionController { for storageKey in candidateStorageKeys(for: requestID) { guard let pendingRequest = pendingRequests.removeValue(forKey: storageKey) else { continue } pendingRequest.timeoutTask?.cancel() - let error = ControllerError.requestTimedOut( + let error = ACPRequestTimeoutError( method: method, timeoutSeconds: timeoutSeconds, launchDescription: launchDescription, - diagnosticHint: timeoutDiagnosticHint() + diagnosticHint: timeoutDiagnosticHint(), + agentIdentity: agentIdentityDescription ) diagnose(.info(error.localizedDescription)) pendingRequest.continuation.resume(throwing: error) @@ -1608,6 +1602,22 @@ actor ACPAgentSessionController { return "The process stayed silent on ACP stdout/stderr. This usually means the selected command started an interactive/non-ACP mode instead of an ACP stdio server." } + private static func agentIdentityDescription(from initializeResponse: [String: Any]) -> String? { + guard let info = initializeResponse["agentInfo"] as? [String: Any] else { return nil } + let name = (info["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let version = (info["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + switch (name?.isEmpty == false ? name : nil, version?.isEmpty == false ? version : nil) { + case let (name?, version?): + return "\(name) \(version)" + case let (name?, nil): + return name + case let (nil, version?): + return version + case (nil, nil): + return nil + } + } + private static func displayCommand(command: String, arguments: [String]) -> String { ([command] + arguments).map(displayShellToken).joined(separator: " ") } diff --git a/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPRequestTimeoutError.swift b/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPRequestTimeoutError.swift new file mode 100644 index 000000000..e95f54d63 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPRequestTimeoutError.swift @@ -0,0 +1,30 @@ +import Foundation + +struct ACPRequestTimeoutError: Error, Equatable, LocalizedError { + let method: String + let timeoutSeconds: TimeInterval + let launchDescription: String? + let diagnosticHint: String? + let agentIdentity: String? + + var errorDescription: String? { + var message = "ACP request \(method) timed out after \(Self.formattedTimeout(timeoutSeconds))." + if let launchDescription, !launchDescription.isEmpty { + message += " Launched: `\(launchDescription)`." + } + if let agentIdentity, !agentIdentity.isEmpty { + message += " Agent: `\(agentIdentity)`." + } + if let diagnosticHint, !diagnosticHint.isEmpty { + message += " \(diagnosticHint)" + } + return message + } + + private static func formattedTimeout(_ seconds: TimeInterval) -> String { + if seconds.rounded(.towardZero) == seconds { + return "\(Int(seconds))s" + } + return String(format: "%.1fs", seconds) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/OpenCode/OpenCodeACPAgentProvider.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/OpenCode/OpenCodeACPAgentProvider.swift index 3d29b734f..c472cf4be 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/OpenCode/OpenCodeACPAgentProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/OpenCode/OpenCodeACPAgentProvider.swift @@ -128,12 +128,26 @@ struct OpenCodeACPAgentProvider: ACPAgentProvider { if error is OpenCodeACPLaunchResolutionError || error is ExecutableFileIdentityError { return AIProviderError.invalidConfiguration(detail: error.localizedDescription) } + if let guidance = Self.openCodeACPStartupGuidance(for: error) { + return AIProviderError.invalidConfiguration(detail: guidance) + } if (error as NSError).domain == NSCocoaErrorDomain { return AIProviderError.invalidConfiguration(detail: "Unable to prepare OpenCode ACP config: \(error.localizedDescription)") } return AIProviderError.apiError(source: error) } + private static func openCodeACPStartupGuidance(for error: Error) -> String? { + guard let timeout = error as? ACPRequestTimeoutError, + ["initialize", "authenticate", "session/new", "session/load"].contains(timeout.method) + else { + return nil + } + + let message = timeout.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return "OpenCode ACP did not finish session startup. Update OpenCode with `opencode upgrade`, then verify `opencode --version` and `opencode acp --help` return promptly before retrying. Original error: \(message)" + } + private func standardizedWorkingDirectory(from workspacePath: String?) -> String { let cwd = workspacePath?.trimmingCharacters(in: .whitespacesAndNewlines) return (cwd?.isEmpty == false ? cwd : nil) diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/OpenCode/OpenCodeACPModelPollingService.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/OpenCode/OpenCodeACPModelPollingService.swift index 3c610dcb1..615556cbc 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/OpenCode/OpenCodeACPModelPollingService.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/OpenCode/OpenCodeACPModelPollingService.swift @@ -60,7 +60,7 @@ struct OpenCodeACPControllerModelDiscoveryClient: OpenCodeACPModelDiscoveryClien return snapshot } catch { await controller.shutdown() - throw error + throw provider.normalizeError(error) } } } diff --git a/Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift b/Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift index 53bb0642c..191d29b81 100644 --- a/Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift +++ b/Tests/RepoPromptTests/AgentMode/OpenCodeACPLaunchResolverTests.swift @@ -222,6 +222,53 @@ final class OpenCodeACPLaunchResolverTests: XCTestCase { } } + func testSessionNewTimeoutNormalizesToOpenCodeRecoveryGuidance() { + let provider = makeProviderForNormalization() + let timeout = ACPRequestTimeoutError( + method: "session/new", + timeoutSeconds: 30, + launchDescription: "/Users/me/.opencode/bin/opencode acp", + diagnosticHint: "The process wrote stdout, but no matching ACP response arrived for this request.", + agentIdentity: "OpenCode 1.17.11" + ) + + let normalized = provider.normalizeError(timeout) + + guard case let AIProviderError.invalidConfiguration(detail) = normalized else { + return XCTFail("Unexpected normalized error: \(normalized)") + } + XCTAssertTrue(detail.contains("OpenCode ACP did not finish session startup")) + XCTAssertTrue(detail.contains("opencode upgrade")) + XCTAssertTrue(detail.contains("opencode acp --help")) + XCTAssertTrue(detail.contains("OpenCode 1.17.11")) + XCTAssertTrue(detail.contains("session/new timed out")) + } + + func testPromptTimeoutDoesNotUseOpenCodeStartupGuidance() { + let provider = makeProviderForNormalization() + let timeout = ACPRequestTimeoutError( + method: "session/prompt", + timeoutSeconds: 30, + launchDescription: "opencode acp", + diagnosticHint: nil, + agentIdentity: "OpenCode 1.17.14" + ) + + let normalized = provider.normalizeError(timeout) + + guard case let AIProviderError.apiError(source) = normalized else { + return XCTFail("Unexpected normalized error: \(normalized)") + } + XCTAssertEqual((source as? ACPRequestTimeoutError), timeout) + } + + private func makeProviderForNormalization() -> OpenCodeACPAgentProvider { + OpenCodeACPAgentProvider( + config: OpenCodeAgentConfig(commandName: "opencode"), + launchResolver: OpenCodeACPLaunchResolver() + ) + } + private func makeRunRequest(workspacePath: String) -> ACPRunRequest { ACPRunRequest( agentKind: .openCode,