diff --git a/.changeset/remote-control-asset-caching.md b/.changeset/remote-control-asset-caching.md new file mode 100644 index 00000000000..4c079d9e1df --- /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. 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 new file mode 100644 index 00000000000..0e108092db5 --- /dev/null +++ b/.changeset/sessions-list-paginated.md @@ -0,0 +1,5 @@ +--- +"@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 an optional `limit` and reports `has_more` (an unsized request still returns every batch). 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..9819bf6e327 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -9,3 +9,8 @@ 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 +dist-web/**/*.tmp 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..0b4c3f9489f --- /dev/null +++ b/apps/kimi-code/scripts/precompress-web-assets.mjs @@ -0,0 +1,214 @@ +#!/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] + +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)), '..'); +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 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 = parseCliArgs(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 }} options + * @returns {Promise<{ processed: number, written: number, skipped: number, removed: number, bytesBefore: number, bytesAfter: number }>} + */ +export async function precompressWebAssets({ distDir, check = false, force = false }) { + const files = await listFiles(distDir); + if (check) { + await assertEntryAssetsPrecompressed(distDir, files); + return { processed: 0, written: 0, skipped: 0, removed: 0, bytesBefore: 0, bytesAfter: 0 }; + } + + const formats = Object.values(FORMATS); + 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 writeSiblingAtomically(siblingPath, compressed); + summary.written++; + smallest = Math.min(smallest, compressed.length); + } + return smallest; +} + +// 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))); + 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(', ')}. ` + + 'Run `pnpm --filter @moonshot-ai/kimi-code run precompress:web` and rebuild.', + ); + } +} + +function isCompressible(file) { + return COMPRESSIBLE_EXTENSIONS.has(extname(file).toLowerCase()); +} + +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 parseCliArgs(args) { + return parseArgs({ + args, + options: { check: { type: 'boolean' }, force: { type: 'boolean' } }, + strict: true, + }).values; +} 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..fc72e3013ad 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -11,7 +11,12 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -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'; @@ -40,6 +45,7 @@ import { type NetworkAddress } from './networks'; import { formatRemoteControlOutput, formatRemoteControlStatus, + REMOTE_CONTROL_CHUNKED_RESPONSES_FLAG_ID, startRemoteControl, type RemoteControlHandle, type RemoteControlOptions, @@ -75,9 +81,14 @@ export interface WebCliOptions extends ServerCliOptions { remoteControl?: boolean; } +/** What the ready hook may ask of the listening server. */ +export interface ForegroundServer { + readonly flags: ExperimentalFlags; +} + 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 +212,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 +238,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 +412,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.flags }); } 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/scripts/precompress-web-assets.test.ts b/apps/kimi-code/test/scripts/precompress-web-assets.test.ts new file mode 100644 index 00000000000..a87c8c19f80 --- /dev/null +++ b/apps/kimi-code/test/scripts/precompress-web-assets.test.ts @@ -0,0 +1,196 @@ +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('does not require a sibling for an entry asset too small to compress', async () => { + const distDir = await makeDist(); + const css = join(distDir, 'assets', 'index-Ab12Cd34.css'); + 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('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/, + ); + }); + + 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, + }); + }); +}); 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/apps/kimi-inspect/src/activity/store.test.ts b/apps/kimi-inspect/src/activity/store.test.ts index 3e600fe8122..e4ce79e0993 100644 --- a/apps/kimi-inspect/src/activity/store.test.ts +++ b/apps/kimi-inspect/src/activity/store.test.ts @@ -122,6 +122,83 @@ describe('SessionActivityHub', () => { hub.close(); }); + 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) => { + 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')).toBeNull(); + 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('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 af77ad6c062..d0a2caed12d 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,38 @@ 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; + // 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 query = + before === undefined + ? '' + : `?${new URLSearchParams({ page_size: String(SEED_PAGE_SIZE), before_id: before })}`; + let envelope: { + code: number; + data?: { items?: Record[]; has_more?: boolean }; + }; + 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; + 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/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 6936ca8a7b7..1243cb8a035 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -138,6 +138,14 @@ 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) | 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 | +| `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_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 | @@ -155,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 e4a49c0c469..c3f032b1df0 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. Text responses (HTML, JavaScript, CSS, JSON, SVG) are gzip-compressed on the tunnel when the browser accepts gzip; the versioned validator is weak, so a compressed copy still revalidates with a `304`. + +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? [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..52feafc36bb 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): 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` | -| `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. 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`; 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. Omit to receive every batch after `since_seq` (the pre-`limit` behaviour) | -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 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 @@ -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..3b3956d9c4f 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -138,6 +138,14 @@ 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`/`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`) | 正整数;非法值被忽略 | +| `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_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` 表示无超时 | 非负整数;非法值被忽略 | @@ -155,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 105fe12e674..6860f6e6ff7 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`,只有真正变化的文件才会重新下载。慢速网络下首次加载仍需完整传输一次。文本响应(HTML、JavaScript、CSS、JSON、SVG)在浏览器接受 gzip 时会在中转链路上以 gzip 压缩发送;带版本的 `ETag` 是弱校验值,因此压缩副本同样可以通过 `304` 重新校验。 + +实验性功能 `remote_control_chunked_responses` 会把较大的 HTTP 响应拆成 256 KiB 的中转帧,而不是一帧发送整个响应。可通过设置 `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL_CHUNKED_RESPONSES=1`、使用 `[experimental]` 配置段或 `KIMI_CODE_EXPERIMENTAL_FLAG` 总开关启用。默认关闭,仅用于排查慢速网络下的问题;除非被要求尝试,否则不要开启。 + ## 远程控制和 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..54f4392194f 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` 时,响应包含全部匹配会话且 `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` | 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。省略时返回整个列表(`archived_only` 默认每页 `20` 条) | +| `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。省略时返回 `since_seq` 之后的全部批次(与引入 `limit` 之前的行为一致) | -成功时,`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:未截断时为日志中最新的 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`:会话不存在 @@ -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/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/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/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index 94e77458223..dabedf0e2d5 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -58,7 +58,7 @@ const WORK_DIR = '/home/user/repo'; function canonicalIds(summaries: readonly SessionSummary[]): string[] { return [...summaries] - .sort((a, b) => (a.updatedAt !== b.updatedAt ? b.updatedAt - a.updatedAt : a.id < b.id ? 1 : -1)) + .toSorted((a, b) => (a.updatedAt !== b.updatedAt ? b.updatedAt - a.updatedAt : a.id < b.id ? 1 : -1)) .map((s) => s.id); } diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts index 542f7005506..062a22ba868 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts @@ -114,7 +114,7 @@ describe('SessionIndexMirror', () => { mirror.record(summary('a', { title: 'first', updatedAt: 1 })); mirror.record(summary('a', { title: 'latest', updatedAt: 5 })); mirror.record(summary('b', { archived: true, updatedAt: 3 })); - expect(mirror.pending().map((s) => s.id).sort()).toEqual(['a', 'b']); + expect(mirror.pending().map((s) => s.id).toSorted()).toEqual(['a', 'b']); await mirror.drain(); expect(mirror.pending()).toEqual([]); diff --git a/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts index 0a5bbcbd444..f2ae7e5a582 100644 --- a/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts @@ -237,7 +237,7 @@ describe('MiniDbQueryStore', () => { { kind: 'put', collection: COLLECTION, key: 'b', value: { v: 2 } }, ]); const found = await store.getMany<{ v: number }>(COLLECTION, ['a', 'missing', 'b']); - expect([...found.keys()].sort()).toEqual(['a', 'b']); + expect([...found.keys()].toSorted()).toEqual(['a', 'b']); expect(found.get('a')).toEqual({ v: 1 }); expect(found.get('b')).toEqual({ v: 2 }); expect(await store.getMany(COLLECTION, [])).toEqual(new Map()); 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/sessions.ts b/packages/kap-server/src/routes/sessions.ts index d4799d6fd20..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 = 20; +const DEFAULT_ARCHIVED_LIST_PAGE_SIZE = 20; 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. 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) => { @@ -308,11 +309,13 @@ 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 }> => { - 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; @@ -321,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, }); @@ -339,55 +342,22 @@ 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; } + if (pageSize === undefined) return { visible: collected, hasMore: false }; 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 pageSize = + raw.page_size ?? (archivedOnly ? DEFAULT_ARCHIVED_LIST_PAGE_SIZE : undefined); 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..79112c58bdb 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 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) => { 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, + ); 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..fbc882e0d04 100644 --- a/packages/kap-server/src/routes/webAssets.ts +++ b/packages/kap-server/src/routes/webAssets.ts @@ -1,9 +1,12 @@ -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'; +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, @@ -11,6 +14,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 +74,101 @@ 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, pickHeader(req.headers, 'accept-encoding')) + : undefined; + const source = variant ?? file; + 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(pickHeader(req.headers, 'if-none-match'), etag)) { + return reply.code(304).send(); } - return reply - .type(mimeType(filePath)) - .header('Cache-Control', cacheControl(assetsDir, filePath)) - .header('Content-Length', String(fileInfo.size)) - .send(createReadStream(filePath)); + 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)); +} + +async function findEncodedVariant( + file: StaticFile, + acceptEncoding: string | undefined, +): Promise { + if (acceptEncoding === undefined) { + return undefined; + } + const accepted = parseAcceptEncoding(acceptEncoding); + const candidates = PRECOMPRESSED_ENCODINGS.map((candidate) => ({ + candidate, + weight: encodingWeight(accepted, candidate.encoding), + })) + .filter(({ weight }) => weight > 0) + .toSorted((a, b) => b.weight - a.weight); + 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; +} + +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 encodingWeight(weights: Map, encoding: string): number { + return weights.get(encoding) ?? weights.get('*') ?? 0; +} + +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 +183,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 +202,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 +236,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 +253,12 @@ 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'; default: return 'application/octet-stream'; } diff --git a/packages/kap-server/src/search/searchService.ts b/packages/kap-server/src/search/searchService.ts index d8e755a2241..ec50ba05a7a 100644 --- a/packages/kap-server/src/search/searchService.ts +++ b/packages/kap-server/src/search/searchService.ts @@ -440,7 +440,7 @@ export class GlobalSearchService implements IGlobalSearchService { items: pageRows.map((row) => this.projectHit(q, row)), hasMore, pageToken: hasMore - ? encodePageToken(q, 'live', boundaryOf(q, pageRows[pageRows.length - 1]!), undefined) + ? encodePageToken(q, 'live', boundaryOf(q, pageRows.at(-1)!), undefined) : undefined, incomplete: matched.incomplete, indexState: { @@ -606,7 +606,7 @@ export class GlobalSearchService implements IGlobalSearchService { ? encodePageToken( q, 'index', - boundaryOf(q, result.rows[result.rows.length - 1]!), + boundaryOf(q, result.rows.at(-1)!), result.generation, ) : undefined, diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 887f1c1c90f..f4d5b382d7f 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,54 @@ 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 { + 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); + } + } + + 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 journalOps(sessionId: string, event: TranscriptChangeEvent): number { const entry = this.live.get(sessionId); if (entry === undefined) return 0; @@ -265,6 +335,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 +344,33 @@ 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; + const lastReturned = batches.at(-1)?.seq; + return { + batches, + latestSeq: hasMore && lastReturned !== undefined ? lastReturned : 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)) { @@ -557,6 +643,7 @@ export class TranscriptService { } dropSession(sessionId: string): void { + this.flushPendingOps(sessionId); this.opsListeners.delete(sessionId); for (const [key, pending] of this.healTimers) { if (key.startsWith(`${sessionId}:`)) { @@ -629,6 +716,16 @@ 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; +} + function projectQuestionInteractionRecords( records: readonly ContextRecord[], sessionId: string, diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 5ad117df270..9dfab5dd543 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, @@ -26,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, @@ -59,7 +61,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 { registerWsV3, WS_PATH_V3 } from './transport/ws/v3/registerWsV3'; import { getServerVersion } from './version'; @@ -83,7 +85,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'; @@ -126,9 +131,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; @@ -196,18 +206,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, @@ -223,6 +221,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) { @@ -348,12 +360,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, @@ -481,6 +500,7 @@ export async function startServer(opts: ServerStartOptions): Promise { diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 06af509aeba..b2f72d23399 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 }), @@ -546,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 3da60b7fc44..a3241ccc2ed 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; @@ -91,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(); @@ -122,7 +124,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,11 +141,11 @@ 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 { - if (delivery === 'immediate') this.sendImmediateFrame(envelope); + if (delivery === 'immediate') this.sendImmediateEnvelope(envelope); else this.sendSubscribedFrame(envelope); } @@ -416,10 +421,25 @@ 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.outbound.push(msg); 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 { @@ -442,38 +462,65 @@ 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 = []; - 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; - if (now - this.backpressureSince >= DEFAULT_BACKPRESSURE_MAX_DELAY_MS) { - this.flush(true); + 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; } 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/search/searchService.test.ts b/packages/kap-server/test/search/searchService.test.ts index 2c0bccc15e9..f5c5f901dbc 100644 --- a/packages/kap-server/test/search/searchService.test.ts +++ b/packages/kap-server/test/search/searchService.test.ts @@ -1836,7 +1836,7 @@ describe('GlobalSearchService', () => { }); expect(page.source).toBe('live'); expect(page.items.length).toBe(3); - expect(page.items.map((h) => h.role).sort()).toEqual(['assistant', 'title', 'user']); + expect(page.items.map((h) => h.role).toSorted()).toEqual(['assistant', 'title', 'user']); await expect(service.search({ query: '苹', mode: 'literal' })).rejects.toMatchObject({ reason: 'invalid_query', diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 3d69189891e..2982c1db216 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'; @@ -56,6 +56,7 @@ import { healTurnOps, TranscriptService, snapshotToOps, + TRANSCRIPT_OPS_BATCH_MAX_OPS, TRANSCRIPT_OPS_JOURNAL_CAPACITY, } from '../../src/services/transcript/transcriptService'; @@ -3691,7 +3692,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 +3987,138 @@ 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'); + }); }); }); 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..c35a9b7ad86 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,54 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.data.has_more).toBe(false); }); + it('returns every session with has_more false 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('keeps an unsized listing unbounded and pages only when page_size is given', 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 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)}&page_size=50`, + ); + 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 +1760,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 df8fe96055e..5ce26e328d8 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 { @@ -900,6 +901,153 @@ 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: base + 1, + }); + + 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('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( + `/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..dfb7ed66a3e 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,227 @@ 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('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 }); + + 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-z]+-[0-9a-z]+-[0-9a-z]+"$/); + 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..46fbc7b65b9 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,146 @@ 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('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(durable('turn.ended', 's1', 7)); + await vi.advanceTimersByTimeAsync(16); + expect(socket.sent).toHaveLength(0); + + 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); + 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(); + }); + + 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(['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 }); @@ -900,3 +1061,68 @@ 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 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).toBe(true); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: 'maybe' }).compression).toBeUndefined(); + expect(parseWsTuning({ KIMI_CODE_WS_COMPRESSION: '' }).compression).toBeUndefined(); + }); +}); diff --git a/packages/minidb/src/cluster/index.ts b/packages/minidb/src/cluster/index.ts index 8068906100d..909ce84ef49 100644 --- a/packages/minidb/src/cluster/index.ts +++ b/packages/minidb/src/cluster/index.ts @@ -125,9 +125,9 @@ export class ClusterDb { for (const { name, fields } of reg.textIndexes) { try { await db.createTextIndex(name, { fields: fields ?? undefined }); - } catch (e) { + } catch (error) { // Idempotent apply: the def may already exist on this shard. - if (!(e instanceof Error) || !e.message.includes('already exists')) throw e; + if (!(error instanceof Error) || !error.message.includes('already exists')) throw error; } } }, @@ -307,9 +307,9 @@ export class ClusterDb { try { const raw = JSON.parse(await fs.readFile(file, 'utf8')) as Partial; return { indexes: raw.indexes ?? [], compoundIndexes: raw.compoundIndexes ?? [], textIndexes: raw.textIndexes ?? [] }; - } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { indexes: [], compoundIndexes: [], textIndexes: [] }; - throw e; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { indexes: [], compoundIndexes: [], textIndexes: [] }; + throw error; } } @@ -423,14 +423,14 @@ export class ClusterDb { createdOn.push(shardId); } }); - } catch (e) { + } catch (error) { // Roll back the partial fan-out: drop the index from exactly the shards // this call created it on, so no shard keeps enforcing an index the // registry never recorded. await this.rollbackShards(createdOn, async (db) => { await db.dropIndex(name); }); - throw e; + throw error; } await this.mutateRegistry((current) => { const existing = current.indexes.find((i) => i.name === name); @@ -538,12 +538,12 @@ export class ClusterDb { createdOn.push(shardId); } }); - } catch (e) { + } catch (error) { // Roll back the partial fan-out (see createIndex). await this.rollbackShards(createdOn, async (db) => { await db.dropCompoundIndex(name); }); - throw e; + throw error; } await this.mutateRegistry((current) => { const existing = current.compoundIndexes.find((i) => i.name === name); @@ -598,17 +598,17 @@ export class ClusterDb { try { await db.createTextIndex(name, opts); createdOn.push(shardId); - } catch (e) { - if (!(e instanceof Error) || !e.message.includes('already exists')) throw e; + } catch (error) { + if (!(error instanceof Error) || !error.message.includes('already exists')) throw error; } }); - } catch (e) { + } catch (error) { // Roll back the partial fan-out: drop the text index only from the // shards this call created it on (see createIndex). await this.rollbackShards(createdOn, async (db) => { await db.dropTextIndex(name); }); - throw e; + throw error; } const fields = opts.fields ?? null; await this.mutateRegistry((current) => { @@ -625,8 +625,8 @@ export class ClusterDb { await this.forEachShardWriter(async (db) => { try { await db.dropTextIndex(name); - } catch (e) { - if (!(e instanceof Error) || !e.message.includes('no such text index')) throw e; + } catch (error) { + if (!(error instanceof Error) || !error.message.includes('no such text index')) throw error; } }); if (!existed) return false; @@ -649,9 +649,9 @@ export class ClusterDb { const rows = await this.reader(id, (db) => { try { return db.search(name, q, opts); - } catch (e) { - if (e instanceof Error && e.message.includes('no such text index')) return []; - throw e; + } catch (error) { + if (error instanceof Error && error.message.includes('no such text index')) return []; + throw error; } }); out.push(...rows); @@ -672,9 +672,9 @@ export class ClusterDb { try { await this.writer(id, (db) => db.compact()); compacted.push(id); - } catch (e) { - if (e instanceof LockError) skipped.push(id); - else throw e; + } catch (error) { + if (error instanceof LockError) skipped.push(id); + else throw error; } } return { compacted, skipped }; diff --git a/packages/minidb/src/generation-builder.ts b/packages/minidb/src/generation-builder.ts index e7d10969fd5..bc9a7f6e13d 100644 --- a/packages/minidb/src/generation-builder.ts +++ b/packages/minidb/src/generation-builder.ts @@ -338,7 +338,7 @@ export class GenerationBuilder { const drainQueue = (): void => { if (gb.queue.length === 0) return; - const ops = gb.queue.splice(0, gb.queue.length); + const ops = gb.queue.splice(0); gb.bytes = 0; for (const op of ops) { if (op.type === TYPE_SET) { @@ -488,8 +488,8 @@ export class GenerationBuilder { let snapAnchor: fsSync.Stats | null = null; try { snapAnchor = fsSync.statSync(snapPath); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } // Host the bounded build in a worker thread when its entry file // exists; otherwise run the SAME bounded core inline on the main @@ -505,9 +505,9 @@ export class GenerationBuilder { if (workerAvailable) { try { workerSlotRelease = await defaultWorkerSlots.acquireBounded(this.deps.textBuildSlotWaitMs(), aborter.signal); - } catch (e) { - if (e instanceof MaintenanceCancelledError) throw new GenerationBuildAborted('worker slot wait cancelled'); - throw e; + } catch (error) { + if (error instanceof MaintenanceCancelledError) throw new GenerationBuildAborted('worker slot wait cancelled'); + throw error; } if (workerSlotRelease === null) inlineReason = 'slot-pressure'; } else { @@ -571,7 +571,7 @@ export class GenerationBuilder { // The store image is written in ascending key order (the load path // bulk-builds the ordered index from file order): the walk's keys were // already sorted, but queue-applied keys appended out of order. - const sortedImageKeys = [...imageRecords.keys()].sort(); + const sortedImageKeys = [...imageRecords.keys()].toSorted(); const storeRes = await writeStoreImage( path.join(tmpDir, STORE_IMAGE_FILE), (function* (): Generator { @@ -599,12 +599,12 @@ export class GenerationBuilder { let result: TextBuildCoreResult; try { result = await workerHandle.promise; - } catch (e) { - if (e instanceof WorkerTextBuildError && e.aborted) { - throw new GenerationBuildAborted(`worker build cancelled: ${e.message}`); + } catch (error) { + if (error instanceof WorkerTextBuildError && error.aborted) { + throw new GenerationBuildAborted(`worker build cancelled: ${error.message}`); } if (!workerHandle.inline) this.deps.stats.textWorkerErrors++; - throw e; + throw error; } checkAlive(); if (result.scannedLiveKeys > imageRecords.size) { @@ -704,8 +704,8 @@ export class GenerationBuilder { let snapshotLinked = false; try { snapSt = await fs.stat(snapSrc); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } if (snapSt) { try { @@ -822,7 +822,7 @@ export class GenerationBuilder { for (const name of workerResults.keys()) { await fs.rm(path.join(generationDir(this.deps.dir(), id), `${textDocsFile(name)}.base`), { force: true }).catch(() => {}); } - } catch (e) { + } catch (error) { if (this.genBuild === gb) this.genBuild = null; // Uncommitted staged builds only disarm their queues (the live indexes // stay authoritative); committed ones keep their new base — its @@ -835,14 +835,14 @@ export class GenerationBuilder { // dir, never in the live generation). for (const [, { ti }] of workerTargets) ti.abortRebase(); if (workerHandle) await workerHandle.cancel(); - if (e instanceof GenerationBuildAborted) { + if (error instanceof GenerationBuildAborted) { this.deps.stats.generationBuildAborts++; this.deps.noteBuildFailure?.(); return; } this.deps.stats.generationBuildErrors++; - this.deps.noteBuildFailure?.(e); - throw e; + this.deps.noteBuildFailure?.(error); + throw error; } finally { if (this.genBuild === gb) this.genBuild = null; workerSlotRelease?.(); diff --git a/packages/minidb/src/lifecycle.ts b/packages/minidb/src/lifecycle.ts index 020ac322c80..4c9a05390c2 100644 --- a/packages/minidb/src/lifecycle.ts +++ b/packages/minidb/src/lifecycle.ts @@ -306,9 +306,9 @@ export async function openMiniDb(db: LifecycleHost, opts: OpenOptions, hoo let ids: ReturnType; try { ids = reader.open(); - } catch (e) { + } catch (error) { reader.close(); - throw e; + throw error; } const sameInode = (a: { dev: number; ino: number } | null, i: { dev: number; ino: number } | null): boolean => a === null ? i === null : i !== null && i.dev === a.dev && i.ino === a.ino; @@ -364,7 +364,7 @@ export async function openMiniDb(db: LifecycleHost, opts: OpenOptions, hoo // text-index base build is still pending in the background. db.lifecycle.time('openMs', performance.now() - openT0); db.lifecycle.finishOpen(); - } catch (err) { + } catch (error) { // A background open-time compaction may still be in flight: settle it // before tearing down the WAL/store/handles it touches. if (db.compacting && db._compactDone) await db._compactDone.catch(() => {}); @@ -385,8 +385,8 @@ export async function openMiniDb(db: LifecycleHost, opts: OpenOptions, hoo // onLockFail:'readonly'): the instance never owned the directory, so // openOrRebuild must not "rebuild" (delete) anything in it — it rethrows // instead of touching a live writer's files (lock-review repro). - if (db.readOnly && err && typeof err === 'object') (err as { readOnlyOpen?: boolean }).readOnlyOpen = true; - throw err; + if (db.readOnly && error && typeof error === 'object') (error as { readOnlyOpen?: boolean }).readOnlyOpen = true; + throw error; } } @@ -454,8 +454,8 @@ async function closeResources(db: LifecycleHost, hooks: LifecycleHooks): P const errors: unknown[] = []; try { hooks.closeAllTextIndexes(); - } catch (e) { - errors.push(e); + } catch (error) { + errors.push(error); } // Drop a read-only deferred build's private scratch dir. The postings // handles are closed above (fd-before-rm for Windows); the dir is outside @@ -464,24 +464,24 @@ async function closeResources(db: LifecycleHost, hooks: LifecycleHooks): P try { await fs.rm(db.roScratchDir, { recursive: true, force: true }); db.roScratchDir = null; - } catch (e) { - errors.push(e); + } catch (error) { + errors.push(error); } } try { db.store.close(); - } catch (e) { - errors.push(e); + } catch (error) { + errors.push(error); } try { db.valueReader?.close(); - } catch (e) { - errors.push(e); + } catch (error) { + errors.push(error); } try { await db.wal.close(); - } catch (e) { - errors.push(e); + } catch (error) { + errors.push(error); } while (!hooks.walRecoveryIdle()) await hooks.walRecoveryChain(); try { @@ -489,8 +489,8 @@ async function closeResources(db: LifecycleHost, hooks: LifecycleHooks): P await db.lock.release(); db.lock = null; } - } catch (e) { - errors.push(e); + } catch (error) { + errors.push(error); } if (errors.length > 0) { throw new AggregateError( @@ -525,17 +525,17 @@ export async function openOrRebuildMiniDb( ): Promise { try { return await open(opts); - } catch (err) { - if (err instanceof LockError || (err as { code?: string }).code === 'ELOCKED') throw err; + } catch (error) { + if (error instanceof LockError || (error as { code?: string }).code === 'ELOCKED') throw error; // Only rebuild on errors that indicate unrecoverable/corrupt state (e.g. // malformed index-definition JSON). Transient I/O errors (EACCES, ENOSPC, // EIO, EMFILE, …) are rethrown so a cache opener never destroys data // because of a recoverable system error. - const rebuildable = err instanceof SyntaxError || (err as { name?: string }).name === 'CorruptFrameError'; - if (!rebuildable) throw err; - if ((err as { readOnlyOpen?: boolean }).readOnlyOpen) throw err; - if (hooks.onRebuild) hooks.onRebuild(err); - if (err instanceof SyntaxError) { + const rebuildable = error instanceof SyntaxError || (error as { name?: string }).name === 'CorruptFrameError'; + if (!rebuildable) throw error; + if ((error as { readOnlyOpen?: boolean }).readOnlyOpen) throw error; + if (hooks.onRebuild) hooks.onRebuild(error); + if (error instanceof SyntaxError) { // A corrupted index-definition sidecar holds only derived metadata and // must not cost the whole database: drop the sidecars (indexes can be // recreated by the caller) and retry once before falling back to a @@ -553,7 +553,7 @@ export async function openOrRebuildMiniDb( } } const outcome = await wipeStoreDir({ dir: opts.dir }); - if (outcome === 'locked') throw err; + if (outcome === 'locked') throw error; return open(opts); } } diff --git a/packages/minidb/src/mini-db.ts b/packages/minidb/src/mini-db.ts index c6b748d7210..91c3357c145 100644 --- a/packages/minidb/src/mini-db.ts +++ b/packages/minidb/src/mini-db.ts @@ -649,8 +649,8 @@ export class MiniDb { const snapAnchor = fsSync.statSync(path.join(this.dir, SNAPSHOT_FILE)); snapshotDev = snapAnchor.dev; snapshotIno = snapAnchor.ino; - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } const checkpoint: TextBuildCheckpoint = { walOffset: walAnchor.size, @@ -832,8 +832,8 @@ export class MiniDb { const snapAnchor = fsSync.statSync(path.join(this.dir, SNAPSHOT_FILE)); snapDev = snapAnchor.dev; snapIno = snapAnchor.ino; - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } } else { sealedOffset = checkpoint.walOffset; @@ -842,9 +842,9 @@ export class MiniDb { snapDev = checkpoint.snapshotDev; snapIno = checkpoint.snapshotIno; } - } catch (e) { + } catch (error) { ti.abortRebase(); - throw e; + throw error; } // In-place builds land artifacts in a per-index tmp dir inside the db @@ -947,9 +947,9 @@ export class MiniDb { }); if (!handle.inline) this.stats.textWorkerBuilds++; return handle.inline ? 'inline' : 'worker'; - } catch (e) { + } catch (error) { ti.abortRebase(); - throw e; + throw error; } finally { slotRelease?.(); if (tmpDir !== null) { @@ -1006,7 +1006,7 @@ export class MiniDb { this.access.delete(k); this.dt.del(k); this.compound.remove(k); - if (this.indexes.size) this.indexes.remove(k, undefined); + if (this.indexes.size > 0) this.indexes.remove(k, undefined); for (const ti of this.text.values()) ti.remove(k); } @@ -1097,8 +1097,8 @@ export class MiniDb { const st = await fs.stat(this.walPath); if (poison.failedAtOffset <= st.size) await fs.truncate(this.walPath, poison.failedAtOffset); }); - } catch (err) { - this.writeDisabled = err; + } catch (error) { + this.writeDisabled = error; return; } await this.wal.refreshSize(); @@ -1178,7 +1178,7 @@ export class MiniDb { return this.store.size; } async mset(entries: readonly (readonly [string, V])[]): Promise { - if (!entries.length) return; + if (entries.length === 0) return; await this.batch(entries.map(([key, value]) => ({ op: 'set' as const, key, value }))); } mget(keys: readonly string[]): (V | undefined)[] { @@ -1357,9 +1357,9 @@ export class MiniDb { } else { try { const existing = await fs.readdir(destDir); - if (existing.length) throw new Error(`restore destination is not empty: ${destDir}`); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + if (existing.length > 0) throw new Error(`restore destination is not empty: ${destDir}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } } await fs.mkdir(destDir, { recursive: true }); diff --git a/packages/minidb/src/rename-replace.ts b/packages/minidb/src/rename-replace.ts index 8e146be798b..cadb02db15a 100644 --- a/packages/minidb/src/rename-replace.ts +++ b/packages/minidb/src/rename-replace.ts @@ -27,8 +27,8 @@ export async function retryEperm(op: () => Promise, opts: RenameReplaceOpt for (let attempt = 0; ; attempt++) { try { return await op(); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'EPERM' || attempt >= retries) throw e; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EPERM' || attempt >= retries) throw error; await sleep(base + Math.floor(Math.random() * (base + 10))); } } diff --git a/packages/remote-control/src/flag.ts b/packages/remote-control/src/flag.ts new file mode 100644 index 00000000000..eb893c31d33 --- /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 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', + 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/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/src/remote-control.ts b/packages/remote-control/src/remote-control.ts index 10989c784a1..6fca9f85649 100644 --- a/packages/remote-control/src/remote-control.ts +++ b/packages/remote-control/src/remote-control.ts @@ -32,6 +32,11 @@ 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_DRAIN_POLL_MS = 20; +const RESPONSE_CHUNK_BYTES = 256 * 1024; const RELAY_PING_INTERVAL_MS = 30_000; const RELAY_SILENCE_TIMEOUT_MS = 300_000; const BLOCKED_REQUEST_HEADERS = new Set([ @@ -50,6 +55,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', @@ -81,6 +95,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; @@ -104,6 +137,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 { @@ -121,6 +160,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, @@ -257,13 +301,13 @@ function requestMatchesETag( headers: readonly [string, string][], etag: string, ): boolean { - const candidates = [etag, etag.replace(/^W\//, '')]; + const candidates = new Set([etag, etag.replace(/^W\//, '')]); for (const [name, value] of headers) { if (name.toLowerCase() !== 'if-none-match') continue; for (const token of value.split(',')) { const candidate = token.trim(); if (candidate === '*') return true; - if (candidates.includes(candidate)) return true; + if (candidates.has(candidate)) return true; } } return false; @@ -342,6 +386,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; @@ -367,6 +412,7 @@ class RemoteControlClient { this.onStatus = options.onStatus ?? (() => {}); this.pingIntervalMs = options.pingIntervalMs ?? RELAY_PING_INTERVAL_MS; this.silenceTimeoutMs = options.silenceTimeoutMs ?? RELAY_SILENCE_TIMEOUT_MS; + this.chunkedResponses = options.chunkedResponses ?? false; } async start(): Promise { @@ -426,11 +472,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)); } } @@ -608,15 +650,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 { @@ -631,13 +680,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) { @@ -654,12 +704,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, @@ -694,8 +744,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 { @@ -742,12 +792,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( @@ -758,6 +809,7 @@ async function connectWebSocket( Authorization: `Bearer ${token}`, }, earlyFrames, + perMessageDeflate, ); } @@ -765,16 +817,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; @@ -801,6 +855,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); } @@ -854,6 +923,8 @@ function requestLocalHttp( publicPrefix: string, ): Promise { const origin = new URL(localOrigin); + const forwardHeaders = filterForwardRequestHeaders(parsed.headers, serverToken); + const headRequest = parsed.method === 'HEAD'; return new Promise((resolve, reject) => { const request = httpRequest( { @@ -862,11 +933,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) => { @@ -877,16 +944,23 @@ function requestLocalHttp( void (async (): Promise => { 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 identityEncoded = response.headers['content-encoding'] === undefined; let body = - response.headers['content-encoding'] === undefined + !bodiless && identityEncoded ? rewriteRemoteControlResponse(contentType, receivedBody, publicPrefix) : receivedBody; - const rewritten = body !== receivedBody; + // Rewritten bodies get a content-hash validator and must revalidate on every load: the + // local server's validators describe the original bytes, so they are dropped, and a + // matching `If-None-Match` short-circuits into a bodiless 304 before compression. + const rewritten = !body.equals(receivedBody); const headers = filterResponseHeaders(response.rawHeaders, rewritten); if (rewritten) { const etag = rewrittenResponseETag(body); headers.push('Cache-Control', 'no-cache', 'ETag', etag); - const statusCode = response.statusCode ?? 502; const revalidatable = (parsed.method === 'GET' || parsed.method === 'HEAD') && statusCode >= 200 && @@ -896,29 +970,38 @@ function requestLocalHttp( } } const negotiated = - response.headers['content-encoding'] === undefined && - response.statusCode !== 206 && + !bodiless && + identityEncoded && + statusCode !== 206 && body.length >= GZIP_MIN_BODY_BYTES && isGzipCompressibleType(contentType); if (negotiated) { let varyCovers = false; for (let index = 0; index < headers.length; index += 2) { if (headers[index]!.toLowerCase() !== 'vary') continue; - const tokens = headers[index + 1]! + const tokens = new Set(headers[index + 1]! .toLowerCase() .split(',') - .map((token) => token.trim()); - if (tokens.includes('*') || tokens.includes('accept-encoding')) varyCovers = true; + .map((token) => token.trim())); + if (tokens.has('*') || tokens.has('accept-encoding')) varyCovers = true; } if (!varyCovers) headers.push('Vary', 'Accept-Encoding'); } if (negotiated && acceptsGzipEncoding(parsed.headers)) { body = await gzipAsync(body); headers.push('Content-Encoding', 'gzip'); + // A strong validator names exact bytes, so it cannot describe the gzip + // representation; drop it. Weak validators (including the content-hash tag + // assigned to rewritten bodies above) cover semantically equivalent encodings + // and keep 304 revalidation working through the tunnel. + for (let index = headers.length - 2; index >= 0; index -= 2) { + if (headers[index]!.toLowerCase() !== 'etag') continue; + if (!headers[index + 1]!.startsWith('W/')) headers.splice(index, 2); + } + } + if (!bodilessStatus) { + headers.push('Content-Length', String(body.length)); } - headers.push('Content-Length', String(body.length)); - const statusCode = response.statusCode ?? 502; - const statusMessage = response.statusMessage ?? 'Bad Gateway'; return Buffer.concat([ Buffer.from(`HTTP/1.1 ${statusCode} ${statusMessage}\r\n${headerLines(headers)}\r\n\r\n`), body, @@ -966,7 +1049,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); @@ -976,16 +1059,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); @@ -993,22 +1080,58 @@ 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 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, +): { + 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_HIGH_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 || @@ -1092,6 +1215,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 eca858e48fa..b15ee1bb2eb 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'; @@ -16,14 +17,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { WebSocketServer, type RawData, type WebSocket } from 'ws'; import { + bridgeSockets, + bufferEarlyFrame, buildRemoteControlUrl, filterForwardRequestHeaders, parseRawHttpRequest, + reconnectDelayMs, resolveRemoteControlRelayOrigin, rewriteRemoteControlResponse, startRemoteControl, + type BridgeSocket, + type EarlyFrameBuffer, 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'; @@ -41,6 +49,8 @@ const cleanups: Array<() => Promise | void> = []; afterEach(async () => { vi.unstubAllEnvs(); + vi.restoreAllMocks(); + vi.useRealTimers(); while (cleanups.length > 0) await cleanups.pop()!(); }); @@ -94,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')), @@ -282,7 +312,21 @@ describe('Remote Control tunnel', () => { const assetText = 'chunk of text\n'.repeat(160); 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.url === '/assets/index.js') { + if (request.headers['if-none-match'] === 'W/"v1"') { + response.writeHead(304, { 'Content-Type': 'text/javascript', ETag: '"v1"' }); + response.end(); + return; + } response.writeHead(200, { 'Content-Type': 'text/javascript', ETag: '"v1"' }); response.end(assetJs); return; @@ -295,6 +339,7 @@ describe('Remote Control tunnel', () => { if (request.url === '/assets/logo.svg') { response.writeHead(200, { 'Content-Type': 'image/svg+xml', + ETag: '"svg-1"', 'Cache-Control': 'public, max-age=31536000, immutable', }); response.end(assetSvg); @@ -314,8 +359,18 @@ describe('Remote Control tunnel', () => { }); request.on('end', () => { localHttpBodyBytes = bodyBytes; + 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', @@ -422,13 +477,76 @@ 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 content-hash validator; + // the local server's own validators describe the original bytes and are dropped. + expect(response).toContain('Cache-Control: no-cache'); expect(response).not.toContain('immutable'); + expect(response).not.toContain('ETag: W/"asset-1"'); + const htmlETag = /ETag: (W\/"[0-9a-f]{64}")/.exec(response)?.[1]; + expect(htmlETag).toBeDefined(); + // Below the gzip threshold: neither compressed nor marked as negotiable. expect(response).not.toContain('Content-Encoding'); expect(response).not.toContain('Vary'); expect(localHttpRequest?.headers['accept-encoding']).toBeUndefined(); - expect(response).toContain('Cache-Control: no-cache'); 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(); + }; + + // The browser revalidates the rewritten copy with the tunnel's hash: the local server sees + // the unknown tag and answers 200, and the tunnel collapses the match into a bodiless 304. + const notModified = await tunnelRequest( + 'request-304', + `GET / HTTP/1.1\r\nHost: relay.test\r\nIf-None-Match: ${htmlETag}\r\n\r\n`, + ); + expect(localHttpRequest?.headers['if-none-match']).toBe(htmlETag); + expect(notModified).toMatch(/^HTTP\/1\.1 304 Not Modified\r\n/); + expect(notModified).toContain(`ETag: ${htmlETag}`); + expect(notModified).toContain('Cache-Control: no-cache'); + expect(notModified).not.toContain('immutable'); + expect(notModified).not.toContain('Content-Length'); + expect(notModified).not.toContain(' { expect(gzipHead).toContain('HTTP/1.1 200 OK'); expect(gzipHead).toContain('Content-Encoding: gzip'); expect(gzipHead).toContain('Vary: Accept-Encoding'); + // Rewritten, so the validator is a weak content hash; weak tags survive compression and + // the local server's strong tag is dropped. expect(gzipHead).toContain('Cache-Control: no-cache'); + expect(gzipHead).not.toContain('ETag: "v1"'); const rewrittenETag = /ETag: (W\/"[0-9a-f]{64}")/.exec(gzipHead)?.[0]; expect(rewrittenETag).toBeDefined(); expect(gzipHead).toContain(`Content-Length: ${gzipBody.length}`); @@ -471,29 +592,17 @@ describe('Remote Control tunnel', () => { assetJs.replaceAll('"/assets/', `"/coding-relay/devices/${handle.deviceId}/assets/`), ); - const revalidateResponsePromise = nextJsonMessage(httpConnections[0]!); - httpConnections[0]!.send( - JSON.stringify({ - request_id: 'request-3b', - type: 'request', - is_last: true, - body_base64: Buffer.from( - `GET /assets/index.js HTTP/1.1\r\nHost: relay.test\r\nAccept-Encoding: br, gzip\r\nIf-None-Match: ${rewrittenETag!.replace('ETag: ', '')}\r\n\r\n`, - ).toString('base64'), - }), - ); - const revalidateResponse = Buffer.from( - (await revalidateResponsePromise)['body_base64'] as string, - 'base64', + // A gzip-capable browser revalidates the compressed copy with a bodiless 304. + const gzipNotModified = await tunnelRequest( + 'request-3-304', + `GET /assets/index.js HTTP/1.1\r\nHost: relay.test\r\nAccept-Encoding: br, gzip\r\nIf-None-Match: ${rewrittenETag!.replace('ETag: ', '')}\r\n\r\n`, ); - const revalidateHead = revalidateResponse - .subarray(0, revalidateResponse.indexOf('\r\n\r\n')) - .toString('latin1'); - expect(revalidateHead).toContain('HTTP/1.1 304 Not Modified'); - expect(revalidateHead).toContain('Cache-Control: no-cache'); - expect(revalidateHead).toContain(rewrittenETag!); - expect(revalidateHead).not.toContain('Content-Encoding'); - expect(revalidateHead).not.toContain('Content-Length'); + expect(gzipNotModified).toMatch(/^HTTP\/1\.1 304 Not Modified\r\n/); + expect(gzipNotModified).toContain(rewrittenETag!); + expect(gzipNotModified).toContain('Cache-Control: no-cache'); + expect(gzipNotModified).not.toContain('Content-Encoding'); + expect(gzipNotModified).not.toContain('Content-Length'); + expect(gzipNotModified.endsWith('\r\n\r\n')).toBe(true); const binaryResponsePromise = nextJsonMessage(httpConnections[0]!); httpConnections[0]!.send( @@ -537,6 +646,7 @@ describe('Remote Control tunnel', () => { expect(excludedHead).toContain('Vary: Accept-Encoding'); expect(excludedHead).toContain(rewrittenETag!); expect(excludedHead).not.toContain('ETag: "v1"'); + expect(excludedHead).toContain('Cache-Control: no-cache'); expect(excludedResponse.subarray(excludedSeparator + 4).toString()).toBe( assetJs.replaceAll('"/assets/', `"/coding-relay/devices/${handle.deviceId}/assets/`), ); @@ -558,6 +668,8 @@ describe('Remote Control tunnel', () => { expect(svgHead).toContain('Content-Encoding: gzip'); expect(svgHead).toContain('Vary: Accept-Encoding'); expect(svgHead).toContain('immutable'); + // Not rewritten: the strong upstream validator cannot name the gzip bytes, so it goes. + expect(svgHead).not.toContain('ETag'); expect(gunzipSync(svgResponse.subarray(svgSeparator + 4)).toString()).toBe(assetSvg); const rangeResponsePromise = nextJsonMessage(httpConnections[0]!); @@ -607,7 +719,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', + }, }, }), ); @@ -619,6 +738,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) => @@ -709,6 +830,265 @@ describe('Remote Control tunnel', () => { }, 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 once it is back at it', () => { + 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 = 1024 * 1024; + 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 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(); + 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( + 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) => { + 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(); + const tunnelOptions = { + homeDir, + localOrigin: `http://127.0.0.1:${localPort}`, + clientVersion: CLIENT_VERSION, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }; + 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) => { + 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('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, + ); + 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('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]); + }); +}); + describe('Remote Control single-instance lock', () => { async function deadPid(): Promise { const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }); @@ -795,7 +1175,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([]); + }); +});