From e8a252ecde695282ce18c23d3961e026cfb5b1ee Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 30 Sep 2026 17:08:35 -0300 Subject: [PATCH] fix: protect download bytes and recover local API errors --- README.md | 6 + extensions/piagentui-server-core.test.ts | 52 ++++- extensions/piagentui-server-core.ts | 27 ++- extensions/piagentui-server.ts | 4 +- package-lock.json | 231 ++++++----------------- src/hooks/useSessionStats.test.tsx | 29 +++ src/hooks/useSessionStats.ts | 5 +- src/utils/downloadUtils.test.ts | 26 +++ src/utils/downloadUtils.ts | 7 +- 9 files changed, 203 insertions(+), 184 deletions(-) create mode 100644 src/utils/downloadUtils.test.ts diff --git a/README.md b/README.md index 447a0a8..b839491 100644 --- a/README.md +++ b/README.md @@ -205,3 +205,9 @@ Two things to know when running the suite: `npm run test:run` needs `dist/` to e ## License GPL-3.0-only, as declared in `package.json` and [`LICENSE`](LICENSE). PiAgentUI is a fork and rework of OpenCodeUI; the original authors are credited in `src-tauri/Cargo.toml`. + +## Review checks (2026-09-30) + +Browser downloads include exactly the requested byte view, without adjacent bytes from its backing buffer. Session statistics skip the full message-text scan when real token usage is available. LAN discovery is best effort: restricted network-interface enumeration keeps the local API usable. Failed requests close partial responses without attempting to send headers twice. + +Run `npm run validate` for application/extension type checks, lint, build and tests. Focused checks: `npm run test:run -- src/utils/downloadUtils.test.ts src/hooks/useSessionStats.test.tsx extensions/piagentui-server-core.test.ts`. Dependencies, native desktop/mobile builds and live agent flows require their own checks; an application unit-test pass does not replace those validations. diff --git a/extensions/piagentui-server-core.test.ts b/extensions/piagentui-server-core.test.ts index a646254..b21e5d7 100644 --- a/extensions/piagentui-server-core.test.ts +++ b/extensions/piagentui-server-core.test.ts @@ -1,4 +1,10 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' +import * as os from 'node:os' + +vi.mock('node:os', async importOriginal => ({ + ...(await importOriginal()), + networkInterfaces: vi.fn().mockReturnValue({}), +})) import { isLoopbackHost, validateBearerToken, @@ -8,9 +14,47 @@ import { isRequestBodySizeAllowed, getLocalNetworkUrls, validateSttBaseUrl, + sendInternalServerError, } from './piagentui-server-core.js' describe('PiAgentUi Server Core Security Helpers', () => { + describe('sendInternalServerError', () => { + const response = () => ({ + headersSent: false, + writableEnded: false, + destroyed: false, + writeHead: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }) + + it('sends a JSON error when the response has not started', () => { + const res = response() + sendInternalServerError(res) + expect(res.writeHead).toHaveBeenCalledWith(500, { 'Content-Type': 'application/json' }) + expect(res.end).toHaveBeenCalledWith('{"error":"Internal Server Error"}') + expect(res.destroy).not.toHaveBeenCalled() + }) + + it('closes a partial response without writing another header', () => { + const res = { ...response(), headersSent: true } + sendInternalServerError(res) + expect(res.destroy).toHaveBeenCalledOnce() + expect(res.writeHead).not.toHaveBeenCalled() + expect(res.end).not.toHaveBeenCalled() + }) + + it('leaves completed and destroyed responses alone', () => { + for (const state of [{ writableEnded: true }, { destroyed: true }]) { + const res = { ...response(), ...state } + sendInternalServerError(res) + expect(res.writeHead).not.toHaveBeenCalled() + expect(res.end).not.toHaveBeenCalled() + expect(res.destroy).not.toHaveBeenCalled() + } + }) + }) + describe('isLoopbackHost', () => { it('should accept loopback hostnames and IPs', () => { expect(isLoopbackHost('localhost')).toBe(true) @@ -84,6 +128,12 @@ describe('PiAgentUi Server Core Security Helpers', () => { }) describe('getLocalNetworkUrls', () => { + it('preserves local access when network enumeration is unavailable', () => { + vi.mocked(os.networkInterfaces).mockImplementationOnce(() => { + throw new Error('network enumeration denied') + }) + expect(getLocalNetworkUrls(58785)).toEqual([]) + }) it('should build LAN URLs from non-internal IPv4 interfaces', () => { const urls = getLocalNetworkUrls(58785, { WiFi: [ diff --git a/extensions/piagentui-server-core.ts b/extensions/piagentui-server-core.ts index 8bb170e..7bf271a 100644 --- a/extensions/piagentui-server-core.ts +++ b/extensions/piagentui-server-core.ts @@ -1,5 +1,19 @@ import * as path from 'node:path' import * as os from 'node:os' +import type { ServerResponse } from 'node:http' + +/** Finish a failed request without sending headers twice or leaving a partial response open. */ +export function sendInternalServerError( + response: Pick, +): void { + if (response.writableEnded || response.destroyed) return + if (response.headersSent) { + response.destroy() + return + } + response.writeHead(500, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ error: 'Internal Server Error' })) +} /** * Checks if a host string matches a loopback host (localhost or 127.0.0.1 or ::1) @@ -123,10 +137,15 @@ export function validateHostAndOriginForAccess( return true } -export function getLocalNetworkUrls( - port: number, - interfaces: NodeJS.Dict = os.networkInterfaces(), -): string[] { +export function getLocalNetworkUrls(port: number, interfaces?: NodeJS.Dict): string[] { + if (!interfaces) { + try { + interfaces = os.networkInterfaces() + } catch { + // LAN discovery is best effort; local API access still works without it. + return [] + } + } const physicalUrls = new Set() const fallbackUrls = new Set() diff --git a/extensions/piagentui-server.ts b/extensions/piagentui-server.ts index 37913c3..1035d3a 100644 --- a/extensions/piagentui-server.ts +++ b/extensions/piagentui-server.ts @@ -12,6 +12,7 @@ import { isRequestBodySizeAllowed, getLocalNetworkUrls, validateSttBaseUrl, + sendInternalServerError, } from './piagentui-server-core.js' import { createPiAgentUiSettingsStore, @@ -2075,8 +2076,7 @@ export default function (pi: ExtensionAPI) { const urlPath = req.url || '' if (urlPath.startsWith('/api/') || urlPath.startsWith('/global/')) { handleApiRequest(req, res).catch(() => { - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Internal Server Error' })) + sendInternalServerError(res) }) } else { handleStaticFile(req, res, urlPath) diff --git a/package-lock.json b/package-lock.json index ea381f0..c55a18e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,9 @@ "typescript-eslint": "^8.57.2", "vite": "^8.0.3", "vitest": "^4.1.2" + }, + "engines": { + "node": ">=22" } }, "node_modules/@adobe/css-tools": { @@ -811,45 +814,12 @@ "specificity": "bin/cli.js" } }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", - "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/gast": "11.1.2", - "@chevrotain/types": "11.1.2", - "lodash-es": "4.17.23" - } - }, - "node_modules/@chevrotain/gast": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", - "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/types": "11.1.2", - "lodash-es": "4.17.23" - } - }, - "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", - "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", - "license": "Apache-2.0" - }, "node_modules/@chevrotain/types": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, - "node_modules/@chevrotain/utils": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", - "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", - "license": "Apache-2.0" - }, "node_modules/@codemirror/commands": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", @@ -1510,9 +1480,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1530,9 +1497,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1550,9 +1514,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1570,9 +1531,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1590,9 +1548,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1897,12 +1852,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz", - "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", "license": "MIT", "dependencies": { - "langium": "^4.0.0" + "@chevrotain/types": "~11.1.2" } }, "node_modules/@mistralai/mistralai": { @@ -4500,32 +4455,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chevrotain": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", - "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/cst-dts-gen": "11.1.2", - "@chevrotain/gast": "11.1.2", - "@chevrotain/regexp-to-ast": "11.1.2", - "@chevrotain/types": "11.1.2", - "@chevrotain/utils": "11.1.2", - "lodash-es": "4.17.23" - } - }, - "node_modules/chevrotain-allstar": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", - "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", - "license": "MIT", - "dependencies": { - "lodash-es": "^4.17.21" - }, - "peerDependencies": { - "chevrotain": "^11.0.0" - } - }, "node_modules/chroma-js": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", @@ -4767,9 +4696,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", - "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", "license": "MIT", "engines": { "node": ">=0.10" @@ -5299,9 +5228,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debug": { @@ -5451,9 +5380,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5525,6 +5454,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5879,6 +5820,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -7117,9 +7067,9 @@ } }, "node_modules/katex": { - "version": "0.16.38", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", - "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -7172,23 +7122,6 @@ "url": "https://liberapay.com/Koromix" } }, - "node_modules/langium": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", - "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", - "license": "MIT", - "dependencies": { - "chevrotain": "~11.1.1", - "chevrotain-allstar": "~0.3.1", - "vscode-languageserver": "~9.0.1", - "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.1.0" - }, - "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" - } - }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", @@ -7487,9 +7420,9 @@ } }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -7906,32 +7839,33 @@ "license": "CC0-1.0" }, "node_modules/mermaid": { - "version": "11.13.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", - "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", + "version": "11.17.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.1.1", + "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.0.1", + "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.1", + "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.19", - "dompurify": "^3.3.1", - "katex": "^0.16.25", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "fastdom": "1.0.12", + "katex": "^0.16.47", "khroma": "^2.1.0", - "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", - "uuid": "^11.1.0" + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "node_modules/mermaid/node_modules/marked": { @@ -9928,6 +9862,12 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -10772,55 +10712,6 @@ "node": ">=0.10.0" } }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", - "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "license": "MIT", - "dependencies": { - "vscode-languageserver-protocol": "3.17.5" - }, - "bin": { - "installServerIntoExtension": "bin/installServerIntoExtension" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "license": "MIT" - }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/src/hooks/useSessionStats.test.tsx b/src/hooks/useSessionStats.test.tsx index 27c591c..d2bf826 100644 --- a/src/hooks/useSessionStats.test.tsx +++ b/src/hooks/useSessionStats.test.tsx @@ -9,6 +9,35 @@ vi.mock('../store', () => ({ })) describe('useSessionStats', () => { + it('does not scan message text when actual usage is available', () => { + const readText = vi.fn(() => 'large historical message') + useMessageStoreMock.mockReturnValue({ + messages: [ + { + info: { + role: 'assistant', + cost: 0, + tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [ + { + type: 'text', + get text() { + return readText() + }, + }, + ], + }, + ], + }) + + const { result } = renderHook(() => useSessionStats(100)) + + expect(result.current.contextUsed).toBe(15) + expect(result.current.contextEstimated).toBe(false) + expect(readText).not.toHaveBeenCalled() + }) + it('switches to estimated context after a compaction turn', () => { useMessageStoreMock.mockReturnValue({ messages: [ diff --git a/src/hooks/useSessionStats.ts b/src/hooks/useSessionStats.ts index 8e82c4b..3277e87 100644 --- a/src/hooks/useSessionStats.ts +++ b/src/hooks/useSessionStats.ts @@ -235,13 +235,12 @@ export function useSessionStats( } } - const totalOrEstimatedContext = estimateCurrentContext(messages) const contextEstimated = shouldUseEstimatedContext(messages, lastAssistantWithTokensIndex, lastAssistantWithTokens) const rawContextUsed = contextEstimated - ? totalOrEstimatedContext + ? estimateCurrentContext(messages) : lastAssistantWithTokens ? tokenTotal(lastAssistantWithTokens.tokens) - : totalOrEstimatedContext + : estimateCurrentContext(messages) const contextUsed = Number.isFinite(rawContextUsed) ? rawContextUsed : 0 const rawContextPercent = contextLimit > 0 ? (contextUsed / contextLimit) * 100 : 0 const contextPercent = Number.isFinite(rawContextPercent) ? Math.min(100, Math.max(0, rawContextPercent)) : 0 diff --git a/src/utils/downloadUtils.test.ts b/src/utils/downloadUtils.test.ts new file mode 100644 index 0000000..6383c20 --- /dev/null +++ b/src/utils/downloadUtils.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { saveData } from './downloadUtils' + +vi.mock('./tauri', () => ({ isTauri: () => false })) + +describe('browser file downloads', () => { + beforeEach(() => { + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test'), revokeObjectURL: vi.fn() }) + }) + + it('downloads exactly the requested byte view without surrounding buffer contents', async () => { + const bytes = new Uint8Array([11, 22, 33, 44, 55]) + saveData(bytes.subarray(1, 4), 'slice.bin') + + const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob + const contents = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as ArrayBuffer) + reader.onerror = reject + reader.readAsArrayBuffer(blob) + }) + expect([...new Uint8Array(contents)]).toEqual([22, 33, 44]) + expect(blob.type).toBe('application/octet-stream') + }) +}) diff --git a/src/utils/downloadUtils.ts b/src/utils/downloadUtils.ts index 238ca0a..5938d65 100644 --- a/src/utils/downloadUtils.ts +++ b/src/utils/downloadUtils.ts @@ -73,9 +73,7 @@ export function downloadFileContent(content: FileContent, fileName: string): voi const encoding = content.encoding || '' const textContent = content.content || '' // 统一转为 Uint8Array - const data = isBinaryContent(encoding) - ? base64ToBytes(textContent) - : new TextEncoder().encode(textContent) + const data = isBinaryContent(encoding) ? base64ToBytes(textContent) : new TextEncoder().encode(textContent) const mimeType = isBinaryContent(encoding) ? content.mimeType || 'application/octet-stream' @@ -95,7 +93,8 @@ export function saveData(data: Uint8Array, fileName: string, mimeType = 'applica console.warn('[downloadUtils] Tauri save failed:', err) }) } else { - const blob = new Blob([data.buffer as ArrayBuffer], { type: mimeType }) + // A view may cover only part of its backing buffer (for example, a file chunk). + const blob = new Blob([new Uint8Array(data)], { type: mimeType }) triggerBrowserDownload(blob, fileName) } }