Skip to content

Commit 5564c2c

Browse files
committed
Keep OpenCode Go Zen wait scoped to usage reads
1 parent 8fd66a4 commit 5564c2c

7 files changed

Lines changed: 231 additions & 83 deletions

File tree

Sources/CodexBarCLI/CLIUsageCommand.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ extension CodexBarCLI {
424424
runtime: .cli,
425425
sourceMode: effectiveSourceMode,
426426
includeCredits: command.includeCredits,
427+
requiresOptionalUsageCompleteness: true,
427428
webTimeout: command.webTimeout,
428429
webDebugDumpHTML: command.webDebugDumpHTML,
429430
verbose: command.verbose,

Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy {
175175
let zenBalance = try await OpenCodeGoUsageFetcher.completedOptionalZenBalance(
176176
from: zenBalanceTask,
177177
timeout: OpenCodeGoUsageFetchStrategy.shouldWaitForZenBalance(context: context)
178-
? nil
178+
? .seconds(OpenCodeGoUsageFetcher.optionalZenBalanceTimeout)
179179
: OpenCodeGoUsageFetcher.optionalZenBalanceJoinGrace)
180180
return (snapshot.withZenBalanceUSD(zenBalance), false)
181181
}
@@ -231,11 +231,12 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy {
231231
let id: String = "opencodego.web"
232232
let kind: ProviderFetchKind = .web
233233

234-
/// One-shot CLI usage reads are foreground commands, so a Zen balance that is merely slower
235-
/// than the subscription page is worth waiting for (bounded by the balance request timeout).
236-
/// The menu-bar app keeps the short optional join grace so refreshes never stall behind it.
234+
/// Usage-snapshot reads (`codexbar usage`, `codexbar serve`) are foreground commands, so a
235+
/// Zen balance that is merely slower than the subscription page is worth waiting for, bounded
236+
/// by the optional-balance timeout. Guard and diagnostic commands keep the short optional join
237+
/// grace so a slow balance cannot consume their deadline.
237238
static func shouldWaitForZenBalance(context: ProviderFetchContext) -> Bool {
238-
context.runtime == .cli
239+
context.requiresOptionalUsageCompleteness
239240
}
240241

241242
func isAvailable(_ context: ProviderFetchContext) async -> Bool {

Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ public struct OpenCodeGoUsageFetcher: Sendable {
153153
return try await self.fetchZenBalance(
154154
workspaceID: workspaceID,
155155
cookieHeader: requestCookieHeader,
156-
timeout: waitForZenBalance ? min(timeout, self.optionalZenBalanceTimeout) : timeout,
156+
timeout: timeout,
157157
session: session)
158158
} : nil
159159
defer {
@@ -196,7 +196,9 @@ public struct OpenCodeGoUsageFetcher: Sendable {
196196
}
197197
let zenBalance = try await self.completedOptionalZenBalance(
198198
from: zenBalanceTask,
199-
timeout: waitForZenBalance ? nil : Self.optionalZenBalanceJoinGrace)
199+
timeout: waitForZenBalance
200+
? .seconds(self.optionalZenBalanceTimeout)
201+
: Self.optionalZenBalanceJoinGrace)
200202
return snapshot.withZenBalanceUSD(zenBalance)
201203
}
202204

Sources/CodexBarCore/Providers/ProviderFetchPlan.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ public struct ProviderFetchContext: Sendable {
2525
public let sourceMode: ProviderSourceMode
2626
public let includeCredits: Bool
2727
public let includeOptionalUsage: Bool
28+
/// Whether this fetch should wait for optional usage data (such as prepaid balances) to
29+
/// complete instead of bounding it with the short optional join grace. Usage-snapshot
30+
/// reads enable this; guard and diagnostic commands keep the bounded join so a slow
31+
/// optional request cannot consume their deadline.
32+
public let requiresOptionalUsageCompleteness: Bool
2833
public let webTimeout: TimeInterval
2934
public let webDebugDumpHTML: Bool
3035
public let verbose: Bool
@@ -52,6 +57,7 @@ public struct ProviderFetchContext: Sendable {
5257
sourceMode: ProviderSourceMode,
5358
includeCredits: Bool,
5459
includeOptionalUsage: Bool = true,
60+
requiresOptionalUsageCompleteness: Bool = false,
5561
webTimeout: TimeInterval,
5662
webDebugDumpHTML: Bool,
5763
verbose: Bool,
@@ -71,6 +77,7 @@ public struct ProviderFetchContext: Sendable {
7177
self.sourceMode = sourceMode
7278
self.includeCredits = includeCredits
7379
self.includeOptionalUsage = includeOptionalUsage
80+
self.requiresOptionalUsageCompleteness = requiresOptionalUsageCompleteness
7481
self.webTimeout = webTimeout
7582
self.webDebugDumpHTML = webDebugDumpHTML
7683
self.verbose = verbose

Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ struct OpenCodeGoProviderStrategyTests {
2020
private func makeContext(
2121
runtime: ProviderRuntime = .app,
2222
sourceMode: ProviderSourceMode = .auto,
23+
requiresOptionalUsageCompleteness: Bool = false,
2324
env: [String: String] = [:],
2425
settings: ProviderSettingsSnapshot? = nil,
2526
selectedTokenAccountID: UUID? = nil) -> ProviderFetchContext
@@ -28,6 +29,7 @@ struct OpenCodeGoProviderStrategyTests {
2829
runtime: runtime,
2930
sourceMode: sourceMode,
3031
includeCredits: false,
32+
requiresOptionalUsageCompleteness: requiresOptionalUsageCompleteness,
3133
webTimeout: 1,
3234
webDebugDumpHTML: false,
3335
verbose: false,
@@ -144,10 +146,12 @@ struct OpenCodeGoProviderStrategyTests {
144146
}
145147

146148
@Test
147-
func `web strategy waits for zen balance only in cli runtime`() {
149+
func `web strategy waits for zen balance only on usage completeness reads`() {
148150
#expect(!OpenCodeGoUsageFetchStrategy.shouldWaitForZenBalance(
149151
context: self.makeContext(runtime: .app)))
150-
#expect(OpenCodeGoUsageFetchStrategy.shouldWaitForZenBalance(
152+
#expect(!OpenCodeGoUsageFetchStrategy.shouldWaitForZenBalance(
151153
context: self.makeContext(runtime: .cli)))
154+
#expect(OpenCodeGoUsageFetchStrategy.shouldWaitForZenBalance(
155+
context: self.makeContext(runtime: .cli, requiresOptionalUsageCompleteness: true)))
152156
}
153157
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import Foundation
2+
import Testing
3+
@testable import CodexBarCore
4+
5+
@Suite(.serialized)
6+
struct OpenCodeGoUsageFetcherCLIWaitTests {
7+
private struct UsageWindow {
8+
let percent: Double
9+
let resetInSec: Int
10+
}
11+
12+
private func makeSession() -> URLSession {
13+
let config = URLSessionConfiguration.ephemeral
14+
config.protocolClasses = [OpenCodeGoCLIWaitStubURLProtocol.self]
15+
return URLSession(configuration: config)
16+
}
17+
18+
@Test
19+
func `cli wait policy includes slow but successful zen balance`() async throws {
20+
defer {
21+
OpenCodeGoCLIWaitStubURLProtocol.handler = nil
22+
}
23+
24+
OpenCodeGoCLIWaitStubURLProtocol.handler = { request in
25+
guard let url = request.url else { throw URLError(.badURL) }
26+
if url.path == "/workspace/wrk_TEST123" {
27+
Thread.sleep(forTimeInterval: 1)
28+
return Self.makeResponse(
29+
url: url,
30+
body: #"<html><body><h2>現在の残高 $98.76</h2></body></html>"#,
31+
statusCode: 200,
32+
contentType: "text/html")
33+
}
34+
return Self.makeResponse(
35+
url: url,
36+
body: Self.goUsagePageHTML(
37+
workspaceID: "wrk_TEST123",
38+
rolling: UsageWindow(percent: 17, resetInSec: 600),
39+
weekly: UsageWindow(percent: 75, resetInSec: 7200),
40+
monthly: nil),
41+
statusCode: 200,
42+
contentType: "text/html")
43+
}
44+
45+
let start = ContinuousClock.now
46+
let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage(
47+
cookieHeader: "auth=test",
48+
timeout: 60,
49+
workspaceIDOverride: "wrk_TEST123",
50+
waitForZenBalance: true,
51+
session: self.makeSession())
52+
let elapsed = start.duration(to: ContinuousClock.now)
53+
54+
#expect(snapshot.rollingUsagePercent == 17)
55+
#expect(snapshot.zenBalanceUSD == 98.76)
56+
#expect(elapsed >= .milliseconds(900))
57+
}
58+
59+
@Test
60+
func `cli wait policy keeps subscription result when balance fetch fails`() async throws {
61+
defer {
62+
OpenCodeGoCLIWaitStubURLProtocol.handler = nil
63+
}
64+
65+
OpenCodeGoCLIWaitStubURLProtocol.handler = { request in
66+
guard let url = request.url else { throw URLError(.badURL) }
67+
if url.path == "/workspace/wrk_TEST123" {
68+
throw URLError(.timedOut)
69+
}
70+
return Self.makeResponse(
71+
url: url,
72+
body: Self.goUsagePageHTML(
73+
workspaceID: "wrk_TEST123",
74+
rolling: UsageWindow(percent: 17, resetInSec: 600),
75+
weekly: UsageWindow(percent: 75, resetInSec: 7200),
76+
monthly: nil),
77+
statusCode: 200,
78+
contentType: "text/html")
79+
}
80+
81+
let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage(
82+
cookieHeader: "auth=test",
83+
timeout: 60,
84+
workspaceIDOverride: "wrk_TEST123",
85+
waitForZenBalance: true,
86+
session: self.makeSession())
87+
88+
#expect(snapshot.rollingUsagePercent == 17)
89+
#expect(snapshot.zenBalanceUSD == nil)
90+
}
91+
92+
@Test
93+
func `cli wait policy keeps configured timeout when zen balance becomes required`() async throws {
94+
defer {
95+
OpenCodeGoCLIWaitStubURLProtocol.handler = nil
96+
}
97+
98+
var rootTimeout: TimeInterval?
99+
OpenCodeGoCLIWaitStubURLProtocol.handler = { request in
100+
guard let url = request.url else { throw URLError(.badURL) }
101+
if url.path == "/workspace/wrk_TEST123" {
102+
rootTimeout = request.timeoutInterval
103+
return Self.makeResponse(
104+
url: url,
105+
body: #"<html><body><h2>Current balance $17.25</h2></body></html>"#,
106+
statusCode: 200,
107+
contentType: "text/html")
108+
}
109+
return Self.makeResponse(
110+
url: url,
111+
body: #"<script>rollingUsage:{usagePercent:12}</script>"#,
112+
statusCode: 200,
113+
contentType: "text/html")
114+
}
115+
116+
let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage(
117+
cookieHeader: "auth=test",
118+
timeout: 60,
119+
workspaceIDOverride: "wrk_TEST123",
120+
waitForZenBalance: true,
121+
session: self.makeSession())
122+
123+
#expect(snapshot.isBalanceOnly)
124+
#expect(snapshot.zenBalanceUSD == 17.25)
125+
#expect(rootTimeout == 60)
126+
}
127+
128+
private static func goUsagePageHTML(
129+
workspaceID: String,
130+
rolling: UsageWindow,
131+
weekly: UsageWindow,
132+
monthly: UsageWindow?) -> String
133+
{
134+
let monthlyField: String? = if let monthly {
135+
#"monthlyUsage:{status:"ok",resetInSec:\#(monthly.resetInSec),usagePercent:\#(monthly.percent)}"#
136+
} else {
137+
nil
138+
}
139+
140+
let usageFields = [
141+
#"rollingUsage:{status:"ok",resetInSec:\#(rolling.resetInSec),usagePercent:\#(rolling.percent)}"#,
142+
#"weeklyUsage:{status:"ok",resetInSec:\#(weekly.resetInSec),usagePercent:\#(weekly.percent)}"#,
143+
monthlyField,
144+
]
145+
.compactMap(\.self)
146+
.joined(separator: ",")
147+
148+
return """
149+
<!DOCTYPE html>
150+
<html>
151+
<body>
152+
<script>
153+
_$HY.r["lite.subscription.get[\\"\(workspaceID)\\"]"]=$R[17]=$R[2]($R[18]={p:0,s:0,f:0});
154+
$R[24]($R[18],$R[27]={mine:!0,useBalance:!1,\(usageFields)});
155+
</script>
156+
</body>
157+
</html>
158+
"""
159+
}
160+
161+
private static func makeResponse(
162+
url: URL,
163+
body: String,
164+
statusCode: Int,
165+
contentType: String) -> (HTTPURLResponse, Data)
166+
{
167+
let response = HTTPURLResponse(
168+
url: url,
169+
statusCode: statusCode,
170+
httpVersion: "HTTP/1.1",
171+
headerFields: ["Content-Type": contentType])!
172+
return (response, Data(body.utf8))
173+
}
174+
}
175+
176+
private final class OpenCodeGoCLIWaitStubURLProtocol: URLProtocol {
177+
private static let handlerBox = LockIsolated<((URLRequest) throws -> (HTTPURLResponse, Data))?>(nil)
178+
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? {
179+
get { Self.handlerBox.value }
180+
set { Self.handlerBox.setValue(newValue) }
181+
}
182+
183+
override static func canInit(with request: URLRequest) -> Bool {
184+
request.url?.host == "opencode.ai"
185+
}
186+
187+
override static func canonicalRequest(for request: URLRequest) -> URLRequest {
188+
request
189+
}
190+
191+
override func startLoading() {
192+
guard let handler = Self.handler else {
193+
self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
194+
return
195+
}
196+
do {
197+
let (response, data) = try handler(self.request)
198+
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
199+
self.client?.urlProtocol(self, didLoad: data)
200+
self.client?.urlProtocolDidFinishLoading(self)
201+
} catch {
202+
self.client?.urlProtocol(self, didFailWithError: error)
203+
}
204+
}
205+
206+
override func stopLoading() {}
207+
}

0 commit comments

Comments
 (0)