diff --git a/src/commands/status-all/gateway.test.ts b/src/commands/status-all/gateway.test.ts index 5ff5da2bc6e0..52e9514ec215 100644 --- a/src/commands/status-all/gateway.test.ts +++ b/src/commands/status-all/gateway.test.ts @@ -24,6 +24,44 @@ describe("summarizeLogTail", () => { expect(lines).toEqual(["[openai] token refresh 401 invalid_grant · re-auth required"]); }); + it.each([ + ["closing brace", "Session invalidated } due to signing in again"], + ["opening brace", "Session invalidated { due to signing in again"], + ["escaped quote before a brace", 'Session invalidated after "}" signing in again'], + ])("keeps OAuth JSON diagnostics with a %s", (_caseName, message) => { + const sentinel = "[gateway] synthetic sentinel after OAuth JSON"; + const lines = summarizeLogTail([ + "[openai] Token refresh failed: 401 {", + `"error":${JSON.stringify({ code: "invalid_grant", message })}`, + "}", + sentinel, + ]); + + expect(lines).toEqual([ + "[openai] token refresh 401 invalid_grant · re-auth required", + sentinel, + ]); + }); + + it("parses the first complete OAuth JSON object before an adjacent object", () => { + const lines = summarizeLogTail([ + "[openai] Token refresh failed: 401 {", + '"error":{"code":"invalid_grant","message":"Session invalidated due to signing in again"}}{"ignored":true}', + ]); + + expect(lines).toEqual(["[openai] token refresh 401 invalid_grant · re-auth required"]); + }); + + it("consumes an incomplete OAuth JSON block through the end of the tail", () => { + const lines = summarizeLogTail([ + "[openai] Token refresh failed: 401 {", + '"error":{"code":"invalid_grant","message":"Session invalidated due to signing in again"}', + "unrelated-looking trailing line", + ]); + + expect(lines).toEqual(["[openai] token refresh 401"]); + }); + it("keeps bounded OAuth diagnostics UTF-16 well-formed", () => { const lines = summarizeLogTail([ "[openai] Token refresh failed: 500 {", diff --git a/src/commands/status-all/gateway.ts b/src/commands/status-all/gateway.ts index bdd1d575d492..81861f23f498 100644 --- a/src/commands/status-all/gateway.ts +++ b/src/commands/status-all/gateway.ts @@ -5,6 +5,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { classifyOAuthRefreshFailureReason } from "../../agents/auth-profiles/oauth-refresh-failure.js"; import { readGatewayLogTailLines } from "../../daemon/diagnostics.js"; +import { extractBalancedJsonPrefix } from "../../shared/balanced-json.js"; /** Reads the last non-empty lines from a gateway log file, returning an empty list on read failure. */ export async function readFileTailLines(filePath: string, maxLines: number): Promise { @@ -16,13 +17,6 @@ export async function readFileTailLines(filePath: string, maxLines: number): Pro return out.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0); } -function countMatches(haystack: string, needle: string): number { - if (!haystack || !needle) { - return 0; - } - return haystack.split(needle).length - 1; -} - function shorten(message: string, maxLen: number): string { const cleaned = message.replace(/\s+/g, " ").trim(); if (cleaned.length <= maxLen) { @@ -51,16 +45,15 @@ function consumeJsonBlock( return null; } - const parts: string[] = [startLine.slice(braceAt)]; - let depth = countMatches(parts[0] ?? "", "{") - countMatches(parts[0] ?? "", "}"); - let i = startIndex; - while (depth > 0 && i + 1 < lines.length) { - i += 1; - const next = lines[i] ?? ""; - parts.push(next); - depth += countMatches(next, "{") - countMatches(next, "}"); + const raw = [startLine.slice(braceAt), ...lines.slice(startIndex + 1)].join("\n"); + const fragment = extractBalancedJsonPrefix(raw); + if (!fragment) { + // A bounded tail can end mid-object. Consume the rest so orphaned JSON + // fields do not escape into the user-facing diagnosis as ordinary lines. + return { json: raw, endIndex: lines.length - 1 }; } - return { json: parts.join("\n"), endIndex: i }; + const consumedLineOffset = fragment.json.split("\n").length - 1; + return { json: fragment.json, endIndex: startIndex + consumedLineOffset }; } /** Summarizes gateway log tail lines, grouping repeated failures and trimming long output. */