diff --git a/scripts/e2e/lib/package-git-fixture.mjs b/scripts/e2e/lib/package-git-fixture.mjs index 09845666df8f..d4ce968d7e32 100644 --- a/scripts/e2e/lib/package-git-fixture.mjs +++ b/scripts/e2e/lib/package-git-fixture.mjs @@ -47,6 +47,7 @@ function prepare(root) { ...packageJson.scripts, openclaw: "node openclaw.mjs", }; + delete packageJson.scripts.postinstall; const aiRuntimeSource = path.join(root, "node_modules", "@openclaw", "ai"); const aiRuntimePackageJson = path.join(aiRuntimeSource, "package.json"); if (!fs.existsSync(aiRuntimePackageJson)) { diff --git a/src/agents/cli-output-stream.ts b/src/agents/cli-output-stream.ts index fc2e3060cbce..d8382879f2ca 100644 --- a/src/agents/cli-output-stream.ts +++ b/src/agents/cli-output-stream.ts @@ -140,7 +140,10 @@ export function normalizeClaudeCliStreamJsonRecord( return normalized ? { line: JSON.stringify(parsed), omittedRawChars } : undefined; } -function streamJsonOutputLimitErrorText(kind: "raw" | "line" | "lines", limit: number): string { +export function streamJsonOutputLimitErrorText( + kind: "raw" | "line" | "lines", + limit: number, +): string { if (kind === "line") { return `CLI JSONL line exceeded ${limit} characters; refusing to parse output.`; } diff --git a/src/agents/cli-runner/claude-live-turn.test.ts b/src/agents/cli-runner/claude-live-turn.test.ts index 4c808e98f4a1..ad9e2890fc16 100644 --- a/src/agents/cli-runner/claude-live-turn.test.ts +++ b/src/agents/cli-runner/claude-live-turn.test.ts @@ -847,7 +847,7 @@ describe("Claude live turn output bounds and result projection", () => { }); await expect(startLiveTurn("run-live-oversized-line", false)).rejects.toThrow( - "Claude CLI JSONL line exceeded output limit.", + "CLI JSONL line exceeded 8388608 characters; refusing to parse output.", ); }); @@ -855,14 +855,17 @@ describe("Claude live turn output bounds and result projection", () => { { name: "a coalesced blank-frame flood", createChunk: () => "\n".repeat(20_001), + expectedError: "CLI JSONL output exceeded 20000 lines; refusing to parse output.", }, { name: "whitespace-only records exceeding the raw budget", createChunk: () => `${" ".repeat(4_300_000)}\n${" ".repeat(4_300_000)}\n`, + expectedError: "CLI JSONL output exceeded 8388608 characters; refusing to parse output.", }, { name: "valid JSON padded beyond the raw budget", createChunk: () => `${" ".repeat(4_300_000)}{}\n${" ".repeat(4_300_000)}{}\n`, + expectedError: "CLI JSONL output exceeded 8388608 characters; refusing to parse output.", }, { name: "internal formatting around compacted Claude media", @@ -886,14 +889,32 @@ describe("Claude live turn output bounds and result projection", () => { }).replace('"message":', `"message":${" ".repeat(4_300_000)}`); return `${line}\n${line}\n`; }, + expectedError: "CLI JSONL output exceeded 8388608 characters; refusing to parse output.", }, - ])("rejects $name from the managed Claude live session", async ({ createChunk }) => { + ])("reports the exact limit for $name", async ({ createChunk, expectedError }) => { const live: ReturnType = mockClaudeLiveRun(supervisorSpawnMock, { onWrite: () => live.spawnInput.onStdout?.(createChunk()), }); - await expect(startLiveTurn("run-live-output-budget", false)).rejects.toThrow( - "Claude CLI turn output exceeded limit.", + await expect(startLiveTurn("run-live-output-budget", false)).rejects.toThrow(expectedError); + }); + + it("reports backend JSONL parser failures without relabeling them as output limits", async () => { + mockClaudeLiveRun(supervisorSpawnMock, { + events: [{ type: "system", subtype: "init", session_id: "live-parser-error" }], + }); + + await expectRejectsWithFields( + startLiveTurn("run-live-parser-error", false, { + parseJsonlEvent: () => { + throw new Error("invalid custom event"); + }, + }), + { + name: "FailoverError", + reason: "format", + message: "CLI backend claude-cli JSONL parser failed: invalid custom event", + }, ); }); diff --git a/src/agents/cli-runner/claude-live-turn.ts b/src/agents/cli-runner/claude-live-turn.ts index 7370585801a3..eee2337ac23f 100644 --- a/src/agents/cli-runner/claude-live-turn.ts +++ b/src/agents/cli-runner/claude-live-turn.ts @@ -26,6 +26,7 @@ import { createCliJsonlStreamingParser, frameBoundedCliJsonlChunk, normalizeClaudeCliStreamJsonRecord, + streamJsonOutputLimitErrorText, } from "../cli-output-stream.js"; import { parseCliOutput } from "../cli-output.js"; import type { FailoverError } from "../failover-error.js"; @@ -360,10 +361,11 @@ function applyBackgroundTasksChanged( function pushTurnLine(host: ClaudeLiveTurnHost, turn: ClaudeLiveTurn, line: string): boolean { turn.streamingParser.push(`${line}\n`); - if (!turn.streamingParser.getErrorText()) { + const errorText = turn.streamingParser.getErrorText(); + if (!errorText) { return true; } - host.close("abort", createClaudeOutputLimitError(host, "Claude CLI turn output exceeded limit.")); + host.close("abort", createClaudeOutputLimitError(host, errorText)); return false; } @@ -492,7 +494,10 @@ export function acceptClaudeStdout(host: ClaudeLiveTurnHost, chunk: string): voi ) { host.close( "abort", - createClaudeOutputLimitError(host, "Claude CLI JSONL line exceeded output limit."), + createClaudeOutputLimitError( + host, + streamJsonOutputLimitErrorText("line", maxPendingLineChars), + ), ); } } catch (error) { diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index ced581bd5287..ef7ab22439b8 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -231,7 +231,10 @@ function requireManifestRegistryLoadParams(index = 0): Record { return call[0]; } -function expectManifestRegistryLoad(index: number, config: OpenClawConfig | Record) { +function expectManifestRegistryLoad( + index: number, + config: OpenClawConfig | Record | undefined, +) { const params = requireManifestRegistryLoadParams(index); expect(params.config).toEqual(config); expect(params.env).toBe(process.env); @@ -1669,7 +1672,7 @@ describe("resolvePluginCapabilityProviders", () => { const providers = resolvePluginCapabilityProviders({ key: "mediaUnderstandingProviders" }); expectResolvedCapabilityProviderIds(providers, ["google"]); - expectManifestRegistryLoad(0, {}); + expectManifestRegistryLoad(0, undefined); expectActiveRegistryLookup(["google"]); }); diff --git a/src/tui/components/hyperlink-markdown.test.ts b/src/tui/components/hyperlink-markdown.test.ts index 7e819c0cb3c5..2de9e9975702 100644 --- a/src/tui/components/hyperlink-markdown.test.ts +++ b/src/tui/components/hyperlink-markdown.test.ts @@ -17,6 +17,30 @@ function osc8Targets(raw: string) { } describe("HyperlinkMarkdown", () => { + it("does not reallocate prepared lines for an unchanged same-width redraw", () => { + const markdown = new HyperlinkMarkdown( + "مرحبا [docs](https://example.test/path)", + 0, + 0, + markdownTheme, + ); + + const first = markdown.render(80); + expect(markdown.render(80)).toBe(first); + + const resized = markdown.render(40); + expect(resized).not.toBe(first); + expect(markdown.render(40)).toBe(resized); + + markdown.setText("updated"); + const updated = markdown.render(40); + expect(updated).not.toBe(resized); + expect(markdown.render(40)).toBe(updated); + + markdown.invalidate(); + expect(markdown.render(40)).not.toBe(updated); + }); + it("moves dunder identifiers intact across fenced code wrap boundaries", () => { const markdown = new HyperlinkMarkdown( ["```python", 'if __name__ == "__main__":', "```"].join("\n"), diff --git a/src/tui/components/hyperlink-markdown.ts b/src/tui/components/hyperlink-markdown.ts index 18a64836f97b..9f0347cebde6 100644 --- a/src/tui/components/hyperlink-markdown.ts +++ b/src/tui/components/hyperlink-markdown.ts @@ -24,6 +24,7 @@ function sanitizeMarkdownDisplayText(text: string): string { export class HyperlinkMarkdown implements Component { private inner: Markdown; private urls: string[]; + private cachedRender?: { width: number; lines: string[] }; constructor( text: string, @@ -39,16 +40,25 @@ export class HyperlinkMarkdown implements Component { } render(width: number): string[] { - return addOsc8Hyperlinks(this.inner.render(width), this.urls).map(isolateRtlRenderedLine); + if (this.cachedRender?.width === width) { + return this.cachedRender.lines; + } + const lines = addOsc8Hyperlinks(this.inner.render(width), this.urls).map( + isolateRtlRenderedLine, + ); + this.cachedRender = { width, lines }; + return lines; } setText(text: string): void { const displayText = sanitizeMarkdownDisplayText(text); this.inner.setText(displayText); this.urls = extractUrls(displayText); + this.cachedRender = undefined; } invalidate(): void { this.inner.invalidate(); + this.cachedRender = undefined; } } diff --git a/test/scripts/package-git-fixture.test.ts b/test/scripts/package-git-fixture.test.ts index 1902b3f1512b..37ac28cd2d1e 100644 --- a/test/scripts/package-git-fixture.test.ts +++ b/test/scripts/package-git-fixture.test.ts @@ -21,6 +21,7 @@ describe("package git fixture", () => { scripts: { build: "node build.mjs", openclaw: "node scripts/run-node.mjs", + postinstall: "node scripts/postinstall-bundled-plugins.mjs", }, }, null, @@ -98,6 +99,7 @@ describe("package git fixture", () => { scripts: { lint: "node lint.mjs", openclaw: "node scripts/run-node.mjs", + postinstall: "node scripts/postinstall-bundled-plugins.mjs", }, }, null, @@ -113,12 +115,9 @@ describe("package git fixture", () => { expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); - expect(packageJson).toMatchObject({ - dependencies: { chalk: "5.6.2" }, - scripts: { - lint: "node lint.mjs", - openclaw: "node openclaw.mjs", - }, + expect(packageJson.scripts).toEqual({ + lint: "node lint.mjs", + openclaw: "node openclaw.mjs", }); expect(packageJson.dependencies).not.toHaveProperty("@openclaw/ai"); });