fix(macos): keep polling gateway-executed wizard steps (#114383)

* fix(macos): keep polling gateway-executed wizard steps

* docs(ui): note the prebuilt control-UI bundle in live verification

* test(macos): fail fast when gateway progress polling stalls

* chore(i18n): refresh native source inventory anchors
This commit is contained in:
Peter Steinberger
2026-07-27 04:33:09 -04:00
committed by GitHub
parent 45bad50e26
commit 5a8db91459
6 changed files with 83 additions and 16 deletions
+3 -3
View File
@@ -39459,7 +39459,7 @@
},
{
"kind": "conditional-branch",
"line": 460,
"line": 470,
"path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift",
"source": " [\\(initial)]",
"surface": "apple",
@@ -39467,7 +39467,7 @@
},
{
"kind": "conditional-branch",
"line": 507,
"line": 517,
"path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift",
"source": " — \\(option.hint!)",
"surface": "apple",
@@ -39475,7 +39475,7 @@
},
{
"kind": "conditional-branch",
"line": 514,
"line": 524,
"path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift",
"source": " [\\(initialIndices.map(String.init).joined(separator: \",\"))]",
"surface": "apple",
@@ -1344,6 +1344,13 @@ extension OnboardingAISetupModel {
self.authSelection = max(0, options.firstIndex {
anyCodableEqual($0.value, step?.initialvalue)
} ?? 0)
// Gateway-executed steps render progress and expose no input control, so
// no user action would ever ask for the next frame. Keep polling; the
// session long-polls until the next update or the terminal result, so a
// download reports live instead of freezing on its first frame.
if let step, wizardStepExecutor(step) == "gateway" {
self.advanceProviderAuth(stepID: nil, value: nil)
}
}
private func reconcileProviderAuthAfterUnknownOutcome(
@@ -381,7 +381,10 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
fputs("wizard: \(error)\n", stderr)
}
if let step = nextResult.step {
// Gateway-executed steps (download/install progress) take no answer;
// echo the frame and poll, or the run stalls on input that can never
// advance the session.
if let step = nextResult.step, wizardStepExecutor(step) != "gateway" {
let answer = try promptAnswer(for: step)
var answerPayload: [String: ProtoAnyCodable] = [
"stepId": ProtoAnyCodable(step.id),
@@ -400,6 +403,9 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
dumpResult(response)
}
} else {
if let step = nextResult.step, !opts.json {
printWizardStepHeader(step)
}
let response = try await client.request(
method: "wizard.next",
params: ["sessionId": ProtoAnyCodable(sessionId)])
@@ -429,14 +435,18 @@ private func dumpResult(_ response: ResponseFrame) {
}
}
private func promptAnswer(for step: WizardStep) throws -> Any {
let type = wizardStepType(step)
private func printWizardStepHeader(_ step: WizardStep) {
if let title = step.title, !title.isEmpty {
print("\n\(title)")
}
if let message = step.message, !message.isEmpty {
print(message)
}
}
private func promptAnswer(for step: WizardStep) throws -> Any {
let type = wizardStepType(step)
printWizardStepHeader(step)
switch type {
case "note":
@@ -372,12 +372,21 @@ private func wizardStartResponse(id: String, sessionID: String) -> Data {
""".utf8)
}
private func wizardProgressResponse(id: String, sessionID: String) -> Data {
private func wizardProgressResponse(id: String, sessionID: String, message: String) -> Data {
Data(
"""
{"type":"res","id":"\(id)","ok":true,"payload":{
"sessionId":"\(sessionID)","done":false,"status":"running",
"step":{"id":"download","type":"progress","message":"Downloading model: 25%"}
"step":{"id":"download","type":"progress","executor":"gateway","message":"\(message)"}
}}
""".utf8)
}
private func wizardDoneResponse(id: String, sessionID: String) -> Data {
Data(
"""
{"type":"res","id":"\(id)","ok":true,"payload":{
"sessionId":"\(sessionID)","done":true,"status":"done"
}}
""".utf8)
}
@@ -637,8 +646,10 @@ struct OnboardingAISetupTests {
"openclaw.setup.prepare.start")
}
@Test func `prepare action starts shared wizard with the local auth choice`() async throws {
@Test func `prepare starts the shared wizard and polls gateway progress`() async throws {
let recorder = AISetupRequestRecorder()
let frames = AISetupSocketGeneration()
let completion = AISetupRequestGate()
let session = GatewayTestWebSocketSession(taskFactory: {
GatewayTestWebSocketTask(
sendHook: { task, message, sendIndex in
@@ -657,9 +668,26 @@ struct OnboardingAISetupTests {
sessionID: sessionID)))
case "wizard.next":
let sessionID = request.params["sessionId"] as? String ?? "prepare-session"
task.emitReceiveSuccess(.data(wizardProgressResponse(
id: request.id,
sessionID: sessionID)))
// Two gateway-executed progress frames, then the terminal
// result: a client that stops after the first frame never
// reaches either follow-up.
switch frames.claim() {
case 0:
task.emitReceiveSuccess(.data(wizardProgressResponse(
id: request.id,
sessionID: sessionID,
message: "Downloading model: 25%")))
case 1:
task.emitReceiveSuccess(.data(wizardProgressResponse(
id: request.id,
sessionID: sessionID,
message: "Downloading model: 80%")))
default:
await completion.wait()
task.emitReceiveSuccess(.data(wizardDoneResponse(
id: request.id,
sessionID: sessionID)))
}
default:
break
}
@@ -685,19 +713,30 @@ struct OnboardingAISetupTests {
await model.detectAndAutoConnect()
let option = try #require(model.prepareOptions.first { $0.id == "llama-cpp" })
model.startProviderPrepare(option)
let requests = await waitForAISetupRequests(recorder, count: 3)
// Bounded wait, not `completion.waitUntilStarted()`: a client that stops
// polling never reaches the gated frame, and this must fail rather than
// hang. Once five requests are recorded the third `wizard.next` is held
// at the gate, so the sheet deterministically shows the second frame.
let requests = await waitForAISetupRequests(recorder, count: 5)
#expect(requests.methods == [
#expect(Array(requests.methods.prefix(5)) == [
"openclaw.setup.detect",
"openclaw.setup.prepare.start",
"wizard.next",
"wizard.next",
"wizard.next",
])
#expect(requests.authChoices == ["llama-cpp"])
#expect(model.isPreparingModel)
for _ in 0..<200 where model.authStep.map(wizardStepType) != "progress" {
#expect(model.authStep.map(wizardStepType) == "progress")
#expect(model.authStep?.message == "Downloading model: 80%")
await completion.release()
for _ in 0..<200 where model.activeAuthOption != nil {
try? await Task.sleep(nanoseconds: 5_000_000)
}
#expect(model.authStep.map(wizardStepType) == "progress")
#expect(model.activeAuthOption == nil)
#expect(model.authError == nil)
}
@Test func `provider auth opens only safe external links`() {
@@ -57,6 +57,13 @@ public func wizardStepType(_ step: WizardStep) -> String {
(step.type.value as? String) ?? ""
}
/// `"gateway"` marks a step the Gateway runs itself (download/install progress).
/// Those steps carry no answer, so clients must poll for the next frame instead
/// of waiting for input that will never come.
public func wizardStepExecutor(_ step: WizardStep) -> String {
(step.executor?.value as? String) ?? ""
}
public func anyCodableString(_ value: AnyCodable?) -> String {
switch value?.value {
case let string as String:
+4
View File
@@ -23,6 +23,10 @@ This directory owns Control UI-specific guidance that should not live in the rep
- Icons: shared 24x24 Lucide icons go through `strokeIcon()` in `ui/src/components/icons-tools.ts` so stroke presentation attributes stay inline and render inside shadow roots. Icon bodies are `svg\`\``fragments, never`html\`\`` (wrong namespace renders nothing).
- `pnpm lint:ui:lit` is an opt-in lit-analyzer diagnostic for template bindings (slow, ~9 min; known baseline of pre-existing findings). It is not a CI gate.
## Live Verification
- The Gateway serves the prebuilt bundle from `dist/control-ui`; editing `ui/src` changes nothing live until `pnpm ui:build`. Confirm the served `/assets/index-*.js` hash changed before trusting a live result.
## Scope
- Keep UI-specific rules here.