diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 0aad843b83be..2e15aa1deef2 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift index f5dc6eafdb0e..e235133947af 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift @@ -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( diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index 919a5634599a..fb9cf67c4292 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -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": diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift index 7861cbe979d1..314092ebefa6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift @@ -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`() { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift index f8339f3dcd1f..a303f5c252fa 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift @@ -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: diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 525ee5f19e94..a2ee9dc4dbc4 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -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.