From b5ae21029a45ea910cdf9036a54089de8d86b724 Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:29:18 +0300 Subject: [PATCH 01/10] perf(web): make the kimi web host usable on slow links and bound browser load The browser UI served by `kimi web` shipped every asset uncompressed with no cache validators, streamed one WebSocket envelope per model token, let `GET /api/v1/sessions` return every session when `page_size` was omitted, and forced `no-cache` on assets tunnelled through Remote Control. Static assets: negotiate precompressed `.br`/`.gz` siblings (generated by the new precompress script during `pnpm build` and the native bundle workflow, gitignored), add weak ETag + 304 revalidation and `Vary`, fix wasm/woff/ttf/riv/map content types, and stat each file once. WebSocket: enable permessage-deflate and a 16 MiB max payload, expose the tuning knobs through `KIMI_CODE_WS_*`, replace the 100 ms forced flush with real backpressure that closes stalled peers with 1013, and always flush control frames. Append-only transcript ops are micro-batched before seq assignment so clients keep receiving contiguous seqs. REST: the sessions list is always paginated (default 50) with `busy` applied while collecting; the transcript ops catch-up accepts `limit` and reports `has_more`. Remote Control: keep upstream cache headers, revalidate rewritten HTML/JS/CSS through a versioned ETag, pass 204/304/HEAD through bodiless, add reconnect jitter, cap early frames, apply pause/resume backpressure on the WebSocket bridge, and add an experimental chunked response mode behind `KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES`. --- .changeset/remote-control-asset-caching.md | 5 + .changeset/sessions-list-paginated.md | 5 + .changeset/web-assets-compression.md | 5 + .changeset/web-stream-batching.md | 5 + .github/workflows/_native-build.yml | 10 + AGENTS.md | 2 +- apps/kimi-code/.gitignore | 4 + apps/kimi-code/package.json | 3 +- .../scripts/precompress-web-assets.mjs | 224 ++++++++++++ .../scripts/precompress-web-assets.test.ts | 183 ++++++++++ apps/kimi-inspect/src/activity/store.test.ts | 45 +++ apps/kimi-inspect/src/activity/store.ts | 35 +- apps/kimi-inspect/src/components/ChatView.tsx | 39 +- apps/kimi-inspect/src/transcript/api.ts | 10 + .../src/transcript/transcript.test.ts | 22 ++ docs/en/configuration/env-vars.md | 9 + docs/en/guides/remote-control.md | 6 + docs/en/reference/server-api.md | 19 +- docs/zh/configuration/env-vars.md | 9 + docs/zh/guides/remote-control.md | 6 + docs/zh/reference/server-api.md | 19 +- packages/kap-server/src/routes/sessions.ts | 52 +-- packages/kap-server/src/routes/transcript.ts | 21 +- packages/kap-server/src/routes/webAssets.ts | 156 +++++++- .../services/transcript/transcriptService.ts | 120 ++++++- packages/kap-server/src/start.ts | 19 +- .../src/transport/ws/v1/registerWsV1.ts | 59 ++- .../ws/v1/sessionEventBroadcaster.ts | 1 + .../src/transport/ws/v1/wsConnectionV1.ts | 53 ++- .../test/services/transcript.test.ts | 151 +++++++- .../test/sessionEventBroadcaster.test.ts | 8 +- packages/kap-server/test/sessions.test.ts | 83 ++++- packages/kap-server/test/transcript.test.ts | 120 +++++++ packages/kap-server/test/webAssets.test.ts | 241 ++++++++++++- .../kap-server/test/wsConnectionV1.test.ts | 163 +++++++++ packages/remote-control/src/remote-control.ts | 250 ++++++++++--- .../test/remote-control.test.ts | 340 +++++++++++++++++- packages/transcript/src/contract/schema.ts | 1 + packages/transcript/src/index.ts | 1 + packages/transcript/src/ops/coalesce.ts | 27 ++ packages/transcript/test/store.test.ts | 80 +++++ 41 files changed, 2402 insertions(+), 209 deletions(-) create mode 100644 .changeset/remote-control-asset-caching.md create mode 100644 .changeset/sessions-list-paginated.md create mode 100644 .changeset/web-assets-compression.md create mode 100644 .changeset/web-stream-batching.md create mode 100644 apps/kimi-code/scripts/precompress-web-assets.mjs create mode 100644 apps/kimi-code/test/scripts/precompress-web-assets.test.ts create mode 100644 packages/transcript/src/ops/coalesce.ts diff --git a/.changeset/remote-control-asset-caching.md b/.changeset/remote-control-asset-caching.md new file mode 100644 index 00000000000..f0750724ae5 --- /dev/null +++ b/.changeset/remote-control-asset-caching.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Let browsers cache `kimi web --remote-control` UI assets across page loads and add reconnect jitter. Enable experimental chunked responses with `KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES=1`. diff --git a/.changeset/sessions-list-paginated.md b/.changeset/sessions-list-paginated.md new file mode 100644 index 00000000000..0358d2aa9c4 --- /dev/null +++ b/.changeset/sessions-list-paginated.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +The sessions list API now always paginates (default 50 per page) and the transcript ops catch-up API accepts a `limit` and reports `has_more`. diff --git a/.changeset/web-assets-compression.md b/.changeset/web-assets-compression.md new file mode 100644 index 00000000000..b79b3f6f12d --- /dev/null +++ b/.changeset/web-assets-compression.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Serve the `kimi web` UI with Brotli/gzip-compressed assets and browser cache revalidation, and fix the content types of wasm and font files. diff --git a/.changeset/web-stream-batching.md b/.changeset/web-stream-batching.md new file mode 100644 index 00000000000..724d2661cf5 --- /dev/null +++ b/.changeset/web-stream-batching.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Batch streamed transcript text and enable WebSocket compression for the `kimi web` UI. Tune with `KIMI_CODE_TRANSCRIPT_OPS_BATCH_MS` and the `KIMI_CODE_WS_*` environment variables. diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml index 483c58e419b..950dce49c7b 100644 --- a/.github/workflows/_native-build.yml +++ b/.github/workflows/_native-build.yml @@ -90,6 +90,16 @@ jobs: # committed (synced from the code-app repo) — just verify it is in place. run: node apps/kimi-code/scripts/check-web-assets.mjs + - name: Precompress Kimi web assets + # Emit .br/.gz siblings next to the bundle so the embedded server can + # answer Accept-Encoding negotiation without compressing on the fly. + run: node apps/kimi-code/scripts/precompress-web-assets.mjs + + - name: Verify precompressed Kimi web assets + # Fail early if an entry bundle is missing its .br sibling instead of + # shipping a binary that falls back to on-the-fly compression. + run: node apps/kimi-code/scripts/precompress-web-assets.mjs --check + - name: Build native executable (release profile, macOS signed) if: runner.os == 'macOS' && inputs.sign-macos run: pnpm --filter @moonshot-ai/kimi-code run build:native:release diff --git a/AGENTS.md b/AGENTS.md index ca4068bbc1e..210f60142ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Project Map - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on engine packages. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). -- the browser web UI: **its source no longer lives in this repo.** It is developed in the code-app repo (`apps/web`) and shipped as the committed, prebuilt bundle `apps/kimi-code/dist-web` (gitignored, force-added), synced from code-app with `KIMI_CODE_REPO= pnpm run sync:web` — sync and commit the bundle in the same change whenever the web UI should ship differently. `apps/kimi-code/scripts/check-web-assets.mjs` guards packaging against a missing bundle. To hack on the web UI against this repo's server, run `pnpm dev:server` here and point code-app's `pnpm dev:web` at it via `KIMI_SERVER_URL`. +- the browser web UI: **its source no longer lives in this repo.** It is developed in the code-app repo (`apps/web`) and shipped as the committed, prebuilt bundle `apps/kimi-code/dist-web` (tracked in git), synced from code-app with `KIMI_CODE_REPO= pnpm run sync:web` — sync and commit the bundle in the same change whenever the web UI should ship differently. `apps/kimi-code/scripts/check-web-assets.mjs` guards packaging against a missing bundle; `pnpm build` then writes gitignored `.br`/`.gz` siblings next to compressible files via `scripts/precompress-web-assets.mjs` (never commit them). To hack on the web UI against this repo's server, run `pnpm dev:server` here and point code-app's `pnpm dev:web` at it via `KIMI_SERVER_URL`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session transcript chat, per-scope Service panels, and the DI unit inspection view. See `apps/kimi-inspect/AGENTS.md`. - `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`); there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler. diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index 762220ab4c0..ef59bfb0d21 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -9,3 +9,7 @@ src/generated/vis-web-asset.ts # Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs /native/ + +# Precompressed siblings generated at build time by scripts/precompress-web-assets.mjs +dist-web/**/*.br +dist-web/**/*.gz diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index a2d86dc85dd..e40c4adb137 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -50,8 +50,9 @@ "provenance": true }, "scripts": { - "build": "tsdown && tsdown --config tsdown.dist-worker.config.ts && node scripts/copy-native-assets.mjs && node scripts/check-web-assets.mjs", + "build": "tsdown && tsdown --config tsdown.dist-worker.config.ts && node scripts/copy-native-assets.mjs && node scripts/check-web-assets.mjs && node scripts/precompress-web-assets.mjs", "prebuild": "node scripts/build-vis-asset.mjs", + "precompress:web": "node scripts/precompress-web-assets.mjs", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", "build:native:js": "node scripts/native/01-bundle.mjs", diff --git a/apps/kimi-code/scripts/precompress-web-assets.mjs b/apps/kimi-code/scripts/precompress-web-assets.mjs new file mode 100644 index 00000000000..b1672481d21 --- /dev/null +++ b/apps/kimi-code/scripts/precompress-web-assets.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +// Emit precompressed `.br` / `.gz` siblings next to the committed web bundle +// (apps/kimi-code/dist-web) so kap-server can answer `Accept-Encoding` +// negotiation by streaming a sibling instead of compressing on the fly. +// +// Siblings are derived artifacts: they are gitignored, regenerated by the +// package build, and pruned here when their base file disappears. Only +// siblings this script could have produced (`.br|.gz`) are ever +// pruned; a `foo.tar.gz` or a bare `foo.br` in the bundle is left alone. The +// script is mtime-idempotent for hashed bundles — a sibling at least as new as +// its source is left alone unless `--force` is given. Unhashed files +// (`index.html`, `boot.js`) are tiny and always regenerated: their name does not +// change with their content, so mtimes are not a trustworthy signal for them. +// +// Usage: node scripts/precompress-web-assets.mjs [--check] [--force] [--only=br|gz] + +import { readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { basename, dirname, extname, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { brotliCompressSync, constants, gzipSync } from 'node:zlib'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DEFAULT_DIST_DIR = resolve(appRoot, 'dist-web'); + +// Text-like bundle outputs that compress well. Everything else (fonts, images, +// icons, rive animations) is either already compressed or binary-opaque. +const COMPRESSIBLE_EXTENSIONS = new Set([ + '.html', + '.js', + '.mjs', + '.css', + '.svg', + '.json', + '.map', + '.wasm', + '.txt', +]); +const SKIPPED_EXTENSIONS = new Set([ + '.woff2', + '.woff', + '.ttf', + '.ico', + '.riv', + '.png', + '.jpg', + '.jpeg', + '.webp', +]); +const MIN_SOURCE_BYTES = 1024; +// A sibling only earns its place when it shaves at least this fraction off. +const MIN_SAVINGS_RATIO = 0.1; +const ENTRY_ASSET_PATTERN = /^index-[A-Za-z0-9_-]+\.(?:js|css)$/; +// Vite output whose name carries a content hash (`index-Dy7xs5tu.js`, +// `font.Ab12Cd34.woff2`); only these can trust the mtime up-to-date skip. +const HASHED_FILE_PATTERN = /[-.][A-Za-z0-9_-]{8}\.[^.]+$/; + +const FORMATS = { + br: { + extension: '.br', + compress: (source) => + brotliCompressSync(source, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 11, + [constants.BROTLI_PARAM_SIZE_HINT]: source.length, + }, + }), + }, + gz: { + extension: '.gz', + compress: (source) => gzipSync(source, { level: 9 }), + }, +}; +const SIBLING_EXTENSIONS = new Set(Object.values(FORMATS).map((format) => format.extension)); + +const isMain = + process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + try { + const options = parseArgs(process.argv.slice(2)); + const summary = await precompressWebAssets({ distDir: DEFAULT_DIST_DIR, ...options }); + console.log( + options.check + ? `[precompress-web-assets] OK: entry bundles in ${DEFAULT_DIST_DIR} have .br siblings` + : formatSummary(summary), + ); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } +} + +/** + * @param {{ distDir: string, check?: boolean, force?: boolean, only?: 'br' | 'gz' }} options + * @returns {Promise<{ processed: number, written: number, skipped: number, removed: number, bytesBefore: number, bytesAfter: number }>} + */ +export async function precompressWebAssets({ distDir, check = false, force = false, only }) { + const files = await listFiles(distDir); + if (check) { + assertEntryAssetsPrecompressed(distDir, files); + return { processed: 0, written: 0, skipped: 0, removed: 0, bytesBefore: 0, bytesAfter: 0 }; + } + + const formats = only === undefined ? Object.values(FORMATS) : [FORMATS[only]]; + const summary = { processed: 0, written: 0, skipped: 0, removed: 0, bytesBefore: 0, bytesAfter: 0 }; + const present = new Set(files); + + for (const file of files) { + if (SIBLING_EXTENSIONS.has(extname(file))) { + const base = file.slice(0, -extname(file).length); + if (isCompressible(base) && !present.has(base)) { + await unlink(file); + summary.removed++; + } + continue; + } + if (!isCompressible(file)) { + continue; + } + const sourceStats = await stat(file); + if (sourceStats.size < MIN_SOURCE_BYTES) { + continue; + } + summary.processed++; + summary.bytesBefore += sourceStats.size; + const emitted = await emitSiblings(file, sourceStats, formats, force, summary); + summary.bytesAfter += emitted; + } + return summary; +} + +async function emitSiblings(file, sourceStats, formats, force, summary) { + let source; + let smallest = sourceStats.size; + const reuseUpToDate = !force && HASHED_FILE_PATTERN.test(basename(file)); + for (const format of formats) { + const siblingPath = `${file}${format.extension}`; + const existing = await stat(siblingPath).catch(() => undefined); + if (reuseUpToDate && existing !== undefined && existing.mtimeMs >= sourceStats.mtimeMs) { + summary.skipped++; + smallest = Math.min(smallest, existing.size); + continue; + } + source ??= await readFile(file); + const compressed = format.compress(source); + if (compressed.length > source.length * (1 - MIN_SAVINGS_RATIO)) { + if (existing !== undefined) { + await unlink(siblingPath); + } + continue; + } + await writeFile(siblingPath, compressed); + summary.written++; + smallest = Math.min(smallest, compressed.length); + } + return smallest; +} + +function assertEntryAssetsPrecompressed(distDir, files) { + const present = new Set(files); + const missing = files + .filter((file) => dirname(file) === join(distDir, 'assets')) + .filter((file) => ENTRY_ASSET_PATTERN.test(relative(join(distDir, 'assets'), file))) + .filter((file) => !present.has(`${file}${FORMATS.br.extension}`)) + .map((file) => relative(distDir, file)); + if (missing.length > 0) { + throw new Error( + `Precompressed web assets are missing a .br sibling for: ${missing.join(', ')}. ` + + 'Run `pnpm --filter @moonshot-ai/kimi-code run precompress:web` and rebuild.', + ); + } +} + +function isCompressible(file) { + const extension = extname(file).toLowerCase(); + return COMPRESSIBLE_EXTENSIONS.has(extension) && !SKIPPED_EXTENSIONS.has(extension); +} + +async function listFiles(dir) { + const entries = await readdir(dir, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)) + .sort(); +} + +function formatSummary(summary) { + const ratio = + summary.bytesBefore === 0 ? 1 : summary.bytesAfter / summary.bytesBefore; + return ( + `[precompress-web-assets] ${summary.processed} files processed, ` + + `${summary.written} siblings written, ${summary.skipped} up to date, ${summary.removed} orphans removed; ` + + `${formatBytes(summary.bytesBefore)} -> ${formatBytes(summary.bytesAfter)} (${(ratio * 100).toFixed(0)}%)` + ); +} + +function formatBytes(bytes) { + return bytes >= 1024 * 1024 + ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` + : `${(bytes / 1024).toFixed(0)} KB`; +} + +function parseArgs(args) { + const options = {}; + for (const arg of args) { + if (arg === '--check') { + options.check = true; + continue; + } + if (arg === '--force') { + options.force = true; + continue; + } + if (arg.startsWith('--only=')) { + const only = arg.slice('--only='.length); + if (!(only in FORMATS)) { + throw new Error(`--only expects one of ${Object.keys(FORMATS).join('|')}, got "${only}".`); + } + options.only = only; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return options; +} diff --git a/apps/kimi-code/test/scripts/precompress-web-assets.test.ts b/apps/kimi-code/test/scripts/precompress-web-assets.test.ts new file mode 100644 index 00000000000..5b79a6109e8 --- /dev/null +++ b/apps/kimi-code/test/scripts/precompress-web-assets.test.ts @@ -0,0 +1,183 @@ +import { access, mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { brotliDecompressSync, gunzipSync } from 'node:zlib'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { precompressWebAssets } from '../../scripts/precompress-web-assets.mjs'; + +const tempRoots: string[] = []; +const LARGE_TEXT = 'export const banner = "kimi";\n'.repeat(200); + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function makeDist(): Promise { + const root = await mkdtemp(join(tmpdir(), 'kimi-precompress-')); + tempRoots.push(root); + const distDir = join(root, 'dist-web'); + await mkdir(join(distDir, 'assets'), { recursive: true }); + return distDir; +} + +async function exists(path: string): Promise { + return access(path).then( + () => true, + () => false, + ); +} + +describe('precompressWebAssets', () => { + it('writes brotli and gzip siblings for js and css bundles', async () => { + const distDir = await makeDist(); + const js = join(distDir, 'assets', 'index-Dy7xs5tu.js'); + const css = join(distDir, 'assets', 'index-Ab12Cd34.css'); + await writeFile(js, LARGE_TEXT); + await writeFile(css, `.kimi{color:red}\n`.repeat(100)); + + const summary = await precompressWebAssets({ distDir }); + + expect(summary.processed).toBe(2); + expect(summary.written).toBe(4); + expect(brotliDecompressSync(await readFile(`${js}.br`)).toString('utf8')).toBe(LARGE_TEXT); + expect(gunzipSync(await readFile(`${js}.gz`)).toString('utf8')).toBe(LARGE_TEXT); + await expect(exists(`${css}.br`)).resolves.toBe(true); + await expect(exists(`${css}.gz`)).resolves.toBe(true); + expect(summary.bytesAfter).toBeLessThan(summary.bytesBefore); + }); + + it('skips fonts and rive animations', async () => { + const distDir = await makeDist(); + const woff2 = join(distDir, 'assets', 'font-Dy7xs5tu.woff2'); + const riv = join(distDir, 'assets', 'anim-Dy7xs5tu.riv'); + await writeFile(woff2, LARGE_TEXT); + await writeFile(riv, LARGE_TEXT); + + const summary = await precompressWebAssets({ distDir }); + + expect(summary.processed).toBe(0); + await expect(exists(`${woff2}.br`)).resolves.toBe(false); + await expect(exists(`${riv}.gz`)).resolves.toBe(false); + }); + + it('skips files smaller than 1024 bytes', async () => { + const distDir = await makeDist(); + const small = join(distDir, 'assets', 'tiny-Dy7xs5tu.js'); + await writeFile(small, 'export {};\n'); + + const summary = await precompressWebAssets({ distDir }); + + expect(summary.processed).toBe(0); + await expect(exists(`${small}.br`)).resolves.toBe(false); + }); + + it('removes orphaned siblings whose base file is gone', async () => { + const distDir = await makeDist(); + const orphanBr = join(distDir, 'assets', 'index-Old00000.js.br'); + const orphanGz = join(distDir, 'assets', 'index-Old00000.js.gz'); + await writeFile(orphanBr, 'stale'); + await writeFile(orphanGz, 'stale'); + + const summary = await precompressWebAssets({ distDir }); + + expect(summary.removed).toBe(2); + await expect(exists(orphanBr)).resolves.toBe(false); + await expect(exists(orphanGz)).resolves.toBe(false); + }); + + it('never prunes archives or bare compressed files that only look like siblings', async () => { + const distDir = await makeDist(); + const tarball = join(distDir, 'assets', 'bundle-Dy7xs5tu.tar.gz'); + const bareBrotli = join(distDir, 'assets', 'payload.br'); + const rootGzip = join(distDir, 'blob.gz'); + const orphan = join(distDir, 'assets', 'index-Old00000.js.br'); + await writeFile(tarball, 'archive'); + await writeFile(bareBrotli, 'opaque'); + await writeFile(rootGzip, 'opaque'); + await writeFile(orphan, 'stale'); + + const summary = await precompressWebAssets({ distDir }); + + expect(summary.removed).toBe(1); + await expect(exists(tarball)).resolves.toBe(true); + await expect(exists(bareBrotli)).resolves.toBe(true); + await expect(exists(rootGzip)).resolves.toBe(true); + await expect(exists(orphan)).resolves.toBe(false); + }); + + it('always regenerates siblings of unhashed files', async () => { + const distDir = await makeDist(); + const html = join(distDir, 'index.html'); + const boot = join(distDir, 'boot.js'); + await writeFile(html, '

kimi

