Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Scripts/Fixtures/test-suite-contract-ledger.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]] ?? []
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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)
Expand All @@ -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: " ")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ struct OpenCodeACPControllerModelDiscoveryClient: OpenCodeACPModelDiscoveryClien
return snapshot
} catch {
await controller.shutdown()
throw error
throw provider.normalizeError(error)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading