mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
45bad50e26
commit
5a8db91459
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user