\n'.repeat(200)); + await writeFile(boot, LARGE_TEXT); + const first = await precompressWebAssets({ distDir }); + const past = new Date(Date.now() - 60_000); + await utimes(html, past, past); + await utimes(boot, past, past); + + const rerun = await precompressWebAssets({ distDir }); + + expect(first.written).toBe(4); + expect(rerun.written).toBe(4); + expect(rerun.skipped).toBe(0); + }); + + it('leaves up-to-date siblings of hashed bundles alone unless forced', async () => { + const distDir = await makeDist(); + const js = join(distDir, 'assets', 'index-Dy7xs5tu.js'); + await writeFile(js, LARGE_TEXT); + await precompressWebAssets({ distDir }); + const before = await stat(`${js}.br`); + const past = new Date(Date.now() - 60_000); + await utimes(js, past, past); + + const rerun = await precompressWebAssets({ distDir }); + const forced = await precompressWebAssets({ distDir, force: true }); + + expect(rerun.written).toBe(0); + expect(rerun.skipped).toBe(2); + expect(forced.written).toBe(2); + expect((await stat(`${js}.br`)).mtimeMs).toBeGreaterThanOrEqual(before.mtimeMs); + }); + + it('fails the check when an entry bundle lacks a brotli sibling', async () => { + const distDir = await makeDist(); + const js = join(distDir, 'assets', 'index-Dy7xs5tu.js'); + await writeFile(js, LARGE_TEXT); + + await expect(precompressWebAssets({ distDir, check: true })).rejects.toThrow( + /index-Dy7xs5tu\.js/, + ); + await expect(exists(`${js}.br`)).resolves.toBe(false); + }); + + it('passes the check once entry bundles have brotli siblings', async () => { + const distDir = await makeDist(); + const js = join(distDir, 'assets', 'index-Dy7xs5tu.js'); + const css = join(distDir, 'assets', 'index-Ab12Cd34.css'); + await writeFile(js, LARGE_TEXT); + await writeFile(css, `.kimi{color:red}\n`.repeat(100)); + await precompressWebAssets({ distDir }); + + await expect(precompressWebAssets({ distDir, check: true })).resolves.toMatchObject({ + written: 0, + }); + }); + + it('emits only brotli siblings with only=br', async () => { + const distDir = await makeDist(); + const js = join(distDir, 'assets', 'index-Dy7xs5tu.js'); + await writeFile(js, LARGE_TEXT); + + const summary = await precompressWebAssets({ distDir, only: 'br' }); + + expect(summary.written).toBe(1); + await expect(exists(`${js}.br`)).resolves.toBe(true); + await expect(exists(`${js}.gz`)).resolves.toBe(false); + }); +}); diff --git a/apps/kimi-inspect/src/activity/store.test.ts b/apps/kimi-inspect/src/activity/store.test.ts index 3e600fe8122..bed7aa36574 100644 --- a/apps/kimi-inspect/src/activity/store.test.ts +++ b/apps/kimi-inspect/src/activity/store.test.ts @@ -122,6 +122,51 @@ describe('SessionActivityHub', () => { hub.close(); }); + it('drains every session page with page_size=100 and before_id while has_more is true', async () => { + const { ctor, instances } = makeFakeWsCtor(); + const urls: string[] = []; + const fetchImpl = vi.fn(async (input: string) => { + urls.push(input); + const before = new URL(input).searchParams.get('before_id'); + const page = + before === null + ? { + items: [{ id: 's1', busy: true, main_turn_active: true, pending_interaction: 'none' }], + has_more: true, + } + : { + items: [ + { id: 's2', busy: false, main_turn_active: false, pending_interaction: 'question' }, + ], + has_more: false, + }; + return { json: async () => ({ code: 0, data: page }) }; + }) as unknown as typeof fetch; + const hub = new SessionActivityHub({ + url: 'http://127.0.0.1:58627', + onListChanged: () => {}, + WebSocketImpl: ctor, + fetchImpl, + }); + + instances[0]!.emit('open'); + await vi.waitFor(() => { + expect(hub.store.get('s2')).toBeDefined(); + }); + + expect(urls).toHaveLength(2); + const firstUrl = new URL(urls[0]!); + expect(firstUrl.pathname).toBe('/api/v1/sessions'); + expect(firstUrl.searchParams.get('page_size')).toBe('100'); + expect(firstUrl.searchParams.get('before_id')).toBeNull(); + const secondUrl = new URL(urls[1]!); + expect(secondUrl.searchParams.get('page_size')).toBe('100'); + expect(secondUrl.searchParams.get('before_id')).toBe('s1'); + expect(hub.store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true })); + expect(hub.store.get('s2')?.pendingInteraction).toBe('question'); + hub.close(); + }); + it('applies live work_changed frames by session id', () => { const { ctor, instances } = makeFakeWsCtor(); const hub = new SessionActivityHub({ diff --git a/apps/kimi-inspect/src/activity/store.ts b/apps/kimi-inspect/src/activity/store.ts index af77ad6c062..86523c4d56b 100644 --- a/apps/kimi-inspect/src/activity/store.ts +++ b/apps/kimi-inspect/src/activity/store.ts @@ -3,8 +3,8 @@ * coarse activity map behind the Sidebar's status badges. * * Two data sources converge into one store: the initial / reconnect - * baseline comes from a single `GET /api/v1/sessions` page (every wire - * session carries `busy` / `main_turn_active` / `pending_interaction` / + * baseline drains the `GET /api/v1/sessions` pages (every wire session + * carries `busy` / `main_turn_active` / `pending_interaction` / * `last_turn_reason`), and live updates arrive as * `event.session.work_changed` frames over the global WS channel (no * subscription needed server-side). List-level facts (session created / @@ -19,6 +19,9 @@ import { GlobalEventsWs, type SessionWorkFacts } from './ws'; export type { SessionWorkFacts }; +/** `GET /api/v1/sessions` page size used while draining the seed baseline (server max). */ +const SEED_PAGE_SIZE = 100; + export class SessionActivityStore { private activities = new Map(); private readonly listeners = new Set<() => void>(); @@ -126,14 +129,28 @@ export class SessionActivityHub { headers['authorization'] = `Bearer ${this.token}`; } try { - const res = await this.fetchImpl(`${this.baseUrl}/api/v1/sessions`, { headers }); - const envelope = (await res.json()) as { - code: number; - data?: { items?: Record[] }; - }; - if (envelope.code !== 0 || envelope.data?.items === undefined) return; + // The list is always paged (default 50, max 100): drain it with the + // before_id cursor so every live session gets a baseline badge. + const items: Record[] = []; + let before: string | undefined; + for (;;) { + const params = new URLSearchParams({ page_size: String(SEED_PAGE_SIZE) }); + if (before !== undefined) params.set('before_id', before); + const res = await this.fetchImpl(`${this.baseUrl}/api/v1/sessions?${params.toString()}`, { + headers, + }); + const envelope = (await res.json()) as { + code: number; + data?: { items?: Record[]; has_more?: boolean }; + }; + if (envelope.code !== 0 || envelope.data?.items === undefined) return; + items.push(...envelope.data.items); + const lastId = envelope.data.items.at(-1)?.['id']; + if (envelope.data.has_more !== true || typeof lastId !== 'string') break; + before = lastId; + } const entries: [string, SessionWorkFacts][] = []; - for (const item of envelope.data.items) { + for (const item of items) { const id = item['id']; if (typeof id !== 'string' || typeof item['busy'] !== 'boolean') continue; const pending = item['pending_interaction']; diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx index 449fc0b9115..2240dfa410c 100644 --- a/apps/kimi-inspect/src/components/ChatView.tsx +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -86,6 +86,9 @@ import { ChatSearchBar } from './ChatSearchBar'; const noopSubscribe = () => () => {}; +/** Upper bound on `has_more` catch-up pages before giving up on the loop. */ +const MAX_CATCHUP_ROUNDS = 50; + /** Active session id for deeply nested interaction views (approve/answer buttons). */ const SessionContext = createContext(''); @@ -240,7 +243,8 @@ function useTranscriptChannel( /** * Targeted catch-up: fetch exactly the op batches after our watermark - * (`GET .../transcript/ops?since_seq=`). Falls back to a full page + * (`GET .../transcript/ops?since_seq=`), paging from the last received + * seq while the server reports `has_more`. Falls back to a full page * reload on a legacy server (no seq / endpoint missing), a journal that * no longer covers the gap (`complete: false`), or a fetch failure. */ @@ -253,22 +257,31 @@ function useTranscriptChannel( buffer = []; bufferedSeq = undefined; try { - const res = await fetchTranscriptOps({ - baseUrl, - token: authToken, - sessionId, - agentId, - sinceSeq: lastSeq, - }); - if (disposed) return; - if (!res.complete) { - await reloadPages(); - } else { + let since: number = lastSeq; + for (let round = 0; round < MAX_CATCHUP_ROUNDS; round++) { + const res = await fetchTranscriptOps({ + baseUrl, + token: authToken, + sessionId, + agentId, + sinceSeq: since, + }); + if (disposed) return; + if (!res.complete) { + await reloadPages(); + break; + } for (const batch of res.batches) { store.applyOps(batch.ops); trail.recordOps(batch.ops, 'catchup', undefined, store.getState()); } - noteSeq(res.latestSeq); + const lastBatch = res.batches.at(-1); + if (!res.hasMore || lastBatch === undefined || lastBatch.seq >= res.latestSeq) { + noteSeq(res.latestSeq); + break; + } + since = lastBatch.seq; + noteSeq(since); } } catch { try { diff --git a/apps/kimi-inspect/src/transcript/api.ts b/apps/kimi-inspect/src/transcript/api.ts index 7aa54cee855..2de90fef3d0 100644 --- a/apps/kimi-inspect/src/transcript/api.ts +++ b/apps/kimi-inspect/src/transcript/api.ts @@ -152,6 +152,11 @@ export interface TranscriptOpsCatchup { readonly latestSeq: number; /** False = the journal cannot cover `sinceSeq`; the caller must full-refresh. */ readonly complete: boolean; + /** + * True = the server capped this response (`limit`) and batches remain below + * `latestSeq`; page again with `sinceSeq` = the last received batch seq. + */ + readonly hasMore: boolean; } export interface FetchTranscriptOpsOptions { @@ -161,6 +166,8 @@ export interface FetchTranscriptOpsOptions { readonly agentId: string; /** Return journaled batches with seq strictly greater than this watermark. */ readonly sinceSeq: number; + /** Max batches per response (1–500; the server defaults to 500). */ + readonly limit?: number | undefined; /** Injectable for tests. */ readonly fetchImpl?: typeof fetch; } @@ -169,6 +176,7 @@ export interface FetchTranscriptOpsOptions { * Point-to-point catch-up: `GET .../transcript/ops?agent_id=&since_seq=N`. * Available on sequenced servers; a 404/envelope error means the server * predates the endpoint and the caller should fall back to a full refresh. + * One call returns at most `limit` batches — keep calling while `hasMore`. */ export async function fetchTranscriptOps( opts: FetchTranscriptOpsOptions, @@ -177,6 +185,7 @@ export async function fetchTranscriptOps( agent_id: opts.agentId, since_seq: String(opts.sinceSeq), }); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); const headers: Record = {}; if (opts.token !== undefined && opts.token !== '') { headers['authorization'] = `Bearer ${opts.token}`; @@ -198,6 +207,7 @@ export async function fetchTranscriptOps( batches: parsed.data.batches, latestSeq: parsed.data.latest_seq, complete: parsed.data.complete, + hasMore: parsed.data.has_more, }; } diff --git a/apps/kimi-inspect/src/transcript/transcript.test.ts b/apps/kimi-inspect/src/transcript/transcript.test.ts index 507d5c15e94..91e72859959 100644 --- a/apps/kimi-inspect/src/transcript/transcript.test.ts +++ b/apps/kimi-inspect/src/transcript/transcript.test.ts @@ -284,6 +284,7 @@ describe('fetchTranscriptOps', () => { ], latest_seq: 7, complete: true, + has_more: false, }; it('requests the ops endpoint with since_seq and unwraps batches in order', async () => { @@ -299,11 +300,32 @@ describe('fetchTranscriptOps', () => { expect(calls[0]!.url).toContain('/api/v1/sessions/s1/transcript/ops?'); expect(calls[0]!.url).toContain('agent_id=main'); expect(calls[0]!.url).toContain('since_seq=5'); + expect(calls[0]!.url).not.toContain('limit='); expect(res.complete).toBe(true); + expect(res.hasMore).toBe(false); expect(res.latestSeq).toBe(7); expect(res.batches.map((batch) => batch.seq)).toEqual([6, 7]); }); + it('forwards limit and surfaces has_more for a capped catch-up', async () => { + const { calls, fetchImpl } = fakeFetch( + okEnvelope({ ...catchupData, batches: catchupData.batches.slice(0, 1), has_more: true }), + ); + const res = await fetchTranscriptOps({ + baseUrl: 'http://h:1', + sessionId: 's1', + agentId: 'main', + sinceSeq: 5, + limit: 1, + fetchImpl, + }); + expect(calls[0]!.url).toContain('limit=1'); + expect(res.hasMore).toBe(true); + expect(res.complete).toBe(true); + expect(res.latestSeq).toBe(7); + expect(res.batches.map((batch) => batch.seq)).toEqual([6]); + }); + it('surfaces an incomplete catch-up (journal cannot cover)', async () => { const { fetchImpl } = fakeFetch( okEnvelope({ ...catchupData, batches: [], latest_seq: 500, complete: false }), diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 6936ca8a7b7..99463a219ef 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -138,6 +138,15 @@ Switches that control the behavior of subsystems such as telemetry, background t | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) | | `KIMI_CODE_PASSWORD` | Parallel auth credential for `kimi web`, recommended when binding beyond loopback (see [Security notes](../guides/web.md#security-notes)) | Any non-empty string; when unset, only the token is valid | +| `KIMI_CODE_WS_COMPRESSION` | Offer `permessage-deflate` on `kimi web` WebSocket connections (default on) | `1`/`true` or `0`/`false`; anything else is ignored | +| `KIMI_CODE_WS_MAX_PAYLOAD_BYTES` | Max inbound WebSocket message size (default `16777216`) | Positive integer; invalid values are ignored | +| `KIMI_CODE_WS_HEARTBEAT_MS` | Server `ping` interval (ms); the connection closes after two silent intervals (default `10000`) | Positive integer; invalid values are ignored | +| `KIMI_CODE_WS_FLUSH_INTERVAL_MS` | Batching window (ms) for subscribed event frames (default `16`) | Positive integer; invalid values are ignored | +| `KIMI_CODE_WS_MAX_BATCH_SIZE` | Buffered subscribed frames that trigger an immediate flush (default `64`) | Positive integer; invalid values are ignored | +| `KIMI_CODE_WS_HIGH_WATER_MARK_BYTES` | Socket `bufferedAmount` above which outbound frames are held back (default `1048576`) | Positive integer; invalid values are ignored | +| `KIMI_CODE_WS_MAX_BUFFER_SIZE` | Per-session replay window, also advertised as `server_hello.max_event_buffer_size` (default `1000`) | Positive integer; invalid values are ignored | +| `KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES` | Experimental: split Remote Control HTTP responses into 256 KiB tunnel frames (default off, one frame per response) | `1`/`true`; anything else keeps the default | +| `KIMI_CODE_TRANSCRIPT_OPS_BATCH_MS` | Window (ms) for merging consecutive streamed text appends into one `transcript.ops` batch before its sequence number is assigned (default `16`; `0` forwards every append immediately) | Non-negative integer; invalid values are ignored | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Keep background tasks when the session closes; higher priority than `config.toml` (default: stop them on exit) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` | Cap on concurrently running background tasks; higher priority than `[background] max_running_tasks` (unset = no cap) | Positive integer; invalid values are ignored | | `KIMI_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S` | Default timeout (seconds) for background `Bash` tasks, also used to re-arm foreground commands moved to the background; higher priority than `[task] bash_task_timeout_s` (`0` = no timeout) | Non-negative integer; invalid values are ignored | diff --git a/docs/en/guides/remote-control.md b/docs/en/guides/remote-control.md index e4a49c0c469..3ada31cb2d0 100644 --- a/docs/en/guides/remote-control.md +++ b/docs/en/guides/remote-control.md @@ -88,6 +88,12 @@ Remote Control is only a remote window — all computation and file operations s - **Local process exits**: pressing `Ctrl+C` or closing the terminal stops Remote Control and takes the device off the remote list. Restart it to recover - **End the remote connection but keep the local task**: just close the web page — the local task is unaffected +## Performance on slow links + +How the tunnel caches the web UI depends on whether it had to rewrite a file. Fonts, wasm and other binary assets (the hashed files under `/assets/`) keep their long-lived `Cache-Control` headers, so the remote browser caches them across sessions. HTML, JavaScript and CSS are rewritten under the device prefix, so they are stored with `Cache-Control: public, no-cache` and a versioned `ETag`: the browser revalidates them on every load, which is a cheap `304 Not Modified` when the bundle has not changed, and only refetches what actually changed. The first load on a slow link still transfers the full bundle once. + +`KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES=1` is an experimental switch that splits large HTTP responses into 256 KiB tunnel frames instead of one frame per response. It is off by default and is only useful for diagnosing slow-link behaviour; leave it unset unless asked to try it. + ## What's the difference between Remote Control and Kimi Code Web? [Kimi Code Web](../guides/web.md) is the graphical interface on your machine or LAN; Remote Control extends it to any device on the public internet: diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index f38aa633d7a..0bbbc395934 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -614,16 +614,16 @@ On success, `data` is [the session object](#the-session-object) of the new sessi #### `GET /api/v1/sessions` -Lists sessions across workspaces, newest `updated_at` first. Cursor pagination follows [Pagination](#pagination), with one twist: without `page_size` (and without `archived_only`) the response is a single unpaginated window whose `has_more` is always `false`, so pass `page_size` to actually page. +Lists sessions across workspaces, newest `updated_at` first. Cursor pagination follows [Pagination](#pagination) and always applies: without `page_size` the response is the first page of `50` sessions, and `has_more` is computed on every response — keep paging with `before_id` until it is `false`. With `after_id` the response is the newest `page_size` sessions newer than the cursor, and `has_more` means more sessions exist between the cursor and that page (`before_id` and `after_id` are mutually exclusive, so the two directions cannot be combined in one request). | Parameter | In | Type | Description | | --- | --- | --- | --- | | `before_id` | query | string | Only sessions older than this id; mutually exclusive with `after_id` | -| `after_id` | query | string | Only sessions newer than this id; mutually exclusive with `before_id` | -| `page_size` | query | integer | 1–100. When paging applies, the default is `20`; see the note above for the unpaginated default behavior | -| `busy` | query | boolean | Keep only busy (or only idle) sessions | +| `after_id` | query | string | Only sessions newer than this id; mutually exclusive with `before_id`. The page holds the newest `page_size` matches above the cursor; `has_more` reports whether more exist between the cursor and the page | +| `page_size` | query | integer | 1–100. Default `50` | +| `busy` | query | boolean | Keep only busy (or only idle) sessions. Applied while collecting, so pages are filled up to `page_size` and `has_more` is accurate | | `include_archive` | query | boolean | Include archived sessions alongside live ones. Default `false` | -| `archived_only` | query | boolean | Keep only archived sessions; mutually exclusive with `include_archive`; implies cursor paging even without `page_size` | +| `archived_only` | query | boolean | Keep only archived sessions; mutually exclusive with `include_archive` | | `exclude_empty` | query | boolean | Drop sessions that carry no user prompt | | `workspace_id` | query | string | Restrict to one workspace (aliases are resolved) | @@ -953,15 +953,16 @@ The page unit is the turn: without a cursor the newest page is returned, and `ha #### `GET /api/v1/sessions/{session_id}/transcript/ops` -Serves point-to-point catch-up from the server's op journal: the journaled op batches with `seq > since_seq` for one agent, oldest first. It is the REST counterpart of the `transcript_since` resume cursor described in [Transcript protocol](#transcript-protocol) and shares the same bounded journal, so the same fallback rule applies. +Serves point-to-point catch-up from the server's op journal: the journaled op batches with `seq > since_seq` for one agent, oldest first, at most `limit` batches per response. It is the REST counterpart of the `transcript_since` resume cursor described in [Transcript protocol](#transcript-protocol) and shares the same bounded journal, so the same fallback rule applies. | Parameter | In | Type | Description | | --- | --- | --- | --- | | `session_id` | path | string | **Required.** Session id | | `agent_id` | query | string | **Required.** Agent id (plain id, same constraint as the transcript endpoint) | | `since_seq` | query | integer | **Required.** The caller's last applied op-batch seq, minimum `0`; batches above it are returned | +| `limit` | query | integer | Maximum batches per response, 1–500. Default `500` | -On success, `data` is `{ agent_id, batches, latest_seq, complete }`, each batch `{ seq, ops }`. `complete: true` means every batch up to `latest_seq` is present; `complete: false` means the journal no longer reaches back to `since_seq` (or the session is not live at all), and the caller must fall back to a full `GET .../transcript` refresh. +On success, `data` is `{ agent_id, batches, latest_seq, complete, has_more }`, each batch `{ seq, ops }`. `latest_seq` is always the journal's newest seq, even when the response is capped. `has_more: true` means the cap cut the response short and batches remain below `latest_seq` — call again with `since_seq` set to the last received batch `seq` until `has_more` is `false`. `complete: true` means the journal covers everything from `since_seq` up to `latest_seq` (a capped response is still `complete`); `complete: false` means the journal no longer reaches back to `since_seq` (or the session is not live at all), and the caller must fall back to a full `GET .../transcript` refresh. - `40001`: validation failure - `40401`: session not found @@ -2353,7 +2354,9 @@ The only endpoint is `ws://:/api/v1/ws`; authentication happens at t } ``` -Note that the server never sends heartbeats and never disconnects an idle connection — keepalive and reconnection are the client's job. +`capabilities.compression` is `true` when the connection negotiated `permessage-deflate`; the server offers it by default (`KIMI_CODE_WS_COMPRESSION=0` disables it). + +The server sends an application-level `ping` frame every 10 s (`KIMI_CODE_WS_HEARTBEAT_MS`) and closes the connection (`1001`) after two intervals without any inbound frame — reply with `pong` (any frame counts). A peer that stops reading is closed with `1013 slow consumer` once outbound frames stall for 15 s or the queue exceeds 4096 frames. Reconnection is the client's job. ### Control frames diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 9ed78e45dd5..0d8deaee880 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -138,6 +138,15 @@ kimi | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1`、`true`、`yes`、`y`(不区分大小写) | | `KIMI_CODE_PASSWORD` | 为 `kimi web` 本地服务设置并列鉴权密码;绑到非本机地址时建议设置,见 [安全注意](../guides/web.md#安全注意) | 任意非空字符串;未设置时仅 token 有效 | +| `KIMI_CODE_WS_COMPRESSION` | `kimi web` WebSocket 连接是否提供 `permessage-deflate`(默认开启) | `1`/`true` 或 `0`/`false`;其他值被忽略 | +| `KIMI_CODE_WS_MAX_PAYLOAD_BYTES` | 入站 WebSocket 消息大小上限(默认 `16777216`) | 正整数;非法值被忽略 | +| `KIMI_CODE_WS_HEARTBEAT_MS` | 服务端 `ping` 间隔(毫秒);连续两个周期无入站帧即关闭连接(默认 `10000`) | 正整数;非法值被忽略 | +| `KIMI_CODE_WS_FLUSH_INTERVAL_MS` | 订阅事件帧的合并发送窗口(毫秒,默认 `16`) | 正整数;非法值被忽略 | +| `KIMI_CODE_WS_MAX_BATCH_SIZE` | 缓冲多少条订阅帧后立即发送(默认 `64`) | 正整数;非法值被忽略 | +| `KIMI_CODE_WS_HIGH_WATER_MARK_BYTES` | socket `bufferedAmount` 超过该值时暂缓发送(默认 `1048576`) | 正整数;非法值被忽略 | +| `KIMI_CODE_WS_MAX_BUFFER_SIZE` | 每个会话的事件回放窗口,同时作为 `server_hello.max_event_buffer_size` 下发(默认 `1000`) | 正整数;非法值被忽略 | +| `KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES` | 实验性:将远程控制的 HTTP 响应拆成 256 KiB 的中转帧发送(默认关闭,每个响应一帧) | `1`/`true`;其他值保持默认 | +| `KIMI_CODE_TRANSCRIPT_OPS_BATCH_MS` | 将连续的流式文本追加合并为一个 `transcript.ops` 批次的时间窗口(毫秒),合并在分配序号之前完成(默认 `16`;`0` 表示每次追加立即转发) | 非负整数;非法值被忽略 | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` | 同时运行的后台任务数上限,优先级高于 `config.toml` 的 `[background] max_running_tasks`;不设置表示无上限 | 正整数;非法值被忽略 | | `KIMI_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S` | 后台 `Bash` 任务的默认超时(秒),也用于前台命令转入后台后的重新计时,优先级高于 `[task] bash_task_timeout_s`;`0` 表示无超时 | 非负整数;非法值被忽略 | diff --git a/docs/zh/guides/remote-control.md b/docs/zh/guides/remote-control.md index 105fe12e674..aaeec0cfaf3 100644 --- a/docs/zh/guides/remote-control.md +++ b/docs/zh/guides/remote-control.md @@ -88,6 +88,12 @@ - **本地进程退出**:按 `Ctrl+C` 或关闭终端后远程控制停止,设备从远程列表中下线。重新启动后可恢复 - **结束远程连接但保留本地任务**:直接关闭网页即可,本机任务不受影响 +## 慢速网络下的性能 + +中转对网页界面的缓存方式取决于文件是否被改写。字体、wasm 等二进制资源(`/assets/` 下带哈希的文件)保留原有的长期 `Cache-Control` 头,因此远程浏览器会跨会话缓存它们。HTML、JavaScript 和 CSS 会被改写到设备前缀之下,因此以 `Cache-Control: public, no-cache` 和带版本的 `ETag` 存储:浏览器每次加载都会重新校验,在包未变化时只是一次开销很小的 `304 Not Modified`,只有真正变化的文件才会重新下载。慢速网络下首次加载仍需完整传输一次。 + +`KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES=1` 是一个实验性开关,会把较大的 HTTP 响应拆成 256 KiB 的中转帧,而不是一帧发送整个响应。默认关闭,仅用于排查慢速网络下的问题;除非被要求尝试,否则不要设置。 + ## 远程控制和 Kimi Code 网页版有什么区别? [Kimi Code 网页版](../guides/web.md) 是本机或局域网里的图形界面,远程控制把它延伸到了公网任意设备: diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 530dfd3cb4b..7add9f55e3a 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -614,16 +614,16 @@ curl -s -H "Authorization: Bearer $TOKEN" \ #### `GET /api/v1/sessions` -跨工作区列出会话,按 `updated_at` 最新在前。游标分页遵循 [分页](#分页),但有一个特例:不提供 `page_size`(且不提供 `archived_only`)时,响应是单个不分页的窗口,其 `has_more` 恒为 `false`,因此要真正翻页请传入 `page_size`。 +跨工作区列出会话,按 `updated_at` 最新在前。游标分页遵循 [分页](#分页) 且始终生效:不提供 `page_size` 时,响应是前 `50` 条会话的第一页,并且每次响应都会计算 `has_more`——请用 `before_id` 持续翻页,直到其为 `false`。使用 `after_id` 时,响应是晚于该游标的最新 `page_size` 条会话,`has_more` 表示游标与这一页之间还存在更多会话(`before_id` 与 `after_id` 互斥,同一请求不能同时向两个方向翻页)。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `before_id` | query | string | 只保留早于该 id 的会话;与 `after_id` 互斥 | -| `after_id` | query | string | 只保留晚于该 id 的会话;与 `before_id` 互斥 | -| `page_size` | query | integer | 1–100。分页生效时默认为 `20`;不分页的默认行为见上文说明 | -| `busy` | query | boolean | 只保留忙碌(或只保留空闲)的会话 | +| `after_id` | query | string | 只保留晚于该 id 的会话;与 `before_id` 互斥。该页为晚于游标的最新 `page_size` 条匹配项;`has_more` 表示游标与这一页之间是否还有更多会话 | +| `page_size` | query | integer | 1–100。默认 `50` | +| `busy` | query | boolean | 只保留忙碌(或只保留空闲)的会话。过滤在收集阶段应用,因此每页会填满到 `page_size` 条,且 `has_more` 准确 | | `include_archive` | query | boolean | 在活跃会话之外同时包含已归档会话。默认 `false` | -| `archived_only` | query | boolean | 只保留已归档会话;与 `include_archive` 互斥;即使不提供 `page_size` 也会启用游标分页 | +| `archived_only` | query | boolean | 只保留已归档会话;与 `include_archive` 互斥 | | `exclude_empty` | query | boolean | 去掉没有任何用户提示词的会话 | | `workspace_id` | query | string | 限定到单个工作区(别名会被解析) | @@ -953,15 +953,16 @@ main agent 的实时状态汇总;读取它会在会话为冷态时将其恢复 #### `GET /api/v1/sessions/{session_id}/transcript/ops` -从服务端的 op 日志提供点对点的补漏:某个 Agent 的 `seq > since_seq` 的已记录 op 批次,最旧在前。它是 [转录协议](#转录协议) 中 `transcript_since` 恢复游标的 REST 对应物,共享同一份有界日志,因此适用相同的回退规则。 +从服务端的 op 日志提供点对点的补漏:某个 Agent 的 `seq > since_seq` 的已记录 op 批次,最旧在前,每次响应最多 `limit` 个批次。它是 [转录协议](#转录协议) 中 `transcript_since` 恢复游标的 REST 对应物,共享同一份有界日志,因此适用相同的回退规则。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `session_id` | path | string | **必填。** 会话 id | | `agent_id` | query | string | **必填。** Agent id(纯文本形式,约束与转录端点相同) | | `since_seq` | query | integer | **必填。** 调用方已应用的最后一个 op 批次 seq,最小为 `0`;返回其之后的批次 | +| `limit` | query | integer | 每次响应最多返回的批次数,1–500。默认 `500` | -成功时,`data` 为 `{ agent_id, batches, latest_seq, complete }`,每个批次为 `{ seq, ops }`。`complete: true` 表示直到 `latest_seq` 的每个批次都在;`complete: false` 表示日志已不再覆盖到 `since_seq`(或会话根本不是活跃状态),调用方必须回退为一次完整的 `GET .../transcript` 刷新。 +成功时,`data` 为 `{ agent_id, batches, latest_seq, complete, has_more }`,每个批次为 `{ seq, ops }`。`latest_seq` 始终是日志中最新的 seq,即使响应被截断也是如此。`has_more: true` 表示响应被 `limit` 截断、`latest_seq` 之前仍有批次未返回——请把 `since_seq` 设为最后收到的批次 `seq` 再次调用,直到 `has_more` 为 `false`。`complete: true` 表示日志覆盖了从 `since_seq` 到 `latest_seq` 的全部批次(被截断的响应仍然是 `complete`);`complete: false` 表示日志已不再覆盖到 `since_seq`(或会话根本不是活跃状态),调用方必须回退为一次完整的 `GET .../transcript` 刷新。 - `40001`:校验失败 - `40401`:会话不存在 @@ -2353,7 +2354,9 @@ locator 寻址的目录(脱敏配置),外加对每个 OAuth 候选的批 } ``` -注意服务端不发送心跳,也不会主动断开空闲连接——保活与重连由客户端自己负责。 +连接协商了 `permessage-deflate` 时 `capabilities.compression` 为 `true`;服务端默认提供该扩展(`KIMI_CODE_WS_COMPRESSION=0` 关闭)。 + +服务端每 10 秒(`KIMI_CODE_WS_HEARTBEAT_MS`)发送一条应用层 `ping` 帧,连续两个周期没有收到任何入站帧就以 `1001` 关闭连接——客户端需回复 `pong`(任意帧均可)。对端停止读取时,出站帧滞留 15 秒或队列超过 4096 帧会以 `1013 slow consumer` 关闭。重连由客户端自己负责。 ### 控制帧 diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index d4799d6fd20..b4532e20e9f 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -96,7 +96,7 @@ const booleanQueryParam = z.preprocess((value) => { return value; }, z.boolean().optional()); -const DEFAULT_SESSION_LIST_PAGE_SIZE = 20; +const DEFAULT_SESSION_LIST_PAGE_SIZE = 50; const sessionsListQueryCoercion = z .object({ @@ -277,7 +277,8 @@ export function registerSessionsRoutes( [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, [ErrorCode.WORKSPACE_NOT_FOUND]: {}, }, - description: 'List sessions', + description: + 'List sessions, newest updated_at first. Filters (busy, archived_only, exclude_empty) are applied while collecting, so pages are filled up to page_size. With before_id the page is the newest page_size sessions older than the cursor; with after_id it is the newest page_size sessions newer than the cursor, and has_more means more sessions exist between the cursor and the page.', tags: ['sessions'], }, async (req, reply) => { @@ -308,7 +309,7 @@ export function registerSessionsRoutes( interface Eligible { readonly summary: SessionSummary; readonly cwd: string; - readonly facts?: SessionFacts; + readonly facts: SessionFacts; } const collect = async (pageSize: number): Promise<{ visible: Eligible[]; hasMore: boolean }> => { @@ -339,14 +340,10 @@ export function registerSessionsRoutes( const cwd = summary.cwd ?? roots.get(summary.workspaceId); if (cwd === undefined) continue; if (raw.exclude_empty === true && (summary.lastPrompt ?? '').length === 0) continue; - if (archivedOnly) { - if (!summary.archived) continue; - const facts = resolveSessionFacts(core, summary.id); - if (raw.busy !== undefined && facts.busy !== raw.busy) continue; - collected.push({ summary, cwd, facts }); - } else { - collected.push({ summary, cwd }); - } + if (archivedOnly && !summary.archived) continue; + const facts = resolveSessionFacts(core, summary.id); + if (raw.busy !== undefined && facts.busy !== raw.busy) continue; + collected.push({ summary, cwd, facts }); } if (exhausted || page.nextCursor === undefined) break; before = page.nextCursor; @@ -354,40 +351,9 @@ export function registerSessionsRoutes( return { visible: collected.slice(0, pageSize), hasMore: collected.length > pageSize }; }; - if (!archivedOnly && raw.page_size === undefined) { - const page = await index.listRecent({ - workspaceIds, - includeArchived, - before: raw.before_id, - after: raw.after_id, - }); - const eligible: Eligible[] = []; - for (const summary of page.items) { - const cwd = summary.cwd ?? roots.get(summary.workspaceId); - if (cwd === undefined) continue; - if (raw.exclude_empty === true && (summary.lastPrompt ?? '').length === 0) continue; - eligible.push({ summary, cwd }); - } - const projected = eligible.map(({ summary, cwd }) => - toWireSession(summary, cwd, resolveSessionFacts(core, summary.id)), - ); - const items = - raw.busy !== undefined - ? projected.filter((session) => session.busy === raw.busy) - : projected; - reply.send(okEnvelope({ items, has_more: false }, req.id)); - return; - } - const pageSize = raw.page_size ?? DEFAULT_SESSION_LIST_PAGE_SIZE; const { visible, hasMore } = await collect(pageSize); - const projected = visible.map(({ summary, cwd, facts }) => - toWireSession(summary, cwd, facts ?? resolveSessionFacts(core, summary.id)), - ); - const items = - raw.busy !== undefined && !archivedOnly - ? projected.filter((session) => session.busy === raw.busy) - : projected; + const items = visible.map(({ summary, cwd, facts }) => toWireSession(summary, cwd, facts)); reply.send(okEnvelope({ items, has_more: hasMore }, req.id)); }, ); diff --git a/packages/kap-server/src/routes/transcript.ts b/packages/kap-server/src/routes/transcript.ts index 069f197b168..0edb59f80a7 100644 --- a/packages/kap-server/src/routes/transcript.ts +++ b/packages/kap-server/src/routes/transcript.ts @@ -63,10 +63,13 @@ const transcriptQueryCoercion = z const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); +const MAX_TRANSCRIPT_OPS_LIMIT = 500; + const transcriptOpsQueryCoercion = z .object({ agent_id: z.string().min(1), since_seq: z.coerce.number().int().min(0), + limit: z.coerce.number().int().min(1).max(MAX_TRANSCRIPT_OPS_LIMIT).optional(), }) .superRefine((value, ctx) => { if (!isPlainAgentId(value.agent_id)) { @@ -222,14 +225,19 @@ export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: Transcr [ErrorCode.SESSION_NOT_FOUND]: {}, }, description: - 'Point-to-point transcript catch-up: journaled op batches with seq > since_seq for one agent, oldest first. complete:false means the session is not live or the journal no longer reaches back to since_seq — the caller must fall back to a full transcript refresh', + 'Point-to-point transcript catch-up: journaled op batches with seq > since_seq for one agent, oldest first, at most limit batches per response (default 500). has_more:true means more batches remain below latest_seq — page again from the last received seq. complete:false means the session is not live or the journal no longer reaches back to since_seq — the caller must fall back to a full transcript refresh', tags: ['transcript'], }, async (req, reply) => { const { session_id } = req.params; const query = req.query; - const catchup = transcriptService.getOpsSince(session_id, query.agent_id, query.since_seq); + const catchup = transcriptService.getOpsSince( + session_id, + query.agent_id, + query.since_seq, + query.limit ?? MAX_TRANSCRIPT_OPS_LIMIT, + ); if (catchup === undefined) { const roster = await transcriptService.readColdRoster(session_id); if (roster === undefined) { @@ -238,7 +246,13 @@ export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: Transcr } reply.send( okEnvelope( - { agent_id: query.agent_id, batches: [], latest_seq: 0, complete: false }, + { + agent_id: query.agent_id, + batches: [], + latest_seq: 0, + complete: false, + has_more: false, + }, req.id, ), ); @@ -251,6 +265,7 @@ export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: Transcr batches: catchup.batches, latest_seq: catchup.latestSeq, complete: catchup.complete, + has_more: catchup.hasMore, }, req.id, ), diff --git a/packages/kap-server/src/routes/webAssets.ts b/packages/kap-server/src/routes/webAssets.ts index e453593171b..b8c00bf8a5d 100644 --- a/packages/kap-server/src/routes/webAssets.ts +++ b/packages/kap-server/src/routes/webAssets.ts @@ -1,4 +1,4 @@ -import { createReadStream } from 'node:fs'; +import { createReadStream, type Stats } from 'node:fs'; import { stat } from 'node:fs/promises'; import { extname, join, normalize, relative, resolve, sep } from 'node:path'; @@ -11,6 +11,33 @@ interface WebAssetRouteHost { ): unknown; } +interface StaticFile { + path: string; + stats: Stats; +} + +interface EncodedVariant extends StaticFile { + encoding: string; + etagSuffix: string; +} + +const COMPRESSIBLE_EXTENSIONS = new Set([ + '.html', + '.js', + '.mjs', + '.css', + '.svg', + '.json', + '.map', + '.wasm', + '.txt', +]); + +const PRECOMPRESSED_ENCODINGS = [ + { encoding: 'br', extension: '.br', etagSuffix: '-br' }, + { encoding: 'gzip', extension: '.gz', etagSuffix: '-gz' }, +]; + export async function registerWebAssetRoutes( app: WebAssetRouteHost, assetsDir: string, @@ -44,21 +71,102 @@ async function serveWebAsset( return reply.callNotFound(); } - const filePath = await resolveStaticFile(assetsDir, requestUrl.pathname); - if (filePath === undefined) { + const file = await resolveStaticFile(assetsDir, requestUrl.pathname); + if (file === undefined) { return reply.code(404).type('text/plain; charset=utf-8').send('Not found'); } - const fileInfo = await stat(filePath).catch(() => undefined); - if (fileInfo === undefined || !fileInfo.isFile()) { - return reply.code(404).type('text/plain; charset=utf-8').send('Not found'); + const compressible = COMPRESSIBLE_EXTENSIONS.has(extname(file.path)); + const variant = compressible + ? await findEncodedVariant(file, headerValue(req.headers['accept-encoding'])) + : undefined; + const source = variant ?? file; + const etag = weakEtag(source.stats, variant?.etagSuffix ?? ''); + + reply.header('ETag', etag).header('Cache-Control', cacheControl(assetsDir, file.path)); + if (compressible) { + reply.header('Vary', 'Accept-Encoding'); + } + if (matchesIfNoneMatch(headerValue(req.headers['if-none-match']), etag)) { + return reply.code(304).send(); + } + + reply + .type(mimeType(file.path)) + .header('Last-Modified', source.stats.mtime.toUTCString()) + .header('Content-Length', String(source.stats.size)); + if (variant !== undefined) { + reply.header('Content-Encoding', variant.encoding); + } + return reply.send(createReadStream(source.path)); +} + +function headerValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value.join(',') : value; +} + +async function findEncodedVariant( + file: StaticFile, + acceptEncoding: string | undefined, +): Promise { + if (acceptEncoding === undefined) { + return undefined; + } + const accepted = parseAcceptEncoding(acceptEncoding); + for (const candidate of PRECOMPRESSED_ENCODINGS) { + if (!isEncodingAccepted(accepted, candidate.encoding)) { + continue; + } + const path = `${file.path}${candidate.extension}`; + const stats = await stat(path).catch(() => undefined); + if (stats?.isFile() === true && stats.mtimeMs >= file.stats.mtimeMs) { + return { path, stats, encoding: candidate.encoding, etagSuffix: candidate.etagSuffix }; + } + } + return undefined; +} + +function parseAcceptEncoding(header: string): Map { + const weights = new Map(); + for (const entry of header.split(',')) { + const [name = '', ...params] = entry.trim().split(';'); + const coding = name.trim().toLowerCase(); + if (coding === '') { + continue; + } + const qParam = params.map((p) => p.trim()).find((p) => p.toLowerCase().startsWith('q=')); + const q = qParam === undefined ? 1 : Number.parseFloat(qParam.slice(2)); + weights.set(coding, Number.isNaN(q) ? 0 : q); + } + return weights; +} + +function isEncodingAccepted(weights: Map, encoding: string): boolean { + const explicit = weights.get(encoding); + if (explicit !== undefined) { + return explicit > 0; } + const wildcard = weights.get('*'); + return wildcard !== undefined && wildcard > 0; +} - return reply - .type(mimeType(filePath)) - .header('Cache-Control', cacheControl(assetsDir, filePath)) - .header('Content-Length', String(fileInfo.size)) - .send(createReadStream(filePath)); +function weakEtag(stats: Stats, suffix: string): string { + return `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}${suffix}"`; +} + +function matchesIfNoneMatch(header: string | undefined, etag: string): boolean { + if (header === undefined) { + return false; + } + const opaque = stripWeakPrefix(etag); + return header.split(',').some((tag) => { + const trimmed = tag.trim(); + return trimmed === '*' || stripWeakPrefix(trimmed) === opaque; + }); +} + +function stripWeakPrefix(tag: string): string { + return tag.startsWith('W/') ? tag.slice(2) : tag; } function cacheControl(assetsDir: string, filePath: string): string { @@ -73,7 +181,7 @@ function cacheControl(assetsDir: string, filePath: string): string { async function resolveStaticFile( assetsDir: string, pathname: string, -): Promise { +): Promise { let decoded: string; try { decoded = decodeURIComponent(pathname); @@ -92,14 +200,19 @@ async function resolveStaticFile( return undefined; } - const info = await stat(candidate).catch(() => undefined); - if (info?.isFile() === true) { - return candidate; + const stats = await stat(candidate).catch(() => undefined); + if (stats?.isFile() === true) { + return { path: candidate, stats }; } if (extname(pathname) !== '') { return undefined; } - return join(root, 'index.html'); + const indexPath = join(root, 'index.html'); + const indexStats = await stat(indexPath).catch(() => undefined); + if (indexStats?.isFile() !== true) { + return undefined; + } + return { path: indexPath, stats: indexStats }; } function isReservedPath(pathname: string): boolean { @@ -121,7 +234,10 @@ function mimeType(filePath: string): string { case '.css': return 'text/css; charset=utf-8'; case '.json': + case '.map': return 'application/json; charset=utf-8'; + case '.txt': + return 'text/plain; charset=utf-8'; case '.svg': return 'image/svg+xml'; case '.png': @@ -135,6 +251,14 @@ function mimeType(filePath: string): string { return 'image/x-icon'; case '.woff2': return 'font/woff2'; + case '.woff': + return 'font/woff'; + case '.ttf': + return 'font/ttf'; + case '.wasm': + return 'application/wasm'; + case '.riv': + return 'application/octet-stream'; default: return 'application/octet-stream'; } diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 887f1c1c90f..7439cfb4581 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -26,6 +26,7 @@ import { } from '@moonshot-ai/agent-core-v2/features/tower/protocol/index'; import { TranscriptStore, + coalesceAppendOps, foldWireRecordFacts, groupMessagesIntoSnapshot, isPlainAgentId, @@ -33,6 +34,7 @@ import { type ActivityMeta, type AgentTranscript, type AgentTranscriptSnapshot, + type AppendOp, type TranscriptChangeEvent, type TranscriptMarker, type TranscriptOperation, @@ -60,8 +62,19 @@ export interface TranscriptServiceDeps { readonly homeDir: string; readonly core: Scope; readonly logger?: TranscriptBindingLogger; + readonly opsBatchMs?: number; } +interface PendingAppends { + readonly ops: AppendOp[]; + textBytes: number; + timer?: NodeJS.Timeout; +} + +export const DEFAULT_TRANSCRIPT_OPS_BATCH_MS = 16; +export const TRANSCRIPT_OPS_BATCH_MAX_OPS = 256; +export const TRANSCRIPT_OPS_BATCH_MAX_TEXT_BYTES = 64 * 1024; + interface LiveEntry { readonly store: TranscriptStore; readonly binding: TranscriptBinding; @@ -81,6 +94,7 @@ export interface TranscriptOpsCatchup { readonly batches: readonly { seq: number; ops: readonly TranscriptOperation[] }[]; readonly latestSeq: number; readonly complete: boolean; + readonly hasMore: boolean; } export class TranscriptService { @@ -90,8 +104,11 @@ export class TranscriptService { Set<(event: TranscriptChangeEvent, seq: number) => void> >(); private readonly healTimers = new Map; timer: NodeJS.Timeout }>(); + private readonly pendingAppends = new Map>(); + private readonly opsBatchMs: number; constructor(private readonly deps: TranscriptServiceDeps) { + this.opsBatchMs = deps.opsBatchMs ?? DEFAULT_TRANSCRIPT_OPS_BATCH_MS; followSessionLifecycles(deps.core.accessor, (service) => { const d1 = service.onDidCloseSession(({ sessionId }) => this.dropSession(sessionId)); const d2 = service.onDidArchiveSession(({ sessionId }) => this.dropSession(sessionId)); @@ -239,6 +256,11 @@ export class TranscriptService { } private dispatchOps(sessionId: string, event: TranscriptChangeEvent): void { + this.flushPendingOps(sessionId, event.agentId); + this.emitOps(sessionId, event); + } + + private emitOps(sessionId: string, event: TranscriptChangeEvent): void { const seq = this.journalOps(sessionId, event); const listeners = this.opsListeners.get(sessionId); if (listeners === undefined) return; @@ -250,6 +272,69 @@ export class TranscriptService { } } + private bufferAppends(sessionId: string, agentId: string, appends: readonly AppendOp[]): void { + let perAgent = this.pendingAppends.get(sessionId); + if (perAgent === undefined) { + perAgent = new Map(); + this.pendingAppends.set(sessionId, perAgent); + } + let pending = perAgent.get(agentId); + if (pending === undefined) { + const timer = setTimeout(() => { + this.flushPendingOps(sessionId, agentId); + }, this.opsBatchMs); + timer.unref(); + pending = { ops: [], textBytes: 0, timer }; + perAgent.set(agentId, pending); + } + for (const op of appends) { + pending.ops.push(op); + pending.textBytes += Buffer.byteLength(op.text); + } + if ( + pending.ops.length >= TRANSCRIPT_OPS_BATCH_MAX_OPS || + pending.textBytes >= TRANSCRIPT_OPS_BATCH_MAX_TEXT_BYTES + ) { + this.flushPendingOps(sessionId, agentId); + } + } + + flushPendingOps(sessionId?: string, agentId?: string): void { + if (sessionId !== undefined && agentId !== undefined) { + const perAgent = this.pendingAppends.get(sessionId); + const pending = perAgent?.get(agentId); + if (perAgent === undefined || pending === undefined) return; + this.flushOnePending(sessionId, agentId, perAgent, pending); + return; + } + for (const [sid, perAgent] of this.pendingAppends) { + if (sessionId !== undefined && sid !== sessionId) continue; + for (const [aid, pending] of perAgent) { + if (agentId !== undefined && aid !== agentId) continue; + this.flushOnePending(sid, aid, perAgent, pending); + } + } + } + + private flushOnePending( + sessionId: string, + agentId: string, + perAgent: Map, + pending: PendingAppends, + ): void { + clearTimeout(pending.timer); + perAgent.delete(agentId); + if (perAgent.size === 0) this.pendingAppends.delete(sessionId); + this.emitOps(sessionId, { agentId, ops: coalesceAppendOps(pending.ops) }); + } + + private discardPendingOps(sessionId: string): void { + const perAgent = this.pendingAppends.get(sessionId); + if (perAgent === undefined) return; + for (const pending of perAgent.values()) clearTimeout(pending.timer); + this.pendingAppends.delete(sessionId); + } + private journalOps(sessionId: string, event: TranscriptChangeEvent): number { const entry = this.live.get(sessionId); if (entry === undefined) return 0; @@ -265,6 +350,7 @@ export class TranscriptService { } getSeqWatermark(sessionId: string, agentId: string): number { + this.flushPendingOps(sessionId, agentId); const journal = this.live.get(sessionId)?.opsJournals.get(agentId); return journal === undefined ? 0 : journal.nextSeq - 1; } @@ -273,18 +359,27 @@ export class TranscriptService { sessionId: string, agentId: string, sinceSeq: number, + limit?: number, ): TranscriptOpsCatchup | undefined { if (this.forSessionLive(sessionId) === undefined) return undefined; + this.flushPendingOps(sessionId, agentId); const journal = this.live.get(sessionId)?.opsJournals.get(agentId); const latestSeq = journal === undefined ? 0 : journal.nextSeq - 1; - if (sinceSeq > latestSeq) return { batches: [], latestSeq, complete: false }; - const batches = journal?.batches.filter((batch) => batch.seq > sinceSeq) ?? []; + if (sinceSeq > latestSeq) return { batches: [], latestSeq, complete: false, hasMore: false }; + const newer = journal?.batches.filter((batch) => batch.seq > sinceSeq) ?? []; const oldest = journal?.batches[0]?.seq; - const complete = batches.length === 0 || (oldest !== undefined && oldest <= sinceSeq + 1); - return { batches, latestSeq, complete }; + const complete = newer.length === 0 || (oldest !== undefined && oldest <= sinceSeq + 1); + const hasMore = limit !== undefined && newer.length > limit; + const batches = hasMore ? newer.slice(0, limit) : newer; + return { batches, latestSeq, complete, hasMore }; } private handleLiveOps(sessionId: string, event: TranscriptChangeEvent): void { + const appends = this.opsBatchMs > 0 ? onlyAppends(event.ops) : undefined; + if (appends !== undefined) { + this.bufferAppends(sessionId, event.agentId, appends); + return; + } this.dispatchOps(sessionId, event); for (const op of event.ops) { if (op.op === 'turn.upsert' && TERMINAL_TURN_STATES.has(op.turn.state)) { @@ -558,6 +653,7 @@ export class TranscriptService { dropSession(sessionId: string): void { this.opsListeners.delete(sessionId); + this.discardPendingOps(sessionId); for (const [key, pending] of this.healTimers) { if (key.startsWith(`${sessionId}:`)) { clearTimeout(pending.timer); @@ -629,6 +725,22 @@ const TERMINAL_TURN_STATES: ReadonlySet = new Set([ 'cancelled', ]); +function onlyAppends(ops: readonly TranscriptOperation[]): AppendOp[] | undefined { + if (ops.length === 0) return undefined; + const appends: AppendOp[] = []; + for (const op of ops) { + if (op.op !== 'append') return undefined; + appends.push(op); + } + return appends; +} + +export function parseTranscriptOpsBatchMs(value: string | undefined): number | undefined { + if (value === undefined || !/^\d+$/.test(value.trim())) return undefined; + const n = Number(value.trim()); + return Number.isSafeInteger(n) ? n : undefined; +} + function projectQuestionInteractionRecords( records: readonly ContextRecord[], sessionId: string, diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index e717ee0c827..755d4eee630 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -59,7 +59,7 @@ import { import { extractWsBearerToken } from './transport/ws/bearerProtocol'; import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcaster'; import type { ConfigWarningItem } from './transport/ws/v1/events'; -import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; +import { parseWsTuning, registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; import { registerWsDebug, WS_DEBUG_PATH } from './transport/ws/debug/registerWsDebug'; import { getServerVersion } from './version'; import { classify } from './security/bindClassify'; @@ -77,7 +77,10 @@ import { type ServerTelemetry, shutdownServerTelemetry, } from './services/telemetry'; -import { TranscriptService } from './services/transcript/transcriptService'; +import { + TranscriptService, + parseTranscriptOpsBatchMs, +} from './services/transcript/transcriptService'; import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler'; import { startConfigChangedPublisher } from './services/config/configChangedPublisher'; import { createAuthFailureLimiter } from './middleware/rateLimit'; @@ -346,12 +349,19 @@ export async function startServer(opts: ServerStartOptions): Promise { - void close().catch((err: unknown) => logger.error({ err }, 'server close failed')); + void close().catch((error: unknown) => logger.error({ error }, 'server close failed')); }, connectionRegistry, broadcaster, @@ -476,6 +486,7 @@ export async function startServer(opts: ServerStartOptions): Promise 0 ? n : undefined; +} + +function parseBoolean(value: string | undefined): boolean | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true') return true; + if (normalized === '0' || normalized === 'false') return false; + return undefined; } export function registerWsV1(core: Scope, opts: RegisterWsV1Options): WebSocketServer { void core; - const wss = new WebSocketServer({ noServer: true, handleProtocols: selectWsBearerProtocol }); + const wss = new WebSocketServer({ + noServer: true, + handleProtocols: selectWsBearerProtocol, + maxPayload: opts.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES, + perMessageDeflate: opts.compression === false ? false : PER_MESSAGE_DEFLATE, + }); const { registry, broadcaster } = opts; wss.on('connection', (socket, req) => { diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 06af509aeba..1be30228485 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -403,6 +403,7 @@ export class SessionEventBroadcaster { transcript: AgentTranscript, grade: TranscriptGrade, ): void { + this.opts.transcriptService?.flushPendingOps(state.sessionId, transcript.agentId); const snapshot = redactSnapshotForGrade( grade, transcript.snapshot({ tailTurns: TRANSCRIPT_RESET_TAIL_TURNS }), diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index 3da60b7fc44..0b29e1c78c7 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -43,7 +43,8 @@ const DEFAULT_FLUSH_INTERVAL_MS = 16; const DEFAULT_MAX_BATCH_SIZE = 64; const DEFAULT_HIGH_WATER_MARK_BYTES = 1 << 20; const DEFAULT_BACKPRESSURE_RETRY_MS = 5; -const DEFAULT_BACKPRESSURE_MAX_DELAY_MS = 100; +export const MAX_OUTBOUND_FRAMES = 4096; +export const MAX_BACKPRESSURE_STALL_MS = 15_000; interface InboundFrame { type: string; @@ -122,7 +123,10 @@ export class WsConnectionV1 implements BroadcastTarget { protocol_version: WS_PROTOCOL_VERSION, heartbeat_ms: this.heartbeatIntervalMs, max_event_buffer_size: this.maxBufferSize, - capabilities: { event_batching: false, compression: false }, + capabilities: { + event_batching: false, + compression: this.socket.extensions.includes('permessage-deflate'), + }, }), ); this.heartbeatTimer = setInterval(() => { @@ -136,7 +140,7 @@ export class WsConnectionV1 implements BroadcastTarget { } get subscriptionSessionIds(): readonly string[] { - return Array.from(this.subscriptions.keys()).sort(); + return Array.from(this.subscriptions.keys()).toSorted(); } send(envelope: EventEnvelope, delivery: BroadcastDelivery = 'subscription'): void { @@ -419,7 +423,7 @@ export class WsConnectionV1 implements BroadcastTarget { private sendImmediateFrame(msg: unknown): void { if (this.closed) return; this.outbound.push(msg); - this.flush(); + this.flush(true); } private scheduleFlush(): void { @@ -442,11 +446,12 @@ export class WsConnectionV1 implements BroadcastTarget { return; } - if (!force && this.socket.bufferedAmount > this.highWaterMarkBytes) { + const aboveHighWaterMark = this.socket.bufferedAmount > this.highWaterMarkBytes; + if (aboveHighWaterMark && !force) { this.deferForBackpressure(); return; } - this.backpressureSince = undefined; + if (!aboveHighWaterMark) this.backpressureSince = undefined; const frames = coalesceFrames(this.outbound); this.outbound = []; @@ -462,18 +467,46 @@ export class WsConnectionV1 implements BroadcastTarget { private deferForBackpressure(): void { const now = Date.now(); if (this.backpressureSince === undefined) this.backpressureSince = now; - if (now - this.backpressureSince >= DEFAULT_BACKPRESSURE_MAX_DELAY_MS) { - this.flush(true); + if (now - this.backpressureSince >= MAX_BACKPRESSURE_STALL_MS) { + this.closeSlowConsumer(); return; } if (this.backpressureRetryTimer !== undefined) return; this.backpressureRetryTimer = setTimeout(() => { - this.backpressureRetryTimer = undefined; - this.flush(); + this.retryAfterBackpressure(); }, DEFAULT_BACKPRESSURE_RETRY_MS); this.backpressureRetryTimer.unref?.(); } + private retryAfterBackpressure(): void { + this.backpressureRetryTimer = undefined; + if ( + this.outbound.length > MAX_OUTBOUND_FRAMES && + this.socket.bufferedAmount > this.highWaterMarkBytes + ) { + this.closeSlowConsumer(); + return; + } + this.flush(); + } + + private closeSlowConsumer(): void { + if (this.closed) return; + this.outbound = []; + if (this.flushTimer !== undefined) { + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } + if (this.backpressureRetryTimer !== undefined) { + clearTimeout(this.backpressureRetryTimer); + this.backpressureRetryTimer = undefined; + } + try { + this.socket.close(1013, 'slow consumer'); + } catch { + } + } + close(code = 1000, reason?: string): void { if (this.closed) return; this.flush(true); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 3d69189891e..771fbbf25a4 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -42,7 +42,7 @@ import { type TranscriptTask, type TranscriptTurn, } from '@moonshot-ai/transcript'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { bindSessionTranscript } from '../../src/services/transcript/coreBinding'; import { toWireQuestion } from '../../src/protocol/question-wire'; @@ -55,7 +55,9 @@ import { import { healTurnOps, TranscriptService, + parseTranscriptOpsBatchMs, snapshotToOps, + TRANSCRIPT_OPS_BATCH_MAX_OPS, TRANSCRIPT_OPS_JOURNAL_CAPACITY, } from '../../src/services/transcript/transcriptService'; @@ -3691,7 +3693,9 @@ describe('bindSessionTranscript', () => { const deadline = Date.now() + timeoutMs; while (!condition()) { if (Date.now() > deadline) throw new Error('waitFor timed out'); - await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); } } @@ -3984,5 +3988,148 @@ describe('bindSessionTranscript', () => { ); service.dropSession('s1'); }); + + interface SeenBatch { + readonly seq: number; + readonly ops: readonly TranscriptOperation[]; + } + + async function openStreamingTurn(opsBatchMs: number): Promise<{ + service: TranscriptService; + bus: { emit(event: ProjectorBusEvent): void }; + seen: SeenBatch[]; + }> { + const agents = new FakeAgents(); + const main = agents.add('main'); + const service = new TranscriptService({ + homeDir: '/nonexistent-home', + core: fakeCoreWithAgents(agents), + opsBatchMs, + }); + service.forSessionLive('s1'); + await service.whenReady('s1'); + const seen: SeenBatch[] = []; + service.onSessionOps('s1', (event, seq) => seen.push({ seq, ops: event.ops })); + main.bus.emit(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' } })); + main.bus.emit(ev({ type: 'turn.step.started', turnId: 0, step: 1 })); + main.bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'Hel' })); + expect(seen.at(-1)?.ops.map((op) => op.op)).toEqual(['frame.upsert', 'append']); + return { service, bus: main.bus, seen }; + } + + function expectContiguous(seen: readonly SeenBatch[]): void { + const first = seen[0]!.seq; + expect(seen.map((batch) => batch.seq)).toEqual(seen.map((_, i) => first + i)); + } + + it('coalesces streamed appends into one batch once the window elapses', async () => { + const { service, bus, seen } = await openStreamingTurn(5); + const before = seen.length; + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'lo' })); + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: ' wor' })); + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'ld' })); + expect(seen).toHaveLength(before); + + await vi.waitFor(() => { + expect(seen.length).toBeGreaterThan(before); + }); + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + expect(seen).toHaveLength(before + 1); + expect(seen.at(-1)!.ops).toEqual([ + { + op: 'append', + target: { type: 'frame', turnId: 't0', stepId: 't0.1', frameId: 't0.1.f1' }, + offset: 3, + text: 'lo world', + }, + ]); + expectContiguous(seen); + expect(service.forSessionLive('s1')?.getAgent('main')?.getTurn('t0')?.steps[0]?.frames[0]).toMatchObject({ + kind: 'text', + text: 'Hello world', + }); + service.dropSession('s1'); + }); + + it('flushes buffered appends before exposing the seq watermark or catch-up batches', async () => { + const { service, bus, seen } = await openStreamingTurn(60_000); + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'lo' })); + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: ' world' })); + const before = seen.length; + + const watermark = service.getSeqWatermark('s1', 'main'); + expect(seen).toHaveLength(before + 1); + expect(seen.at(-1)).toMatchObject({ + seq: watermark, + ops: [{ op: 'append', offset: 3, text: 'lo world' }], + }); + + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: '!' })); + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: '?' })); + const catchup = service.getOpsSince('s1', 'main', watermark); + expect(catchup?.batches).toEqual([ + { seq: watermark + 1, ops: [expect.objectContaining({ op: 'append', offset: 11, text: '!?' })] }, + ]); + expect(catchup?.latestSeq).toBe(watermark + 1); + expect(seen.at(-1)?.seq).toBe(watermark + 1); + expectContiguous(seen); + service.dropSession('s1'); + }); + + it('flushes buffered appends ahead of a non-append event so seqs stay ordered', async () => { + const { service, bus, seen } = await openStreamingTurn(60_000); + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'lo' })); + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: ' world' })); + const before = seen.length; + + bus.emit(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); + expect(seen).toHaveLength(before + 2); + expect(seen.at(-2)!.ops).toEqual([expect.objectContaining({ op: 'append', offset: 3, text: 'lo world' })]); + expect(seen.at(-1)!.ops.map((op) => op.op)).toContain('turn.upsert'); + expect(seen.at(-1)!.seq).toBe(seen.at(-2)!.seq + 1); + expectContiguous(seen); + service.dropSession('s1'); + }); + + it('flushes immediately once the buffered append count reaches the cap', async () => { + const { service, bus, seen } = await openStreamingTurn(60_000); + const before = seen.length; + for (let i = 0; i < TRANSCRIPT_OPS_BATCH_MAX_OPS - 1; i++) { + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'x' })); + } + expect(seen).toHaveLength(before); + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'x' })); + expect(seen).toHaveLength(before + 1); + expect(seen.at(-1)!.ops).toEqual([ + expect.objectContaining({ op: 'append', offset: 3, text: 'x'.repeat(TRANSCRIPT_OPS_BATCH_MAX_OPS) }), + ]); + service.dropSession('s1'); + }); + + it('keeps one batch per append when batching is disabled', async () => { + const { service, bus, seen } = await openStreamingTurn(0); + const before = seen.length; + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'lo' })); + bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: ' world' })); + expect(seen).toHaveLength(before + 2); + expect(seen.slice(-2).map((batch) => batch.ops)).toEqual([ + [expect.objectContaining({ op: 'append', offset: 3, text: 'lo' })], + [expect.objectContaining({ op: 'append', offset: 5, text: ' world' })], + ]); + expectContiguous(seen); + service.dropSession('s1'); + }); + + it('parses the ops batch window env value as a non-negative integer', () => { + expect(parseTranscriptOpsBatchMs(undefined)).toBeUndefined(); + expect(parseTranscriptOpsBatchMs('')).toBeUndefined(); + expect(parseTranscriptOpsBatchMs('abc')).toBeUndefined(); + expect(parseTranscriptOpsBatchMs('-1')).toBeUndefined(); + expect(parseTranscriptOpsBatchMs('1.5')).toBeUndefined(); + expect(parseTranscriptOpsBatchMs('0')).toBe(0); + expect(parseTranscriptOpsBatchMs(' 32 ')).toBe(32); + }); }); }); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 88a83865664..c35cac9993e 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -2288,7 +2288,7 @@ describe('SessionEventBroadcaster', () => { eventsDir: dir, core, maxBufferSize: 3, - transcriptService: new TranscriptService({ homeDir: dir, core }), + transcriptService: new TranscriptService({ homeDir: dir, core, opsBatchMs: 0 }), }); } @@ -2479,7 +2479,7 @@ describe('SessionEventBroadcaster', () => { const ids = transcriptEnvelopes(view.envelopes) .filter((e) => e.type === 'transcript.reset') .map((e) => (e.payload as { agent_id: string }).agent_id) - .sort(); + .toSorted(); expect(ids).toEqual(['main', 'sub-1']); }); @@ -2504,7 +2504,7 @@ describe('SessionEventBroadcaster', () => { lc.addAgent('main'); sessions.set('s1', lc); const core = makeCore(sessions, eventBus, { 'sub-1': { type: 'sub' } }); - const service = new TranscriptService({ homeDir: dir, core }); + const service = new TranscriptService({ homeDir: dir, core, opsBatchMs: 0 }); let releaseBackfill!: () => void; const gate = new Promise((resolve) => { releaseBackfill = resolve; @@ -2539,7 +2539,7 @@ describe('SessionEventBroadcaster', () => { const main = lc.addAgent('main'); sessions.set('s1', lc); const core = makeCore(sessions, eventBus); - const service = new TranscriptService({ homeDir: dir, core }); + const service = new TranscriptService({ homeDir: dir, core, opsBatchMs: 0 }); bc = new SessionEventBroadcaster({ eventsDir: dir, core, diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 3e2dbc0d932..0cdabae1d0a 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -23,6 +23,7 @@ import { IAgentLifecycleService, IEventBus, IEventService, + ISessionActivityView, ISessionManager, IWorkspaceService, MAIN_AGENT_ID, @@ -532,17 +533,17 @@ describe('server-v2 /api/v1/sessions', () => { const page1 = await getJson('/api/v1/sessions?page_size=3'); expect(page1.body.code).toBe(0); - expect(page1.body.data.items.map((s) => s.id)).toEqual(ids.slice(4).reverse()); + expect(page1.body.data.items.map((s) => s.id)).toEqual(ids.slice(4).toReversed()); expect(page1.body.data.has_more).toBe(true); - const cursor1 = page1.body.data.items[page1.body.data.items.length - 1]!.id; + const cursor1 = page1.body.data.items.at(-1)!.id; const page2 = await getJson( `/api/v1/sessions?page_size=3&before_id=${encodeURIComponent(cursor1)}`, ); - expect(page2.body.data.items.map((s) => s.id)).toEqual(ids.slice(1, 4).reverse()); + expect(page2.body.data.items.map((s) => s.id)).toEqual(ids.slice(1, 4).toReversed()); expect(page2.body.data.has_more).toBe(true); - const cursor2 = page2.body.data.items[page2.body.data.items.length - 1]!.id; + const cursor2 = page2.body.data.items.at(-1)!.id; const page3 = await getJson( `/api/v1/sessions?page_size=3&before_id=${encodeURIComponent(cursor2)}`, ); @@ -575,6 +576,49 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.data.has_more).toBe(false); }); + it('pages with a computed has_more when page_size is omitted', async () => { + await restartWithFreshHome(); + const cwd = home as string; + const ids: string[] = []; + for (let i = 0; i < 3; i++) { + const { body } = await postJson('/api/v1/sessions', { metadata: { cwd } }); + expect(body.code).toBe(0); + ids.push(body.data.id); + } + + const { body } = await getJson('/api/v1/sessions'); + expect(body.code).toBe(0); + expect(new Set(body.data.items.map((s) => s.id))).toEqual(new Set(ids)); + expect(body.data.has_more).toBe(false); + }); + + it('caps an unsized listing at the default page size of 50 and pages the rest', async () => { + await restartWithFreshHome(); + const cwd = home as string; + const ids: string[] = []; + for (let i = 0; i < 51; i++) { + const { body } = await postJson('/api/v1/sessions', { metadata: { cwd } }); + expect(body.code).toBe(0); + ids.push(body.data.id); + } + + const first = await getJson('/api/v1/sessions'); + expect(first.body.code).toBe(0); + expect(first.body.data.items).toHaveLength(50); + expect(first.body.data.has_more).toBe(true); + + const cursor = first.body.data.items.at(-1)!.id; + const rest = await getJson( + `/api/v1/sessions?before_id=${encodeURIComponent(cursor)}`, + ); + expect(rest.body.code).toBe(0); + expect(rest.body.data.items).toHaveLength(1); + expect(rest.body.data.has_more).toBe(false); + + const seen = [...first.body.data.items, ...rest.body.data.items].map((s) => s.id); + expect(new Set(seen)).toEqual(new Set(ids)); + }); + it('gets a session by id and 404s for unknown', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); @@ -1711,6 +1755,37 @@ describe('server-v2 /api/v1/sessions', () => { expect(running.body.data.items.some((s) => s.id === id)).toBe(false); }); + it('fills a busy-filtered page across idle sessions newer than the busy one', async () => { + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const cwd = home as string; + const busy = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const busyId = busy.body.data.id; + const live = getLiveSessionById((server as RunningServer).core.accessor, busyId); + if (live === undefined) throw new Error('expected a live session'); + vi.spyOn(live.accessor.get(ISessionActivityView), 'state').mockReturnValue({ + busy: true, + mainTurnActive: true, + pendingInteraction: 'none', + }); + await sleep(5); + for (let i = 0; i < 4; i++) { + const { body } = await postJson('/api/v1/sessions', { metadata: { cwd } }); + expect(body.code).toBe(0); + await sleep(5); + } + + const page = await getJson('/api/v1/sessions?busy=true&page_size=2'); + expect(page.body.code).toBe(0); + expect(page.body.data.items.map((s) => s.id)).toEqual([busyId]); + expect(page.body.data.items[0]?.busy).toBe(true); + expect(page.body.data.has_more).toBe(false); + + const idle = await getJson('/api/v1/sessions?busy=false&page_size=2'); + expect(idle.body.data.items).toHaveLength(2); + expect(idle.body.data.items.every((s) => !s.busy)).toBe(true); + expect(idle.body.data.has_more).toBe(true); + }); + it('filters child sessions by the busy query', async () => { const cwd = home as string; const parent = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/kap-server/test/transcript.test.ts b/packages/kap-server/test/transcript.test.ts index 4cb3ca25a02..6478827de08 100644 --- a/packages/kap-server/test/transcript.test.ts +++ b/packages/kap-server/test/transcript.test.ts @@ -79,6 +79,7 @@ interface OpsCatchupContract { batches: { seq: number; ops: { op: string }[] }[]; latest_seq: number; complete: boolean; + has_more: boolean; } interface UserMessagesContract { @@ -897,6 +898,125 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=99999`, ); expect(stale.body.data.complete).toBe(false); + expect(stale.body.data.has_more).toBe(false); + }); + + it('caps the ops route at limit batches and pages the rest with has_more', async () => { + const id = await createSession(); + await ensureMainAgent(id); + + const bound = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); + const base = bound.body.data.seq!; + + const bus = mainAgentBus(id); + bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + + const all = await getJson( + `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${base}`, + ); + expect(all.body.data.has_more).toBe(false); + const allSeqs = all.body.data.batches.map((batch) => batch.seq); + expect(allSeqs.length).toBeGreaterThanOrEqual(2); + + const first = await getJson( + `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${base}&limit=1`, + ); + expect(first.body.code).toBe(0); + expect(first.body.data.batches.map((batch) => batch.seq)).toEqual([base + 1]); + expect(first.body.data).toMatchObject({ + has_more: true, + complete: true, + latest_seq: all.body.data.latest_seq, + }); + + const rest = await getJson( + `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${base + 1}&limit=500`, + ); + expect(rest.body.data.batches.map((batch) => batch.seq)).toEqual(allSeqs.slice(1)); + expect(rest.body.data).toMatchObject({ + has_more: false, + complete: true, + latest_seq: all.body.data.latest_seq, + }); + }); + + it('rejects an out-of-range limit on the ops route with 40001', async () => { + const id = await createSession(); + const zero = await getJson( + `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=0&limit=0`, + ); + expect(zero.body.code).toBe(40001); + const tooLarge = await getJson( + `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=0&limit=501`, + ); + expect(tooLarge.body.code).toBe(40001); + }); + + it('serves streamed deltas as one coalesced ops batch after the batching window', async () => { + const id = await createSession(); + await ensureMainAgent(id); + await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); + + const bus = mainAgentBus(id); + bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 })); + bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: 'Hel' })); + const opened = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); + const base = opened.body.data.seq!; + + bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: 'lo' })); + bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: ' wor' })); + bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: 'ld' })); + await new Promise((resolve) => setTimeout(resolve, 60)); + + const catchup = await getJson( + `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${base}`, + ); + expect(catchup.body.data.complete).toBe(true); + expect(catchup.body.data.latest_seq).toBe(base + 1); + expect(catchup.body.data.batches).toEqual([ + { + seq: base + 1, + ops: [ + { + op: 'append', + target: expect.objectContaining({ type: 'frame', turnId: 't1' }), + offset: 3, + text: 'lo world', + }, + ], + }, + ]); + }); + + it('flushes buffered deltas when the live transcript watermark is read, leaving no seq gap', async () => { + const id = await createSession(); + await ensureMainAgent(id); + await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); + + const bus = mainAgentBus(id); + bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 })); + bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: 'Hel' })); + const opened = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); + const base = opened.body.data.seq!; + + bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: 'lo' })); + bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: ' world' })); + const read = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); + expect(read.body.data.seq).toBe(base + 1); + + const catchup = await getJson( + `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${base}`, + ); + expect(catchup.body.data.latest_seq).toBe(base + 1); + expect(catchup.body.data.batches).toEqual([ + { + seq: base + 1, + ops: [expect.objectContaining({ op: 'append', offset: 3, text: 'lo world' })], + }, + ]); }); it('answers complete:false for a cold session and 40401 for an unknown one on the ops route', async () => { diff --git a/packages/kap-server/test/webAssets.test.ts b/packages/kap-server/test/webAssets.test.ts index 70dbb5995f3..6592b833908 100644 --- a/packages/kap-server/test/webAssets.test.ts +++ b/packages/kap-server/test/webAssets.test.ts @@ -1,13 +1,17 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from 'node:zlib'; import Fastify, { type FastifyInstance } from 'fastify'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { registerWebAssetRoutes } from '../src/routes/webAssets'; -describe('web asset cache policy', () => { +const HASHED_JS = '/assets/index-Dy7xs5tu.js'; +const HASHED_JS_SOURCE = `export const answer = 42;\n${'export {};\n'.repeat(64)}`; + +describe('web asset routes', () => { let app: FastifyInstance; let assetsDir: string; @@ -16,8 +20,18 @@ describe('web asset cache policy', () => { await mkdir(join(assetsDir, 'assets')); await Promise.all([ writeFile(join(assetsDir, 'index.html'), '
Kimi
'), - writeFile(join(assetsDir, 'assets', 'index-Dy7xs5tu.js'), 'export {};'), + writeFile(join(assetsDir, 'assets', 'index-Dy7xs5tu.js'), HASHED_JS_SOURCE), + writeFile( + join(assetsDir, 'assets', 'index-Dy7xs5tu.js.br'), + brotliCompressSync(HASHED_JS_SOURCE), + ), + writeFile(join(assetsDir, 'assets', 'index-Dy7xs5tu.js.gz'), gzipSync(HASHED_JS_SOURCE)), writeFile(join(assetsDir, 'assets', 'application-configuration.json'), '{}'), + writeFile(join(assetsDir, 'assets', 'engine-AbCdEf12.wasm'), Buffer.from([0, 0x61, 0x73])), + writeFile(join(assetsDir, 'assets', 'font-AbCdEf12.woff'), Buffer.from('wOFF')), + writeFile(join(assetsDir, 'assets', 'font-AbCdEf12.ttf'), Buffer.from([0, 1, 0, 0])), + writeFile(join(assetsDir, 'assets', 'anim-AbCdEf12.riv'), Buffer.from('RIVE')), + writeFile(join(assetsDir, 'assets', 'index-Dy7xs5tu.js.map'), '{"version":3}'), writeFile(join(assetsDir, 'favicon.svg'), ''), ]); app = Fastify(); @@ -29,25 +43,216 @@ describe('web asset cache policy', () => { await rm(assetsDir, { recursive: true, force: true }); }); - it('caches content-hashed assets as immutable', async () => { - const response = await app.inject({ method: 'GET', url: '/assets/index-Dy7xs5tu.js' }); + describe('cache policy', () => { + it('caches content-hashed assets as immutable', async () => { + const response = await app.inject({ method: 'GET', url: HASHED_JS }); - expect(response.statusCode).toBe(200); - expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable'); - }); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + }); - it.each([ - '/index.html', - '/sessions/active', - '/favicon.svg', - '/assets/application-configuration.json', - ])( - 'requires revalidation for %s', - async (url) => { + it.each([ + '/index.html', + '/sessions/active', + '/favicon.svg', + '/assets/application-configuration.json', + ])('requires revalidation for %s', async (url) => { const response = await app.inject({ method: 'GET', url }); expect(response.statusCode).toBe(200); expect(response.headers['cache-control']).toBe('no-cache'); - }, - ); + }); + }); + + describe('content negotiation', () => { + it('serves the brotli sibling when br is acceptable', async () => { + const sibling = await stat(join(assetsDir, 'assets', 'index-Dy7xs5tu.js.br')); + + const response = await app.inject({ + method: 'GET', + url: HASHED_JS, + headers: { 'accept-encoding': 'gzip, deflate, br' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-encoding']).toBe('br'); + expect(response.headers['content-length']).toBe(String(sibling.size)); + expect(response.headers['content-type']).toBe('text/javascript; charset=utf-8'); + expect(response.headers.vary).toBe('Accept-Encoding'); + expect(brotliDecompressSync(response.rawPayload).toString('utf8')).toBe(HASHED_JS_SOURCE); + }); + + it('serves the gzip sibling when only gzip is acceptable', async () => { + const sibling = await stat(join(assetsDir, 'assets', 'index-Dy7xs5tu.js.gz')); + + const response = await app.inject({ + method: 'GET', + url: HASHED_JS, + headers: { 'accept-encoding': 'gzip' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-encoding']).toBe('gzip'); + expect(response.headers['content-length']).toBe(String(sibling.size)); + expect(gunzipSync(response.rawPayload).toString('utf8')).toBe(HASHED_JS_SOURCE); + }); + + it('falls back to gzip when br is excluded with q=0', async () => { + const response = await app.inject({ + method: 'GET', + url: HASHED_JS, + headers: { 'accept-encoding': 'br;q=0, gzip' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-encoding']).toBe('gzip'); + }); + + it('serves identity with Vary when no Accept-Encoding header is sent', async () => { + const response = await app.inject({ method: 'GET', url: HASHED_JS }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-encoding']).toBeUndefined(); + expect(response.headers['content-length']).toBe( + String(Buffer.byteLength(HASHED_JS_SOURCE)), + ); + expect(response.headers.vary).toBe('Accept-Encoding'); + expect(response.body).toBe(HASHED_JS_SOURCE); + }); + + it('serves identity when the precompressed sibling is older than the source', async () => { + const source = join(assetsDir, 'assets', 'index-Dy7xs5tu.js'); + const stale = new Date(Date.now() - 60_000); + await Promise.all([ + utimes(`${source}.br`, stale, stale), + utimes(`${source}.gz`, stale, stale), + ]); + + const response = await app.inject({ + method: 'GET', + url: HASHED_JS, + headers: { 'accept-encoding': 'gzip, br' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-encoding']).toBeUndefined(); + expect(response.headers.vary).toBe('Accept-Encoding'); + expect(response.body).toBe(HASHED_JS_SOURCE); + }); + + it('serves identity for files without precompressed siblings', async () => { + const response = await app.inject({ + method: 'GET', + url: '/favicon.svg', + headers: { 'accept-encoding': 'gzip, br' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-encoding']).toBeUndefined(); + expect(response.headers.vary).toBe('Accept-Encoding'); + expect(response.body).toBe(''); + }); + + it('omits Vary on non-compressible types', async () => { + const response = await app.inject({ + method: 'GET', + url: '/assets/font-AbCdEf12.woff', + headers: { 'accept-encoding': 'gzip, br' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers.vary).toBeUndefined(); + }); + + it('keeps the immutable cache policy on compressed variants', async () => { + const response = await app.inject({ + method: 'GET', + url: HASHED_JS, + headers: { 'accept-encoding': 'br' }, + }); + + expect(response.headers['content-encoding']).toBe('br'); + expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + }); + }); + + describe('conditional requests', () => { + it('sends a weak ETag and Last-Modified on 200', async () => { + const response = await app.inject({ method: 'GET', url: HASHED_JS }); + + expect(response.statusCode).toBe(200); + expect(response.headers.etag).toMatch(/^W\/"[0-9a-f]+-[0-9a-f]+"$/); + expect(response.headers['last-modified']).toMatch(/GMT$/); + }); + + it('replies 304 without a body when If-None-Match matches', async () => { + const first = await app.inject({ method: 'GET', url: HASHED_JS }); + const etag = first.headers.etag as string; + + const response = await app.inject({ + method: 'GET', + url: HASHED_JS, + headers: { 'if-none-match': `"stale", ${etag}` }, + }); + + expect(response.statusCode).toBe(304); + expect(response.body).toBe(''); + expect(response.headers.etag).toBe(etag); + expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + expect(response.headers.vary).toBe('Accept-Encoding'); + }); + + it('replies 304 for a wildcard If-None-Match', async () => { + const response = await app.inject({ + method: 'GET', + url: '/index.html', + headers: { 'if-none-match': '*' }, + }); + + expect(response.statusCode).toBe(304); + expect(response.body).toBe(''); + }); + + it('uses a different ETag for the identity and brotli representations', async () => { + const identity = await app.inject({ method: 'GET', url: HASHED_JS }); + const brotli = await app.inject({ + method: 'GET', + url: HASHED_JS, + headers: { 'accept-encoding': 'br' }, + }); + + expect(brotli.headers.etag).toMatch(/-br"$/); + expect(brotli.headers.etag).not.toBe(identity.headers.etag); + }); + + it('serves the full response for a stale ETag', async () => { + const response = await app.inject({ + method: 'GET', + url: HASHED_JS, + headers: { 'if-none-match': 'W/"0-0"' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(HASHED_JS_SOURCE); + }); + }); + + describe('mime types', () => { + it.each([ + ['/index.html', 'text/html; charset=utf-8'], + [HASHED_JS, 'text/javascript; charset=utf-8'], + ['/assets/application-configuration.json', 'application/json; charset=utf-8'], + ['/assets/index-Dy7xs5tu.js.map', 'application/json; charset=utf-8'], + ['/favicon.svg', 'image/svg+xml'], + ['/assets/engine-AbCdEf12.wasm', 'application/wasm'], + ['/assets/font-AbCdEf12.woff', 'font/woff'], + ['/assets/font-AbCdEf12.ttf', 'font/ttf'], + ['/assets/anim-AbCdEf12.riv', 'application/octet-stream'], + ])('serves %s as %s', async (url, contentType) => { + const response = await app.inject({ method: 'GET', url }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe(contentType); + }); + }); }); diff --git a/packages/kap-server/test/wsConnectionV1.test.ts b/packages/kap-server/test/wsConnectionV1.test.ts index 8d29a50dd1c..d57860e1e49 100644 --- a/packages/kap-server/test/wsConnectionV1.test.ts +++ b/packages/kap-server/test/wsConnectionV1.test.ts @@ -3,8 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IConnectionRegistry } from '../src/transport/ws/connectionRegistry'; import type { SessionEventBroadcaster } from '../src/transport/ws/v1/sessionEventBroadcaster'; +import { parseWsTuning } from '../src/transport/ws/v1/registerWsV1'; import { type WsConnectionV1Options, + MAX_BACKPRESSURE_STALL_MS, + MAX_OUTBOUND_FRAMES, WsConnectionV1, coalesceFrames, } from '../src/transport/ws/v1/wsConnectionV1'; @@ -14,6 +17,7 @@ class FakeSocket { readonly CLOSED = 3; readyState = 1; bufferedAmount = 0; + extensions = ''; sent: string[] = []; closeCalls: Array<{ code?: number; reason?: string }> = []; private readonly handlers = new Map void>>(); @@ -609,6 +613,23 @@ describe('WsConnectionV1 outbound buffer', () => { conn.close(); }); + it('advertises compression=false when permessage-deflate was not negotiated', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket); + const hello = socket.frames()[0] as { payload: { capabilities: { compression: boolean } } }; + expect(hello.payload.capabilities.compression).toBe(false); + conn.close(); + }); + + it('advertises compression=true when permessage-deflate was negotiated', () => { + const socket = new FakeSocket(); + socket.extensions = 'permessage-deflate; server_no_context_takeover; client_no_context_takeover'; + const conn = makeConn(socket); + const hello = socket.frames()[0] as { payload: { capabilities: { compression: boolean } } }; + expect(hello.payload.capabilities.compression).toBe(true); + conn.close(); + }); + it('buffers subscribe_v2 transcript frames without merging them', async () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 16 }); @@ -705,6 +726,85 @@ describe('WsConnectionV1 outbound buffer', () => { conn.close(); }); + it('never force-flushes while above the high-water mark', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16, highWaterMarkBytes: 100 }); + socket.sent = []; + + socket.bufferedAmount = 200; + conn.send(delta('s1', 'main', 1, 'stuck', 0)); + await vi.advanceTimersByTimeAsync(200); + expect(socket.sent).toHaveLength(0); + expect(socket.closeCalls).toHaveLength(0); + conn.close(); + }); + + it('closes 1013 slow consumer after the stall limit', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { + flushIntervalMs: 16, + highWaterMarkBytes: 100, + heartbeatIntervalMs: 60_000, + }); + socket.sent = []; + + socket.bufferedAmount = 200; + conn.send(delta('s1', 'main', 1, 'stuck', 0)); + await vi.advanceTimersByTimeAsync(MAX_BACKPRESSURE_STALL_MS - 1); + expect(socket.closeCalls).toHaveLength(0); + await vi.advanceTimersByTimeAsync(MAX_BACKPRESSURE_STALL_MS); + expect(socket.closeCalls).toEqual([{ code: 1013, reason: 'slow consumer' }]); + expect(socket.sent).toHaveLength(0); + conn.close(); + expect(socket.sent).toHaveLength(0); + }); + + it('closes when the queue exceeds MAX_OUTBOUND_FRAMES after a deferred flush', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16, highWaterMarkBytes: 100 }); + socket.sent = []; + + socket.bufferedAmount = 200; + for (let i = 0; i <= MAX_OUTBOUND_FRAMES; i++) conn.send(durable('turn.ended', 's1', i)); + expect(socket.closeCalls).toHaveLength(0); + await vi.advanceTimersByTimeAsync(5); + expect(socket.closeCalls).toEqual([{ code: 1013, reason: 'slow consumer' }]); + expect(socket.sent).toHaveLength(0); + conn.close(); + }); + + it('does not close on a synchronous burst above MAX_OUTBOUND_FRAMES while the socket is draining', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16, highWaterMarkBytes: 100 }); + socket.sent = []; + + socket.bufferedAmount = 200; + for (let i = 0; i <= MAX_OUTBOUND_FRAMES; i++) conn.send(durable('turn.ended', 's1', i)); + expect(socket.closeCalls).toHaveLength(0); + socket.bufferedAmount = 0; + await vi.advanceTimersByTimeAsync(5); + expect(socket.closeCalls).toHaveLength(0); + expect(socket.sent).toHaveLength(MAX_OUTBOUND_FRAMES + 1); + conn.close(); + }); + + it('flushes a control frame even above the high-water mark', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16, highWaterMarkBytes: 100 }); + socket.sent = []; + + socket.bufferedAmount = 200; + conn.send(delta('s1', 'main', 1, 'stuck', 0)); + await vi.advanceTimersByTimeAsync(16); + expect(socket.sent).toHaveLength(0); + + conn.send(durable('session.work_changed', 's1', 7), 'immediate'); + const frames = socket.frames() as Array<{ type: string }>; + expect(frames.map((f) => f.type)).toEqual(['assistant.delta', 'session.work_changed']); + expect(socket.closeCalls).toHaveLength(0); + conn.close(); + }); + it('force-flushes buffered subscription frames on close', () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 1000 }); @@ -900,3 +1000,66 @@ describe('WsConnectionV1 global target registration', () => { conn.close(); }); }); + +describe('parseWsTuning', () => { + it('returns all-undefined for an empty env', () => { + expect(parseWsTuning({})).toEqual({ + flushIntervalMs: undefined, + maxBatchSize: undefined, + highWaterMarkBytes: undefined, + heartbeatIntervalMs: undefined, + maxBufferSize: undefined, + compression: undefined, + maxPayloadBytes: undefined, + }); + }); + + it('parses positive integers from every KIMI_CODE_WS_* variable', () => { + expect( + parseWsTuning({ + KIMI_CODE_WS_FLUSH_INTERVAL_MS: '8', + KIMI_CODE_WS_MAX_BATCH_SIZE: '128', + KIMI_CODE_WS_HIGH_WATER_MARK_BYTES: '2097152', + KIMI_CODE_WS_HEARTBEAT_MS: '5000', + KIMI_CODE_WS_MAX_BUFFER_SIZE: '2000', + KIMI_CODE_WS_COMPRESSION: '1', + KIMI_CODE_WS_MAX_PAYLOAD_BYTES: '1048576', + }), + ).toEqual({ + flushIntervalMs: 8, + maxBatchSize: 128, + highWaterMarkBytes: 2097152, + heartbeatIntervalMs: 5000, + maxBufferSize: 2000, + compression: true, + maxPayloadBytes: 1048576, + }); + }); + + it('rejects zero, negative, fractional, and non-numeric values', () => { + const tuning = parseWsTuning({ + KIMI_CODE_WS_FLUSH_INTERVAL_MS: '0', + KIMI_CODE_WS_MAX_BATCH_SIZE: '-5', + KIMI_CODE_WS_HIGH_WATER_MARK_BYTES: '1.5', + KIMI_CODE_WS_HEARTBEAT_MS: 'abc', + KIMI_CODE_WS_MAX_BUFFER_SIZE: '', + KIMI_CODE_WS_MAX_PAYLOAD_BYTES: '1e6', + }); + expect(tuning.flushIntervalMs).toBeUndefined(); + expect(tuning.maxBatchSize).toBeUndefined(); + expect(tuning.highWaterMarkBytes).toBeUndefined(); + expect(tuning.heartbeatIntervalMs).toBeUndefined(); + expect(tuning.maxBufferSize).toBeUndefined(); + expect(tuning.maxPayloadBytes).toBeUndefined(); + }); + + it('maps compression 0/false to false, 1/true to true, anything else to undefined', () => { + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: '0' }).compression).toBe(false); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'false' }).compression).toBe(false); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'FALSE' }).compression).toBe(false); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: '1' }).compression).toBe(true); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'true' }).compression).toBe(true); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'yes' }).compression).toBeUndefined(); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: '' }).compression).toBeUndefined(); + }); +}); diff --git a/packages/remote-control/src/remote-control.ts b/packages/remote-control/src/remote-control.ts index 88a9a3b1d19..b2a09318908 100644 --- a/packages/remote-control/src/remote-control.ts +++ b/packages/remote-control/src/remote-control.ts @@ -29,8 +29,22 @@ const MAX_HTTP_REQUEST_BYTES = 10 * 1024 * 1024; const HTTP_REQUEST_TIMEOUT_MS = 30_000; const REGISTER_TIMEOUT_MS = 10_000; const MAX_RECONNECT_DELAY_MS = 30_000; +const MAX_EARLY_FRAME_BYTES = 1024 * 1024; +const MAX_EARLY_FRAMES = 256; +const BRIDGE_HIGH_WATER_MARK_BYTES = 1024 * 1024; +const BRIDGE_LOW_WATER_MARK_BYTES = 256 * 1024; +const BRIDGE_DRAIN_POLL_MS = 20; +const RESPONSE_CHUNK_BYTES = 256 * 1024; +// Experimental: the relay's handling of multi-frame responses (is_last: false) is unverified. +export const REMOTE_CONTROL_CHUNKED_RESPONSES_ENV = 'KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES'; const RELAY_PING_INTERVAL_MS = 30_000; const RELAY_SILENCE_TIMEOUT_MS = 300_000; +// Bump whenever the `rewriteRemoteControlResponse` rules change. Rewritten bodies are stored by +// the browser under an ETag carrying this version and revalidated on every load, so a bump makes +// the stored validator miss and forces a fresh rewrite instead of serving year-old bytes. +export const REMOTE_CONTROL_REWRITE_VERSION = 1; +const REWRITE_ETAG_SUFFIX = `-rc${REMOTE_CONTROL_REWRITE_VERSION}`; +const REWRITTEN_CACHE_CONTROL = 'public, no-cache'; const BLOCKED_REQUEST_HEADERS = new Set([ 'authorization', 'cookie', @@ -70,6 +84,25 @@ interface PendingHttpRequest { size: number; } +export interface EarlyFrameBuffer { + readonly frames: [RawData, boolean][]; + bytes: number; +} + +export interface BridgeSocket { + readonly readyState: number; + readonly bufferedAmount: number; + readonly isPaused: boolean; + pause(): void; + resume(): void; + send(data: RawData, options: { binary: boolean }): void; + close(code?: number, reason?: Buffer): void; + on(event: 'message', listener: (data: RawData, isBinary: boolean) => void): unknown; + once(event: 'close', listener: (code: number, reason: Buffer) => void): unknown; + once(event: 'error', listener: (error: Error) => void): unknown; + removeAllListeners(event: 'message'): unknown; +} + export interface ParsedRawHttpRequest { readonly method: string; readonly path: string; @@ -110,6 +143,11 @@ interface ActiveStream { class RegistrationError extends Error {} +export function reconnectDelayMs(attempt: number, random: () => number = Math.random): number { + const delay = Math.min(MAX_RECONNECT_DELAY_MS, 1000 * 2 ** Math.min(attempt - 1, 5)); + return delay / 2 + random() * (delay / 2); +} + export function buildRemoteControlUrl( deviceId: string, sessionId?: string, @@ -183,6 +221,32 @@ export function filterForwardRequestHeaders( return result; } +// Drops the rewrite-version suffix from entity tags in an `If-None-Match` value so the local +// server's weak comparison matches its own tag and answers 304. Only the current version is +// stripped: a tag from an older rewrite must miss so the browser fetches a fresh rewrite. +export function stripRewriteVersion(ifNoneMatch: string): string { + return ifNoneMatch.replaceAll(/-rc\d+"/g, (match) => + match === `${REWRITE_ETAG_SUFFIX}"` ? '"' : match, + ); +} + +// Marks a response whose body was rewritten (or a 304 validating such a body): the browser may +// store it but must revalidate on every load, and its ETag carries the rewrite version. +export function applyRewrittenCacheHeaders(headers: string[]): void { + let cacheControlIndex = -1; + for (let index = 0; index < headers.length; index += 2) { + const lower = headers[index]!.toLowerCase(); + if (lower === 'cache-control') { + cacheControlIndex = index; + } else if (lower === 'etag') { + const tag = /^(?:W\/)?("[^"]*)"$/.exec(headers[index + 1]!); + if (tag !== null) headers[index + 1] = `W/${tag[1]}${REWRITE_ETAG_SUFFIX}"`; + } + } + if (cacheControlIndex < 0) headers.push('Cache-Control', REWRITTEN_CACHE_CONTROL); + else headers[cacheControlIndex + 1] = REWRITTEN_CACHE_CONTROL; +} + export function rewriteRemoteControlResponse( contentType: string, body: Buffer, @@ -290,6 +354,7 @@ class RemoteControlClient { private reconnectImmediately = false; private readonly pingIntervalMs: number; private readonly silenceTimeoutMs: number; + private readonly chunkedResponses: boolean; private stopped = false; private connected = false; private relayOnline = false; @@ -315,6 +380,8 @@ class RemoteControlClient { this.onStatus = options.onStatus ?? (() => {}); this.pingIntervalMs = options.pingIntervalMs ?? RELAY_PING_INTERVAL_MS; this.silenceTimeoutMs = options.silenceTimeoutMs ?? RELAY_SILENCE_TIMEOUT_MS; + const chunked = process.env[REMOTE_CONTROL_CHUNKED_RESPONSES_ENV]?.trim().toLowerCase(); + this.chunkedResponses = chunked === '1' || chunked === 'true'; } async start(): Promise { @@ -374,11 +441,7 @@ class RemoteControlClient { continue; } this.reconnectAttempt += 1; - const delay = Math.min( - MAX_RECONNECT_DELAY_MS, - 1000 * 2 ** Math.min(this.reconnectAttempt - 1, 5), - ); - await this.waitForReconnect(delay); + await this.waitForReconnect(reconnectDelayMs(this.reconnectAttempt)); } } @@ -550,15 +613,22 @@ class RemoteControlClient { } private sendHttpResponse(requestId: string, response: Buffer): void { - if (this.http?.readyState !== WebSocket.OPEN) return; - this.http.send( - JSON.stringify({ - request_id: requestId, - type: 'response', - is_last: true, - body_base64: response.toString('base64'), - }), - ); + const http = this.http; + if (http?.readyState !== WebSocket.OPEN) return; + const chunkBytes = this.chunkedResponses ? RESPONSE_CHUNK_BYTES : Math.max(response.length, 1); + let offset = 0; + do { + const end = Math.min(response.length, offset + chunkBytes); + http.send( + JSON.stringify({ + request_id: requestId, + type: 'response', + is_last: end >= response.length, + body_base64: response.subarray(offset, end).toString('base64'), + }), + ); + offset = end; + } while (offset < response.length); } private async openStream(payload: Record): Promise { @@ -573,13 +643,14 @@ class RemoteControlClient { let local: WebSocket | undefined; let tunnel: WebSocket | undefined; - const earlyLocalFrames: [RawData, boolean][] = []; + const earlyLocalFrames: EarlyFrameBuffer = { frames: [], bytes: 0 }; try { local = await connectWebSocket( localWebSocketUrl(this.localOrigin, path), this.localServerToken(), relayHeaders(payload['headers']), earlyLocalFrames, + false, ); tunnel = await this.connectRelay(`/v1/remote/stream/${encodeURIComponent(streamId)}`); if (this.stopped || this.management?.readyState !== WebSocket.OPEN) { @@ -596,12 +667,12 @@ class RemoteControlClient { this.onStatus('device_disconnected'); } }, - earlyLocalFrames, + earlyLocalFrames.frames, ); this.sendOpenStreamResult(streamId, true); } catch (error) { - local?.close(); - tunnel?.close(); + if (local !== undefined) closeResumed(local); + if (tunnel !== undefined) closeResumed(tunnel); this.sendOpenStreamResult( streamId, false, @@ -636,8 +707,8 @@ class RemoteControlClient { if (stream === undefined) return; this.streams.delete(streamId); this.onStatus('device_disconnected'); - stream.local.close(); - stream.tunnel.close(); + closeResumed(stream.local); + closeResumed(stream.tunnel); } private clearPendingHttpRequest(requestId: string): void { @@ -684,12 +755,13 @@ async function connectWebSocket( url: string, token: string, headers: Record = {}, - earlyFrames?: [RawData, boolean][], + earlyFrames?: EarlyFrameBuffer, + perMessageDeflate = true, ): Promise { const protocol = `kimi-code.bearer.${token}`; if (isWebSocketProtocolToken(protocol)) { try { - return await connectWebSocketAttempt(url, [protocol], headers, earlyFrames); + return await connectWebSocketAttempt(url, [protocol], headers, earlyFrames, perMessageDeflate); } catch {} } return connectWebSocketAttempt( @@ -700,6 +772,7 @@ async function connectWebSocket( Authorization: `Bearer ${token}`, }, earlyFrames, + perMessageDeflate, ); } @@ -707,16 +780,18 @@ function connectWebSocketAttempt( url: string, protocols: string[] | undefined, headers: Record, - earlyFrames?: [RawData, boolean][], + earlyFrames: EarlyFrameBuffer | undefined, + perMessageDeflate: boolean, ): Promise { return new Promise((resolve, reject) => { const socket = new WebSocket(url, protocols, { headers, handshakeTimeout: REGISTER_TIMEOUT_MS, + perMessageDeflate, }); if (earlyFrames !== undefined) { socket.on('message', (data, isBinary) => { - earlyFrames.push([data, isBinary]); + bufferEarlyFrame(socket, earlyFrames, data, isBinary); }); } let settled = false; @@ -743,6 +818,21 @@ function connectWebSocketAttempt( }); } +// Frames the local server pushes before the tunnel stream exists. Pausing stops reading +// from the TCP socket; frames already decoded from the current chunk still arrive and are kept. +export function bufferEarlyFrame( + socket: Pick, + buffer: EarlyFrameBuffer, + data: RawData, + isBinary: boolean, +): void { + buffer.frames.push([data, isBinary]); + buffer.bytes += rawDataLength(data); + if (buffer.bytes > MAX_EARLY_FRAME_BYTES || buffer.frames.length >= MAX_EARLY_FRAMES) { + socket.pause(); + } +} + function isWebSocketProtocolToken(value: string): boolean { return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value); } @@ -796,6 +886,18 @@ function requestLocalHttp( publicPrefix: string, ): Promise { const origin = new URL(localOrigin); + const forwardHeaders = filterForwardRequestHeaders(parsed.headers, serverToken); + // A versioned tag means the browser holds a rewritten copy; strip the suffix so the local + // server can answer 304 and remember to describe the 304 as the rewritten representation. + let validatesRewrite = false; + for (let index = 0; index < forwardHeaders.length; index += 2) { + if (forwardHeaders[index]!.toLowerCase() !== 'if-none-match') continue; + const stripped = stripRewriteVersion(forwardHeaders[index + 1]!); + if (stripped === forwardHeaders[index + 1]) continue; + forwardHeaders[index + 1] = stripped; + validatesRewrite = true; + } + const headRequest = parsed.method === 'HEAD'; return new Promise((resolve, reject) => { const request = httpRequest( { @@ -804,11 +906,7 @@ function requestLocalHttp( port: origin.port, method: parsed.method, path: parsed.path, - headers: [ - ...filterForwardRequestHeaders(parsed.headers, serverToken), - 'Host', - origin.host, - ], + headers: [...forwardHeaders, 'Host', origin.host], timeout: HTTP_REQUEST_TIMEOUT_MS, }, (response) => { @@ -818,16 +916,22 @@ function requestLocalHttp( response.once('end', () => { const contentType = response.headers['content-type'] ?? ''; const receivedBody = Buffer.concat(chunks); + const statusCode = response.statusCode ?? 502; + const statusMessage = response.statusMessage ?? 'Bad Gateway'; + const bodilessStatus = headRequest || statusCode === 204 || statusCode === 304; + const bodiless = bodilessStatus || receivedBody.length === 0; const body = - response.headers['content-encoding'] === undefined + !bodiless && response.headers['content-encoding'] === undefined ? rewriteRemoteControlResponse(contentType, receivedBody, publicPrefix) : receivedBody; - const rewritten = body !== receivedBody; - const headers = filterResponseHeaders(response.rawHeaders, rewritten); - if (rewritten) headers.push('Cache-Control', 'no-cache'); - headers.push('Content-Length', String(body.length)); - const statusCode = response.statusCode ?? 502; - const statusMessage = response.statusMessage ?? 'Bad Gateway'; + const rewritten = body !== receivedBody && !body.equals(receivedBody); + const headers = filterResponseHeaders(response.rawHeaders); + if (rewritten || (statusCode === 304 && validatesRewrite)) { + applyRewrittenCacheHeaders(headers); + } + if (!bodilessStatus) { + headers.push('Content-Length', String(body.length)); + } resolve( Buffer.concat([ Buffer.from(`HTTP/1.1 ${statusCode} ${statusMessage}\r\n${headerLines(headers)}\r\n\r\n`), @@ -843,7 +947,7 @@ function requestLocalHttp( }); } -function filterResponseHeaders(rawHeaders: readonly string[], blockCacheControl = false): string[] { +function filterResponseHeaders(rawHeaders: readonly string[]): string[] { const connectionHeaders = new Set(); for (let index = 0; index < rawHeaders.length; index += 2) { if (rawHeaders[index]!.toLowerCase() === 'connection') { @@ -859,7 +963,6 @@ function filterResponseHeaders(rawHeaders: readonly string[], blockCacheControl if (BLOCKED_RESPONSE_HEADERS.has(lower) || connectionHeaders.has(lower)) { continue; } - if (blockCacheControl && lower === 'cache-control') continue; result.push(name, rawHeaders[index + 1]!); } return result; @@ -881,16 +984,20 @@ function relayHeaders(value: unknown): Record { return Object.fromEntries(entries); } -function bridgeSockets( - left: WebSocket, - right: WebSocket, +export function bridgeSockets( + left: BridgeSocket, + right: BridgeSocket, onClose: () => void, - earlyLeftFrames?: [RawData, boolean][], + earlyLeftFrames?: readonly [RawData, boolean][], ): void { let closed = false; - const closeBoth = (code = 1000, reason = Buffer.alloc(0)): void => { + const leftToRight = createPump(left, right); + const rightToLeft = createPump(right, left); + const closeBoth = (code = 1000, reason: Buffer = Buffer.alloc(0)): void => { if (closed) return; closed = true; + leftToRight.dispose(); + rightToLeft.dispose(); onClose(); const safeCode = isValidCloseCode(code) ? code : 1000; if (left.readyState === WebSocket.OPEN) left.close(safeCode, reason); @@ -898,22 +1005,56 @@ function bridgeSockets( }; if (earlyLeftFrames !== undefined) { left.removeAllListeners('message'); - for (const [data, isBinary] of earlyLeftFrames) { - if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary }); - } + for (const [data, isBinary] of earlyLeftFrames) leftToRight.forward(data, isBinary); + if (left.isPaused && !leftToRight.throttled()) left.resume(); } - left.on('message', (data, isBinary) => { - if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary }); - }); - right.on('message', (data, isBinary) => { - if (left.readyState === WebSocket.OPEN) left.send(data, { binary: isBinary }); - }); + left.on('message', leftToRight.forward); + right.on('message', rightToLeft.forward); left.once('close', closeBoth); right.once('close', closeBoth); left.once('error', () => closeBoth(1011)); right.once('error', () => closeBoth(1011)); } +// One direction of the bridge: pauses the source while the sink's send buffer is above the +// high-water mark and polls it back below the low-water mark before resuming. +function createPump( + from: BridgeSocket, + to: BridgeSocket, +): { + readonly forward: (data: RawData, isBinary: boolean) => void; + readonly throttled: () => boolean; + readonly dispose: () => void; +} { + let drain: NodeJS.Timeout | undefined; + // Always leaves the source reading: a close handshake on a paused socket never sees the + // peer's close frame and lingers until ws gives up on it. + const dispose = (): void => { + if (drain !== undefined) { + clearInterval(drain); + drain = undefined; + } + if (from.isPaused) from.resume(); + }; + const forward = (data: RawData, isBinary: boolean): void => { + if (to.readyState !== WebSocket.OPEN) return; + to.send(data, { binary: isBinary }); + if (drain !== undefined || to.bufferedAmount <= BRIDGE_HIGH_WATER_MARK_BYTES) return; + from.pause(); + drain = setInterval(() => { + if (to.bufferedAmount < BRIDGE_LOW_WATER_MARK_BYTES) dispose(); + }, BRIDGE_DRAIN_POLL_MS); + }; + return { forward, throttled: () => drain !== undefined, dispose }; +} + +// Resumes a socket paused by back-pressure or early-frame buffering before closing it so the +// close handshake can complete instead of waiting out the close timer. +function closeResumed(socket: Pick): void { + if (socket.isPaused) socket.resume(); + socket.close(); +} + function isValidCloseCode(code: number): boolean { return ( code === 1000 || @@ -974,6 +1115,11 @@ function decodeBase64(value: string): Buffer { return Buffer.from(value, 'base64'); } +function rawDataLength(data: RawData): number { + if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.length, 0); + return data.byteLength; +} + function rawDataText(data: RawData): string { if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); return Buffer.from(data as ArrayBuffer).toString('utf8'); diff --git a/packages/remote-control/test/remote-control.test.ts b/packages/remote-control/test/remote-control.test.ts index a3a1d66ac49..69dd2fd96bd 100644 --- a/packages/remote-control/test/remote-control.test.ts +++ b/packages/remote-control/test/remote-control.test.ts @@ -1,5 +1,6 @@ import { createServer, type IncomingMessage } from 'node:http'; import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -15,12 +16,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { WebSocketServer, type RawData, type WebSocket } from 'ws'; import { + applyRewrittenCacheHeaders, + bridgeSockets, + bufferEarlyFrame, buildRemoteControlUrl, filterForwardRequestHeaders, parseRawHttpRequest, + reconnectDelayMs, + REMOTE_CONTROL_REWRITE_VERSION, resolveRemoteControlRelayOrigin, rewriteRemoteControlResponse, startRemoteControl, + stripRewriteVersion, + type BridgeSocket, + type EarlyFrameBuffer, type RemoteControlHandle, } from '../src/remote-control'; import { remoteControlLockPath } from '../src/lock'; @@ -40,6 +49,8 @@ const cleanups: Array<() => Promise | void> = []; afterEach(async () => { vi.unstubAllEnvs(); + vi.restoreAllMocks(); + vi.useRealTimers(); while (cleanups.length > 0) await cleanups.pop()!(); }); @@ -93,6 +104,26 @@ describe('Remote Control HTTP forwarding', () => { ]); }); + it('forwards conditional request headers but not accept-encoding', () => { + expect( + filterForwardRequestHeaders( + [ + ['If-None-Match', 'W/"abc"'], + ['If-Modified-Since', 'Wed, 21 Oct 2015 07:28:00 GMT'], + ['Accept-Encoding', 'gzip, br'], + ], + 'local-token', + ), + ).toEqual([ + 'If-None-Match', + 'W/"abc"', + 'If-Modified-Since', + 'Wed, 21 Oct 2015 07:28:00 GMT', + 'Authorization', + 'Bearer local-token', + ]); + }); + it('rejects absolute-form and malformed request targets', () => { expect(() => parseRawHttpRequest(Buffer.from('GET https://example.test/ HTTP/1.1\r\n\r\n')), @@ -132,6 +163,28 @@ describe('Remote Control HTTP forwarding', () => { ).toString(); expect(css).toBe(`.x{background:url(${prefix}/assets/x.png)}`); }); + + it('strips only the current rewrite version from If-None-Match entity tags', () => { + const suffix = `-rc${REMOTE_CONTROL_REWRITE_VERSION}`; + expect(stripRewriteVersion(`W/"asset-1${suffix}"`)).toBe('W/"asset-1"'); + expect(stripRewriteVersion(`"asset-1${suffix}", W/"asset-2${suffix}"`)).toBe( + '"asset-1", W/"asset-2"', + ); + expect(stripRewriteVersion('W/"asset-1-rc0"')).toBe('W/"asset-1-rc0"'); + expect(stripRewriteVersion('W/"asset-1"')).toBe('W/"asset-1"'); + expect(stripRewriteVersion('*')).toBe('*'); + }); + + it('marks rewritten responses as revalidate-always with a versioned weak ETag', () => { + const suffix = `-rc${REMOTE_CONTROL_REWRITE_VERSION}`; + const weak = ['ETag', 'W/"asset-1"', 'Cache-Control', 'public, max-age=31536000, immutable']; + applyRewrittenCacheHeaders(weak); + expect(weak).toEqual(['ETag', `W/"asset-1${suffix}"`, 'Cache-Control', 'public, no-cache']); + + const strong = ['etag', '"asset-1"']; + applyRewrittenCacheHeaders(strong); + expect(strong).toEqual(['etag', `W/"asset-1${suffix}"`, 'Cache-Control', 'public, no-cache']); + }); }); describe('Remote Control tunnel', () => { @@ -276,8 +329,27 @@ describe('Remote Control tunnel', () => { const localWsServer = new WebSocketServer({ noServer: true }); const localServer = createServer((request, response) => { localHttpRequest = request; + if (request.url === '/assets/font-1.woff2') { + response.writeHead(200, { + 'Content-Type': 'font/woff2', + ETag: 'W/"font-1"', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + response.end('font-bytes'); + return; + } + if (request.headers['if-none-match'] === 'W/"asset-1"') { + response.writeHead(304, { + 'Content-Type': 'text/html', + ETag: 'W/"asset-1"', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + response.end(); + return; + } response.writeHead(200, { 'Content-Type': 'text/html', + ETag: 'W/"asset-1"', 'Cache-Control': 'public, max-age=31536000, immutable', Connection: 'X-Remove', 'X-Remove': 'gone', @@ -383,10 +455,69 @@ describe('Remote Control tunnel', () => { expect(localHttpRequest?.headers['x-hop']).toBeUndefined(); expect(localHttpRequest?.headers['x-keep']).toBe('yes'); expect(response).not.toContain('X-Remove'); + // Rewritten bodies are stored but revalidated per load under a versioned validator. + const rewriteSuffix = `-rc${REMOTE_CONTROL_REWRITE_VERSION}`; + expect(response).toContain('Cache-Control: public, no-cache'); expect(response).not.toContain('immutable'); - expect(response).toContain('Cache-Control: no-cache'); + expect(response).toContain(`ETag: W/"asset-1${rewriteSuffix}"`); expect(response).toContain(`/coding-relay/devices/${handle.deviceId}/boot.js`); + const tunnelRequest = async (requestId: string, raw: string): Promise => { + const reply = nextJsonMessage(httpConnections[0]!); + httpConnections[0]!.send( + JSON.stringify({ + request_id: requestId, + type: 'request', + is_last: true, + body_base64: Buffer.from(raw).toString('base64'), + }), + ); + return Buffer.from((await reply)['body_base64'] as string, 'base64').toString(); + }; + + const notModified = await tunnelRequest( + 'request-304', + `GET / HTTP/1.1\r\nHost: relay.test\r\nIf-None-Match: W/"asset-1${rewriteSuffix}"\r\n\r\n`, + ); + expect(localHttpRequest?.headers['if-none-match']).toBe('W/"asset-1"'); + expect(notModified).toMatch(/^HTTP\/1\.1 304 Not Modified\r\n/); + expect(notModified).toContain(`ETag: W/"asset-1${rewriteSuffix}"`); + expect(notModified).toContain('Cache-Control: public, no-cache'); + expect(notModified).not.toContain('immutable'); + expect(notModified).not.toContain('Content-Length'); + expect(notModified).not.toContain(' { }, 15_000); }); +describe('Remote Control reconnect backoff', () => { + it('applies equal jitter within the exponential schedule', () => { + const random = vi.spyOn(Math, 'random'); + random.mockReturnValue(0); + expect(reconnectDelayMs(1)).toBe(500); + expect(reconnectDelayMs(2)).toBe(1000); + expect(reconnectDelayMs(6)).toBe(15_000); + expect(reconnectDelayMs(20)).toBe(15_000); + random.mockReturnValue(1); + expect(reconnectDelayMs(1)).toBe(1000); + expect(reconnectDelayMs(3)).toBe(4000); + expect(reconnectDelayMs(20)).toBe(30_000); + expect(reconnectDelayMs(4, () => 0.5)).toBe(6000); + }); +}); + +describe('Remote Control stream bridge', () => { + class FakeSocket extends EventEmitter implements BridgeSocket { + readyState = 1; + bufferedAmount = 0; + isPaused = false; + readonly sent: string[] = []; + readonly closes: number[] = []; + pause(): void { + this.isPaused = true; + } + resume(): void { + this.isPaused = false; + } + send(data: RawData): void { + this.sent.push(rawDataText(data)); + } + close(code = 1000): void { + this.closes.push(code); + this.readyState = 3; + this.emit('close', code, Buffer.alloc(0)); + } + } + + it('caps early local frames by pausing the socket and resumes it after replay', () => { + const socket = new FakeSocket(); + const buffer: EarlyFrameBuffer = { frames: [], bytes: 0 }; + for (let index = 0; index < 255; index += 1) { + bufferEarlyFrame(socket, buffer, Buffer.from('x'), false); + } + expect(socket.isPaused).toBe(false); + bufferEarlyFrame(socket, buffer, Buffer.from('x'), false); + expect(socket.isPaused).toBe(true); + expect(buffer.frames).toHaveLength(256); + + const large = new FakeSocket(); + const largeBuffer: EarlyFrameBuffer = { frames: [], bytes: 0 }; + bufferEarlyFrame(large, largeBuffer, Buffer.alloc(1024 * 1024), true); + expect(large.isPaused).toBe(false); + bufferEarlyFrame(large, largeBuffer, [Buffer.alloc(1)], true); + expect(large.isPaused).toBe(true); + expect(largeBuffer.bytes).toBe(1024 * 1024 + 1); + + const tunnel = new FakeSocket(); + bridgeSockets(socket, tunnel, () => {}, buffer.frames); + expect(tunnel.sent).toHaveLength(256); + expect(socket.isPaused).toBe(false); + }); + + it('pauses the source while the sink is above the high-water mark and resumes below the low one', () => { + vi.useFakeTimers(); + const local = new FakeSocket(); + const tunnel = new FakeSocket(); + const onClose = vi.fn(); + bridgeSockets(local, tunnel, onClose); + + local.emit('message', Buffer.from('one'), false); + expect(tunnel.sent).toEqual(['one']); + expect(local.isPaused).toBe(false); + + tunnel.bufferedAmount = 1024 * 1024 + 1; + local.emit('message', Buffer.from('two'), false); + expect(tunnel.sent).toEqual(['one', 'two']); + expect(local.isPaused).toBe(true); + + vi.advanceTimersByTime(40); + expect(local.isPaused).toBe(true); + tunnel.bufferedAmount = 256 * 1024; + vi.advanceTimersByTime(20); + expect(local.isPaused).toBe(true); + tunnel.bufferedAmount = 256 * 1024 - 1; + vi.advanceTimersByTime(20); + expect(local.isPaused).toBe(false); + + tunnel.emit('message', Buffer.from('back'), true); + expect(local.sent).toEqual(['back']); + expect(vi.getTimerCount()).toBe(0); + + tunnel.bufferedAmount = 2 * 1024 * 1024; + local.emit('message', Buffer.from('three'), false); + expect(local.isPaused).toBe(true); + expect(vi.getTimerCount()).toBe(1); + tunnel.close(1001); + expect(onClose).toHaveBeenCalledTimes(1); + expect(local.closes).toEqual([1001]); + expect(local.isPaused).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('resumes a source paused by back-pressure before closing it', () => { + vi.useFakeTimers(); + const local = new FakeSocket(); + const tunnel = new FakeSocket(); + bridgeSockets(local, tunnel, () => {}); + + tunnel.bufferedAmount = 2 * 1024 * 1024; + local.emit('message', Buffer.from('one'), false); + expect(local.isPaused).toBe(true); + expect(vi.getTimerCount()).toBe(1); + + local.emit('error', new Error('boom')); + expect(local.isPaused).toBe(false); + expect(tunnel.closes).toEqual([1011]); + expect(vi.getTimerCount()).toBe(0); + }); + + it('resumes a source still paused from early-frame buffering when the peer closes', () => { + const local = new FakeSocket(); + const tunnel = new FakeSocket(); + const buffer: EarlyFrameBuffer = { frames: [], bytes: 0 }; + bufferEarlyFrame(local, buffer, Buffer.alloc(1024 * 1024 + 1), true); + expect(local.isPaused).toBe(true); + // The tunnel is already backed up, so replaying the early frame keeps the source paused. + tunnel.bufferedAmount = 2 * 1024 * 1024; + bridgeSockets(local, tunnel, () => {}, buffer.frames); + expect(local.isPaused).toBe(true); + + tunnel.close(1000); + expect(local.isPaused).toBe(false); + expect(local.closes).toEqual([1000]); + }); +}); + +describe('Remote Control chunked responses', () => { + async function tunnelLargeResponse(): Promise>> { + const body = Buffer.alloc(600 * 1024); + for (let index = 0; index < body.length; index += 1) body[index] = index % 251; + const localServer = createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/octet-stream' }); + response.end(body); + }); + const localPort = await listen(localServer); + cleanups.push(() => closeServer(localServer)); + const homeDir = await createRemoteControlHome(TOKEN.refreshToken); + const relay = await startAuthRelay(); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + handle = await startRemoteControl({ + homeDir, + localOrigin: `http://127.0.0.1:${localPort}`, + localServerToken: 'local-server-token', + clientVersion: CLIENT_VERSION, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + const http = relay.httpSockets[0]!; + const frames: Array> = []; + const done = new Promise((resolve) => { + http.on('message', (data) => { + const frame = JSON.parse(rawDataText(data)) as Record; + frames.push(frame); + if (frame['is_last'] === true) resolve(); + }); + }); + http.send( + JSON.stringify({ + request_id: 'large', + type: 'request', + is_last: true, + body_base64: Buffer.from('GET /blob HTTP/1.1\r\nHost: relay.test\r\n\r\n').toString('base64'), + }), + ); + await done; + const raw = Buffer.concat( + frames.map((frame) => Buffer.from(frame['body_base64'] as string, 'base64')), + ); + const separator = raw.indexOf('\r\n\r\n'); + expect(raw.subarray(0, separator).toString()).toMatch(/^HTTP\/1\.1 200 OK\r\n/); + expect(raw.subarray(separator + 4).equals(body)).toBe(true); + return frames; + } + + it('splits responses into 256 KiB frames when the flag is set', async () => { + vi.stubEnv('KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES', '1'); + const frames = await tunnelLargeResponse(); + expect(frames.map((frame) => frame['is_last'])).toEqual([false, false, true]); + expect(frames.every((frame) => frame['request_id'] === 'large' && frame['type'] === 'response')).toBe( + true, + ); + const lengths = frames.map((frame) => Buffer.from(frame['body_base64'] as string, 'base64').length); + expect(lengths[0]).toBe(256 * 1024); + expect(lengths[1]).toBe(256 * 1024); + }); + + it('sends a single frame by default', async () => { + const frames = await tunnelLargeResponse(); + expect(frames.map((frame) => frame['is_last'])).toEqual([true]); + }); +}); + describe('Remote Control single-instance lock', () => { async function deadPid(): Promise { const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }); @@ -594,7 +930,7 @@ describe('Remote Control single-instance lock', () => { second = await startRemoteControl(options); expect(second.url).toContain('/devices/'); - relay.managementSockets[relay.managementSockets.length - 1]!.send( + relay.managementSockets.at(-1)!.send( JSON.stringify({ type: 'disconnect', payload: { reason: 'user_requested' } }), ); await second.closed; diff --git a/packages/transcript/src/contract/schema.ts b/packages/transcript/src/contract/schema.ts index a06865a76ea..4d514f2620c 100644 --- a/packages/transcript/src/contract/schema.ts +++ b/packages/transcript/src/contract/schema.ts @@ -498,6 +498,7 @@ export const transcriptOpsCatchupResponseSchema = z.object({ ), latest_seq: transcriptSeqSchema, complete: z.boolean(), + has_more: z.boolean().default(false), }); export const transcriptUserMessageSchema = z.object({ diff --git a/packages/transcript/src/index.ts b/packages/transcript/src/index.ts index affc4316c81..f2ae97a8285 100644 --- a/packages/transcript/src/index.ts +++ b/packages/transcript/src/index.ts @@ -9,6 +9,7 @@ export * from './model/task'; export * from './model/meta'; export * from './model/prompt'; export * from './ops/operation'; +export * from './ops/coalesce'; export { EMPTY_AGENT_STATE, applyOperation, appendAtOffset } from './ops/apply'; export type { AgentState, ApplyResult } from './ops/apply'; export * from './store/agentTranscript'; diff --git a/packages/transcript/src/ops/coalesce.ts b/packages/transcript/src/ops/coalesce.ts new file mode 100644 index 00000000000..4f93eb3f766 --- /dev/null +++ b/packages/transcript/src/ops/coalesce.ts @@ -0,0 +1,27 @@ +import type { AppendOp, AppendTarget, TranscriptOperation } from './operation'; + +export function coalesceAppendOps(ops: readonly TranscriptOperation[]): TranscriptOperation[] { + const out: TranscriptOperation[] = []; + for (const op of ops) { + const prev = out.at(-1); + if (op.op === 'append' && prev?.op === 'append' && continuesAppend(prev, op)) { + out[out.length - 1] = { ...prev, text: prev.text + op.text }; + continue; + } + out.push(op); + } + return out; +} + +function continuesAppend(prev: AppendOp, next: AppendOp): boolean { + return prev.offset + prev.text.length === next.offset && sameTarget(prev.target, next.target); +} + +function sameTarget(a: AppendTarget, b: AppendTarget): boolean { + if (a.type === 'frame') { + return ( + b.type === 'frame' && a.turnId === b.turnId && a.stepId === b.stepId && a.frameId === b.frameId + ); + } + return b.type === 'task' && a.taskId === b.taskId; +} diff --git a/packages/transcript/test/store.test.ts b/packages/transcript/test/store.test.ts index 3a952e496b6..830c7845676 100644 --- a/packages/transcript/test/store.test.ts +++ b/packages/transcript/test/store.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'; import { AgentTranscript } from '#/store/agentTranscript'; import { TranscriptStore } from '#/store/transcriptStore'; import { appendAtOffset } from '#/ops/apply'; +import { coalesceAppendOps } from '#/ops/coalesce'; import type { + AppendOp, FrameUpsertOp, TurnUpsertOp, TranscriptOperation, @@ -585,3 +587,81 @@ describe('TranscriptStore', () => { expect(rosters).toHaveLength(1); }); }); + +describe('coalesceAppendOps', () => { + const frame = { type: 'frame', turnId: 't1', stepId: 't1.1', frameId: 't1.1.f1' } as const; + const append = ( + target: AppendOp['target'], + offset: number, + text: string, + ): AppendOp => ({ op: 'append', target, offset, text }); + + it('merges adjacent contiguous appends to the same target into one op', () => { + expect( + coalesceAppendOps([append(frame, 0, 'Hel'), append(frame, 3, 'lo'), append(frame, 5, ' 🙂')]), + ).toEqual([append(frame, 0, 'Hello 🙂')]); + }); + + it('measures contiguity in UTF-16 code units like appendAtOffset', () => { + expect(coalesceAppendOps([append(frame, 0, '🙂'), append(frame, 2, '!')])).toEqual([ + append(frame, 0, '🙂!'), + ]); + expect(coalesceAppendOps([append(frame, 0, '🙂'), append(frame, 1, '!')])).toEqual([ + append(frame, 0, '🙂'), + append(frame, 1, '!'), + ]); + }); + + it('keeps appends apart when the next offset leaves a gap or overlaps', () => { + expect(coalesceAppendOps([append(frame, 0, 'ab'), append(frame, 3, 'c')])).toEqual([ + append(frame, 0, 'ab'), + append(frame, 3, 'c'), + ]); + expect(coalesceAppendOps([append(frame, 0, 'ab'), append(frame, 1, 'bc')])).toEqual([ + append(frame, 0, 'ab'), + append(frame, 1, 'bc'), + ]); + }); + + it('keeps appends apart when the targets differ', () => { + const otherFrame = { ...frame, frameId: 't1.1.f2' } as const; + const task = { type: 'task', taskId: 'task_1' } as const; + expect( + coalesceAppendOps([ + append(frame, 0, 'a'), + append(otherFrame, 1, 'b'), + append(task, 0, 'x'), + append(task, 1, 'y'), + append({ type: 'task', taskId: 'task_2' }, 2, 'z'), + ]), + ).toEqual([ + append(frame, 0, 'a'), + append(otherFrame, 1, 'b'), + append(task, 0, 'xy'), + append({ type: 'task', taskId: 'task_2' }, 2, 'z'), + ]); + }); + + it('passes non-append ops through untouched and preserves order across them', () => { + const stepUpsert: TranscriptOperation = { + op: 'step.upsert', + turnId: 't1', + step: { kind: 'step', stepId: 't1.1', turnId: 't1', ordinal: 1, state: 'running' }, + }; + const meta: TranscriptOperation = { op: 'meta.merge', meta: { activity: 'turn' } }; + expect( + coalesceAppendOps([ + stepUpsert, + append(frame, 0, 'a'), + append(frame, 1, 'b'), + meta, + append(frame, 2, 'c'), + append(frame, 3, 'd'), + ]), + ).toEqual([stepUpsert, append(frame, 0, 'ab'), meta, append(frame, 2, 'cd')]); + }); + + it('returns an empty list for empty input', () => { + expect(coalesceAppendOps([])).toEqual([]); + }); +}); From 9818ce40895b49db57feb73f4082a0e1d618b09c Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:35:59 +0300 Subject: [PATCH 02/10] fix(remote-control): drop browser WebSocket handshake headers on the local hop The relay forwards the browser's Sec-WebSocket-Extensions header, but the loopback ws client runs with permessage-deflate disabled, so when the local server accepted the advertised extension the client rejected the upgrade. Strip the browser's handshake fields and let ws negotiate its own. --- packages/remote-control/src/remote-control.ts | 11 ++++++++++- packages/remote-control/test/remote-control.test.ts | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/remote-control/src/remote-control.ts b/packages/remote-control/src/remote-control.ts index b2a09318908..f7f5b35d236 100644 --- a/packages/remote-control/src/remote-control.ts +++ b/packages/remote-control/src/remote-control.ts @@ -61,6 +61,15 @@ const BLOCKED_REQUEST_HEADERS = new Set([ 'transfer-encoding', 'upgrade', ]); +// The local `ws` client negotiates its own handshake fields; forwarding the +// browser's copies would make it request an extension it has no handler for +// (permessage-deflate is disabled on the loopback hop) and fail the upgrade. +const BLOCKED_WS_UPGRADE_HEADERS = new Set([ + 'sec-websocket-extensions', + 'sec-websocket-key', + 'sec-websocket-version', + 'sec-websocket-accept', +]); const BLOCKED_RESPONSE_HEADERS = new Set([ 'connection', 'content-length', @@ -974,7 +983,7 @@ function relayHeaders(value: unknown): Record { for (const [name, raw] of Object.entries(value)) { if (typeof raw !== 'string') continue; const lower = name.toLowerCase(); - if (BLOCKED_REQUEST_HEADERS.has(lower)) continue; + if (BLOCKED_REQUEST_HEADERS.has(lower) || BLOCKED_WS_UPGRADE_HEADERS.has(lower)) continue; try { validateHeaderName(name); validateHeaderValue(name, raw); diff --git a/packages/remote-control/test/remote-control.test.ts b/packages/remote-control/test/remote-control.test.ts index 69dd2fd96bd..318170d7150 100644 --- a/packages/remote-control/test/remote-control.test.ts +++ b/packages/remote-control/test/remote-control.test.ts @@ -537,7 +537,14 @@ describe('Remote Control tunnel', () => { payload: { stream_id: 'stream-1', path: '/api/v1/ws', - headers: { Cookie: 'relay-cookie', Origin: 'https://relay.test', 'X-Keep': 'yes' }, + headers: { + Cookie: 'relay-cookie', + Origin: 'https://relay.test', + 'X-Keep': 'yes', + 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits', + 'Sec-WebSocket-Key': 'browser-key', + 'Sec-WebSocket-Version': '13', + }, }, }), ); @@ -549,6 +556,8 @@ describe('Remote Control tunnel', () => { expect(localWsRequest?.headers.cookie).toBeUndefined(); expect(localWsRequest?.headers.origin).toBeUndefined(); expect(localWsRequest?.headers['x-keep']).toBe('yes'); + expect(localWsRequest?.headers['sec-websocket-extensions']).toBeUndefined(); + expect(localWsRequest?.headers['sec-websocket-key']).not.toBe('browser-key'); await waitFor(() => managementMessages.some( (value) => From 9ecc53b827ea9513322b658ccced9fd910c2e949 Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:56:21 +0300 Subject: [PATCH 03/10] fix: keep the unsized sessions list unbounded and gate chunked responses behind an experimental flag The always-paginated sessions list silently truncated callers that never passed page_size, which is a breaking API change under a patch changeset. Without page_size the listing now returns every eligible session again (archived_only keeps its historical page of 20); explicit page_size keeps the collect-while-filtering behaviour and accurate has_more. Remote Control chunked responses were toggled by a standalone env var that bypassed KIMI_CODE_EXPERIMENTAL_FLAG and the [experimental] config. The feature is now the `remote_control_chunked_responses` flag registered through registerFlagDefinition; `kimi web --rc` and the TUI /rc command resolve it through IFlagService and pass it to the tunnel as an option. --- .changeset/remote-control-asset-caching.md | 2 +- .changeset/sessions-list-paginated.md | 2 +- .../src/cli/sub/web/remote-control.ts | 1 + apps/kimi-code/src/cli/sub/web/run.ts | 14 +++++++++--- apps/kimi-code/src/tui/commands/web.ts | 4 +++- apps/kimi-code/test/cli/web/web.test.ts | 2 +- apps/kimi-code/test/tui/commands/web.test.ts | 18 ++++++++++----- docs/en/configuration/env-vars.md | 2 +- docs/en/guides/remote-control.md | 2 +- docs/en/reference/server-api.md | 4 ++-- docs/zh/configuration/env-vars.md | 2 +- docs/zh/guides/remote-control.md | 2 +- docs/zh/reference/server-api.md | 4 ++-- packages/kap-server/src/routes/sessions.ts | 18 +++++++++------ packages/kap-server/test/sessions.test.ts | 13 +++++++---- packages/remote-control/src/flag.ts | 22 +++++++++++++++++++ packages/remote-control/src/index.ts | 1 + packages/remote-control/src/remote-control.ts | 11 ++++++---- .../test/remote-control.test.ts | 19 ++++++++++++---- 19 files changed, 103 insertions(+), 40 deletions(-) create mode 100644 packages/remote-control/src/flag.ts diff --git a/.changeset/remote-control-asset-caching.md b/.changeset/remote-control-asset-caching.md index f0750724ae5..4c079d9e1df 100644 --- a/.changeset/remote-control-asset-caching.md +++ b/.changeset/remote-control-asset-caching.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Let browsers cache `kimi web --remote-control` UI assets across page loads and add reconnect jitter. Enable experimental chunked responses with `KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES=1`. +Let browsers cache `kimi web --remote-control` UI assets across page loads and add reconnect jitter. Chunked tunnel responses ship as the `remote_control_chunked_responses` experimental feature (`KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1`). diff --git a/.changeset/sessions-list-paginated.md b/.changeset/sessions-list-paginated.md index 0358d2aa9c4..f0d177232e7 100644 --- a/.changeset/sessions-list-paginated.md +++ b/.changeset/sessions-list-paginated.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -The sessions list API now always paginates (default 50 per page) and the transcript ops catch-up API accepts a `limit` and reports `has_more`. +The sessions list API now applies its filters while collecting so paged responses are full and `has_more` is accurate (an unsized request still returns the whole list), and the transcript ops catch-up API accepts a `limit` and reports `has_more`. diff --git a/apps/kimi-code/src/cli/sub/web/remote-control.ts b/apps/kimi-code/src/cli/sub/web/remote-control.ts index d719767768b..ce1150059ce 100644 --- a/apps/kimi-code/src/cli/sub/web/remote-control.ts +++ b/apps/kimi-code/src/cli/sub/web/remote-control.ts @@ -15,6 +15,7 @@ export { parseRawHttpRequest, remoteControlLockPath, RemoteControlAlreadyRunningError, + REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID, REMOTE_CONTROL_RELAY_ORIGIN, REMOTE_CONTROL_RELAY_URL_ENV, resolveRemoteControlRelayOrigin, diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index b10a666d9cd..f694da4d101 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -11,6 +11,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; +import { IFlagService } from '@moonshot-ai/agent-core-v2'; import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; import chalk from 'chalk'; @@ -40,6 +41,7 @@ import { type NetworkAddress } from './networks'; import { formatRemoteControlOutput, formatRemoteControlStatus, + REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID, startRemoteControl, type RemoteControlHandle, type RemoteControlOptions, @@ -75,9 +77,14 @@ export interface WebCliOptions extends ServerCliOptions { remoteControl?: boolean; } +/** What the ready hook may ask of the listening server. */ +export interface ForegroundServer { + readonly flags: Pick; +} + export interface StartForegroundHooks { /** Fires once the server is listening, before the foreground runner blocks. */ - onReady?: (origin: string) => void | Promise; + onReady?: (origin: string, server: ForegroundServer) => void | Promise; onShutdown?: (reason: string) => void | Promise; } @@ -201,7 +208,7 @@ export async function handleWebCommand( const run = deps.startServerForeground ?? startServerForeground; let remoteControl: RemoteControlHandle | undefined; await run(parsed, { - onReady: async (origin) => { + onReady: async (origin, server) => { // Resolve the persistent token only once the server is up: a fresh // server writes `server.token` on first boot, so reading it beforehand // would miss first-time starts and the browser would hit the auth gate. @@ -227,6 +234,7 @@ export async function handleWebCommand( clientVersion: `kimi-code/${getVersion()}`, stderr: deps.stderr, onStatus, + chunkedResponses: server.flags.enabled(REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID), }); const qrCode = await generateRemoteControlQr(remoteControl.url, dataDir); deps.stdout.write( @@ -400,7 +408,7 @@ async function runServerInProcess( running.logger.info({ address: running.address }, 'server ready'); try { - await hooks.onReady?.(running.address); + await hooks.onReady?.(running.address, { flags: v2.core.accessor.get(IFlagService) }); } catch (error) { try { await hooks.onShutdown?.('startup_failed'); diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts index 95c310d71ec..784cf1ee8f6 100644 --- a/apps/kimi-code/src/tui/commands/web.ts +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -8,6 +8,7 @@ import { formatRemoteControlOutput, formatRemoteControlStatus, inspectRemoteControlLock, + REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID, startRemoteControl, type RemoteControlStatus, } from '#/cli/sub/web/remote-control'; @@ -56,7 +57,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis let remoteControl: Awaited> | undefined; try { await startServerForeground(options, { - onReady: async (origin) => { + onReady: async (origin, server) => { const dataDir = getDataDir(); const token = tryResolveServerToken(dataDir); if (token === undefined) throw new Error('Unable to read the local server token.'); @@ -73,6 +74,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis localServerToken: token, clientVersion: `kimi-code/${getVersion()}`, onStatus, + chunkedResponses: server.flags.enabled(REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID), }); const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id); const qrCode = await generateRemoteControlQr(url, dataDir); diff --git a/apps/kimi-code/test/cli/web/web.test.ts b/apps/kimi-code/test/cli/web/web.test.ts index 816f5d7871f..e7e04b8e4d5 100644 --- a/apps/kimi-code/test/cli/web/web.test.ts +++ b/apps/kimi-code/test/cli/web/web.test.ts @@ -52,7 +52,7 @@ function makeRunner(origin = 'http://127.0.0.1:58627'): { const calls: { options: ParsedServerOptions | undefined } = { options: undefined }; const runner: ForegroundRunner = async (options, hooks) => { calls.options = options; - await hooks?.onReady?.(origin); + await hooks?.onReady?.(origin, { flags: { enabled: () => false } }); return undefined as never; }; return { runner, calls }; diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index d31e10ca416..6dcb903d622 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -19,6 +19,9 @@ const mocks = vi.hoisted(() => ({ openUrl: vi.fn(), })); +type FakeServer = { flags: { enabled: (id: string) => boolean } }; +const FLAGS_OFF: FakeServer = { flags: { enabled: () => false } }; + vi.mock('#/cli/sub/web/remote-control', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, startRemoteControl: mocks.startRemoteControl }; @@ -121,8 +124,8 @@ describe('handleWebCommand', () => { mocks.tryResolveServerToken.mockReturnValue('tok-1'); const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); mocks.startServerForeground.mockImplementation( - async (_options: unknown, hooks: { onReady?: (origin: string) => void }) => { - hooks.onReady?.('http://127.0.0.1:58627'); + async (_options: unknown, hooks: { onReady?: (origin: string, server: FakeServer) => void }) => { + hooks.onReady?.('http://127.0.0.1:58627', FLAGS_OFF); }, ); const host = makeHost(); @@ -210,11 +213,13 @@ describe('handleRemoteControlCommand', () => { async ( _options: unknown, hooks: { - onReady?: (origin: string) => void | Promise; + onReady?: (origin: string, server: FakeServer) => void | Promise; onShutdown?: (reason: string) => void | Promise; }, ) => { - await hooks.onReady?.('http://127.0.0.1:58627'); + await hooks.onReady?.('http://127.0.0.1:58627', { + flags: { enabled: (id) => id === 'remote_control_chunked_responses' }, + }); await hooks.onShutdown?.('SIGINT'); }, ); @@ -231,6 +236,7 @@ describe('handleRemoteControlCommand', () => { homeDir: dataDir, localOrigin: 'http://127.0.0.1:58627', localServerToken: 'local-server-token', + chunkedResponses: true, }), ); expect(mocks.openUrl).toHaveBeenCalledWith(sessionUrl); @@ -279,11 +285,11 @@ describe('handleRemoteControlCommand', () => { async ( _options: unknown, hooks: { - onReady?: (origin: string) => void | Promise; + onReady?: (origin: string, server: FakeServer) => void | Promise; onShutdown?: (reason: string) => void | Promise; }, ) => { - await hooks.onReady?.('http://127.0.0.1:58627'); + await hooks.onReady?.('http://127.0.0.1:58627', FLAGS_OFF); await hooks.onShutdown?.('SIGINT'); }, ); diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 99463a219ef..869922f3824 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -145,7 +145,6 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_WS_MAX_BATCH_SIZE` | Buffered subscribed frames that trigger an immediate flush (default `64`) | Positive integer; invalid values are ignored | | `KIMI_CODE_WS_HIGH_WATER_MARK_BYTES` | Socket `bufferedAmount` above which outbound frames are held back (default `1048576`) | Positive integer; invalid values are ignored | | `KIMI_CODE_WS_MAX_BUFFER_SIZE` | Per-session replay window, also advertised as `server_hello.max_event_buffer_size` (default `1000`) | Positive integer; invalid values are ignored | -| `KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES` | Experimental: split Remote Control HTTP responses into 256 KiB tunnel frames (default off, one frame per response) | `1`/`true`; anything else keeps the default | | `KIMI_CODE_TRANSCRIPT_OPS_BATCH_MS` | Window (ms) for merging consecutive streamed text appends into one `transcript.ops` batch before its sequence number is assigned (default `16`; `0` forwards every append immediately) | Non-negative integer; invalid values are ignored | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Keep background tasks when the session closes; higher priority than `config.toml` (default: stop them on exit) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` | Cap on concurrently running background tasks; higher priority than `[background] max_running_tasks` (unset = no cap) | Positive integer; invalid values are ignored | @@ -164,6 +163,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Offer the built-in skills documenting Kimi Code itself to the model; higher priority than `builtin_product_skills` | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | Experimental fullscreen UI: scrollable transcript, mouse selection, clickable links, Ctrl-Shift-F search | `1` enables it; anything else keeps the regular inline UI | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Experimental `fork` parameter on `Agent`/`AgentSwarm`: start the subagent from a snapshot of the caller's history instead of an empty context; `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | Experimental: split Remote Control HTTP responses into 256 KiB tunnel frames instead of one frame per response; `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_SEARCH_WORKER` | Run the global search index in a dedicated worker thread; higher priority than `[database] search` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` | Use the minidb-backed read model for session indexing; higher priority than `[database] base` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for MCP servers; overrides the config file, but `mcp.json` `startupTimeoutMs` still wins | Integer from `1` to `2147483647`; invalid values are ignored | diff --git a/docs/en/guides/remote-control.md b/docs/en/guides/remote-control.md index 3ada31cb2d0..e2d3974408c 100644 --- a/docs/en/guides/remote-control.md +++ b/docs/en/guides/remote-control.md @@ -92,7 +92,7 @@ Remote Control is only a remote window — all computation and file operations s How the tunnel caches the web UI depends on whether it had to rewrite a file. Fonts, wasm and other binary assets (the hashed files under `/assets/`) keep their long-lived `Cache-Control` headers, so the remote browser caches them across sessions. HTML, JavaScript and CSS are rewritten under the device prefix, so they are stored with `Cache-Control: public, no-cache` and a versioned `ETag`: the browser revalidates them on every load, which is a cheap `304 Not Modified` when the bundle has not changed, and only refetches what actually changed. The first load on a slow link still transfers the full bundle once. -`KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES=1` is an experimental switch that splits large HTTP responses into 256 KiB tunnel frames instead of one frame per response. It is off by default and is only useful for diagnosing slow-link behaviour; leave it unset unless asked to try it. +The `remote_control_chunked_responses` experimental feature splits large HTTP responses into 256 KiB tunnel frames instead of one frame per response. Enable it like any other experimental feature (`KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1`, the `[experimental]` config section, or `KIMI_CODE_EXPERIMENTAL_FLAG=1`). It is off by default and is only useful for diagnosing slow-link behaviour; leave it off unless asked to try it. ## What's the difference between Remote Control and Kimi Code Web? diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index 0bbbc395934..efe4fd2f335 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -614,13 +614,13 @@ On success, `data` is [the session object](#the-session-object) of the new sessi #### `GET /api/v1/sessions` -Lists sessions across workspaces, newest `updated_at` first. Cursor pagination follows [Pagination](#pagination) and always applies: without `page_size` the response is the first page of `50` sessions, and `has_more` is computed on every response — keep paging with `before_id` until it is `false`. With `after_id` the response is the newest `page_size` sessions newer than the cursor, and `has_more` means more sessions exist between the cursor and that page (`before_id` and `after_id` are mutually exclusive, so the two directions cannot be combined in one request). +Lists sessions across workspaces, newest `updated_at` first. Cursor pagination follows [Pagination](#pagination): without `page_size` the response holds every matching session and `has_more` is `false` (`archived_only` listings default to pages of `20`); with `page_size` the response is one page and `has_more` is computed — keep paging with `before_id` until it is `false`. With `after_id` the response is the newest `page_size` sessions newer than the cursor, and `has_more` means more sessions exist between the cursor and that page (`before_id` and `after_id` are mutually exclusive, so the two directions cannot be combined in one request). | Parameter | In | Type | Description | | --- | --- | --- | --- | | `before_id` | query | string | Only sessions older than this id; mutually exclusive with `after_id` | | `after_id` | query | string | Only sessions newer than this id; mutually exclusive with `before_id`. The page holds the newest `page_size` matches above the cursor; `has_more` reports whether more exist between the cursor and the page | -| `page_size` | query | integer | 1–100. Default `50` | +| `page_size` | query | integer | 1–100. Omitted: the whole list (`20` per page for `archived_only`) | | `busy` | query | boolean | Keep only busy (or only idle) sessions. Applied while collecting, so pages are filled up to `page_size` and `has_more` is accurate | | `include_archive` | query | boolean | Include archived sessions alongside live ones. Default `false` | | `archived_only` | query | boolean | Keep only archived sessions; mutually exclusive with `include_archive` | diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 0d8deaee880..e88cd73e77b 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -145,7 +145,6 @@ kimi | `KIMI_CODE_WS_MAX_BATCH_SIZE` | 缓冲多少条订阅帧后立即发送(默认 `64`) | 正整数;非法值被忽略 | | `KIMI_CODE_WS_HIGH_WATER_MARK_BYTES` | socket `bufferedAmount` 超过该值时暂缓发送(默认 `1048576`) | 正整数;非法值被忽略 | | `KIMI_CODE_WS_MAX_BUFFER_SIZE` | 每个会话的事件回放窗口,同时作为 `server_hello.max_event_buffer_size` 下发(默认 `1000`) | 正整数;非法值被忽略 | -| `KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES` | 实验性:将远程控制的 HTTP 响应拆成 256 KiB 的中转帧发送(默认关闭,每个响应一帧) | `1`/`true`;其他值保持默认 | | `KIMI_CODE_TRANSCRIPT_OPS_BATCH_MS` | 将连续的流式文本追加合并为一个 `transcript.ops` 批次的时间窗口(毫秒),合并在分配序号之前完成(默认 `16`;`0` 表示每次追加立即转发) | 非负整数;非法值被忽略 | | `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS` | 同时运行的后台任务数上限,优先级高于 `config.toml` 的 `[background] max_running_tasks`;不设置表示无上限 | 正整数;非法值被忽略 | @@ -164,6 +163,7 @@ kimi | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills` | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen 界面:可滚动 transcript、鼠标选择、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent`/`AgentSwarm` 上启用实验性 `fork` 参数:以调用方对话历史快照而非空上下文启动 subagent | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | 实验性:将远程控制的 HTTP 响应拆成 256 KiB 的中转帧发送,而不是每个响应一帧;`KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_SEARCH_WORKER` | 在独立 worker 线程中运行全局搜索索引,优先级高于 `[database] search`(默认 `true`) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` | 会话索引使用基于 minidb 的读模型,优先级高于 `[database] base`(默认 `true`) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | MCP server 全局默认连接超时(毫秒);优先级高于配置文件,低于 `mcp.json` 的 `startupTimeoutMs` | `1` 到 `2147483647` 的整数;非法值被忽略 | diff --git a/docs/zh/guides/remote-control.md b/docs/zh/guides/remote-control.md index aaeec0cfaf3..a9fff4305b6 100644 --- a/docs/zh/guides/remote-control.md +++ b/docs/zh/guides/remote-control.md @@ -92,7 +92,7 @@ 中转对网页界面的缓存方式取决于文件是否被改写。字体、wasm 等二进制资源(`/assets/` 下带哈希的文件)保留原有的长期 `Cache-Control` 头,因此远程浏览器会跨会话缓存它们。HTML、JavaScript 和 CSS 会被改写到设备前缀之下,因此以 `Cache-Control: public, no-cache` 和带版本的 `ETag` 存储:浏览器每次加载都会重新校验,在包未变化时只是一次开销很小的 `304 Not Modified`,只有真正变化的文件才会重新下载。慢速网络下首次加载仍需完整传输一次。 -`KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES=1` 是一个实验性开关,会把较大的 HTTP 响应拆成 256 KiB 的中转帧,而不是一帧发送整个响应。默认关闭,仅用于排查慢速网络下的问题;除非被要求尝试,否则不要设置。 +实验性功能 `remote_control_chunked_responses` 会把较大的 HTTP 响应拆成 256 KiB 的中转帧,而不是一帧发送整个响应。它与其他实验性功能的启用方式相同(`KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1`、`[experimental]` 配置段或 `KIMI_CODE_EXPERIMENTAL_FLAG=1`)。默认关闭,仅用于排查慢速网络下的问题;除非被要求尝试,否则不要开启。 ## 远程控制和 Kimi Code 网页版有什么区别? diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 7add9f55e3a..0f4421e2c79 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -614,13 +614,13 @@ curl -s -H "Authorization: Bearer $TOKEN" \ #### `GET /api/v1/sessions` -跨工作区列出会话,按 `updated_at` 最新在前。游标分页遵循 [分页](#分页) 且始终生效:不提供 `page_size` 时,响应是前 `50` 条会话的第一页,并且每次响应都会计算 `has_more`——请用 `before_id` 持续翻页,直到其为 `false`。使用 `after_id` 时,响应是晚于该游标的最新 `page_size` 条会话,`has_more` 表示游标与这一页之间还存在更多会话(`before_id` 与 `after_id` 互斥,同一请求不能同时向两个方向翻页)。 +跨工作区列出会话,按 `updated_at` 最新在前。游标分页遵循 [分页](#分页):不提供 `page_size` 时,响应包含全部匹配会话且 `has_more` 为 `false`(`archived_only` 列表默认每页 `20` 条);提供 `page_size` 时响应为一页并计算 `has_more`——请用 `before_id` 持续翻页,直到其为 `false`。使用 `after_id` 时,响应是晚于该游标的最新 `page_size` 条会话,`has_more` 表示游标与这一页之间还存在更多会话(`before_id` 与 `after_id` 互斥,同一请求不能同时向两个方向翻页)。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | | `before_id` | query | string | 只保留早于该 id 的会话;与 `after_id` 互斥 | | `after_id` | query | string | 只保留晚于该 id 的会话;与 `before_id` 互斥。该页为晚于游标的最新 `page_size` 条匹配项;`has_more` 表示游标与这一页之间是否还有更多会话 | -| `page_size` | query | integer | 1–100。默认 `50` | +| `page_size` | query | integer | 1–100。省略时返回整个列表(`archived_only` 默认每页 `20` 条) | | `busy` | query | boolean | 只保留忙碌(或只保留空闲)的会话。过滤在收集阶段应用,因此每页会填满到 `page_size` 条,且 `has_more` 准确 | | `include_archive` | query | boolean | 在活跃会话之外同时包含已归档会话。默认 `false` | | `archived_only` | query | boolean | 只保留已归档会话;与 `include_archive` 互斥 | diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index b4532e20e9f..c2e7aeff997 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -96,7 +96,7 @@ const booleanQueryParam = z.preprocess((value) => { return value; }, z.boolean().optional()); -const DEFAULT_SESSION_LIST_PAGE_SIZE = 50; +const DEFAULT_ARCHIVED_LIST_PAGE_SIZE = 20; const sessionsListQueryCoercion = z .object({ @@ -278,7 +278,7 @@ export function registerSessionsRoutes( [ErrorCode.WORKSPACE_NOT_FOUND]: {}, }, description: - 'List sessions, newest updated_at first. Filters (busy, archived_only, exclude_empty) are applied while collecting, so pages are filled up to page_size. With before_id the page is the newest page_size sessions older than the cursor; with after_id it is the newest page_size sessions newer than the cursor, and has_more means more sessions exist between the cursor and the page.', + 'List sessions, newest updated_at first. Without page_size the response holds every eligible session and has_more is false (archived_only defaults to pages of 20). Filters (busy, archived_only, exclude_empty) are applied while collecting, so pages are filled up to page_size. With before_id the page is the newest page_size sessions older than the cursor; with after_id it is the newest page_size sessions newer than the cursor, and has_more means more sessions exist between the cursor and the page.', tags: ['sessions'], }, async (req, reply) => { @@ -312,8 +312,10 @@ export function registerSessionsRoutes( readonly facts: SessionFacts; } - const collect = async (pageSize: number): Promise<{ visible: Eligible[]; hasMore: boolean }> => { - const wanted = pageSize + 1; + const collect = async ( + pageSize: number | undefined, + ): Promise<{ visible: Eligible[]; hasMore: boolean }> => { + const wanted = pageSize === undefined ? undefined : pageSize + 1; const collected: Eligible[] = []; let before = raw.before_id; const after = raw.after_id; @@ -322,11 +324,11 @@ export function registerSessionsRoutes( afterCursor === undefined || summary.updatedAt > afterCursor.updatedAt || (summary.updatedAt === afterCursor.updatedAt && summary.id > afterCursor.id); - while (collected.length < wanted) { + while (wanted === undefined || collected.length < wanted) { const page = await index.listRecent({ workspaceIds, includeArchived, - limit: wanted - collected.length, + limit: wanted === undefined ? undefined : wanted - collected.length, before, after: before === undefined ? after : undefined, }); @@ -348,10 +350,12 @@ export function registerSessionsRoutes( if (exhausted || page.nextCursor === undefined) break; before = page.nextCursor; } + if (pageSize === undefined) return { visible: collected, hasMore: false }; return { visible: collected.slice(0, pageSize), hasMore: collected.length > pageSize }; }; - const pageSize = raw.page_size ?? DEFAULT_SESSION_LIST_PAGE_SIZE; + const pageSize = + raw.page_size ?? (archivedOnly ? DEFAULT_ARCHIVED_LIST_PAGE_SIZE : undefined); const { visible, hasMore } = await collect(pageSize); const items = visible.map(({ summary, cwd, facts }) => toWireSession(summary, cwd, facts)); reply.send(okEnvelope({ items, has_more: hasMore }, req.id)); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 0cdabae1d0a..c35a9b7ad86 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -576,7 +576,7 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.data.has_more).toBe(false); }); - it('pages with a computed has_more when page_size is omitted', async () => { + it('returns every session with has_more false when page_size is omitted', async () => { await restartWithFreshHome(); const cwd = home as string; const ids: string[] = []; @@ -592,7 +592,7 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.data.has_more).toBe(false); }); - it('caps an unsized listing at the default page size of 50 and pages the rest', async () => { + it('keeps an unsized listing unbounded and pages only when page_size is given', async () => { await restartWithFreshHome(); const cwd = home as string; const ids: string[] = []; @@ -602,14 +602,19 @@ describe('server-v2 /api/v1/sessions', () => { ids.push(body.data.id); } - const first = await getJson('/api/v1/sessions'); + const all = await getJson('/api/v1/sessions'); + expect(all.body.code).toBe(0); + expect(all.body.data.items).toHaveLength(51); + expect(all.body.data.has_more).toBe(false); + + const first = await getJson('/api/v1/sessions?page_size=50'); expect(first.body.code).toBe(0); expect(first.body.data.items).toHaveLength(50); expect(first.body.data.has_more).toBe(true); const cursor = first.body.data.items.at(-1)!.id; const rest = await getJson( - `/api/v1/sessions?before_id=${encodeURIComponent(cursor)}`, + `/api/v1/sessions?before_id=${encodeURIComponent(cursor)}&page_size=50`, ); expect(rest.body.code).toBe(0); expect(rest.body.data.items).toHaveLength(1); diff --git a/packages/remote-control/src/flag.ts b/packages/remote-control/src/flag.ts new file mode 100644 index 00000000000..3eee43206a8 --- /dev/null +++ b/packages/remote-control/src/flag.ts @@ -0,0 +1,22 @@ +import { + type FlagDefinitionInput, + registerFlagDefinition, +} from '@moonshot-ai/agent-core-v2/app/flag/flagRegistry'; + +export const REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID = 'remote_control_chunked_responses'; +export const REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ENV = + 'KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES'; + +// The relay's handling of multi-frame responses (is_last: false) is unverified, so the +// split stays off until it has been exercised end to end. +export const remoteControlChunkedResponsesFlag: FlagDefinitionInput = { + id: REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID, + title: 'Chunked Remote Control responses', + description: + 'Split large Remote Control HTTP responses into 256 KiB tunnel frames instead of sending one frame per response.', + env: REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(remoteControlChunkedResponsesFlag); diff --git a/packages/remote-control/src/index.ts b/packages/remote-control/src/index.ts index c7745987b2b..276022e15bb 100644 --- a/packages/remote-control/src/index.ts +++ b/packages/remote-control/src/index.ts @@ -1,3 +1,4 @@ +export * from './flag'; export * from './remote-control'; export * from './lock'; export * from './manager'; diff --git a/packages/remote-control/src/remote-control.ts b/packages/remote-control/src/remote-control.ts index f7f5b35d236..d831cd3e38a 100644 --- a/packages/remote-control/src/remote-control.ts +++ b/packages/remote-control/src/remote-control.ts @@ -35,8 +35,6 @@ const BRIDGE_HIGH_WATER_MARK_BYTES = 1024 * 1024; const BRIDGE_LOW_WATER_MARK_BYTES = 256 * 1024; const BRIDGE_DRAIN_POLL_MS = 20; const RESPONSE_CHUNK_BYTES = 256 * 1024; -// Experimental: the relay's handling of multi-frame responses (is_last: false) is unverified. -export const REMOTE_CONTROL_CHUNKED_RESPONSES_ENV = 'KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES'; const RELAY_PING_INTERVAL_MS = 30_000; const RELAY_SILENCE_TIMEOUT_MS = 300_000; // Bump whenever the `rewriteRemoteControlResponse` rules change. Rewritten bodies are stored by @@ -135,6 +133,12 @@ export interface RemoteControlOptions { readonly onStatus?: (status: RemoteControlStatus) => void; readonly pingIntervalMs?: number; readonly silenceTimeoutMs?: number; + /** + * Split HTTP responses into 256 KiB tunnel frames. Resolve it from the + * `remote_control_chunked_responses` experimental flag (see `flag.ts`); + * off by default. + */ + readonly chunkedResponses?: boolean; } export interface RemoteControlHandle { @@ -389,8 +393,7 @@ class RemoteControlClient { this.onStatus = options.onStatus ?? (() => {}); this.pingIntervalMs = options.pingIntervalMs ?? RELAY_PING_INTERVAL_MS; this.silenceTimeoutMs = options.silenceTimeoutMs ?? RELAY_SILENCE_TIMEOUT_MS; - const chunked = process.env[REMOTE_CONTROL_CHUNKED_RESPONSES_ENV]?.trim().toLowerCase(); - this.chunkedResponses = chunked === '1' || chunked === 'true'; + this.chunkedResponses = options.chunkedResponses ?? false; } async start(): Promise { diff --git a/packages/remote-control/test/remote-control.test.ts b/packages/remote-control/test/remote-control.test.ts index 318170d7150..ab834dc111b 100644 --- a/packages/remote-control/test/remote-control.test.ts +++ b/packages/remote-control/test/remote-control.test.ts @@ -32,6 +32,7 @@ import { type EarlyFrameBuffer, type RemoteControlHandle, } from '../src/remote-control'; +import { remoteControlChunkedResponsesFlag } from '../src/flag'; import { remoteControlLockPath } from '../src/lock'; const CLIENT_VERSION = 'kimi-code/test'; @@ -787,7 +788,9 @@ describe('Remote Control stream bridge', () => { }); describe('Remote Control chunked responses', () => { - async function tunnelLargeResponse(): Promise>> { + async function tunnelLargeResponse( + options: { chunkedResponses?: boolean } = {}, + ): Promise>> { const body = Buffer.alloc(600 * 1024); for (let index = 0; index < body.length; index += 1) body[index] = index % 251; const localServer = createServer((_request, response) => { @@ -807,6 +810,7 @@ describe('Remote Control chunked responses', () => { clientVersion: CLIENT_VERSION, relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, stderr: { write: () => true }, + ...options, }); const http = relay.httpSockets[0]!; const frames: Array> = []; @@ -835,9 +839,16 @@ describe('Remote Control chunked responses', () => { return frames; } - it('splits responses into 256 KiB frames when the flag is set', async () => { - vi.stubEnv('KIMI_CODE_REMOTE_CONTROL_CHUNKED_RESPONSES', '1'); - const frames = await tunnelLargeResponse(); + it('registers chunked responses as an experimental flag that defaults off', () => { + expect(remoteControlChunkedResponsesFlag).toMatchObject({ + id: 'remote_control_chunked_responses', + env: 'KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES', + default: false, + }); + }); + + it('splits responses into 256 KiB frames when chunked responses are enabled', async () => { + const frames = await tunnelLargeResponse({ chunkedResponses: true }); expect(frames.map((frame) => frame['is_last'])).toEqual([false, false, true]); expect(frames.every((frame) => frame['request_id'] === 'large' && frame['type'] === 'response')).toBe( true, From ee738db7fdd7997c84f51c58ca062c1480a67839 Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:06:14 +0300 Subject: [PATCH 04/10] fix(kap-server): honor Accept-Encoding q-weights and expose experimental flags to the CLI Pre-compressed asset selection now picks the accepted encoding with the highest q value, falling back to the built-in br-before-gzip order only on ties. RunningServer gains a `flags` handle so the CLI reads the remote-control chunked-responses flag through kap-server instead of importing the engine's IFlagService directly. --- apps/kimi-code/src/cli/sub/web/run.ts | 12 ++++++++---- packages/kap-server/src/index.ts | 2 +- packages/kap-server/src/routes/webAssets.ts | 20 +++++++++----------- packages/kap-server/src/start.ts | 17 ++++++++++++++++- packages/kap-server/test/webAssets.test.ts | 11 +++++++++++ 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index f694da4d101..fc72e3013ad 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -11,8 +11,12 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { IFlagService } from '@moonshot-ai/agent-core-v2'; -import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server'; +import { + createServerLogger, + startServer, + type ExperimentalFlags, + type ServerLogger, +} from '@moonshot-ai/kap-server'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; import chalk from 'chalk'; import { type Command, Option } from 'commander'; @@ -79,7 +83,7 @@ export interface WebCliOptions extends ServerCliOptions { /** What the ready hook may ask of the listening server. */ export interface ForegroundServer { - readonly flags: Pick; + readonly flags: ExperimentalFlags; } export interface StartForegroundHooks { @@ -408,7 +412,7 @@ async function runServerInProcess( running.logger.info({ address: running.address }, 'server ready'); try { - await hooks.onReady?.(running.address, { flags: v2.core.accessor.get(IFlagService) }); + await hooks.onReady?.(running.address, { flags: v2.flags }); } catch (error) { try { await hooks.onShutdown?.('startup_failed'); diff --git a/packages/kap-server/src/index.ts b/packages/kap-server/src/index.ts index 5026680cffe..a441f23b16c 100644 --- a/packages/kap-server/src/index.ts +++ b/packages/kap-server/src/index.ts @@ -1,5 +1,5 @@ export { startServer } from './start'; -export type { ServerHostIdentity, ServerStartOptions, RunningServer } from './start'; +export type { ExperimentalFlags, ServerHostIdentity, ServerStartOptions, RunningServer } from './start'; export { okEnvelope, errEnvelope } from './envelope'; export type { Envelope } from './envelope'; export { classify } from './security/bindClassify'; diff --git a/packages/kap-server/src/routes/webAssets.ts b/packages/kap-server/src/routes/webAssets.ts index b8c00bf8a5d..4e139efda66 100644 --- a/packages/kap-server/src/routes/webAssets.ts +++ b/packages/kap-server/src/routes/webAssets.ts @@ -113,10 +113,13 @@ async function findEncodedVariant( return undefined; } const accepted = parseAcceptEncoding(acceptEncoding); - for (const candidate of PRECOMPRESSED_ENCODINGS) { - if (!isEncodingAccepted(accepted, candidate.encoding)) { - continue; - } + const candidates = PRECOMPRESSED_ENCODINGS.map((candidate) => ({ + candidate, + weight: encodingWeight(accepted, candidate.encoding), + })) + .filter(({ weight }) => weight > 0) + .toSorted((a, b) => b.weight - a.weight); + for (const { candidate } of candidates) { const path = `${file.path}${candidate.extension}`; const stats = await stat(path).catch(() => undefined); if (stats?.isFile() === true && stats.mtimeMs >= file.stats.mtimeMs) { @@ -141,13 +144,8 @@ function parseAcceptEncoding(header: string): Map { return weights; } -function isEncodingAccepted(weights: Map, encoding: string): boolean { - const explicit = weights.get(encoding); - if (explicit !== undefined) { - return explicit > 0; - } - const wildcard = weights.get('*'); - return wildcard !== undefined && wildcard > 0; +function encodingWeight(weights: Map, encoding: string): number { + return weights.get(encoding) ?? weights.get('*') ?? 0; } function weakEtag(stats: Stats, suffix: string): string { diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 755d4eee630..5a252489182 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -9,6 +9,7 @@ import { IAppendLogStore, IConfigService, IEventService, + IFlagService, IMcpOAuthService, IOAuthService, IProviderDiscoveryService, @@ -127,9 +128,14 @@ export interface ServerStartOptions { readonly telemetry?: boolean; } +export interface ExperimentalFlags { + enabled(id: string): boolean; +} + export interface RunningServer { readonly app: FastifyInstance; readonly core: Scope; + readonly flags: ExperimentalFlags; readonly connectionRegistry: IConnectionRegistry; readonly authTokenService: IAuthTokenService; readonly host: string; @@ -620,7 +626,16 @@ export async function startServer(opts: ServerStartOptions): Promise { expect(response.headers['content-encoding']).toBe('gzip'); }); + it('prefers the encoding with the higher q value over the built-in order', async () => { + const response = await app.inject({ + method: 'GET', + url: HASHED_JS, + headers: { 'accept-encoding': 'gzip;q=1, br;q=0.1' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-encoding']).toBe('gzip'); + }); + it('serves identity with Vary when no Accept-Encoding header is sent', async () => { const response = await app.inject({ method: 'GET', url: HASHED_JS }); From 83d82bd2a8a4e97d75d678a2db7581e71c3c0280 Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:16:15 +0300 Subject: [PATCH 05/10] fix(remote-control): honor the chunked-responses flag for manager-created tunnels Tunnels started through POST /api/v1/remote-control go through createRemoteControlManager, which never passed chunkedResponses. The manager now takes a chunkedResponses thunk resolved at each tunnel start, and kap-server wires it to the remote_control_chunked_responses flag via IFlagService (the manager is created after the engine core bootstraps). --- packages/kap-server/src/start.ts | 31 +++++++++------- packages/remote-control/src/manager.ts | 6 ++++ .../test/remote-control.test.ts | 35 +++++++++++++++---- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 7ec020c07e3..410480a8e59 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -87,7 +87,10 @@ import { ProjectionService } from './services/projection'; import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler'; import { startConfigChangedPublisher } from './services/config/configChangedPublisher'; import { createAuthFailureLimiter } from './middleware/rateLimit'; -import { createRemoteControlManager } from '@moonshot-ai/remote-control'; +import { + createRemoteControlManager, + REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID, +} from '@moonshot-ai/remote-control'; import { createAuthTokenService, type IAuthTokenService } from './services/auth/authTokenService'; import { createCredentialValidator } from './services/auth/credentials'; @@ -205,18 +208,6 @@ export async function startServer(opts: ServerStartOptions): Promise `http://${localOriginHost}:${boundPort}`, - localServerToken: () => authTokenService.getToken(), - clientVersion: `kimi-code/${serverVersion}`, - stderr: { - write: (text) => { - logger.warn(String(text).trimEnd()); - return true; - }, - }, - }); const { app: core } = bootstrap( { homeDir, @@ -232,6 +223,20 @@ export async function startServer(opts: ServerStartOptions): Promise `http://${localOriginHost}:${boundPort}`, + localServerToken: () => authTokenService.getToken(), + clientVersion: `kimi-code/${serverVersion}`, + chunkedResponses: () => + core.accessor.get(IFlagService).enabled(REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID), + stderr: { + write: (text) => { + logger.warn(String(text).trimEnd()); + return true; + }, + }, + }); let telemetry: ServerTelemetry = {}; if (opts.telemetry === true) { diff --git a/packages/remote-control/src/manager.ts b/packages/remote-control/src/manager.ts index d9b02dfc6d7..b22ef2bf887 100644 --- a/packages/remote-control/src/manager.ts +++ b/packages/remote-control/src/manager.ts @@ -27,6 +27,11 @@ export interface RemoteControlManagerOptions { readonly clientVersion: string; readonly relayOrigin?: string; readonly stderr?: Pick; + /** + * Resolved each time a tunnel starts, so a flag flipped while Remote + * Control is off applies to the next tunnel (see `flag.ts`). + */ + readonly chunkedResponses?: () => boolean; } interface RemoteControlMachineContext { @@ -54,6 +59,7 @@ function createRemoteControlMachine( clientVersion: options.clientVersion, relayOrigin: options.relayOrigin, stderr: options.stderr, + chunkedResponses: options.chunkedResponses?.(), }); onTunnelStarted(handle); return handle; diff --git a/packages/remote-control/test/remote-control.test.ts b/packages/remote-control/test/remote-control.test.ts index ab834dc111b..adb6e947f18 100644 --- a/packages/remote-control/test/remote-control.test.ts +++ b/packages/remote-control/test/remote-control.test.ts @@ -33,6 +33,7 @@ import { type RemoteControlHandle, } from '../src/remote-control'; import { remoteControlChunkedResponsesFlag } from '../src/flag'; +import { createRemoteControlManager } from '../src/manager'; import { remoteControlLockPath } from '../src/lock'; const CLIENT_VERSION = 'kimi-code/test'; @@ -789,8 +790,9 @@ describe('Remote Control stream bridge', () => { describe('Remote Control chunked responses', () => { async function tunnelLargeResponse( - options: { chunkedResponses?: boolean } = {}, + options: { chunkedResponses?: boolean; viaManager?: boolean } = {}, ): Promise>> { + const { viaManager, ...tunnelFlags } = options; const body = Buffer.alloc(600 * 1024); for (let index = 0; index < body.length; index += 1) body[index] = index % 251; const localServer = createServer((_request, response) => { @@ -801,17 +803,31 @@ describe('Remote Control chunked responses', () => { cleanups.push(() => closeServer(localServer)); const homeDir = await createRemoteControlHome(TOKEN.refreshToken); const relay = await startAuthRelay(); - let handle: RemoteControlHandle | undefined; - cleanups.push(async () => handle?.close()); - handle = await startRemoteControl({ + const tunnelOptions = { homeDir, localOrigin: `http://127.0.0.1:${localPort}`, - localServerToken: 'local-server-token', clientVersion: CLIENT_VERSION, relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, stderr: { write: () => true }, - ...options, - }); + }; + if (viaManager === true) { + const manager = createRemoteControlManager({ + ...tunnelOptions, + localOrigin: () => tunnelOptions.localOrigin, + localServerToken: () => 'local-server-token', + chunkedResponses: () => tunnelFlags.chunkedResponses ?? false, + }); + cleanups.push(() => manager.close()); + await manager.enable(); + } else { + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + handle = await startRemoteControl({ + ...tunnelOptions, + localServerToken: 'local-server-token', + ...tunnelFlags, + }); + } const http = relay.httpSockets[0]!; const frames: Array> = []; const done = new Promise((resolve) => { @@ -858,6 +874,11 @@ describe('Remote Control chunked responses', () => { expect(lengths[1]).toBe(256 * 1024); }); + it('splits responses for manager-created tunnels when the flag resolves true', async () => { + const frames = await tunnelLargeResponse({ chunkedResponses: true, viaManager: true }); + expect(frames.map((frame) => frame['is_last'])).toEqual([false, false, true]); + }); + it('sends a single frame by default', async () => { const frames = await tunnelLargeResponse(); expect(frames.map((frame) => frame['is_last'])).toEqual([true]); From 0d31b4b988100c3880d57def366ede1b0c9e7cd5 Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:02:13 +0300 Subject: [PATCH 06/10] fix(web): address review findings on the web host performance changes - ws v1: control frames no longer force-flush the deferred backlog above the high-water mark, and the slow-consumer clock resets while the peer drains - transcript ops catch-up: a capped response reports latest_seq as the last returned batch so cursors written against the old contract stay correct - transcript service: pending appends are flushed, not discarded, when a session is dropped or purged - kimi-inspect: seed from one unsized request, drain with before_id only when has_more, and keep the pages already collected when a later page fails - remote-control: the chunked-responses flag is excluded from the KIMI_CODE_EXPERIMENTAL_FLAG master switch - precompress: write siblings atomically, and --check honours the size threshold and sibling freshness - webAssets: stat precompressed siblings in parallel, reuse pickHeader and buildEtag, drop the redundant .riv case - reuse the shared env parsers for KIMI_CODE_WS_* and the ops batch window, simplify flushPendingOps, drop the unused --only flag and skip list, and share a header lookup in remote-control --- apps/kimi-code/.gitignore | 1 + .../scripts/precompress-web-assets.mjs | 88 ++++++++----------- .../scripts/precompress-web-assets.test.ts | 33 ++++--- apps/kimi-inspect/src/activity/store.test.ts | 36 +++++++- apps/kimi-inspect/src/activity/store.ts | 28 ++++-- docs/en/configuration/env-vars.md | 4 +- docs/en/guides/remote-control.md | 2 +- docs/en/reference/server-api.md | 2 +- docs/zh/configuration/env-vars.md | 4 +- docs/zh/guides/remote-control.md | 2 +- docs/zh/reference/server-api.md | 2 +- packages/agent-core-v2/src/_base/utils/env.ts | 12 +++ .../src/app/flag/flagRegistry.ts | 1 + .../agent-core-v2/src/app/flag/flagService.ts | 5 +- .../test/_base/utils/env.test.ts | 20 ++++- .../agent-core-v2/test/app/flag/flag.test.ts | 14 +++ packages/kap-server/src/routes/transcript.ts | 2 +- packages/kap-server/src/routes/webAssets.ts | 36 ++++---- .../services/transcript/transcriptService.ts | 43 +++------ packages/kap-server/src/start.ts | 8 +- .../src/transport/ws/v1/registerWsV1.ts | 28 ++---- .../ws/v1/sessionEventBroadcaster.ts | 1 + .../src/transport/ws/v1/wsConnectionV1.ts | 27 +++--- .../test/services/transcript.test.ts | 11 --- packages/kap-server/test/transcript.test.ts | 2 +- packages/kap-server/test/webAssets.test.ts | 2 +- .../kap-server/test/wsConnectionV1.test.ts | 54 +++++++++++- packages/remote-control/src/flag.ts | 4 +- packages/remote-control/src/remote-control.ts | 36 ++++---- 29 files changed, 312 insertions(+), 196 deletions(-) diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index ef59bfb0d21..9819bf6e327 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -13,3 +13,4 @@ src/generated/vis-web-asset.ts # Precompressed siblings generated at build time by scripts/precompress-web-assets.mjs dist-web/**/*.br dist-web/**/*.gz +dist-web/**/*.tmp diff --git a/apps/kimi-code/scripts/precompress-web-assets.mjs b/apps/kimi-code/scripts/precompress-web-assets.mjs index b1672481d21..0b4c3f9489f 100644 --- a/apps/kimi-code/scripts/precompress-web-assets.mjs +++ b/apps/kimi-code/scripts/precompress-web-assets.mjs @@ -12,11 +12,12 @@ // (`index.html`, `boot.js`) are tiny and always regenerated: their name does not // change with their content, so mtimes are not a trustworthy signal for them. // -// Usage: node scripts/precompress-web-assets.mjs [--check] [--force] [--only=br|gz] +// Usage: node scripts/precompress-web-assets.mjs [--check] [--force] -import { readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; import { basename, dirname, extname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; import { brotliCompressSync, constants, gzipSync } from 'node:zlib'; const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -35,17 +36,6 @@ const COMPRESSIBLE_EXTENSIONS = new Set([ '.wasm', '.txt', ]); -const SKIPPED_EXTENSIONS = new Set([ - '.woff2', - '.woff', - '.ttf', - '.ico', - '.riv', - '.png', - '.jpg', - '.jpeg', - '.webp', -]); const MIN_SOURCE_BYTES = 1024; // A sibling only earns its place when it shaves at least this fraction off. const MIN_SAVINGS_RATIO = 0.1; @@ -76,7 +66,7 @@ const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { try { - const options = parseArgs(process.argv.slice(2)); + const options = parseCliArgs(process.argv.slice(2)); const summary = await precompressWebAssets({ distDir: DEFAULT_DIST_DIR, ...options }); console.log( options.check @@ -90,17 +80,17 @@ if (isMain) { } /** - * @param {{ distDir: string, check?: boolean, force?: boolean, only?: 'br' | 'gz' }} options + * @param {{ distDir: string, check?: boolean, force?: boolean }} options * @returns {Promise<{ processed: number, written: number, skipped: number, removed: number, bytesBefore: number, bytesAfter: number }>} */ -export async function precompressWebAssets({ distDir, check = false, force = false, only }) { +export async function precompressWebAssets({ distDir, check = false, force = false }) { const files = await listFiles(distDir); if (check) { - assertEntryAssetsPrecompressed(distDir, files); + await assertEntryAssetsPrecompressed(distDir, files); return { processed: 0, written: 0, skipped: 0, removed: 0, bytesBefore: 0, bytesAfter: 0 }; } - const formats = only === undefined ? Object.values(FORMATS) : [FORMATS[only]]; + const formats = Object.values(FORMATS); const summary = { processed: 0, written: 0, skipped: 0, removed: 0, bytesBefore: 0, bytesAfter: 0 }; const present = new Set(files); @@ -148,20 +138,37 @@ async function emitSiblings(file, sourceStats, formats, force, summary) { } continue; } - await writeFile(siblingPath, compressed); + await writeSiblingAtomically(siblingPath, compressed); summary.written++; smallest = Math.min(smallest, compressed.length); } return smallest; } -function assertEntryAssetsPrecompressed(distDir, files) { - const present = new Set(files); - const missing = files +// A sibling is written to a temp name and renamed into place so an interrupted +// build never leaves a truncated `.br`/`.gz` that the server would trust. +async function writeSiblingAtomically(siblingPath, data) { + const tempPath = `${siblingPath}.${process.pid}.tmp`; + await writeFile(tempPath, data); + await rename(tempPath, siblingPath); +} + +// Mirrors the runtime rule in kap-server's webAssets route: a sibling only +// counts when it exists and is at least as new as its source. Entry files the +// writer would skip (under MIN_SOURCE_BYTES) are not required to have one. +async function assertEntryAssetsPrecompressed(distDir, files) { + const entryFiles = files .filter((file) => dirname(file) === join(distDir, 'assets')) - .filter((file) => ENTRY_ASSET_PATTERN.test(relative(join(distDir, 'assets'), file))) - .filter((file) => !present.has(`${file}${FORMATS.br.extension}`)) - .map((file) => relative(distDir, file)); + .filter((file) => ENTRY_ASSET_PATTERN.test(relative(join(distDir, 'assets'), file))); + const missing = []; + for (const file of entryFiles) { + const sourceStats = await stat(file); + if (sourceStats.size < MIN_SOURCE_BYTES) continue; + const sibling = await stat(`${file}${FORMATS.br.extension}`).catch(() => undefined); + if (sibling === undefined || sibling.mtimeMs < sourceStats.mtimeMs) { + missing.push(relative(distDir, file)); + } + } if (missing.length > 0) { throw new Error( `Precompressed web assets are missing a .br sibling for: ${missing.join(', ')}. ` + @@ -171,8 +178,7 @@ function assertEntryAssetsPrecompressed(distDir, files) { } function isCompressible(file) { - const extension = extname(file).toLowerCase(); - return COMPRESSIBLE_EXTENSIONS.has(extension) && !SKIPPED_EXTENSIONS.has(extension); + return COMPRESSIBLE_EXTENSIONS.has(extname(file).toLowerCase()); } async function listFiles(dir) { @@ -199,26 +205,10 @@ function formatBytes(bytes) { : `${(bytes / 1024).toFixed(0)} KB`; } -function parseArgs(args) { - const options = {}; - for (const arg of args) { - if (arg === '--check') { - options.check = true; - continue; - } - if (arg === '--force') { - options.force = true; - continue; - } - if (arg.startsWith('--only=')) { - const only = arg.slice('--only='.length); - if (!(only in FORMATS)) { - throw new Error(`--only expects one of ${Object.keys(FORMATS).join('|')}, got "${only}".`); - } - options.only = only; - continue; - } - throw new Error(`Unknown argument: ${arg}`); - } - return options; +function parseCliArgs(args) { + return parseArgs({ + args, + options: { check: { type: 'boolean' }, force: { type: 'boolean' } }, + strict: true, + }).values; } diff --git a/apps/kimi-code/test/scripts/precompress-web-assets.test.ts b/apps/kimi-code/test/scripts/precompress-web-assets.test.ts index 5b79a6109e8..a87c8c19f80 100644 --- a/apps/kimi-code/test/scripts/precompress-web-assets.test.ts +++ b/apps/kimi-code/test/scripts/precompress-web-assets.test.ts @@ -156,28 +156,41 @@ describe('precompressWebAssets', () => { await expect(exists(`${js}.br`)).resolves.toBe(false); }); - it('passes the check once entry bundles have brotli siblings', async () => { + it('does not require a sibling for an entry asset too small to compress', async () => { const distDir = await makeDist(); - const js = join(distDir, 'assets', 'index-Dy7xs5tu.js'); const css = join(distDir, 'assets', 'index-Ab12Cd34.css'); - await writeFile(js, LARGE_TEXT); - await writeFile(css, `.kimi{color:red}\n`.repeat(100)); - await precompressWebAssets({ distDir }); + await writeFile(css, '.kimi{color:red}\n'); + await precompressWebAssets({ distDir }); + await expect(exists(`${css}.br`)).resolves.toBe(false); await expect(precompressWebAssets({ distDir, check: true })).resolves.toMatchObject({ written: 0, }); }); - it('emits only brotli siblings with only=br', async () => { + it('fails the check when an entry sibling is older than its source', async () => { const distDir = await makeDist(); const js = join(distDir, 'assets', 'index-Dy7xs5tu.js'); await writeFile(js, LARGE_TEXT); + await precompressWebAssets({ distDir }); + const stale = new Date(Date.now() - 60_000); + await utimes(`${js}.br`, stale, stale); + + await expect(precompressWebAssets({ distDir, check: true })).rejects.toThrow( + /index-Dy7xs5tu\.js/, + ); + }); - const summary = await precompressWebAssets({ distDir, only: 'br' }); + it('passes the check once entry bundles have brotli siblings', async () => { + const distDir = await makeDist(); + const js = join(distDir, 'assets', 'index-Dy7xs5tu.js'); + const css = join(distDir, 'assets', 'index-Ab12Cd34.css'); + await writeFile(js, LARGE_TEXT); + await writeFile(css, `.kimi{color:red}\n`.repeat(100)); + await precompressWebAssets({ distDir }); - expect(summary.written).toBe(1); - await expect(exists(`${js}.br`)).resolves.toBe(true); - await expect(exists(`${js}.gz`)).resolves.toBe(false); + await expect(precompressWebAssets({ distDir, check: true })).resolves.toMatchObject({ + written: 0, + }); }); }); diff --git a/apps/kimi-inspect/src/activity/store.test.ts b/apps/kimi-inspect/src/activity/store.test.ts index bed7aa36574..e4ce79e0993 100644 --- a/apps/kimi-inspect/src/activity/store.test.ts +++ b/apps/kimi-inspect/src/activity/store.test.ts @@ -122,7 +122,7 @@ describe('SessionActivityHub', () => { hub.close(); }); - it('drains every session page with page_size=100 and before_id while has_more is true', async () => { + it('seeds from an unsized request and drains with before_id only while has_more is true', async () => { const { ctor, instances } = makeFakeWsCtor(); const urls: string[] = []; const fetchImpl = vi.fn(async (input: string) => { @@ -157,7 +157,7 @@ describe('SessionActivityHub', () => { expect(urls).toHaveLength(2); const firstUrl = new URL(urls[0]!); expect(firstUrl.pathname).toBe('/api/v1/sessions'); - expect(firstUrl.searchParams.get('page_size')).toBe('100'); + expect(firstUrl.searchParams.get('page_size')).toBeNull(); expect(firstUrl.searchParams.get('before_id')).toBeNull(); const secondUrl = new URL(urls[1]!); expect(secondUrl.searchParams.get('page_size')).toBe('100'); @@ -167,6 +167,38 @@ describe('SessionActivityHub', () => { hub.close(); }); + it('seeds the pages already collected when a later page fails', async () => { + const { ctor, instances } = makeFakeWsCtor(); + const fetchImpl = vi.fn(async (input: string) => { + const before = new URL(input).searchParams.get('before_id'); + if (before === null) { + return { + json: async () => ({ + code: 0, + data: { + items: [{ id: 's1', busy: true, main_turn_active: true, pending_interaction: 'none' }], + has_more: true, + }, + }), + }; + } + return { json: async () => ({ code: 50000, message: 'boom' }) }; + }) as unknown as typeof fetch; + const hub = new SessionActivityHub({ + url: 'http://127.0.0.1:58627', + onListChanged: () => {}, + WebSocketImpl: ctor, + fetchImpl, + }); + + instances[0]!.emit('open'); + await vi.waitFor(() => { + expect(hub.store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true })); + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + hub.close(); + }); + it('applies live work_changed frames by session id', () => { const { ctor, instances } = makeFakeWsCtor(); const hub = new SessionActivityHub({ diff --git a/apps/kimi-inspect/src/activity/store.ts b/apps/kimi-inspect/src/activity/store.ts index 86523c4d56b..d0a2caed12d 100644 --- a/apps/kimi-inspect/src/activity/store.ts +++ b/apps/kimi-inspect/src/activity/store.ts @@ -129,21 +129,31 @@ export class SessionActivityHub { headers['authorization'] = `Bearer ${this.token}`; } try { - // The list is always paged (default 50, max 100): drain it with the - // before_id cursor so every live session gets a baseline badge. + // An unsized request returns every session in one response today; the + // before_id drain only kicks in should the server ever report has_more. + // A page that fails after the first one still seeds what was collected. const items: Record[] = []; let before: string | undefined; for (;;) { - const params = new URLSearchParams({ page_size: String(SEED_PAGE_SIZE) }); - if (before !== undefined) params.set('before_id', before); - const res = await this.fetchImpl(`${this.baseUrl}/api/v1/sessions?${params.toString()}`, { - headers, - }); - const envelope = (await res.json()) as { + const query = + before === undefined + ? '' + : `?${new URLSearchParams({ page_size: String(SEED_PAGE_SIZE), before_id: before })}`; + let envelope: { code: number; data?: { items?: Record[]; has_more?: boolean }; }; - if (envelope.code !== 0 || envelope.data?.items === undefined) return; + try { + const res = await this.fetchImpl(`${this.baseUrl}/api/v1/sessions${query}`, { headers }); + envelope = (await res.json()) as typeof envelope; + } catch { + if (items.length === 0) return; + break; + } + if (envelope.code !== 0 || envelope.data?.items === undefined) { + if (items.length === 0) return; + break; + } items.push(...envelope.data.items); const lastId = envelope.data.items.at(-1)?.['id']; if (envelope.data.has_more !== true || typeof lastId !== 'string') break; diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 869922f3824..0cf47e27604 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -138,7 +138,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) | | `KIMI_CODE_PASSWORD` | Parallel auth credential for `kimi web`, recommended when binding beyond loopback (see [Security notes](../guides/web.md#security-notes)) | Any non-empty string; when unset, only the token is valid | -| `KIMI_CODE_WS_COMPRESSION` | Offer `permessage-deflate` on `kimi web` WebSocket connections (default on) | `1`/`true` or `0`/`false`; anything else is ignored | +| `KIMI_CODE_WS_COMPRESSION` | Offer `permessage-deflate` on `kimi web` WebSocket connections (default on) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off`; anything else is ignored | | `KIMI_CODE_WS_MAX_PAYLOAD_BYTES` | Max inbound WebSocket message size (default `16777216`) | Positive integer; invalid values are ignored | | `KIMI_CODE_WS_HEARTBEAT_MS` | Server `ping` interval (ms); the connection closes after two silent intervals (default `10000`) | Positive integer; invalid values are ignored | | `KIMI_CODE_WS_FLUSH_INTERVAL_MS` | Batching window (ms) for subscribed event frames (default `16`) | Positive integer; invalid values are ignored | @@ -163,7 +163,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Offer the built-in skills documenting Kimi Code itself to the model; higher priority than `builtin_product_skills` | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | Experimental fullscreen UI: scrollable transcript, mouse selection, clickable links, Ctrl-Shift-F search | `1` enables it; anything else keeps the regular inline UI | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Experimental `fork` parameter on `Agent`/`AgentSwarm`: start the subagent from a snapshot of the caller's history instead of an empty context; `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | -| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | Experimental: split Remote Control HTTP responses into 256 KiB tunnel frames instead of one frame per response; `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | Experimental: split Remote Control HTTP responses into 256 KiB tunnel frames instead of one frame per response; not enabled by `KIMI_CODE_EXPERIMENTAL_FLAG`, only by this variable or the `[experimental]` config section | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_SEARCH_WORKER` | Run the global search index in a dedicated worker thread; higher priority than `[database] search` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` | Use the minidb-backed read model for session indexing; higher priority than `[database] base` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for MCP servers; overrides the config file, but `mcp.json` `startupTimeoutMs` still wins | Integer from `1` to `2147483647`; invalid values are ignored | diff --git a/docs/en/guides/remote-control.md b/docs/en/guides/remote-control.md index e2d3974408c..cf2ffb19822 100644 --- a/docs/en/guides/remote-control.md +++ b/docs/en/guides/remote-control.md @@ -92,7 +92,7 @@ Remote Control is only a remote window — all computation and file operations s How the tunnel caches the web UI depends on whether it had to rewrite a file. Fonts, wasm and other binary assets (the hashed files under `/assets/`) keep their long-lived `Cache-Control` headers, so the remote browser caches them across sessions. HTML, JavaScript and CSS are rewritten under the device prefix, so they are stored with `Cache-Control: public, no-cache` and a versioned `ETag`: the browser revalidates them on every load, which is a cheap `304 Not Modified` when the bundle has not changed, and only refetches what actually changed. The first load on a slow link still transfers the full bundle once. -The `remote_control_chunked_responses` experimental feature splits large HTTP responses into 256 KiB tunnel frames instead of one frame per response. Enable it like any other experimental feature (`KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1`, the `[experimental]` config section, or `KIMI_CODE_EXPERIMENTAL_FLAG=1`). It is off by default and is only useful for diagnosing slow-link behaviour; leave it off unless asked to try it. +The `remote_control_chunked_responses` experimental feature splits large HTTP responses into 256 KiB tunnel frames instead of one frame per response. Enable it explicitly with `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1` or the `[experimental]` config section; the `KIMI_CODE_EXPERIMENTAL_FLAG` master switch deliberately leaves it off. It is off by default and is only useful for diagnosing slow-link behaviour; leave it off unless asked to try it. ## What's the difference between Remote Control and Kimi Code Web? diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index efe4fd2f335..62ff9875c4e 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -962,7 +962,7 @@ Serves point-to-point catch-up from the server's op journal: the journaled op ba | `since_seq` | query | integer | **Required.** The caller's last applied op-batch seq, minimum `0`; batches above it are returned | | `limit` | query | integer | Maximum batches per response, 1–500. Default `500` | -On success, `data` is `{ agent_id, batches, latest_seq, complete, has_more }`, each batch `{ seq, ops }`. `latest_seq` is always the journal's newest seq, even when the response is capped. `has_more: true` means the cap cut the response short and batches remain below `latest_seq` — call again with `since_seq` set to the last received batch `seq` until `has_more` is `false`. `complete: true` means the journal covers everything from `since_seq` up to `latest_seq` (a capped response is still `complete`); `complete: false` means the journal no longer reaches back to `since_seq` (or the session is not live at all), and the caller must fall back to a full `GET .../transcript` refresh. +On success, `data` is `{ agent_id, batches, latest_seq, complete, has_more }`, each batch `{ seq, ops }`. `latest_seq` is the newest seq covered by this response: the journal's newest seq, or the last returned batch `seq` when the response is capped. `has_more: true` means the cap cut the response short and newer batches remain — call again with `since_seq` set to `latest_seq` until `has_more` is `false`. `complete: true` means every batch from `since_seq` up to `latest_seq` is present (a capped response is still `complete`); `complete: false` means the journal no longer reaches back to `since_seq` (or the session is not live at all), and the caller must fall back to a full `GET .../transcript` refresh. - `40001`: validation failure - `40401`: session not found diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index e88cd73e77b..bb44540f587 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -138,7 +138,7 @@ kimi | --- | --- | --- | | `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1`、`true`、`yes`、`y`(不区分大小写) | | `KIMI_CODE_PASSWORD` | 为 `kimi web` 本地服务设置并列鉴权密码;绑到非本机地址时建议设置,见 [安全注意](../guides/web.md#安全注意) | 任意非空字符串;未设置时仅 token 有效 | -| `KIMI_CODE_WS_COMPRESSION` | `kimi web` WebSocket 连接是否提供 `permessage-deflate`(默认开启) | `1`/`true` 或 `0`/`false`;其他值被忽略 | +| `KIMI_CODE_WS_COMPRESSION` | `kimi web` WebSocket 连接是否提供 `permessage-deflate`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off`;其他值被忽略 | | `KIMI_CODE_WS_MAX_PAYLOAD_BYTES` | 入站 WebSocket 消息大小上限(默认 `16777216`) | 正整数;非法值被忽略 | | `KIMI_CODE_WS_HEARTBEAT_MS` | 服务端 `ping` 间隔(毫秒);连续两个周期无入站帧即关闭连接(默认 `10000`) | 正整数;非法值被忽略 | | `KIMI_CODE_WS_FLUSH_INTERVAL_MS` | 订阅事件帧的合并发送窗口(毫秒,默认 `16`) | 正整数;非法值被忽略 | @@ -163,7 +163,7 @@ kimi | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills` | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen 界面:可滚动 transcript、鼠标选择、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent`/`AgentSwarm` 上启用实验性 `fork` 参数:以调用方对话历史快照而非空上下文启动 subagent | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | -| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | 实验性:将远程控制的 HTTP 响应拆成 256 KiB 的中转帧发送,而不是每个响应一帧;`KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | 实验性:将远程控制的 HTTP 响应拆成 256 KiB 的中转帧发送,而不是每个响应一帧;不受 `KIMI_CODE_EXPERIMENTAL_FLAG` 影响,只能通过本变量或 `[experimental]` 配置段启用 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_SEARCH_WORKER` | 在独立 worker 线程中运行全局搜索索引,优先级高于 `[database] search`(默认 `true`) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` | 会话索引使用基于 minidb 的读模型,优先级高于 `[database] base`(默认 `true`) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | MCP server 全局默认连接超时(毫秒);优先级高于配置文件,低于 `mcp.json` 的 `startupTimeoutMs` | `1` 到 `2147483647` 的整数;非法值被忽略 | diff --git a/docs/zh/guides/remote-control.md b/docs/zh/guides/remote-control.md index a9fff4305b6..a8e4f714734 100644 --- a/docs/zh/guides/remote-control.md +++ b/docs/zh/guides/remote-control.md @@ -92,7 +92,7 @@ 中转对网页界面的缓存方式取决于文件是否被改写。字体、wasm 等二进制资源(`/assets/` 下带哈希的文件)保留原有的长期 `Cache-Control` 头,因此远程浏览器会跨会话缓存它们。HTML、JavaScript 和 CSS 会被改写到设备前缀之下,因此以 `Cache-Control: public, no-cache` 和带版本的 `ETag` 存储:浏览器每次加载都会重新校验,在包未变化时只是一次开销很小的 `304 Not Modified`,只有真正变化的文件才会重新下载。慢速网络下首次加载仍需完整传输一次。 -实验性功能 `remote_control_chunked_responses` 会把较大的 HTTP 响应拆成 256 KiB 的中转帧,而不是一帧发送整个响应。它与其他实验性功能的启用方式相同(`KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1`、`[experimental]` 配置段或 `KIMI_CODE_EXPERIMENTAL_FLAG=1`)。默认关闭,仅用于排查慢速网络下的问题;除非被要求尝试,否则不要开启。 +实验性功能 `remote_control_chunked_responses` 会把较大的 HTTP 响应拆成 256 KiB 的中转帧,而不是一帧发送整个响应。需要显式启用:设置 `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1` 或使用 `[experimental]` 配置段;`KIMI_CODE_EXPERIMENTAL_FLAG` 总开关会刻意跳过它。默认关闭,仅用于排查慢速网络下的问题;除非被要求尝试,否则不要开启。 ## 远程控制和 Kimi Code 网页版有什么区别? diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 0f4421e2c79..3fdd80f31a7 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -962,7 +962,7 @@ main agent 的实时状态汇总;读取它会在会话为冷态时将其恢复 | `since_seq` | query | integer | **必填。** 调用方已应用的最后一个 op 批次 seq,最小为 `0`;返回其之后的批次 | | `limit` | query | integer | 每次响应最多返回的批次数,1–500。默认 `500` | -成功时,`data` 为 `{ agent_id, batches, latest_seq, complete, has_more }`,每个批次为 `{ seq, ops }`。`latest_seq` 始终是日志中最新的 seq,即使响应被截断也是如此。`has_more: true` 表示响应被 `limit` 截断、`latest_seq` 之前仍有批次未返回——请把 `since_seq` 设为最后收到的批次 `seq` 再次调用,直到 `has_more` 为 `false`。`complete: true` 表示日志覆盖了从 `since_seq` 到 `latest_seq` 的全部批次(被截断的响应仍然是 `complete`);`complete: false` 表示日志已不再覆盖到 `since_seq`(或会话根本不是活跃状态),调用方必须回退为一次完整的 `GET .../transcript` 刷新。 +成功时,`data` 为 `{ agent_id, batches, latest_seq, complete, has_more }`,每个批次为 `{ seq, ops }`。`latest_seq` 是本次响应覆盖到的最新 seq:未截断时为日志中最新的 seq,被 `limit` 截断时为最后返回的批次 `seq`。`has_more: true` 表示响应被截断、之后仍有更新的批次未返回——请把 `since_seq` 设为 `latest_seq` 再次调用,直到 `has_more` 为 `false`。`complete: true` 表示从 `since_seq` 到 `latest_seq` 的全部批次都已包含(被截断的响应仍然是 `complete`);`complete: false` 表示日志已不再覆盖到 `since_seq`(或会话根本不是活跃状态),调用方必须回退为一次完整的 `GET .../transcript` 刷新。 - `40001`:校验失败 - `40401`:会话不存在 diff --git a/packages/agent-core-v2/src/_base/utils/env.ts b/packages/agent-core-v2/src/_base/utils/env.ts index 12a62fc7464..0ca11caca82 100644 --- a/packages/agent-core-v2/src/_base/utils/env.ts +++ b/packages/agent-core-v2/src/_base/utils/env.ts @@ -8,3 +8,15 @@ export function parseBooleanEnv(value: string | undefined): boolean | undefined if (FALSE_BOOLEAN_ENV_VALUES.has(normalized)) return false; return undefined; } + +export function parseNonNegativeIntEnv(value: string | undefined): number | undefined { + const normalized = value?.trim(); + if (normalized === undefined || !/^\d+$/.test(normalized)) return undefined; + const n = Number(normalized); + return Number.isSafeInteger(n) ? n : undefined; +} + +export function parsePositiveIntEnv(value: string | undefined): number | undefined { + const n = parseNonNegativeIntEnv(value); + return n !== undefined && n > 0 ? n : undefined; +} diff --git a/packages/agent-core-v2/src/app/flag/flagRegistry.ts b/packages/agent-core-v2/src/app/flag/flagRegistry.ts index ba28fd52389..dec67dcb2fb 100644 --- a/packages/agent-core-v2/src/app/flag/flagRegistry.ts +++ b/packages/agent-core-v2/src/app/flag/flagRegistry.ts @@ -15,6 +15,7 @@ export interface FlagDefinitionInput { readonly default: boolean; readonly surface: FlagSurface; readonly isExposed?: (flags: IFlagService) => boolean; + readonly excludeFromMaster?: boolean; } const contributedFlags: FlagDefinitionInput[] = []; diff --git a/packages/agent-core-v2/src/app/flag/flagService.ts b/packages/agent-core-v2/src/app/flag/flagService.ts index f89bd858c96..6f9ba7f6ff1 100644 --- a/packages/agent-core-v2/src/app/flag/flagService.ts +++ b/packages/agent-core-v2/src/app/flag/flagService.ts @@ -58,7 +58,10 @@ export class FlagService extends Disposable implements IFlagService { const override = parseBooleanEnv(this.bootstrap.getEnv(def.env)); if (override !== undefined) return this.state(def, override, 'env', configValue); if (configValue !== undefined) return this.state(def, configValue, 'config', configValue); - if (parseBooleanEnv(this.bootstrap.getEnv(MASTER_ENV)) === true) { + if ( + def.excludeFromMaster !== true && + parseBooleanEnv(this.bootstrap.getEnv(MASTER_ENV)) === true + ) { return this.state(def, true, 'master-env', configValue); } return this.state(def, def.default, 'default', undefined); diff --git a/packages/agent-core-v2/test/_base/utils/env.test.ts b/packages/agent-core-v2/test/_base/utils/env.test.ts index 2677fea43d9..2ffaef40917 100644 --- a/packages/agent-core-v2/test/_base/utils/env.test.ts +++ b/packages/agent-core-v2/test/_base/utils/env.test.ts @@ -1,6 +1,24 @@ import { describe, expect, it } from 'vitest'; -import { parseBooleanEnv } from '#/_base/utils/env'; +import { parseBooleanEnv, parseNonNegativeIntEnv, parsePositiveIntEnv } from '#/_base/utils/env'; + +describe('parseNonNegativeIntEnv', () => { + it.each([undefined, '', 'abc', '-1', '1.5', '1e3'])('rejects %j', (value) => { + expect(parseNonNegativeIntEnv(value)).toBeUndefined(); + }); + + it('accepts zero and trims surrounding whitespace', () => { + expect(parseNonNegativeIntEnv('0')).toBe(0); + expect(parseNonNegativeIntEnv(' 32 ')).toBe(32); + }); +}); + +describe('parsePositiveIntEnv', () => { + it('rejects zero but keeps positive integers', () => { + expect(parsePositiveIntEnv('0')).toBeUndefined(); + expect(parsePositiveIntEnv('7')).toBe(7); + }); +}); describe('parseBooleanEnv', () => { it.each(['1', 'true', 'yes', 'on'])('parses %j as true', (value) => { diff --git a/packages/agent-core-v2/test/app/flag/flag.test.ts b/packages/agent-core-v2/test/app/flag/flag.test.ts index f33e15de812..68597405855 100644 --- a/packages/agent-core-v2/test/app/flag/flag.test.ts +++ b/packages/agent-core-v2/test/app/flag/flag.test.ts @@ -158,6 +158,20 @@ describe('FlagService', () => { expect(state?.source).toBe('master-env'); }); + it('leaves flags marked excludeFromMaster on their default under the master env', () => { + const { flags, flagRegistry } = makeFlags({ [MASTER_ENV]: '1' }); + flagRegistry.register({ + ...exampleFlag, + id: 'guarded_flag', + env: 'KIMI_CODE_EXPERIMENTAL_GUARDED_FLAG', + default: false, + excludeFromMaster: true, + }); + const state = flags.explain('guarded_flag'); + expect(state?.enabled).toBe(false); + expect(state?.source).toBe('default'); + }); + it('treats a falsy master env as unset', () => { const { flags } = makeFlags({ [MASTER_ENV]: '0' }); const state = flags.explain('example_flag'); diff --git a/packages/kap-server/src/routes/transcript.ts b/packages/kap-server/src/routes/transcript.ts index 0edb59f80a7..222346d6653 100644 --- a/packages/kap-server/src/routes/transcript.ts +++ b/packages/kap-server/src/routes/transcript.ts @@ -225,7 +225,7 @@ export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: Transcr [ErrorCode.SESSION_NOT_FOUND]: {}, }, description: - 'Point-to-point transcript catch-up: journaled op batches with seq > since_seq for one agent, oldest first, at most limit batches per response (default 500). has_more:true means more batches remain below latest_seq — page again from the last received seq. complete:false means the session is not live or the journal no longer reaches back to since_seq — the caller must fall back to a full transcript refresh', + 'Point-to-point transcript catch-up: journaled op batches with seq > since_seq for one agent, oldest first, at most limit batches per response (default 500). latest_seq is the newest seq covered by the response (the last returned batch when capped); has_more:true means newer batches remain — page again with since_seq=latest_seq. complete:false means the session is not live or the journal no longer reaches back to since_seq — the caller must fall back to a full transcript refresh', tags: ['transcript'], }, async (req, reply) => { diff --git a/packages/kap-server/src/routes/webAssets.ts b/packages/kap-server/src/routes/webAssets.ts index 4e139efda66..fbc882e0d04 100644 --- a/packages/kap-server/src/routes/webAssets.ts +++ b/packages/kap-server/src/routes/webAssets.ts @@ -2,8 +2,11 @@ import { createReadStream, type Stats } from 'node:fs'; import { stat } from 'node:fs/promises'; import { extname, join, normalize, relative, resolve, sep } from 'node:path'; +import { buildEtag } from '@moonshot-ai/agent-core-v2/_base/utils/fileMeta'; import type { FastifyReply, FastifyRequest } from 'fastify'; +import { pickHeader } from '../lib/httpRange'; + interface WebAssetRouteHost { get( path: string, @@ -78,16 +81,16 @@ async function serveWebAsset( const compressible = COMPRESSIBLE_EXTENSIONS.has(extname(file.path)); const variant = compressible - ? await findEncodedVariant(file, headerValue(req.headers['accept-encoding'])) + ? await findEncodedVariant(file, pickHeader(req.headers, 'accept-encoding')) : undefined; const source = variant ?? file; - const etag = weakEtag(source.stats, variant?.etagSuffix ?? ''); + const etag = `W/"${buildEtag(source.stats)}${variant?.etagSuffix ?? ''}"`; reply.header('ETag', etag).header('Cache-Control', cacheControl(assetsDir, file.path)); if (compressible) { reply.header('Vary', 'Accept-Encoding'); } - if (matchesIfNoneMatch(headerValue(req.headers['if-none-match']), etag)) { + if (matchesIfNoneMatch(pickHeader(req.headers, 'if-none-match'), etag)) { return reply.code(304).send(); } @@ -101,10 +104,6 @@ async function serveWebAsset( return reply.send(createReadStream(source.path)); } -function headerValue(value: string | string[] | undefined): string | undefined { - return Array.isArray(value) ? value.join(',') : value; -} - async function findEncodedVariant( file: StaticFile, acceptEncoding: string | undefined, @@ -119,11 +118,20 @@ async function findEncodedVariant( })) .filter(({ weight }) => weight > 0) .toSorted((a, b) => b.weight - a.weight); - for (const { candidate } of candidates) { - const path = `${file.path}${candidate.extension}`; - const stats = await stat(path).catch(() => undefined); - if (stats?.isFile() === true && stats.mtimeMs >= file.stats.mtimeMs) { + if (candidates.length === 0) { + return undefined; + } + const siblings = await Promise.all( + candidates.map(async ({ candidate }) => { + const path = `${file.path}${candidate.extension}`; + const stats = await stat(path).catch(() => undefined); return { path, stats, encoding: candidate.encoding, etagSuffix: candidate.etagSuffix }; + }), + ); + for (const sibling of siblings) { + const { stats } = sibling; + if (stats?.isFile() === true && stats.mtimeMs >= file.stats.mtimeMs) { + return { ...sibling, stats }; } } return undefined; @@ -148,10 +156,6 @@ function encodingWeight(weights: Map, encoding: string): number return weights.get(encoding) ?? weights.get('*') ?? 0; } -function weakEtag(stats: Stats, suffix: string): string { - return `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}${suffix}"`; -} - function matchesIfNoneMatch(header: string | undefined, etag: string): boolean { if (header === undefined) { return false; @@ -255,8 +259,6 @@ function mimeType(filePath: string): string { return 'font/ttf'; case '.wasm': return 'application/wasm'; - case '.riv': - return 'application/octet-stream'; default: return 'application/octet-stream'; } diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 7439cfb4581..f4d5b382d7f 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -299,20 +299,12 @@ export class TranscriptService { } } - flushPendingOps(sessionId?: string, agentId?: string): void { - if (sessionId !== undefined && agentId !== undefined) { - const perAgent = this.pendingAppends.get(sessionId); - const pending = perAgent?.get(agentId); - if (perAgent === undefined || pending === undefined) return; - this.flushOnePending(sessionId, agentId, perAgent, pending); - return; - } - for (const [sid, perAgent] of this.pendingAppends) { - if (sessionId !== undefined && sid !== sessionId) continue; - for (const [aid, pending] of perAgent) { - if (agentId !== undefined && aid !== agentId) continue; - this.flushOnePending(sid, aid, perAgent, pending); - } + flushPendingOps(sessionId: string, agentId?: string): void { + const perAgent = this.pendingAppends.get(sessionId); + if (perAgent === undefined) return; + for (const [aid, pending] of perAgent) { + if (agentId !== undefined && aid !== agentId) continue; + this.flushOnePending(sessionId, aid, perAgent, pending); } } @@ -328,13 +320,6 @@ export class TranscriptService { this.emitOps(sessionId, { agentId, ops: coalesceAppendOps(pending.ops) }); } - private discardPendingOps(sessionId: string): void { - const perAgent = this.pendingAppends.get(sessionId); - if (perAgent === undefined) return; - for (const pending of perAgent.values()) clearTimeout(pending.timer); - this.pendingAppends.delete(sessionId); - } - private journalOps(sessionId: string, event: TranscriptChangeEvent): number { const entry = this.live.get(sessionId); if (entry === undefined) return 0; @@ -371,7 +356,13 @@ export class TranscriptService { const complete = newer.length === 0 || (oldest !== undefined && oldest <= sinceSeq + 1); const hasMore = limit !== undefined && newer.length > limit; const batches = hasMore ? newer.slice(0, limit) : newer; - return { batches, latestSeq, complete, hasMore }; + const lastReturned = batches.at(-1)?.seq; + return { + batches, + latestSeq: hasMore && lastReturned !== undefined ? lastReturned : latestSeq, + complete, + hasMore, + }; } private handleLiveOps(sessionId: string, event: TranscriptChangeEvent): void { @@ -652,8 +643,8 @@ export class TranscriptService { } dropSession(sessionId: string): void { + this.flushPendingOps(sessionId); this.opsListeners.delete(sessionId); - this.discardPendingOps(sessionId); for (const [key, pending] of this.healTimers) { if (key.startsWith(`${sessionId}:`)) { clearTimeout(pending.timer); @@ -735,12 +726,6 @@ function onlyAppends(ops: readonly TranscriptOperation[]): AppendOp[] | undefine return appends; } -export function parseTranscriptOpsBatchMs(value: string | undefined): number | undefined { - if (value === undefined || !/^\d+$/.test(value.trim())) return undefined; - const n = Number(value.trim()); - return Number.isSafeInteger(n) ? n : undefined; -} - function projectQuestionInteractionRecords( records: readonly ContextRecord[], sessionId: string, diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 410480a8e59..9dfab5dd543 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -27,6 +27,7 @@ import { type Scope, type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; +import { parseNonNegativeIntEnv } from '@moonshot-ai/agent-core-v2/_base/utils/env'; import { createKimiDefaultHeaders, kimiRegionProfile, @@ -79,10 +80,7 @@ import { type ServerTelemetry, shutdownServerTelemetry, } from './services/telemetry'; -import { - TranscriptService, - parseTranscriptOpsBatchMs, -} from './services/transcript/transcriptService'; +import { TranscriptService } from './services/transcript/transcriptService'; import { ProjectionService } from './services/projection'; import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler'; import { startConfigChangedPublisher } from './services/config/configChangedPublisher'; @@ -366,7 +364,7 @@ export async function startServer(opts: ServerStartOptions): Promise 0 ? n : undefined; -} - -function parseBoolean(value: string | undefined): boolean | undefined { - const normalized = value?.trim().toLowerCase(); - if (normalized === '1' || normalized === 'true') return true; - if (normalized === '0' || normalized === 'false') return false; - return undefined; -} - export function registerWsV1(core: Scope, opts: RegisterWsV1Options): WebSocketServer { void core; const wss = new WebSocketServer({ diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 1be30228485..b2f72d23399 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -547,6 +547,7 @@ export class SessionEventBroadcaster { private async purgeSession(sessionId: string): Promise { await this.pendingStates.get(sessionId); + this.opts.transcriptService?.flushPendingOps(sessionId); const state = this.sessions.get(sessionId); if (state !== undefined) { this.sessions.delete(sessionId); diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index 0b29e1c78c7..31c6bf44668 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -92,6 +92,7 @@ export class WsConnectionV1 implements BroadcastTarget { private flushTimer?: ReturnType; private backpressureRetryTimer?: ReturnType; private backpressureSince?: number; + private backpressureBufferedAmount = 0; private heartbeatTimer?: ReturnType; private lastInboundAt = Date.now(); @@ -422,8 +423,16 @@ export class WsConnectionV1 implements BroadcastTarget { private sendImmediateFrame(msg: unknown): void { if (this.closed) return; - this.outbound.push(msg); - this.flush(true); + this.flush(); + this.sendFrame(msg); + } + + private sendFrame(frame: unknown): void { + if (this.closed || this.socket.readyState !== this.socket.OPEN) return; + try { + this.socket.send(JSON.stringify(frame)); + } catch { + } } private scheduleFlush(): void { @@ -455,18 +464,16 @@ export class WsConnectionV1 implements BroadcastTarget { const frames = coalesceFrames(this.outbound); this.outbound = []; - for (const frame of frames) { - if (this.closed || this.socket.readyState !== this.socket.OPEN) return; - try { - this.socket.send(JSON.stringify(frame)); - } catch { - } - } + for (const frame of frames) this.sendFrame(frame); } private deferForBackpressure(): void { const now = Date.now(); - if (this.backpressureSince === undefined) this.backpressureSince = now; + const buffered = this.socket.bufferedAmount; + if (this.backpressureSince === undefined || buffered < this.backpressureBufferedAmount) { + this.backpressureSince = now; + } + this.backpressureBufferedAmount = buffered; if (now - this.backpressureSince >= MAX_BACKPRESSURE_STALL_MS) { this.closeSlowConsumer(); return; diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 771fbbf25a4..2982c1db216 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -55,7 +55,6 @@ import { import { healTurnOps, TranscriptService, - parseTranscriptOpsBatchMs, snapshotToOps, TRANSCRIPT_OPS_BATCH_MAX_OPS, TRANSCRIPT_OPS_JOURNAL_CAPACITY, @@ -4121,15 +4120,5 @@ describe('bindSessionTranscript', () => { expectContiguous(seen); service.dropSession('s1'); }); - - it('parses the ops batch window env value as a non-negative integer', () => { - expect(parseTranscriptOpsBatchMs(undefined)).toBeUndefined(); - expect(parseTranscriptOpsBatchMs('')).toBeUndefined(); - expect(parseTranscriptOpsBatchMs('abc')).toBeUndefined(); - expect(parseTranscriptOpsBatchMs('-1')).toBeUndefined(); - expect(parseTranscriptOpsBatchMs('1.5')).toBeUndefined(); - expect(parseTranscriptOpsBatchMs('0')).toBe(0); - expect(parseTranscriptOpsBatchMs(' 32 ')).toBe(32); - }); }); }); diff --git a/packages/kap-server/test/transcript.test.ts b/packages/kap-server/test/transcript.test.ts index 6478827de08..bccd3b13e97 100644 --- a/packages/kap-server/test/transcript.test.ts +++ b/packages/kap-server/test/transcript.test.ts @@ -927,7 +927,7 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { expect(first.body.data).toMatchObject({ has_more: true, complete: true, - latest_seq: all.body.data.latest_seq, + latest_seq: base + 1, }); const rest = await getJson( diff --git a/packages/kap-server/test/webAssets.test.ts b/packages/kap-server/test/webAssets.test.ts index e60d8a9b2e5..dfb7ed66a3e 100644 --- a/packages/kap-server/test/webAssets.test.ts +++ b/packages/kap-server/test/webAssets.test.ts @@ -192,7 +192,7 @@ describe('web asset routes', () => { const response = await app.inject({ method: 'GET', url: HASHED_JS }); expect(response.statusCode).toBe(200); - expect(response.headers.etag).toMatch(/^W\/"[0-9a-f]+-[0-9a-f]+"$/); + expect(response.headers.etag).toMatch(/^W\/"[0-9a-z]+-[0-9a-z]+-[0-9a-z]+"$/); expect(response.headers['last-modified']).toMatch(/GMT$/); }); diff --git a/packages/kap-server/test/wsConnectionV1.test.ts b/packages/kap-server/test/wsConnectionV1.test.ts index d57860e1e49..23c85c25c47 100644 --- a/packages/kap-server/test/wsConnectionV1.test.ts +++ b/packages/kap-server/test/wsConnectionV1.test.ts @@ -788,7 +788,7 @@ describe('WsConnectionV1 outbound buffer', () => { conn.close(); }); - it('flushes a control frame even above the high-water mark', async () => { + it('sends a control frame above the high-water mark without flushing the backlog', async () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 16, highWaterMarkBytes: 100 }); socket.sent = []; @@ -799,12 +799,56 @@ describe('WsConnectionV1 outbound buffer', () => { expect(socket.sent).toHaveLength(0); conn.send(durable('session.work_changed', 's1', 7), 'immediate'); + let frames = socket.frames() as Array<{ type: string }>; + expect(frames.map((f) => f.type)).toEqual(['session.work_changed']); + expect(socket.closeCalls).toHaveLength(0); + + socket.bufferedAmount = 0; + await vi.advanceTimersByTimeAsync(5); + frames = socket.frames() as Array<{ type: string }>; + expect(frames.map((f) => f.type)).toEqual(['session.work_changed', 'assistant.delta']); + conn.close(); + }); + + it('keeps the heartbeat from draining the backlog above the high-water mark', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { + flushIntervalMs: 16, + highWaterMarkBytes: 100, + heartbeatIntervalMs: 50, + }); + socket.sent = []; + + socket.bufferedAmount = 200; + conn.send(delta('s1', 'main', 1, 'stuck', 0)); + await vi.advanceTimersByTimeAsync(60); const frames = socket.frames() as Array<{ type: string }>; - expect(frames.map((f) => f.type)).toEqual(['assistant.delta', 'session.work_changed']); + expect(frames.map((f) => f.type)).toEqual(['ping']); expect(socket.closeCalls).toHaveLength(0); conn.close(); }); + it('does not close a peer that keeps draining while above the high-water mark', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { + flushIntervalMs: 16, + highWaterMarkBytes: 100, + heartbeatIntervalMs: 60_000, + }); + socket.sent = []; + + socket.bufferedAmount = 100_000; + conn.send(delta('s1', 'main', 1, 'stuck', 0)); + const drain = setInterval(() => { + socket.bufferedAmount = Math.max(101, socket.bufferedAmount - 1); + }, 1000); + await vi.advanceTimersByTimeAsync(MAX_BACKPRESSURE_STALL_MS * 2); + clearInterval(drain); + expect(socket.closeCalls).toHaveLength(0); + expect(socket.sent).toHaveLength(0); + conn.close(); + }); + it('force-flushes buffered subscription frames on close', () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 1000 }); @@ -1053,13 +1097,15 @@ describe('parseWsTuning', () => { expect(tuning.maxPayloadBytes).toBeUndefined(); }); - it('maps compression 0/false to false, 1/true to true, anything else to undefined', () => { + it('maps compression through the shared boolean env parser', () => { expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: '0' }).compression).toBe(false); expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'false' }).compression).toBe(false); expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'FALSE' }).compression).toBe(false); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'off' }).compression).toBe(false); expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: '1' }).compression).toBe(true); expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'true' }).compression).toBe(true); - expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'yes' }).compression).toBeUndefined(); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'yes' }).compression).toBe(true); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'maybe' }).compression).toBeUndefined(); expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: '' }).compression).toBeUndefined(); }); }); diff --git a/packages/remote-control/src/flag.ts b/packages/remote-control/src/flag.ts index 3eee43206a8..12efe027747 100644 --- a/packages/remote-control/src/flag.ts +++ b/packages/remote-control/src/flag.ts @@ -8,7 +8,8 @@ export const REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES'; // The relay's handling of multi-frame responses (is_last: false) is unverified, so the -// split stays off until it has been exercised end to end. +// split stays off until it has been exercised end to end. It is also excluded from the +// KIMI_CODE_EXPERIMENTAL_FLAG master switch: only an explicit per-flag opt-in enables it. export const remoteControlChunkedResponsesFlag: FlagDefinitionInput = { id: REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID, title: 'Chunked Remote Control responses', @@ -17,6 +18,7 @@ export const remoteControlChunkedResponsesFlag: FlagDefinitionInput = { env: REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ENV, default: false, surface: 'core', + excludeFromMaster: true, }; registerFlagDefinition(remoteControlChunkedResponsesFlag); diff --git a/packages/remote-control/src/remote-control.ts b/packages/remote-control/src/remote-control.ts index d831cd3e38a..daaefe06f7a 100644 --- a/packages/remote-control/src/remote-control.ts +++ b/packages/remote-control/src/remote-control.ts @@ -246,20 +246,24 @@ export function stripRewriteVersion(ifNoneMatch: string): string { // Marks a response whose body was rewritten (or a 304 validating such a body): the browser may // store it but must revalidate on every load, and its ETag carries the rewrite version. export function applyRewrittenCacheHeaders(headers: string[]): void { - let cacheControlIndex = -1; - for (let index = 0; index < headers.length; index += 2) { - const lower = headers[index]!.toLowerCase(); - if (lower === 'cache-control') { - cacheControlIndex = index; - } else if (lower === 'etag') { - const tag = /^(?:W\/)?("[^"]*)"$/.exec(headers[index + 1]!); - if (tag !== null) headers[index + 1] = `W/${tag[1]}${REWRITE_ETAG_SUFFIX}"`; - } + const etagIndex = findHeaderIndex(headers, 'etag'); + if (etagIndex >= 0) { + const tag = /^(?:W\/)?("[^"]*)"$/.exec(headers[etagIndex + 1]!); + if (tag !== null) headers[etagIndex + 1] = `W/${tag[1]}${REWRITE_ETAG_SUFFIX}"`; } + const cacheControlIndex = findHeaderIndex(headers, 'cache-control'); if (cacheControlIndex < 0) headers.push('Cache-Control', REWRITTEN_CACHE_CONTROL); else headers[cacheControlIndex + 1] = REWRITTEN_CACHE_CONTROL; } +// Index of a header name in a flat `[name, value, name, value]` list, or -1 when absent. +function findHeaderIndex(headers: readonly string[], name: string): number { + for (let index = 0; index < headers.length; index += 2) { + if (headers[index]!.toLowerCase() === name) return index; + } + return -1; +} + export function rewriteRemoteControlResponse( contentType: string, body: Buffer, @@ -902,12 +906,12 @@ function requestLocalHttp( // A versioned tag means the browser holds a rewritten copy; strip the suffix so the local // server can answer 304 and remember to describe the 304 as the rewritten representation. let validatesRewrite = false; - for (let index = 0; index < forwardHeaders.length; index += 2) { - if (forwardHeaders[index]!.toLowerCase() !== 'if-none-match') continue; - const stripped = stripRewriteVersion(forwardHeaders[index + 1]!); - if (stripped === forwardHeaders[index + 1]) continue; - forwardHeaders[index + 1] = stripped; - validatesRewrite = true; + const ifNoneMatchIndex = findHeaderIndex(forwardHeaders, 'if-none-match'); + if (ifNoneMatchIndex >= 0) { + const received = forwardHeaders[ifNoneMatchIndex + 1]!; + const stripped = stripRewriteVersion(received); + validatesRewrite = stripped !== received; + forwardHeaders[ifNoneMatchIndex + 1] = stripped; } const headRequest = parsed.method === 'HEAD'; return new Promise((resolve, reject) => { @@ -936,7 +940,7 @@ function requestLocalHttp( !bodiless && response.headers['content-encoding'] === undefined ? rewriteRemoteControlResponse(contentType, receivedBody, publicPrefix) : receivedBody; - const rewritten = body !== receivedBody && !body.equals(receivedBody); + const rewritten = !body.equals(receivedBody); const headers = filterResponseHeaders(response.rawHeaders); if (rewritten || (statusCode === 304 && validatesRewrite)) { applyRewrittenCacheHeaders(headers); From 9f18f97e03642d535a0c20e655284d98373b0ffb Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:22:47 +0300 Subject: [PATCH 07/10] fix(remote-control): let the experimental master switch enable chunked responses Drop the excludeFromMaster escape hatch added for remote_control_chunked_responses so KIMI_CODE_EXPERIMENTAL_FLAG=1 enables it like every other experimental flag. The flag still defaults to off and the per-flag env var and [experimental] config keep precedence. --- docs/en/configuration/env-vars.md | 2 +- docs/en/guides/remote-control.md | 2 +- docs/zh/configuration/env-vars.md | 2 +- docs/zh/guides/remote-control.md | 2 +- .../agent-core-v2/src/app/flag/flagRegistry.ts | 1 - packages/agent-core-v2/src/app/flag/flagService.ts | 5 +---- packages/agent-core-v2/test/app/flag/flag.test.ts | 14 -------------- packages/remote-control/src/flag.ts | 4 +--- 8 files changed, 6 insertions(+), 26 deletions(-) diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 0cf47e27604..1243cb8a035 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -163,7 +163,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Offer the built-in skills documenting Kimi Code itself to the model; higher priority than `builtin_product_skills` | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | Experimental fullscreen UI: scrollable transcript, mouse selection, clickable links, Ctrl-Shift-F search | `1` enables it; anything else keeps the regular inline UI | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Experimental `fork` parameter on `Agent`/`AgentSwarm`: start the subagent from a snapshot of the caller's history instead of an empty context; `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | -| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | Experimental: split Remote Control HTTP responses into 256 KiB tunnel frames instead of one frame per response; not enabled by `KIMI_CODE_EXPERIMENTAL_FLAG`, only by this variable or the `[experimental]` config section | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | Experimental: split Remote Control HTTP responses into 256 KiB tunnel frames instead of one frame per response; `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_SEARCH_WORKER` | Run the global search index in a dedicated worker thread; higher priority than `[database] search` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` | Use the minidb-backed read model for session indexing; higher priority than `[database] base` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for MCP servers; overrides the config file, but `mcp.json` `startupTimeoutMs` still wins | Integer from `1` to `2147483647`; invalid values are ignored | diff --git a/docs/en/guides/remote-control.md b/docs/en/guides/remote-control.md index cf2ffb19822..ce01a5c53d8 100644 --- a/docs/en/guides/remote-control.md +++ b/docs/en/guides/remote-control.md @@ -92,7 +92,7 @@ Remote Control is only a remote window — all computation and file operations s How the tunnel caches the web UI depends on whether it had to rewrite a file. Fonts, wasm and other binary assets (the hashed files under `/assets/`) keep their long-lived `Cache-Control` headers, so the remote browser caches them across sessions. HTML, JavaScript and CSS are rewritten under the device prefix, so they are stored with `Cache-Control: public, no-cache` and a versioned `ETag`: the browser revalidates them on every load, which is a cheap `304 Not Modified` when the bundle has not changed, and only refetches what actually changed. The first load on a slow link still transfers the full bundle once. -The `remote_control_chunked_responses` experimental feature splits large HTTP responses into 256 KiB tunnel frames instead of one frame per response. Enable it explicitly with `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1` or the `[experimental]` config section; the `KIMI_CODE_EXPERIMENTAL_FLAG` master switch deliberately leaves it off. It is off by default and is only useful for diagnosing slow-link behaviour; leave it off unless asked to try it. +The `remote_control_chunked_responses` experimental feature splits large HTTP responses into 256 KiB tunnel frames instead of one frame per response. Enable it with `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1`, the `[experimental]` config section, or the `KIMI_CODE_EXPERIMENTAL_FLAG` master switch. It is off by default and is only useful for diagnosing slow-link behaviour; leave it off unless asked to try it. ## What's the difference between Remote Control and Kimi Code Web? diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index bb44540f587..3b3956d9c4f 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -163,7 +163,7 @@ kimi | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills` | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen 界面:可滚动 transcript、鼠标选择、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent`/`AgentSwarm` 上启用实验性 `fork` 参数:以调用方对话历史快照而非空上下文启动 subagent | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | -| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | 实验性:将远程控制的 HTTP 响应拆成 256 KiB 的中转帧发送,而不是每个响应一帧;不受 `KIMI_CODE_EXPERIMENTAL_FLAG` 影响,只能通过本变量或 `[experimental]` 配置段启用 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES` | 实验性:将远程控制的 HTTP 响应拆成 256 KiB 的中转帧发送,而不是每个响应一帧;`KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用它 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_SEARCH_WORKER` | 在独立 worker 线程中运行全局搜索索引,优先级高于 `[database] search`(默认 `true`) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_PERSISTENCE_MINIDB_READMODEL` | 会话索引使用基于 minidb 的读模型,优先级高于 `[database] base`(默认 `true`) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | MCP server 全局默认连接超时(毫秒);优先级高于配置文件,低于 `mcp.json` 的 `startupTimeoutMs` | `1` 到 `2147483647` 的整数;非法值被忽略 | diff --git a/docs/zh/guides/remote-control.md b/docs/zh/guides/remote-control.md index a8e4f714734..752121e0430 100644 --- a/docs/zh/guides/remote-control.md +++ b/docs/zh/guides/remote-control.md @@ -92,7 +92,7 @@ 中转对网页界面的缓存方式取决于文件是否被改写。字体、wasm 等二进制资源(`/assets/` 下带哈希的文件)保留原有的长期 `Cache-Control` 头,因此远程浏览器会跨会话缓存它们。HTML、JavaScript 和 CSS 会被改写到设备前缀之下,因此以 `Cache-Control: public, no-cache` 和带版本的 `ETag` 存储:浏览器每次加载都会重新校验,在包未变化时只是一次开销很小的 `304 Not Modified`,只有真正变化的文件才会重新下载。慢速网络下首次加载仍需完整传输一次。 -实验性功能 `remote_control_chunked_responses` 会把较大的 HTTP 响应拆成 256 KiB 的中转帧,而不是一帧发送整个响应。需要显式启用:设置 `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1` 或使用 `[experimental]` 配置段;`KIMI_CODE_EXPERIMENTAL_FLAG` 总开关会刻意跳过它。默认关闭,仅用于排查慢速网络下的问题;除非被要求尝试,否则不要开启。 +实验性功能 `remote_control_chunked_responses` 会把较大的 HTTP 响应拆成 256 KiB 的中转帧,而不是一帧发送整个响应。可通过设置 `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1`、使用 `[experimental]` 配置段或 `KIMI_CODE_EXPERIMENTAL_FLAG` 总开关启用。默认关闭,仅用于排查慢速网络下的问题;除非被要求尝试,否则不要开启。 ## 远程控制和 Kimi Code 网页版有什么区别? diff --git a/packages/agent-core-v2/src/app/flag/flagRegistry.ts b/packages/agent-core-v2/src/app/flag/flagRegistry.ts index dec67dcb2fb..ba28fd52389 100644 --- a/packages/agent-core-v2/src/app/flag/flagRegistry.ts +++ b/packages/agent-core-v2/src/app/flag/flagRegistry.ts @@ -15,7 +15,6 @@ export interface FlagDefinitionInput { readonly default: boolean; readonly surface: FlagSurface; readonly isExposed?: (flags: IFlagService) => boolean; - readonly excludeFromMaster?: boolean; } const contributedFlags: FlagDefinitionInput[] = []; diff --git a/packages/agent-core-v2/src/app/flag/flagService.ts b/packages/agent-core-v2/src/app/flag/flagService.ts index 6f9ba7f6ff1..f89bd858c96 100644 --- a/packages/agent-core-v2/src/app/flag/flagService.ts +++ b/packages/agent-core-v2/src/app/flag/flagService.ts @@ -58,10 +58,7 @@ export class FlagService extends Disposable implements IFlagService { const override = parseBooleanEnv(this.bootstrap.getEnv(def.env)); if (override !== undefined) return this.state(def, override, 'env', configValue); if (configValue !== undefined) return this.state(def, configValue, 'config', configValue); - if ( - def.excludeFromMaster !== true && - parseBooleanEnv(this.bootstrap.getEnv(MASTER_ENV)) === true - ) { + if (parseBooleanEnv(this.bootstrap.getEnv(MASTER_ENV)) === true) { return this.state(def, true, 'master-env', configValue); } return this.state(def, def.default, 'default', undefined); diff --git a/packages/agent-core-v2/test/app/flag/flag.test.ts b/packages/agent-core-v2/test/app/flag/flag.test.ts index 68597405855..f33e15de812 100644 --- a/packages/agent-core-v2/test/app/flag/flag.test.ts +++ b/packages/agent-core-v2/test/app/flag/flag.test.ts @@ -158,20 +158,6 @@ describe('FlagService', () => { expect(state?.source).toBe('master-env'); }); - it('leaves flags marked excludeFromMaster on their default under the master env', () => { - const { flags, flagRegistry } = makeFlags({ [MASTER_ENV]: '1' }); - flagRegistry.register({ - ...exampleFlag, - id: 'guarded_flag', - env: 'KIMI_CODE_EXPERIMENTAL_GUARDED_FLAG', - default: false, - excludeFromMaster: true, - }); - const state = flags.explain('guarded_flag'); - expect(state?.enabled).toBe(false); - expect(state?.source).toBe('default'); - }); - it('treats a falsy master env as unset', () => { const { flags } = makeFlags({ [MASTER_ENV]: '0' }); const state = flags.explain('example_flag'); diff --git a/packages/remote-control/src/flag.ts b/packages/remote-control/src/flag.ts index 12efe027747..eb893c31d33 100644 --- a/packages/remote-control/src/flag.ts +++ b/packages/remote-control/src/flag.ts @@ -8,8 +8,7 @@ export const REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES'; // The relay's handling of multi-frame responses (is_last: false) is unverified, so the -// split stays off until it has been exercised end to end. It is also excluded from the -// KIMI_CODE_EXPERIMENTAL_FLAG master switch: only an explicit per-flag opt-in enables it. +// split stays off by default until it has been exercised end to end. export const remoteControlChunkedResponsesFlag: FlagDefinitionInput = { id: REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID, title: 'Chunked Remote Control responses', @@ -18,7 +17,6 @@ export const remoteControlChunkedResponsesFlag: FlagDefinitionInput = { env: REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ENV, default: false, surface: 'core', - excludeFromMaster: true, }; registerFlagDefinition(remoteControlChunkedResponsesFlag); From e583d36acd54ffb3525ca62a6b25a9feb1fd4860 Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:31:15 +0300 Subject: [PATCH 08/10] fix(kap-server): keep the unsized transcript ops catch-up unbounded Omitting limit on GET .../transcript/ops returns every journaled batch after since_seq again, as it did before limit existed; only an explicit limit caps the response and sets has_more. --- .changeset/sessions-list-paginated.md | 2 +- docs/en/reference/server-api.md | 2 +- docs/zh/reference/server-api.md | 2 +- packages/kap-server/src/routes/transcript.ts | 4 +-- packages/kap-server/test/transcript.test.ts | 28 ++++++++++++++++++++ 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.changeset/sessions-list-paginated.md b/.changeset/sessions-list-paginated.md index f0d177232e7..0e108092db5 100644 --- a/.changeset/sessions-list-paginated.md +++ b/.changeset/sessions-list-paginated.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -The sessions list API now applies its filters while collecting so paged responses are full and `has_more` is accurate (an unsized request still returns the whole list), and the transcript ops catch-up API accepts a `limit` and reports `has_more`. +The sessions list API now applies its filters while collecting so paged responses are full and `has_more` is accurate (an unsized request still returns the whole list), and the transcript ops catch-up API accepts an optional `limit` and reports `has_more` (an unsized request still returns every batch). diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index 62ff9875c4e..52feafc36bb 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -960,7 +960,7 @@ Serves point-to-point catch-up from the server's op journal: the journaled op ba | `session_id` | path | string | **Required.** Session id | | `agent_id` | query | string | **Required.** Agent id (plain id, same constraint as the transcript endpoint) | | `since_seq` | query | integer | **Required.** The caller's last applied op-batch seq, minimum `0`; batches above it are returned | -| `limit` | query | integer | Maximum batches per response, 1–500. Default `500` | +| `limit` | query | integer | Maximum batches per response, 1–500. Omit to receive every batch after `since_seq` (the pre-`limit` behaviour) | On success, `data` is `{ agent_id, batches, latest_seq, complete, has_more }`, each batch `{ seq, ops }`. `latest_seq` is the newest seq covered by this response: the journal's newest seq, or the last returned batch `seq` when the response is capped. `has_more: true` means the cap cut the response short and newer batches remain — call again with `since_seq` set to `latest_seq` until `has_more` is `false`. `complete: true` means every batch from `since_seq` up to `latest_seq` is present (a capped response is still `complete`); `complete: false` means the journal no longer reaches back to `since_seq` (or the session is not live at all), and the caller must fall back to a full `GET .../transcript` refresh. diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 3fdd80f31a7..54f4392194f 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -960,7 +960,7 @@ main agent 的实时状态汇总;读取它会在会话为冷态时将其恢复 | `session_id` | path | string | **必填。** 会话 id | | `agent_id` | query | string | **必填。** Agent id(纯文本形式,约束与转录端点相同) | | `since_seq` | query | integer | **必填。** 调用方已应用的最后一个 op 批次 seq,最小为 `0`;返回其之后的批次 | -| `limit` | query | integer | 每次响应最多返回的批次数,1–500。默认 `500` | +| `limit` | query | integer | 每次响应最多返回的批次数,1–500。省略时返回 `since_seq` 之后的全部批次(与引入 `limit` 之前的行为一致) | 成功时,`data` 为 `{ agent_id, batches, latest_seq, complete, has_more }`,每个批次为 `{ seq, ops }`。`latest_seq` 是本次响应覆盖到的最新 seq:未截断时为日志中最新的 seq,被 `limit` 截断时为最后返回的批次 `seq`。`has_more: true` 表示响应被截断、之后仍有更新的批次未返回——请把 `since_seq` 设为 `latest_seq` 再次调用,直到 `has_more` 为 `false`。`complete: true` 表示从 `since_seq` 到 `latest_seq` 的全部批次都已包含(被截断的响应仍然是 `complete`);`complete: false` 表示日志已不再覆盖到 `since_seq`(或会话根本不是活跃状态),调用方必须回退为一次完整的 `GET .../transcript` 刷新。 diff --git a/packages/kap-server/src/routes/transcript.ts b/packages/kap-server/src/routes/transcript.ts index 222346d6653..79112c58bdb 100644 --- a/packages/kap-server/src/routes/transcript.ts +++ b/packages/kap-server/src/routes/transcript.ts @@ -225,7 +225,7 @@ export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: Transcr [ErrorCode.SESSION_NOT_FOUND]: {}, }, description: - 'Point-to-point transcript catch-up: journaled op batches with seq > since_seq for one agent, oldest first, at most limit batches per response (default 500). latest_seq is the newest seq covered by the response (the last returned batch when capped); has_more:true means newer batches remain — page again with since_seq=latest_seq. complete:false means the session is not live or the journal no longer reaches back to since_seq — the caller must fall back to a full transcript refresh', + 'Point-to-point transcript catch-up: journaled op batches with seq > since_seq for one agent, oldest first, at most limit batches per response when limit is given (every batch when it is omitted). latest_seq is the newest seq covered by the response (the last returned batch when capped); has_more:true means newer batches remain — page again with since_seq=latest_seq. complete:false means the session is not live or the journal no longer reaches back to since_seq — the caller must fall back to a full transcript refresh', tags: ['transcript'], }, async (req, reply) => { @@ -236,7 +236,7 @@ export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: Transcr session_id, query.agent_id, query.since_seq, - query.limit ?? MAX_TRANSCRIPT_OPS_LIMIT, + query.limit, ); if (catchup === undefined) { const roster = await transcriptService.readColdRoster(session_id); diff --git a/packages/kap-server/test/transcript.test.ts b/packages/kap-server/test/transcript.test.ts index e42f1d526b1..5ce26e328d8 100644 --- a/packages/kap-server/test/transcript.test.ts +++ b/packages/kap-server/test/transcript.test.ts @@ -944,6 +944,34 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { }); }); + it('returns every journaled batch when limit is omitted, even past the 500 cap', async () => { + const id = await createSession(); + await ensureMainAgent(id); + + const bound = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); + const base = bound.body.data.seq!; + + const bus = mainAgentBus(id); + for (let turnId = 1; turnId <= 260; turnId += 1) { + bus.publish(serverEvent({ type: 'turn.started', turnId, origin: { kind: 'user' } })); + bus.publish(serverEvent({ type: 'turn.ended', turnId, reason: 'completed' })); + } + + const unsized = await getJson( + `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${base}`, + ); + expect(unsized.body.code).toBe(0); + expect(unsized.body.data.batches.length).toBeGreaterThan(500); + expect(unsized.body.data).toMatchObject({ has_more: false, complete: true }); + expect(unsized.body.data.latest_seq).toBe(unsized.body.data.batches.at(-1)!.seq); + + const capped = await getJson( + `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${base}&limit=500`, + ); + expect(capped.body.data.batches).toHaveLength(500); + expect(capped.body.data.has_more).toBe(true); + }); + it('rejects an out-of-range limit on the ops route with 40001', async () => { const id = await createSession(); const zero = await getJson( From 2f7a3949c51455754a12d8b00daabe19a8fe83d3 Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:44:22 +0300 Subject: [PATCH 09/10] fix(kap-server): queue immediate envelopes behind a deferred backlog to keep seqs ordered --- .../src/transport/ws/v1/wsConnectionV1.ts | 9 +++++- .../kap-server/test/wsConnectionV1.test.ts | 31 ++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index 31c6bf44668..a3241ccc2ed 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -145,7 +145,7 @@ export class WsConnectionV1 implements BroadcastTarget { } send(envelope: EventEnvelope, delivery: BroadcastDelivery = 'subscription'): void { - if (delivery === 'immediate') this.sendImmediateFrame(envelope); + if (delivery === 'immediate') this.sendImmediateEnvelope(envelope); else this.sendSubscribedFrame(envelope); } @@ -421,6 +421,13 @@ export class WsConnectionV1 implements BroadcastTarget { this.scheduleFlush(); } + private sendImmediateEnvelope(envelope: EventEnvelope): void { + if (this.closed) return; + this.flush(); + if (this.outbound.length > 0) this.outbound.push(envelope); + else this.sendFrame(envelope); + } + private sendImmediateFrame(msg: unknown): void { if (this.closed) return; this.flush(); diff --git a/packages/kap-server/test/wsConnectionV1.test.ts b/packages/kap-server/test/wsConnectionV1.test.ts index 23c85c25c47..46fbc7b65b9 100644 --- a/packages/kap-server/test/wsConnectionV1.test.ts +++ b/packages/kap-server/test/wsConnectionV1.test.ts @@ -788,25 +788,42 @@ describe('WsConnectionV1 outbound buffer', () => { conn.close(); }); - it('sends a control frame above the high-water mark without flushing the backlog', async () => { + it('queues an immediate event behind a deferred backlog so seqs stay in order', async () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 16, highWaterMarkBytes: 100 }); socket.sent = []; socket.bufferedAmount = 200; - conn.send(delta('s1', 'main', 1, 'stuck', 0)); + conn.send(durable('turn.ended', 's1', 7)); await vi.advanceTimersByTimeAsync(16); expect(socket.sent).toHaveLength(0); - conn.send(durable('session.work_changed', 's1', 7), 'immediate'); - let frames = socket.frames() as Array<{ type: string }>; - expect(frames.map((f) => f.type)).toEqual(['session.work_changed']); + conn.send(durable('session.meta.updated', 's1', 8), 'immediate'); + expect(socket.sent).toHaveLength(0); expect(socket.closeCalls).toHaveLength(0); socket.bufferedAmount = 0; await vi.advanceTimersByTimeAsync(5); - frames = socket.frames() as Array<{ type: string }>; - expect(frames.map((f) => f.type)).toEqual(['session.work_changed', 'assistant.delta']); + const frames = socket.frames() as Array<{ type: string; seq: number }>; + expect(frames.map((f) => [f.type, f.seq])).toEqual([ + ['turn.ended', 7], + ['session.meta.updated', 8], + ]); + conn.close(); + }); + + it('still sends an immediate event straight away when nothing is deferred', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16, highWaterMarkBytes: 100 }); + socket.sent = []; + + conn.send(durable('turn.ended', 's1', 7)); + conn.send(durable('session.meta.updated', 's1', 8), 'immediate'); + const frames = socket.frames() as Array<{ type: string; seq: number }>; + expect(frames.map((f) => [f.type, f.seq])).toEqual([ + ['turn.ended', 7], + ['session.meta.updated', 8], + ]); conn.close(); }); From 028c709bb2d986fc4256349000c49f3e85504f0c Mon Sep 17 00:00:00 2001 From: REtoolsx <123335736+REtoolsx@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:44:31 +0300 Subject: [PATCH 10/10] fix(remote-control): resume the bridge pump as soon as the tunnel drains to the high-water mark --- packages/remote-control/src/remote-control.ts | 7 ++-- .../test/remote-control.test.ts | 34 ++++++++++++++++--- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/remote-control/src/remote-control.ts b/packages/remote-control/src/remote-control.ts index 9965edbdd7b..6fca9f85649 100644 --- a/packages/remote-control/src/remote-control.ts +++ b/packages/remote-control/src/remote-control.ts @@ -35,7 +35,6 @@ const MAX_RECONNECT_DELAY_MS = 30_000; const MAX_EARLY_FRAME_BYTES = 1024 * 1024; const MAX_EARLY_FRAMES = 256; const BRIDGE_HIGH_WATER_MARK_BYTES = 1024 * 1024; -const BRIDGE_LOW_WATER_MARK_BYTES = 256 * 1024; const BRIDGE_DRAIN_POLL_MS = 20; const RESPONSE_CHUNK_BYTES = 256 * 1024; const RELAY_PING_INTERVAL_MS = 30_000; @@ -1093,7 +1092,9 @@ export function bridgeSockets( } // One direction of the bridge: pauses the source while the sink's send buffer is above the -// high-water mark and polls it back below the low-water mark before resuming. +// high-water mark and resumes as soon as a poll sees it back at the mark. There is no lower +// resume threshold on purpose: the local server closes a peer whose socket makes no progress +// for 15 s, so each pause must stay short even when the relay link drains slowly. function createPump( from: BridgeSocket, to: BridgeSocket, @@ -1118,7 +1119,7 @@ function createPump( if (drain !== undefined || to.bufferedAmount <= BRIDGE_HIGH_WATER_MARK_BYTES) return; from.pause(); drain = setInterval(() => { - if (to.bufferedAmount < BRIDGE_LOW_WATER_MARK_BYTES) dispose(); + if (to.bufferedAmount <= BRIDGE_HIGH_WATER_MARK_BYTES) dispose(); }, BRIDGE_DRAIN_POLL_MS); }; return { forward, throttled: () => drain !== undefined, dispose }; diff --git a/packages/remote-control/test/remote-control.test.ts b/packages/remote-control/test/remote-control.test.ts index 81e479fee1f..b15ee1bb2eb 100644 --- a/packages/remote-control/test/remote-control.test.ts +++ b/packages/remote-control/test/remote-control.test.ts @@ -894,7 +894,7 @@ describe('Remote Control stream bridge', () => { expect(socket.isPaused).toBe(false); }); - it('pauses the source while the sink is above the high-water mark and resumes below the low one', () => { + it('pauses the source while the sink is above the high-water mark and resumes once it is back at it', () => { vi.useFakeTimers(); const local = new FakeSocket(); const tunnel = new FakeSocket(); @@ -912,10 +912,7 @@ describe('Remote Control stream bridge', () => { vi.advanceTimersByTime(40); expect(local.isPaused).toBe(true); - tunnel.bufferedAmount = 256 * 1024; - vi.advanceTimersByTime(20); - expect(local.isPaused).toBe(true); - tunnel.bufferedAmount = 256 * 1024 - 1; + tunnel.bufferedAmount = 1024 * 1024; vi.advanceTimersByTime(20); expect(local.isPaused).toBe(false); @@ -934,6 +931,33 @@ describe('Remote Control stream bridge', () => { expect(vi.getTimerCount()).toBe(0); }); + it('resumes the source as soon as a slowly draining sink is back at the high-water mark', () => { + vi.useFakeTimers(); + const local = new FakeSocket(); + const tunnel = new FakeSocket(); + bridgeSockets(local, tunnel, () => {}); + + tunnel.bufferedAmount = 1024 * 1024 + 64 * 1024; + local.emit('message', Buffer.from('one'), false); + expect(local.isPaused).toBe(true); + + // A slow link drains a little per poll; the source must be released as soon as the sink + // is back at the mark, not after a further deep drain the local server would time out on. + for (let polls = 0; polls < 63; polls += 1) { + tunnel.bufferedAmount -= 1024; + vi.advanceTimersByTime(20); + expect(local.isPaused).toBe(true); + } + tunnel.bufferedAmount -= 1024; + vi.advanceTimersByTime(20); + expect(local.isPaused).toBe(false); + expect(vi.getTimerCount()).toBe(0); + + local.emit('message', Buffer.from('two'), false); + expect(tunnel.sent).toEqual(['one', 'two']); + expect(local.isPaused).toBe(false); + }); + it('resumes a source paused by back-pressure before closing it', () => { vi.useFakeTimers(); const local = new FakeSocket();