From 583055145ad8310c03f9981faa0e0c42d336b06c Mon Sep 17 00:00:00 2001 From: Iuri Ribeiro Date: Tue, 22 Sep 2026 18:59:00 -0300 Subject: [PATCH 1/2] Report Grok failures instead of Claude's missing response Grok 1.0.40 still exits 0 from grok models while signed out, and an error result omits result and puts the cause in errors. The shared Claude decoder then showed Claude could not finish the response. --- Tests/ai-provider-test.swift | 13 +++++++ Tests/installed-ai-test.swift | 14 +++++++ Tinycast/Features/AI/Model/InstalledAI.swift | 9 +++++ .../Features/AI/Model/InstalledAIStream.swift | 37 ++++++++++++++++--- .../AI/Service/InstalledAIManager.swift | 6 ++- docs/features/ai.md | 8 +++- 6 files changed, 78 insertions(+), 9 deletions(-) diff --git a/Tests/ai-provider-test.swift b/Tests/ai-provider-test.swift index 942a65225..2dc88db75 100644 --- a/Tests/ai-provider-test.swift +++ b/Tests/ai-provider-test.swift @@ -1057,6 +1057,19 @@ struct AIProviderTests { grokFrame.events == [.usage(AIUsage(inputTokens: 5, outputTokens: 1))] && grokFrame.completed && grokFrame.sessionID == "ses_g", "Grok result usage ends the stream and names the session to delete") + let grokError = Data( + #"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["Not signed in."],"session_id":""}"# + .utf8) + let grokErrorFrame = InstalledAIStreamDecoder.decode(grokError, kind: .grok) + expect( + grokErrorFrame.error == "Not signed in." && grokErrorFrame.sessionID == nil + && !grokErrorFrame.completed, + "Grok execution errors name the cause, not Claude, and ignore an empty session id") + let grokBare = Data(#"{"type":"result","is_error":true}"#.utf8) + expect( + InstalledAIStreamDecoder.decode(grokBare, kind: .grok).error + == "Grok could not finish the response.", + "a Grok error with no cause still names Grok") } /// A secret on argv is in `ps`, and a key Codex does not know is a server that never starts. diff --git a/Tests/installed-ai-test.swift b/Tests/installed-ai-test.swift index 8210fb244..6c2c2fb06 100644 --- a/Tests/installed-ai-test.swift +++ b/Tests/installed-ai-test.swift @@ -167,6 +167,20 @@ struct InstalledAITests { expect( models.first?.efforts.map(\.id) == ["low", "medium", "high", "xhigh"], "Grok models expose the CLI's advertised reasoning efforts") + let signedOut = """ + You are not authenticated. + + Default model: grok-4.6 + + Available models: + * grok-4.6 (default) + - grok-4.5 + """ + expect( + !InstalledAIModel.grokSignedIn(signedOut) + && InstalledAIModel.grokCatalog(signedOut).map(\.id) == ["grok-4.6", "grok-4.5"], + "a signed-out Grok catalog is not a login") + expect(InstalledAIModel.grokSignedIn(output), "a logged-in Grok catalog counts as signed in") } static func claudeDiscoveryReadsTheCLIsOwnModelList() { diff --git a/Tinycast/Features/AI/Model/InstalledAI.swift b/Tinycast/Features/AI/Model/InstalledAI.swift index ae7b2f04a..c3ebfef12 100644 --- a/Tinycast/Features/AI/Model/InstalledAI.swift +++ b/Tinycast/Features/AI/Model/InstalledAI.swift @@ -213,6 +213,15 @@ struct InstalledAIModel: Equatable, Identifiable, Sendable { return models } + /// `grok models` exits 0 and still prints the catalog when the CLI is signed out. + static func grokSignedIn(_ output: String) -> Bool { + let clean = output.replacingOccurrences( + of: "\u{001B}\\[[0-9;]*[A-Za-z]", with: "", options: .regularExpression + ) + .lowercased() + return !clean.contains("not authenticated") && !clean.contains("not signed in") + } + static func openCodeCatalog(_ output: String) -> [InstalledAIModel] { let clean = output.replacingOccurrences( of: "\u{001B}\\[[0-9;]*[A-Za-z]", with: "", options: .regularExpression) diff --git a/Tinycast/Features/AI/Model/InstalledAIStream.swift b/Tinycast/Features/AI/Model/InstalledAIStream.swift index 6207a64e3..f8b8b6198 100644 --- a/Tinycast/Features/AI/Model/InstalledAIStream.swift +++ b/Tinycast/Features/AI/Model/InstalledAIStream.swift @@ -23,11 +23,7 @@ enum InstalledAIStreamDecoder { case .openCode: return openCode(object, type: type) case .claude: return claude(object, type: type, servers: servers) case .cursor: return cursor(object, type: type) - case .grok: - // Grok shares the frame shape but never the tools: `--deny *` refuses every call. - var frame = claude(object, type: type, servers: []) - frame.sessionID = object["session_id"] as? String - return frame + case .grok: return grok(object, type: type) case .codex: return InstalledAIStreamFrame() } } @@ -162,6 +158,37 @@ enum InstalledAIStreamDecoder { return (prompt, integer(model["contextWindow"]) ?? 0) } + /// Grok's error result omits `result` and names the cause in `errors`. + private static func grok( + _ object: [String: Any], type: String + ) -> InstalledAIStreamFrame { + // Grok shares the frame shape but never the tools: `--deny *` refuses every call. + var frame = claude(object, type: type, servers: []) + if let sessionID = object["session_id"] as? String, !sessionID.isEmpty { + frame.sessionID = sessionID + } + if type == "result", object["is_error"] as? Bool == true { + frame.error = grokFailure(object) + } + return frame + } + + private static func grokFailure(_ object: [String: Any]) -> String { + if let errors = object["errors"] as? [Any] { + let lines = errors.compactMap { item -> String? in + guard let text = item as? String else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if !lines.isEmpty { return lines.joined(separator: "\n") } + } + if let result = object["result"] as? String { + let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + return "Grok could not finish the response." + } + private static func cursor( _ object: [String: Any], type: String ) -> InstalledAIStreamFrame { diff --git a/Tinycast/Features/AI/Service/InstalledAIManager.swift b/Tinycast/Features/AI/Service/InstalledAIManager.swift index f5bee9d82..c6b0def48 100644 --- a/Tinycast/Features/AI/Service/InstalledAIManager.swift +++ b/Tinycast/Features/AI/Service/InstalledAIManager.swift @@ -232,11 +232,13 @@ final class InstalledAIManager { let models = await InstalledAIProbe.run( executable: executable, arguments: ["models"], workspace: workspace) let catalog = InstalledAIModel.grokCatalog(models.output) + let signedIn = models.status == 0 && InstalledAIModel.grokSignedIn(models.output) return ( kind, InstalledAIStatus( - phase: models.status == 0 && !catalog.isEmpty ? .ready : .signInRequired, - version: version, executable: executable, models: catalog) + phase: signedIn && !catalog.isEmpty ? .ready : .signInRequired, + version: version, executable: executable, + models: signedIn ? catalog : []) ) case .cursor: let auth = await InstalledAIProbe.run( diff --git a/docs/features/ai.md b/docs/features/ai.md index 42f62411f..78d130964 100644 --- a/docs/features/ai.md +++ b/docs/features/ai.md @@ -555,8 +555,10 @@ locations and every nvm Node version, newest first — a fallback that can pick commands are never installed by Tinycast; Settings links to their own install docs and offers a sign-in command to copy. `InstalledAIManager` probes Claude, Grok, OpenCode and Cursor off-main, in parallel. Claude's auth status gates an `initialize` control request, and `InstalledAIModel.claudeCatalog` builds -its model list from the answer; a successful Grok or OpenCode model list is both its auth check and -catalog; Cursor's `status --format json` gates `--list-models`. +its model list from the answer. OpenCode's successful model list is both its auth check and catalog. +Grok's `models` output is the catalog, but a signed-out CLI still exits 0 and prints that catalog under +"You are not authenticated." — that banner is the auth check, not the exit status. Cursor's +`status --format json` gates `--list-models`. `ChatGPTSubscriptionManager` retains its historical type name but now owns only the installed Codex app-server lifecycle and discovered account metadata. Production never sets `CODEX_HOME`, so the @@ -594,6 +596,8 @@ channel and closes it on the CLI's own result frame; writes are chained rather t because two racing the same pipe would interleave a line. Grok uses `streaming-messages-json` and `--effort`, with `--deny *` so tools cannot run even when the user's Grok config is always-approve; it captures the session id, then calls `grok sessions delete`. +An error result omits `result` and carries the cause in `errors`; that text is the failure, not +Claude's missing-`result` fallback. OpenCode runs pure with an inline deny-all configuration and passes the selected model variant through `--variant`; it captures the returned session identifier, then calls `opencode session delete` after the process exits. Cursor runs ask mode with `--trust`, From 0425b47e805405b5f359ea3cca94763a1bf2f532 Mon Sep 17 00:00:00 2001 From: Iuri Ribeiro Date: Tue, 22 Sep 2026 19:58:18 -0300 Subject: [PATCH 2/2] Test signed-out Grok discovery --- Tests/ai-fixtures/installed-cli-stub.js | 18 ++++++++++++++++++ Tests/installed-ai-test.swift | 15 ++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Tests/ai-fixtures/installed-cli-stub.js b/Tests/ai-fixtures/installed-cli-stub.js index bca865599..ae25629fc 100644 --- a/Tests/ai-fixtures/installed-cli-stub.js +++ b/Tests/ai-fixtures/installed-cli-stub.js @@ -164,6 +164,24 @@ composer-2.5 - Composer 2.5 } } +if (command === "grok") { + if (args.includes("--version")) { + console.log("1.0.40"); + process.exit(0); + } + if (args[0] === "models") { + console.log(`You are not authenticated. + +Default model: grok-4.6 + +Available models: + * grok-4.6 (default) + - grok-4.5 +`); + process.exit(0); + } +} + if (command === "grok" && args.slice(0, 2).join(" ") === "sessions delete") { record("grok-deleted.log", args[2]); process.exit(0); diff --git a/Tests/installed-ai-test.swift b/Tests/installed-ai-test.swift index 6c2c2fb06..61f7865c2 100644 --- a/Tests/installed-ai-test.swift +++ b/Tests/installed-ai-test.swift @@ -37,13 +37,14 @@ struct InstalledAITests { defer { fixture.tearDown() } openCodeCatalogCarriesModelVariants() cursorCatalogParsesListModels() - grokCatalogParsesListedModels() statusJSONRecognizesLogin() versionKeepsPrereleaseAndBuild() await openCodeRunsWithoutToolsAndDeletesItsSession(fixture) claudeDiscoveryReadsTheCLIsOwnModelList() await claudeRunsWithoutToolsOrHistory(fixture) await grokRunsWithoutToolsAndDeletesItsSession(fixture) + grokCatalogParsesListedModels() + await grokDiscoveryRequiresLoginAndFiltersModels(fixture) await cursorRunsAskModeWithoutForce(fixture) await cursorDiscoveryRequiresLoginAndListsModels(fixture) await oversizedCompleteFrameFailsTheTurn(fixture) @@ -258,6 +259,18 @@ struct InstalledAITests { + String(describing: usage.contextWindow)) } + private static func grokDiscoveryRequiresLoginAndFiltersModels(_ fixture: Fixture) async { + let manager = InstalledAIManager(supportDirectory: fixture.root) + await manager.refresh(kind: .grok).value + let status = manager.status(for: .grok) + expect( + status.phase == .signInRequired, + "Grok discovery requires sign-in despite a successful catalog response") + expect( + status.models.isEmpty, + "Grok discovery hides listed models while signed out") + } + private static func claudeRunsWithoutToolsOrHistory(_ fixture: Fixture) async { let events = await fixture.events(kind: .claude, model: "sonnet", effort: "xhigh") expect(events.contains(.text("Claude reply")), "Claude text reaches the provider stream")