feat(vis): faithful wire.jsonl rendering + built-in kimi vis command (#788)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run

* feat: polish vis

* feat: add 'kimi vis' command for session visualization

* fix(vis): drop metadata app_version/resumed removed upstream

#786 stopped recording resume version metadata, so those fields no
longer exist on the metadata wire record. The vis-into-typecheck wiring
caught the stale field reads after merging main; drop them from the
metadata headline.

* fix(vis): drop unnecessary return-await in startVisServer

oxlint typescript-eslint(return-await) flags returning an awaited
promise outside try/catch; return the promise directly.

* fix(vis): green CI — tolerate unbuilt embedded asset + bump nix pnpmDeps hash

- handleVis: wrap the embedded-SPA dynamic import in try/catch. The value
  module is generated at build time (prebuild); in contexts without a build
  (tests run pnpm test, not build) only the .d.ts type stub exists, so the
  runtime import throws. Tolerate it and fall back to filesystem serving.
- flake.nix: update the fetchPnpmDeps hash after adding the vis-web /
  vis-server / vite-plugin-singlefile dependencies.

* refactor(vis): drop redundant alwaysBundle in tsdown config

#775's single-entry build (codeSplitting: false) already bundles
everything not declared in dependencies/peerDependencies. hono /
@hono/node-server (transitive via vis-server) and @moonshot-ai/vis-server
(a devDependency) are all undeclared there, so they bundle by default —
the explicit alwaysBundle was redundant. Verified the emitted main.mjs is
still fully self-contained and 'kimi vis' serves.

* fix(vis): address review — context-token resets, IPv6 url, marker-safe indexing

- contextTokens now mirrors agent-core on lifecycle records: 0 on
  context.clear, tokensAfter on context.apply_compaction (was only
  updated from step.end.usage, leaving a stale live fill after a
  clear/compaction).
- start.ts brackets IPv6 hosts in the returned url (http://[::1]:port/);
  hostForUrl moved to config.ts and shared with the startup banner.
- compaction slice + micro-compaction blanking now index over real
  history entries only, so synthetic undo/clear UI markers no longer
  offset agent-core's compactedCount / cutoff.

* chore: add changeset for kimi vis command

* fix(vis): cross-platform single-file build + history-count micro clamp

- build-vis-asset.mjs sets VIS_SINGLEFILE via the spawn env and runs
  'vite build' directly (cross-platform), instead of the POSIX-inline-env
  'build:single' script that broke on Windows cmd; removed the now-unused
  build:single script. Fixes the win32 build path (the asset generator
  runs in the kimi-code prebuild + native bundle).
- context.undo now clamps the micro-compaction cutoff by history-entry
  count (excluding synthetic undo/clear markers) instead of messages.length,
  mirroring agent-core undo() -> microCompaction.reset(_history.length); a
  surviving marker no longer leaves the cutoff one too high and wrongly
  blanks a later-appended tool result.

* fix(vis): run the single-file build through a shell for Windows pnpm

The win32 native binary is built on Windows runners
(.github/workflows/_native-build.yml), which run this generator. pnpm's
launcher there is pnpm.cmd, which a bare argv exec can't resolve without
a shell. Use execSync with a single command string so the platform shell
(cmd on Windows) resolves the shim; a command string (not an args array)
avoids the args+shell deprecation. Args are static.

* fix(vis): show model-facing tool result content in the context view

agent-core normalizes tool results via toolResultOutputForModel before
they enter history (error -> '<system>ERROR: ...' prefix, empty ->
'<system>Tool output is empty.' sentinel). The projector was using the
raw ev.result.output, so the Context tab's model view showed content the
model never saw for failed/empty tool calls. Replicate that normalization
(the upstream helper is module-private) so the projected tool message
matches what the model received.
This commit is contained in:
Kai 2026-06-16 16:54:14 +08:00 committed by GitHub
parent 7b5b818815
commit efdf8a1b2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 3197 additions and 909 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Add a built-in `kimi vis` command that launches the session visualizer in your browser, pointed at your local sessions. Supports `--port`/`--host`, `--no-open`, and `kimi vis <sessionId>` deep-links.

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules/
dist/
dist-single/
dist-native/
.tmp-api-extractor/
coverage/

View file

@ -1,2 +1,8 @@
# Copied from packages/kimi-core at build time
agents/
# Generated at build time by scripts/build-vis-asset.mjs.
# Only the ~150KB base64 VALUE file is ignored; the committed `.d.ts` stub
# next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh
# clone (before any build has produced the `.ts`).
src/generated/vis-web-asset.ts

View file

@ -36,7 +36,8 @@
"#/tui/theme": "./src/tui/theme/index.ts",
"#/*": [
"./src/*.ts",
"./src/*/index.ts"
"./src/*/index.ts",
"./src/*.d.ts"
]
},
"publishConfig": {
@ -44,6 +45,7 @@
"provenance": true
},
"scripts": {
"prebuild": "node scripts/build-vis-asset.mjs",
"build": "tsdown",
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
"smoke": "node scripts/smoke.mjs",
@ -77,6 +79,8 @@
"@moonshot-ai/kimi-code-sdk": "workspace:^",
"@moonshot-ai/kimi-telemetry": "workspace:^",
"@moonshot-ai/migration-legacy": "workspace:^",
"@moonshot-ai/vis-server": "workspace:^",
"@moonshot-ai/vis-web": "workspace:*",
"@types/semver": "^7.7.0",
"@types/yazl": "^2.4.6",
"chalk": "^5.4.1",

View file

@ -0,0 +1,54 @@
// Builds the vis web single-file bundle, gzips it, and writes a generated
// TS module that embeds it as base64 so tsdown can later bundle it into
// dist/main.mjs (works identically for the npm package and the native SEA
// binary).
import { execSync } from 'node:child_process';
import { gzipSync } from 'node:zlib';
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..', '..', '..');
const visWeb = join(repoRoot, 'apps', 'vis', 'web');
const out = join(here, '..', 'src', 'generated', 'vis-web-asset.ts');
console.log('[build-vis-asset] building vis web single-file bundle…');
try {
// Run vite with VIS_SINGLEFILE set on the spawn so the build is
// cross-platform (Node sets the env, not a POSIX-only inline-env shell
// prefix). `pnpm --filter X exec` runs in X's package dir, so vite picks up
// vis-web's vite.config.ts, which gates the single-file output on
// `process.env.VIS_SINGLEFILE === '1'`.
// execSync runs through the platform shell, which is required on Windows:
// pnpm's launcher is `pnpm.cmd`, which a bare argv exec cannot resolve (no
// PATHEXT without a shell). The win32 native binary IS built on Windows
// runners (.github/workflows/_native-build.yml), which run this generator.
// A single command string (not an args array) avoids the args+shell
// deprecation; the command is static (no injection surface).
execSync('pnpm --filter @moonshot-ai/vis-web exec vite build', {
stdio: 'inherit',
cwd: repoRoot,
env: { ...process.env, VIS_SINGLEFILE: '1' },
});
} catch (err) {
throw new Error(
`[build-vis-asset] failed to run the vis-web single-file build via pnpm (is pnpm on PATH?): ${err instanceof Error ? err.message : String(err)}`,
);
}
const html = readFileSync(join(visWeb, 'dist-single', 'index.html'));
if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes('<!doctype html')) {
throw new Error(
`[build-vis-asset] dist-single/index.html looks invalid (${html.length} bytes) — the web build may have failed`,
);
}
const b64 = gzipSync(html, { level: 9 }).toString('base64');
mkdirSync(dirname(out), { recursive: true });
writeFileSync(
out,
`// GENERATED by scripts/build-vis-asset.mjs — do not edit.\n` +
`export const VIS_WEB_GZIP_B64 = ${JSON.stringify(b64)};\n`,
);
console.log(`[build-vis-asset] wrote ${out} (${(b64.length / 1024).toFixed(0)} KB base64)`);

View file

@ -6,8 +6,14 @@ import { run } from './exec.mjs';
const requireFromScript = createRequire(import.meta.url);
const tsdownCliPath = requireFromScript.resolve('tsdown/run');
const checkBundlePath = resolve(import.meta.dirname, 'check-bundle.mjs');
const buildVisAssetPath = resolve(import.meta.dirname, '..', 'build-vis-asset.mjs');
export async function runBundleStep() {
// Generate the embedded `kimi vis` web asset before bundling. The native
// tsdown run here never goes through the npm `prebuild` lifecycle, so the
// generated module must be produced explicitly first or the bundle would
// miss it (npm builds get it via the `prebuild` script).
await run(process.execPath, [buildVisAssetPath]);
await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']);
await run(process.execPath, [checkBundlePath]);
}

View file

@ -8,6 +8,7 @@ import { registerDoctorCommand } from './sub/doctor';
import { registerExportCommand } from './sub/export';
import { registerLoginCommand } from './sub/login';
import { registerProviderCommand } from './sub/provider';
import { registerVisCommand } from './sub/vis';
export type MainCommandHandler = (opts: CLIOptions) => void;
export type MigrateCommandHandler = () => void;
@ -80,6 +81,7 @@ export function createProgram(
registerAcpCommand(program);
registerLoginCommand(program);
registerDoctorCommand(program);
registerVisCommand(program);
registerMigrateCommand(program, onMigrate);
program
.command('upgrade')

View file

@ -0,0 +1,158 @@
/**
* `kimi vis` sub-command.
*
* CLI glue only: resolves the kimi home, starts the in-process session
* visualizer server (auto-picking a free port by default), prints the URL,
* optionally opens the browser (with an optional session deep-link), then
* waits for Ctrl-C and shuts the server down. The visualizer server itself
* lives in `@moonshot-ai/vis-server`.
*/
import type { Command } from 'commander';
import { createCliTelemetryBootstrap } from '#/cli/telemetry';
import { openUrl } from '#/utils/open-url';
interface WritableLike {
write(chunk: string): boolean;
}
export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly close: () => Promise<void>;
}
export interface StartVisServerArgs {
readonly homeDir: string;
readonly port: number;
readonly host?: string;
readonly webAsset?: { gzipped: Uint8Array };
}
export interface VisDeps {
readonly getHomeDir: () => string;
readonly startVisServer: (opts: StartVisServerArgs) => Promise<StartedVisServer>;
readonly openUrl: (url: string) => Promise<void>;
readonly waitForShutdown: () => Promise<void>;
readonly stdout: WritableLike;
readonly stderr: WritableLike;
readonly exit: (code: number) => never;
}
export interface VisOptions {
readonly open: boolean;
readonly port?: number;
readonly host?: string;
readonly sessionId?: string;
}
export async function handleVis(deps: VisDeps, opts: VisOptions): Promise<void> {
const homeDir = deps.getHomeDir();
// Lazily load the embedded single-file SPA so normal `kimi` startup never
// pays for it. The module is generated at build time (prebuild). When running
// from source without a build — e.g. tests — the generated value module is
// absent and the dynamic import throws; in that case the server falls back to
// its own static `public/` directory.
let webAsset: { gzipped: Uint8Array } | undefined;
try {
const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset');
if (VIS_WEB_GZIP_B64.length > 0) {
webAsset = { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) };
}
} catch {
// Embedded asset not generated in this context — fall back to filesystem.
}
let server: StartedVisServer;
try {
server = await deps.startVisServer({
homeDir,
port: opts.port ?? 0,
...(opts.host === undefined ? {} : { host: opts.host }),
...(webAsset === undefined ? {} : { webAsset }),
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
deps.stderr.write(`Failed to start kimi vis: ${msg}\n`);
return deps.exit(1);
}
const target =
opts.sessionId === undefined
? server.url
: `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`;
deps.stdout.write(`kimi vis is running at ${server.url}\n`);
deps.stdout.write('Press Ctrl-C to stop.\n');
if (opts.open) {
try {
await deps.openUrl(target);
} catch {
deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`);
}
}
await deps.waitForShutdown();
await server.close();
}
export function registerVisCommand(parent: Command, overrides?: Partial<VisDeps>): void {
parent
.command('vis')
.description('Launch the session visualizer in your browser.')
.option('--port <number>', 'Port to bind. Default: auto-pick a free port.')
.option('--host <host>', 'Host to bind. Default: 127.0.0.1.')
.option('--no-open', 'Do not open the browser automatically.')
.argument('[sessionId]', 'Open directly to this session.')
.action(
async (
sessionId: string | undefined,
options: { port?: string; host?: string; open?: boolean },
) => {
const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10);
await handleVis(createDefaultVisDeps(overrides), {
open: options.open !== false,
...(port === undefined || Number.isNaN(port) ? {} : { port }),
...(options.host === undefined ? {} : { host: options.host }),
...(sessionId === undefined ? {} : { sessionId }),
});
},
);
}
function createDefaultVisDeps(overrides: Partial<VisDeps> = {}): VisDeps {
return {
getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir),
startVisServer:
overrides.startVisServer ??
(async (opts) => {
// Dynamic import keeps the vis server (and Hono) out of the hot path.
const { startVisServer } = await import('@moonshot-ai/vis-server/start');
return startVisServer(opts);
}),
// `openUrl` is a synchronous fire-and-forget; adapt it to the async dep.
openUrl:
overrides.openUrl ??
(async (url: string) => {
openUrl(url);
}),
waitForShutdown: overrides.waitForShutdown ?? waitForSigint,
stdout: overrides.stdout ?? process.stdout,
stderr: overrides.stderr ?? process.stderr,
exit: overrides.exit ?? ((code: number) => process.exit(code)),
};
}
function waitForSigint(): Promise<void> {
return new Promise<void>((resolve) => {
const onSig = (): void => {
process.off('SIGINT', onSig);
resolve();
};
process.on('SIGINT', onSig);
});
}

View file

@ -0,0 +1 @@
export declare const VIS_WEB_GZIP_B64: string;

View file

@ -347,6 +347,7 @@ describe('CLI options parsing', () => {
'acp',
'login',
'doctor',
'vis',
'migrate',
'upgrade',
]);

View file

@ -0,0 +1,114 @@
/**
* `kimi vis`
*
* Verifies the CLI layer for the session visualizer: home + auto-port
* resolution, browser open vs `--no-open`, and the session deep-link path.
* Uses injected deps so no real port is bound and the real vis server is
* never started.
*/
import { describe, it, expect, vi } from 'vitest';
import { handleVis, type VisDeps } from '#/cli/sub/vis';
function makeDeps(over: Partial<VisDeps> = {}): {
deps: VisDeps;
opened: string[];
out: string[];
} {
const opened: string[] = [];
const out: string[] = [];
const deps: VisDeps = {
getHomeDir: () => '/home/k',
startVisServer: vi.fn(async (o) => ({
port: 41234,
host: '127.0.0.1',
url: 'http://127.0.0.1:41234/',
close: async () => {},
_opts: o,
})) as unknown as VisDeps['startVisServer'],
openUrl: async (u: string) => {
opened.push(u);
},
waitForShutdown: async () => {},
stdout: {
write: (s: string) => {
out.push(s);
return true;
},
},
stderr: { write: () => true },
exit: vi.fn() as unknown as VisDeps['exit'],
...over,
};
return { deps, opened, out };
}
describe('handleVis', () => {
it('starts the server with the home dir + auto port and opens the browser', async () => {
const { deps, opened, out } = makeDeps();
await handleVis(deps, { open: true });
expect(deps.startVisServer).toHaveBeenCalledWith(
expect.objectContaining({ homeDir: '/home/k', port: 0 }),
);
expect(opened).toEqual(['http://127.0.0.1:41234/']);
expect(out.join('')).toContain('http://127.0.0.1:41234/');
});
it('does not open the browser when open is false', async () => {
const { deps, opened } = makeDeps();
await handleVis(deps, { open: false });
expect(opened).toEqual([]);
});
it('deep-links to a session when sessionId is given', async () => {
const { deps, opened } = makeDeps();
await handleVis(deps, { open: true, sessionId: 'sess_abc' });
expect(opened[0]).toBe('http://127.0.0.1:41234/sessions/sess_abc');
});
it('uses the explicit port when provided', async () => {
const { deps } = makeDeps();
await handleVis(deps, { open: false, port: 4321 });
expect(deps.startVisServer).toHaveBeenCalledWith(
expect.objectContaining({ homeDir: '/home/k', port: 4321 }),
);
});
it('closes the server after shutdown', async () => {
const close = vi.fn(async () => {});
const { deps } = makeDeps({
startVisServer: vi.fn(async () => ({
port: 41234,
host: '127.0.0.1',
url: 'http://127.0.0.1:41234/',
close,
})) as unknown as VisDeps['startVisServer'],
});
await handleVis(deps, { open: false });
expect(close).toHaveBeenCalledOnce();
});
it('reports a clean error and exits when the server fails to start', async () => {
const errored: string[] = [];
const { deps, opened } = makeDeps({
startVisServer: vi.fn(async () => {
throw new Error('listen EADDRINUSE: address already in use 127.0.0.1:4321');
}) as unknown as VisDeps['startVisServer'],
stderr: {
write: (s: string) => {
errored.push(s);
return true;
},
},
waitForShutdown: vi.fn(async () => {}),
});
await handleVis(deps, { open: true, port: 4321 });
expect(errored.join('')).toContain('Failed to start kimi vis');
expect(errored.join('')).toContain('EADDRINUSE');
expect(deps.exit).toHaveBeenCalledWith(1);
// Nothing past the failed start should run.
expect(opened).toEqual([]);
expect(deps.waitForShutdown).not.toHaveBeenCalled();
});
});

View file

@ -21,6 +21,9 @@ const optionalNativeDependencies = new Set(['cpu-features']);
function shouldAlwaysBundle(id: string): boolean {
if (builtins.has(id) || id.startsWith('node:')) return false;
if (optionalNativeDependencies.has(id)) return false;
// Everything else is force-bundled, which covers `@moonshot-ai/*` (incl.
// vis-server for `kimi vis`) plus its transitive `hono` / `@hono/node-server`
// — so the SEA bundle is self-contained (check-bundle.mjs enforces this).
return true;
}

View file

@ -10,14 +10,30 @@
"./src/*/index.ts"
]
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./start": {
"types": "./src/start.ts",
"default": "./src/start.ts"
},
"./package.json": {
"types": "./package.json",
"default": "./package.json"
}
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsdown",
"test": "vitest run"
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@moonshot-ai/agent-core": "workspace:^",
"@moonshot-ai/kosong": "workspace:^",
"hono": "^4.7.7"
},
"devDependencies": {

View file

@ -4,6 +4,8 @@ import { join, resolve } from 'node:path';
import { Hono } from 'hono';
import { KIMI_CODE_HOME } from './config';
import { serveWebAsset, type WebAsset } from './lib/web-asset';
import { blobsRoute } from './routes/blobs';
import { contextRoute } from './routes/context';
import { sessionDetailRoute } from './routes/session-detail';
@ -52,6 +54,10 @@ function mimeFor(path: string): string {
export interface CreateAppOptions {
readonly authToken?: string;
readonly homeDir?: string;
/** When provided, serve this single-file SPA from memory and skip the
* filesystem `public/` lookup. */
readonly webAsset?: WebAsset;
}
function bearerToken(value: string | undefined): string | null {
@ -73,6 +79,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Hono> {
// /api/* handlers.
const api = new Hono();
const authToken = options.authToken;
const home = options.homeDir ?? KIMI_CODE_HOME;
if (authToken !== undefined && authToken.length > 0) {
api.use('*', async (c, next) => {
const token = bearerToken(c.req.header('authorization'));
@ -84,57 +91,71 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Hono> {
return c.json({ error: 'unauthorized', code: 'UNAUTHORIZED' }, 401);
});
}
api.route('/sessions', sessionsRoute());
api.route('/sessions', sessionDetailRoute());
api.route('/sessions', wireRoute());
api.route('/sessions', subagentsRoute());
api.route('/sessions', blobsRoute());
api.route('/sessions', sessionsRoute(home));
api.route('/sessions', sessionDetailRoute(home));
api.route('/sessions', wireRoute(home));
api.route('/sessions', subagentsRoute(home));
api.route('/sessions', blobsRoute(home));
// Mount contextRoute last because it currently uses a catch-all stub
// (Phase C scope) that would otherwise shadow more specific routes
// registered below it.
api.route('/sessions', contextRoute());
api.route('/sessions', contextRoute(home));
app.route('/api', api);
// Static + SPA fallback (production only).
const publicDir = await resolvePublicDir();
if (publicDir !== null) {
app.get('*', async (c) => {
const url = new URL(c.req.url);
let pathname = decodeURIComponent(url.pathname);
// Static + SPA fallback.
if (options.webAsset !== undefined) {
// Serve the embedded single-file SPA from memory for any non-/api GET.
const asset = options.webAsset;
app.get('*', (c) => {
const pathname = new URL(c.req.url).pathname;
if (pathname.startsWith('/api')) {
// Should have been routed above; 404 here.
return c.json({ error: `api route not found: ${pathname}`, code: 'NOT_FOUND' }, 404);
}
if (pathname === '/' || pathname === '') pathname = '/index.html';
const resolved = resolve(publicDir, `.${pathname}`);
if (!resolved.startsWith(publicDir)) {
return c.text('forbidden', 403);
}
try {
const s = await stat(resolved);
if (s.isFile()) {
const buf = await readFile(resolved);
const body = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
return new Response(body, {
headers: { 'content-type': mimeFor(resolved) },
});
}
} catch {
// fall through to SPA fallback
}
// SPA fallback — index.html for any unknown GET so client-side
// React Router can resolve the route.
try {
const indexHtml = await readFile(join(publicDir, 'index.html'));
const body = new Uint8Array(indexHtml.buffer, indexHtml.byteOffset, indexHtml.byteLength);
return new Response(body, {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
} catch {
return c.text('not found', 404);
}
return serveWebAsset(asset);
});
} else {
// Filesystem static serving (production standalone only).
const publicDir = await resolvePublicDir();
if (publicDir !== null) {
app.get('*', async (c) => {
const url = new URL(c.req.url);
let pathname = decodeURIComponent(url.pathname);
if (pathname.startsWith('/api')) {
// Should have been routed above; 404 here.
return c.json({ error: `api route not found: ${pathname}`, code: 'NOT_FOUND' }, 404);
}
if (pathname === '/' || pathname === '') pathname = '/index.html';
const resolved = resolve(publicDir, `.${pathname}`);
if (!resolved.startsWith(publicDir)) {
return c.text('forbidden', 403);
}
try {
const s = await stat(resolved);
if (s.isFile()) {
const buf = await readFile(resolved);
const body = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
return new Response(body, {
headers: { 'content-type': mimeFor(resolved) },
});
}
} catch {
// fall through to SPA fallback
}
// SPA fallback — index.html for any unknown GET so client-side
// React Router can resolve the route.
try {
const indexHtml = await readFile(join(publicDir, 'index.html'));
const body = new Uint8Array(indexHtml.buffer, indexHtml.byteOffset, indexHtml.byteLength);
return new Response(body, {
headers: { 'content-type': 'text/html; charset=utf-8' },
});
} catch {
return c.text('not found', 404);
}
});
}
}
return app;

View file

@ -2,7 +2,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
/** Resolve KIMI_CODE_HOME (env > ~/.kimi-code). */
function resolveKimiCodeHome(): string {
export function resolveKimiCodeHome(): string {
const envHome = process.env['KIMI_CODE_HOME'];
if (envHome !== undefined && envHome.length > 0) {
return envHome;
@ -39,6 +39,15 @@ export function isLoopbackHost(host: string): boolean {
);
}
/** Format a host for embedding in a URL authority. Bare IPv6 literals (which
* contain ':') must be bracketed, e.g. `::1` `[::1]`, otherwise
* `http://::1:3001/` is an invalid URL. Already-bracketed literals, IPv4
* addresses, and hostnames are returned unchanged. */
export function hostForUrl(host: string): string {
if (host.includes(':') && !host.startsWith('[')) return `[${host}]`;
return host;
}
export function resolveVisAuthToken(host: string = resolveHost()): string | undefined {
const raw = process.env['VIS_AUTH_TOKEN'];
const token = raw?.trim();

View file

@ -1,25 +1,14 @@
import { serve } from '@hono/node-server';
import { createApp } from './app';
import { KIMI_CODE_HOME, resolveHost, resolvePort, resolveVisAuthToken } from './config';
import { KIMI_CODE_HOME, resolveHost, resolveVisAuthToken } from './config';
import { startVisServer } from './start';
import { formatStartupBanner } from './startup-banner';
async function main(): Promise<void> {
const host = resolveHost();
const authToken = resolveVisAuthToken(host);
const app = await createApp({ authToken });
const port = resolvePort();
serve({ fetch: app.fetch, hostname: host, port }, (info) => {
// Startup banner.
process.stdout.write(
formatStartupBanner({
authToken,
host,
kimiCodeHome: KIMI_CODE_HOME,
port: info.port,
}),
);
});
const { port } = await startVisServer({ host, authToken });
process.stdout.write(
formatStartupBanner({ authToken, host, kimiCodeHome: KIMI_CODE_HOME, port }),
);
}
try {

View file

@ -20,6 +20,11 @@ export type {
export { AGENT_WIRE_PROTOCOL_VERSION } from '@moonshot-ai/agent-core';
export type { Message, ContentPart, ToolCall, TokenUsage } from '@moonshot-ai/kosong';
// Local binding for the `AgentRecord` type used by the vis-only DTOs below
// (e.g. `WireEntry.data`). The `export type { … }` re-export above forwards
// the name to consumers but does NOT bring it into this module's scope.
import type { AgentRecord } from '@moonshot-ai/agent-core';
// ── vis-only DTOs ──────────────────────────────────────────────────────────
export interface ApiError {
@ -30,16 +35,14 @@ export interface ApiError {
| 'UNAUTHORIZED'
| 'READ_ERROR'
| 'PARSE_ERROR'
| 'DELETE_ERROR'
| 'UNSUPPORTED_PROTOCOL';
| 'DELETE_ERROR';
}
export type SessionHealth =
| 'ok'
| 'broken_state'
| 'broken_main_wire'
| 'missing_main_wire'
| 'unsupported_protocol';
| 'missing_main_wire';
export interface SessionSummary {
sessionId: string;
@ -65,6 +68,11 @@ export interface AgentInfo {
wireExists: boolean;
wireRecordCount: number;
wireProtocolVersion: string | null;
/** Per-item swarm work label persisted by agent-core for swarm-spawned
* sub-agents (`AgentMeta.swarmItem`). `null` when the agent is not a
* swarm item or when the value cannot be recovered (e.g. disk-only
* inventory of a session with a corrupt `state.json`). */
swarmItem: string | null;
}
export interface SessionDetail {

View file

@ -9,7 +9,9 @@ export interface AgentNode extends AgentInfo {
* `state.json.agents`. Roots are agents with no `parentAgentId`, plus any
* agent whose `parentAgentId` does not resolve in the inventory (orphans).
* The returned roots are sorted so that the `main` agent always appears
* first; remaining roots fall back to a stable lexicographic order.
* first; remaining agents fall back to a numeric `agent-N` order (so
* `agent-2` precedes `agent-10`), then a stable lexicographic order. The
* same ordering is applied to each node's children.
*/
export function buildAgentTree(agents: ReadonlyArray<AgentInfo>): AgentNode[] {
const byId = new Map<string, AgentNode>();
@ -23,11 +25,36 @@ export function buildAgentTree(agents: ReadonlyArray<AgentInfo>): AgentNode[] {
roots.push(node);
}
}
for (const node of byId.values()) {
node.children.sort(sortAgents);
}
return roots.sort(sortAgents);
}
function sortAgents(a: AgentNode, b: AgentNode): number {
if (a.agentId === 'main') return -1;
if (b.agentId === 'main') return 1;
return a.agentId.localeCompare(b.agentId);
return compareAgentIds(a.agentId, b.agentId);
}
/**
* Shared agent-id ordering: `main` always first, then `agent-N` records by
* numeric suffix (so `agent-2` precedes `agent-10`), with a lexicographic
* fallback for any id that does not match the `agent-N` shape.
*
* In practice a sibling set is `main` plus `agent-N` ids (agent-core's id
* generator only emits those). The `na`/`nb`-only branches below exist solely
* to keep a stable TOTAL order when foreign/hand-edited ids (reachable via
* `state.json` keys or `discoverAgentsFromDisk` directory names) are mixed in:
* all `agent-N` ids sort before any non-`agent-N` id, so the comparator stays
* transitive instead of degenerating into V8's order-dependent output.
*/
export function compareAgentIds(a: string, b: string): number {
if (a === b) return 0;
if (a === 'main') return -1;
if (b === 'main') return 1;
const na = /^agent-(\d+)$/.exec(a);
const nb = /^agent-(\d+)$/.exec(b);
if (na && nb) return Number(na[1]) - Number(nb[1]);
if (na) return -1; // all agent-N sort before any non-agent-N id
if (nb) return 1;
return a.localeCompare(b);
}

View file

@ -11,9 +11,13 @@ import type {
export interface ProjectedMessage {
lineNo: number;
time?: number;
source: 'append_message' | 'compaction_summary';
source: 'append_message' | 'compaction_summary' | 'undo' | 'clear';
message: ContextMessage;
toolStepUuids: string[];
/** Set only when source === 'undo'. */
undo?: { count: number; removedMessageCount: number };
/** Set only on the summary bubble of source === 'compaction_summary'. */
compaction?: { compactedCount: number; tokensBefore: number; tokensAfter: number };
}
export interface UsageTotals {
@ -29,12 +33,32 @@ export interface ConfigSnapshot {
systemPrompt?: string;
}
export interface GoalSnapshot {
goalId: string;
objective: string;
completionCriterion?: string;
status?: string;
actor?: string;
reason?: string;
tokensUsed?: number;
turnsUsed?: number;
wallClockMs?: number;
}
export interface ContextProjection {
messages: ProjectedMessage[];
usage: UsageTotals;
/** Absolute current context-window fill, mirroring agent-core
* ContextMemory._tokenCount. Updated from the latest step.end.usage, and
* also reset on the lifecycle events agent-core touches: context.clear 0,
* context.apply_compaction tokensAfter. Distinct from the cumulative
* `usage` totals. */
contextTokens: number;
config: ConfigSnapshot;
permission: { mode: PermissionMode | null };
planMode: { active: boolean; id?: string };
goal: GoalSnapshot | null;
swarm: { active: boolean; trigger?: string };
}
const ZERO: TokenUsage = { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 };
@ -54,8 +78,28 @@ const ZERO: TokenUsage = { inputOther: 0, output: 0, inputCacheRead: 0, inputCac
*
* Without this loop-event reconstruction the timeline would only
* show user prompts agent-core does not emit a synthetic
* `context.append_message` for assistant turns. */
export function projectContext(entries: ReadonlyArray<WireEntry>): ContextProjection {
* `context.append_message` for assistant turns.
*
* `mode` selects between two views of the four destructive lifecycle
* events (compaction / undo / clear / micro-compaction):
*
* - `'model'` (default): faithfully mirrors what the model currently
* sees compaction drops the compacted prefix, undo splices removed
* messages out, clear empties the list, micro-compaction blanks old
* tool results. All existing behaviour.
* - `'full'`: full reconstructed history for debugging the same four
* events insert an INLINE MARKER but do NOT mutate/drop the message
* list, so messages compacted/undone/cleared away stay visible and
* micro-compacted tool results keep their original content.
*
* Everything else (append_message, loop events, goal/swarm/permission/
* plan/config/usage/contextTokens derived state) is identical in both
* modes `mode` only affects the `messages` array and which markers
* appear. */
export function projectContext(
entries: ReadonlyArray<WireEntry>,
mode: 'model' | 'full' = 'model',
): ContextProjection {
let messages: ProjectedMessage[] = [];
const usage: UsageTotals = {
byScope: { session: { ...ZERO }, turn: { ...ZERO } },
@ -65,6 +109,10 @@ export function projectContext(entries: ReadonlyArray<WireEntry>): ContextProjec
let permissionMode: PermissionMode | null = null;
let planActive = false;
let planId: string | undefined;
let contextTokens = 0;
let goal: GoalSnapshot | null = null;
let swarm: { active: boolean; trigger?: string } = { active: false };
let microCutoff = 0;
// Maps step.uuid → the assistant ProjectedMessage that step is filling in.
// Cleared on context.clear / context.apply_compaction.
let openSteps = new Map<string, ProjectedMessage>();
@ -120,13 +168,26 @@ export function projectContext(entries: ReadonlyArray<WireEntry>): ContextProjec
});
}
} else if (ev.type === 'step.end') {
// Absolute context-window fill, mirroring agent-core
// ContextMemory._tokenCount: the latest step.end usage REPLACES the
// snapshot (it is not cumulative — see Task P1.7 note on byScope).
if ('usage' in ev && ev.usage !== undefined) {
contextTokens =
ev.usage.inputCacheRead +
ev.usage.inputCacheCreation +
ev.usage.inputOther +
ev.usage.output;
}
openSteps.delete(ev.uuid);
} else if (ev.type === 'tool.result') {
const output = ev.result.output;
const content: ContentPart[] =
typeof output === 'string'
? [{ type: 'text', text: output }]
: (output as ContentPart[]);
// Mirror what the MODEL saw, not the raw output. agent-core's
// ContextMemory.appendLoopEvent (`tool.result` case) stores
// `createToolMessage(toolCallId, toolResultOutputForModel(result))`,
// which normalizes error / empty outputs with sentinel strings. Using
// `ev.result.output` directly would surface content the model never
// received for failed / empty tool calls. See
// `toolResultContentForModel` below.
const content = toolResultContentForModel(ev.result);
const toolMsg: ContextMessage = {
role: 'tool',
content,
@ -145,18 +206,42 @@ export function projectContext(entries: ReadonlyArray<WireEntry>): ContextProjec
break;
}
case 'context.clear':
messages = [];
openSteps = new Map();
if (mode === 'model') {
messages = [];
openSteps = new Map();
// Mirror agent-core clear() → microCompaction.reset() (cutoff → 0):
// the message indices are wiped, so any prior cutoff is meaningless.
microCutoff = 0;
} else {
// Full history: keep all preceding messages and openSteps as-is, just
// append a synthetic 'clear' marker inline. The original tool results
// stay un-blanked, so the cutoff is not applied (the end-of-loop
// blanking pass is gated on model mode).
messages.push({
lineNo: entry.lineNo,
time: rec.time,
source: 'clear',
// Synthetic marker: never rendered as a bubble (the web dispatches on
// `source === 'clear'`). `role: 'assistant'` keeps it out of any
// role-counting / tool-blanking path.
message: { role: 'assistant', content: [], toolCalls: [] } as ContextMessage,
toolStepUuids: [],
});
}
// Mirror agent-core clear() → _tokenCount = 0: the context-window fill is
// wiped. Derived state, so it is mode-INDEPENDENT (applied for both modes).
contextTokens = 0;
break;
case 'context.apply_compaction':
case 'context.apply_compaction': {
openSteps = new Map();
// Mirror agent-core's actual `applyCompaction` behaviour: the
// summary is inserted as an *assistant* message tagged with
// `origin.kind = 'compaction_summary'` (see
// `packages/agent-core/src/agent/context/index.ts`). Using
// 'system' here would skew role counts and any downstream tool
// that diffs the projected timeline against agent-core history.
messages = [{
// Mirror agent-core's actual `applyCompaction` behaviour
// (`packages/agent-core/src/agent/context/index.ts`): history becomes
// `[summaryBubble, ...history.slice(compactedCount)]`. The summary is
// an *assistant* message tagged `origin.kind = 'compaction_summary'`
// (using 'system' would skew role counts and any downstream diff
// against agent-core history). The post-compaction tail is preserved
// rather than dropped, so messages still in context stay visible.
const summaryBubble: ProjectedMessage = {
lineNo: entry.lineNo,
time: rec.time,
source: 'compaction_summary',
@ -167,9 +252,51 @@ export function projectContext(entries: ReadonlyArray<WireEntry>): ContextProjec
origin: { kind: 'compaction_summary' },
} as ContextMessage,
toolStepUuids: [],
}];
compaction: {
compactedCount: rec.compactedCount,
tokensBefore: rec.tokensBefore,
tokensAfter: rec.tokensAfter,
},
};
if (mode === 'model') {
// Drop the first `rec.compactedCount` HISTORY entries (NOT array
// entries): agent-core's `compactedCount` indexes into `_history`,
// which never contains our synthetic 'undo'/'clear' markers. Walk the
// array counting only history entries (`isHistoryEntry`) until
// `compactedCount` are passed, then slice there — any UI-only markers
// in the dropped region go with it (correct: they precede the
// compaction). With no markers this is exactly `slice(compactedCount)`.
let sliceAt = messages.length;
let passed = 0;
for (let i = 0; i < messages.length; i++) {
if (passed >= rec.compactedCount) {
sliceAt = i;
break;
}
if (isHistoryEntry(messages[i]!)) passed++;
}
if (passed < rec.compactedCount) sliceAt = messages.length;
messages = [summaryBubble, ...messages.slice(sliceAt)];
} else {
// Full history: keep ALL preceding messages, just append the summary
// marker inline so the compacted prefix stays visible.
messages.push(summaryBubble);
}
// Mirror agent-core applyCompaction() → microCompaction.reset() (cutoff
// → 0): the message list is rebuilt as [summary, ...tail], so the old
// index-based cutoff no longer points at the same messages. (In full
// mode the blanking pass does not run, so this is a no-op there.)
microCutoff = 0;
// Mirror agent-core applyCompaction() → _tokenCount = result.tokensAfter:
// the live context-window fill is now the post-compaction count. Derived
// state, so it is mode-INDEPENDENT.
contextTokens = rec.tokensAfter;
break;
}
case 'usage.record': {
// byScope keeps per-scope cumulative spend. This is NOT the live context-window
// fill — that is `contextTokens` (latest step.end.usage). The web TokenBar shows
// contextTokens; byScope/byModel are for the cumulative breakdown only.
const scope = (rec.usageScope ?? 'session') as 'session' | 'turn';
addUsage(usage.byScope[scope], rec.usage);
if (!usage.byModel[rec.model]) usage.byModel[rec.model] = { ...ZERO };
@ -193,17 +320,150 @@ export function projectContext(entries: ReadonlyArray<WireEntry>): ContextProjec
case 'plan_mode.cancel':
case 'plan_mode.exit':
planActive = false; planId = undefined; break;
default:
case 'context.undo': {
// Mirror agent-core `undo` (`agent/context/index.ts`): walk from the
// end, skip `origin.kind === 'injection'`, stop at
// `origin.kind === 'compaction_summary'`, remove others, counting real
// user prompts via `isRealUserPrompt` until `count` is reached. Then
// leave an undo marker.
//
// `computeUndoCutoff` is the single source of truth for that skip/stop
// walk (shared by both modes); only the actual removal is gated on
// `'model'` mode.
const { cutoff, removedMessageCount } = computeUndoCutoff(messages, rec.count);
if (mode === 'model') {
// Remove everything from `cutoff` onward EXCEPT injections, which the
// walk skips (they survive even when inside the undo window). Using
// the same `origin.kind === 'injection'` predicate keeps removal in
// lockstep with the counting walk above.
messages = messages.filter(
(pm, i) => i < cutoff || pm.message.origin?.kind === 'injection',
);
openSteps = new Map();
// Mirror agent-core undo() → microCompaction.reset(this._history.length):
// clamp the cutoff to the post-undo HISTORY-entry count so a later append
// does not get blanked by a now-too-large stale cutoff. Count only history
// entries (`isHistoryEntry`) — `messages.length` would include any surviving
// synthetic undo/clear marker, which agent-core's `_history.length` does
// NOT, so an array-length clamp could be too high by the marker count.
// (Clamp before pushing the undo marker, which is a non-tool pseudo-message
// and unaffected by blanking regardless.) With no markers, historyCount ===
// messages.length, so this is a no-op then.
const historyCount = messages.reduce((n, pm) => (isHistoryEntry(pm) ? n + 1 : n), 0);
microCutoff = Math.min(microCutoff, historyCount);
}
// In 'full' mode: do NOT remove — keep the undone messages and openSteps
// as-is, only push the undo marker. `removedMessageCount` still reflects
// what WOULD have been removed.
messages.push({
lineNo: entry.lineNo,
time: rec.time,
source: 'undo',
// Synthetic message: never rendered. The web dispatches on
// `source === 'undo'`; this only satisfies ProjectedMessage.
// `role: 'assistant'` is deliberate so this marker can never match the
// `role: 'tool'` micro-compaction blanking gate — keep it non-tool if
// you ever change the placeholder.
message: { role: 'assistant', content: [], toolCalls: [] } as ContextMessage,
toolStepUuids: [],
undo: { count: rec.count, removedMessageCount },
});
break;
}
case 'micro_compaction.apply':
// Track the latest cutoff; the actual content blanking is applied
// after the loop (mirrors agent-core MicroCompaction.compact, which
// runs over the full history at projection time).
microCutoff = rec.cutoff;
break;
case 'goal.create':
goal = {
goalId: rec.goalId,
objective: rec.objective,
completionCriterion: rec.completionCriterion,
};
break;
case 'goal.update':
if (goal !== null) {
const prev: GoalSnapshot = goal;
goal = {
...prev,
status: rec.status ?? prev.status,
actor: rec.actor ?? prev.actor,
reason: rec.reason ?? prev.reason,
tokensUsed: rec.tokensUsed ?? prev.tokensUsed,
turnsUsed: rec.turnsUsed ?? prev.turnsUsed,
wallClockMs: rec.wallClockMs ?? prev.wallClockMs,
};
}
break;
case 'goal.clear':
goal = null;
break;
case 'swarm_mode.enter':
swarm = { active: true, trigger: rec.trigger };
break;
case 'swarm_mode.exit':
swarm = { active: false };
break;
// Kinds that don't affect the projected timeline / derived state:
case 'metadata':
case 'forked':
case 'turn.prompt':
case 'turn.steer':
case 'turn.cancel':
case 'permission.record_approval_result':
case 'full_compaction.begin':
case 'full_compaction.cancel':
case 'full_compaction.complete':
case 'tools.register_user_tool':
case 'tools.unregister_user_tool':
case 'tools.set_active_tools':
case 'tools.update_store':
break;
default: {
const _exhaustive: never = rec;
void _exhaustive;
break;
}
}
}
// Micro-compaction blanking (mirrors agent-core MicroCompaction.compact):
// blank any message whose HISTORY index < cutoff that is a `role: 'tool'`
// result with a defined toolCallId and content large enough (≥ the
// min-content gate), replacing its content with the truncation marker. The
// cutoff is an agent-core `_history` index, which never includes our synthetic
// 'undo'/'clear' markers, so we count only history entries (`isHistoryEntry`)
// — array indices would be offset by any preceding marker. This rewrite is the
// model's-eye view, so it runs ONLY in 'model' mode — in 'full' mode the
// original tool results are shown un-blanked.
if (mode === 'model' && microCutoff > 0) {
let historyIndex = 0;
for (const pm of messages) {
if (!isHistoryEntry(pm)) continue;
if (historyIndex >= microCutoff) break;
historyIndex++;
const m = pm.message;
if (
m.role === 'tool' &&
m.toolCallId !== undefined &&
estimateContentTokens(m.content) >= MICRO_MIN_CONTENT_TOKENS
) {
pm.message = { ...m, content: [{ type: 'text', text: MICRO_TRUNCATED_MARKER }] };
}
}
}
return {
messages,
usage,
contextTokens,
config,
permission: { mode: permissionMode },
planMode: { active: planActive, id: planId },
goal,
swarm,
};
}
@ -213,3 +473,146 @@ function addUsage(into: TokenUsage, src: TokenUsage): void {
(into as any).inputCacheRead += src.inputCacheRead;
(into as any).inputCacheCreation += src.inputCacheCreation;
}
// ── Tool-result normalization (mirror of agent-core) ─────────────────────────
// These replicate agent-core's `toolResultOutputForModel` so vis's model-view
// shows the EXACT content the model received for a tool result. The constants
// and branch conditions are copied verbatim from
// `packages/agent-core/src/agent/context/index.ts` (lines 18-22, 350-377). Keep
// them byte-identical with that source — if agent-core changes the sentinels or
// branch logic, update here too.
const TOOL_ERROR_STATUS = '<system>ERROR: Tool execution failed.</system>';
const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>';
const TOOL_EMPTY_ERROR_STATUS =
'<system>ERROR: Tool execution failed. Tool output is empty.</system>';
const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.';
/** Mirrors agent-core `isEmptyOutputText`
* (`packages/agent-core/src/agent/context/index.ts` ~line 375). */
function isEmptyOutputText(output: string): boolean {
return output.length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT;
}
/** Mirrors agent-core `toolResultOutputForModel`
* (`packages/agent-core/src/agent/context/index.ts` ~line 350), then wraps the
* result into `ContentPart[]` exactly as `createToolMessage` does (a string
* output a single `{ type: 'text', text }` part). The model saw this
* normalized content in BOTH model and full views (agent-core normalizes at
* append time, before any of the destructive lifecycle events), so the
* tool.result branch uses this output mode-independently. */
function toolResultContentForModel(result: {
output: string | ContentPart[];
isError?: boolean;
}): ContentPart[] {
const output = result.output;
if (typeof output === 'string') {
let normalized: string;
if (result.isError === true) {
if (output.length === 0) {
normalized = TOOL_EMPTY_ERROR_STATUS;
} else if (output.trimStart().startsWith('<system>ERROR:')) {
normalized = output;
} else {
normalized = `${TOOL_ERROR_STATUS}\n${output}`;
}
} else {
normalized = isEmptyOutputText(output) ? TOOL_EMPTY_STATUS : output;
}
// Match createToolMessage: a string output becomes a single text part.
return [{ type: 'text', text: normalized }];
}
if (output.length === 0) {
return [
{
type: 'text',
text: result.isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS,
},
];
}
if (result.isError === true) {
return [{ type: 'text', text: TOOL_ERROR_STATUS }, ...output];
}
return output;
}
const MICRO_TRUNCATED_MARKER = '[Old tool result content cleared]';
const MICRO_MIN_CONTENT_TOKENS = 100;
/** Replicates agent-core's per-char token weighting exactly, over the same
* `text` + `think` parts its gate counts. agent-core
* (`packages/agent-core/src/utils/tokens.ts`) sums per-part estimates, each
* `estimateTokens(s) = Math.ceil(asciiCount / 4) + nonAsciiCount` (ASCII ~4
* chars/token, every non-ASCII/CJK code point a full token); other part types
* contribute 0. Matching it ensures Chinese-heavy tool results blank at the
* same gate as the agent. */
function estimateTokens(text: string): number {
let asciiCount = 0;
let nonAsciiCount = 0;
for (const char of text) {
if (char.codePointAt(0)! <= 127) {
asciiCount++;
} else {
nonAsciiCount++;
}
}
return Math.ceil(asciiCount / 4) + nonAsciiCount;
}
function estimateContentTokens(content: readonly ContentPart[]): number {
let total = 0;
for (const p of content) {
if (p.type === 'text') total += estimateTokens(p.text);
else if (p.type === 'think') total += estimateTokens(p.think);
}
return total;
}
/** True for messages that correspond to a real agent-core `_history` entry
* i.e. `append_message` and `compaction_summary` (the summary IS in `_history`).
* The synthetic UI-only markers (`undo` / `clear`) are NOT in `_history`, so
* index-based operations that mirror agent-core (compaction slice, micro-
* compaction cutoff) must skip them to stay aligned with agent-core indices. */
function isHistoryEntry(pm: ProjectedMessage): boolean {
return pm.source !== 'undo' && pm.source !== 'clear';
}
/** Mirrors agent-core `isRealUserPrompt` (`agent/context/index.ts`): a message
* counts toward an undo only if it is a genuine user prompt. */
function isRealUserPrompt(message: ContextMessage): boolean {
if (message.role !== 'user') return false;
const origin = message.origin;
if (origin === undefined || origin.kind === 'user') return true;
if (origin.kind === 'skill_activation') return origin.trigger === 'user-slash';
return false;
}
/** Single source of truth for the `context.undo` backward walk, shared by both
* projection modes. Mirrors agent-core `undo` (`agent/context/index.ts`): walk
* from the end, skip `origin.kind === 'injection'` (those are KEPT even when
* they sit inside the undo window), stop at `origin.kind === 'compaction_summary'`,
* and count real user prompts via `isRealUserPrompt` until `count` is reached.
*
* Returns the `cutoff` (lowest index to remove from, inclusive) plus the
* `removedMessageCount` (number of non-skipped messages in the window). In
* `'model'` mode the caller removes everything from `cutoff` onward EXCEPT
* injections; in `'full'` mode only `removedMessageCount` is reported on the
* undo marker (no removal). Defining the skip/stop predicate exactly once here
* keeps the two modes from drifting. */
function computeUndoCutoff(
messages: readonly ProjectedMessage[],
count: number,
): { cutoff: number; removedMessageCount: number } {
let removedUserCount = 0;
let removedMessageCount = 0;
let cutoff = messages.length;
for (let i = messages.length - 1; i >= 0; i--) {
const origin = messages[i]?.message.origin;
if (origin?.kind === 'injection') continue; // skip, keep
if (origin?.kind === 'compaction_summary') break; // stop
removedMessageCount++;
cutoff = i;
if (isRealUserPrompt(messages[i]!.message) && ++removedUserCount >= count) break;
}
return { cutoff, removedMessageCount };
}

View file

@ -4,6 +4,7 @@ import { join, resolve, sep } from 'node:path';
import { createInterface } from 'node:readline';
import type { SessionSummary, SessionDetail, AgentInfo, SessionHealth } from './agent-record-types';
import { compareAgentIds } from './agent-tree';
const SESSION_ID_RE = /^session_[A-Za-z0-9._-]+$/;
const AGENT_ID_RE = /^[A-Za-z0-9._-]+$/;
@ -23,7 +24,7 @@ interface StateJson {
title?: string;
isCustomTitle?: boolean;
lastPrompt?: string;
agents?: Record<string, { homedir: string; type: 'main' | 'sub' | 'independent'; parentAgentId: string | null }>;
agents?: Record<string, { homedir: string; type: 'main' | 'sub' | 'independent'; parentAgentId: string | null; swarmItem?: string }>;
custom?: Record<string, unknown>;
}
@ -106,13 +107,12 @@ async function discoverAgentsFromDisk(sessionDir: string): Promise<AgentInfo[]>
wireExists: readable,
wireRecordCount: info.count,
wireProtocolVersion: info.protocolVersion,
// swarmItem is persisted in state.json, which is unavailable on this
// disk-only fallback path, so it cannot be recovered here.
swarmItem: null,
});
}
return out.sort((a, b) => {
if (a.agentId === 'main') return -1;
if (b.agentId === 'main') return 1;
return a.agentId.localeCompare(b.agentId);
});
return out.sort((a, b) => compareAgentIds(a.agentId, b.agentId));
}
async function tryReadSummary(sessionDir: string, sessionId: string, workDir: string): Promise<SessionSummary | null> {
@ -222,13 +222,10 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise<Ag
wireExists: readable,
wireRecordCount: info.count,
wireProtocolVersion: info.protocolVersion,
swarmItem: meta.swarmItem ?? null,
});
}
return result.sort((a, b) => {
if (a.agentId === 'main') return -1;
if (b.agentId === 'main') return 1;
return a.agentId.localeCompare(b.agentId);
});
return result.sort((a, b) => compareAgentIds(a.agentId, b.agentId));
}
async function readState(sessionDir: string): Promise<StateJson | null> {

View file

@ -0,0 +1,17 @@
/** A pre-gzipped self-contained SPA HTML page, served from memory. */
export interface WebAsset {
/** gzip-compressed bytes of the single-file index.html. */
readonly gzipped: Uint8Array;
}
/** Serve the embedded SPA for any non-/api GET (SPA fallback: same page for
* every client route, since the bundle is a single self-contained HTML). */
export function serveWebAsset(asset: WebAsset): Response {
return new Response(asset.gzipped, {
headers: {
'content-type': 'text/html; charset=utf-8',
'content-encoding': 'gzip',
'cache-control': 'no-store',
},
});
}

View file

@ -15,11 +15,12 @@ export interface WireReadResult {
warnings: string[];
}
/** Best-effort fallback when a wire file declares a protocol_version that
* `agent-core` does not know about (e.g. the historic "2.2" alias that
* pre-dates the 1.x renumber). We try to apply the chain *starting* from
* the oldest known version (1.0) and warn the caller. If even that fails
* we just pass records through unchanged. */
/** Best-effort fallback for a wire file whose declared `protocol_version` is
* below the known migration chain (below 1.0, or otherwise unrecognized-low):
* `resolveWireMigrations` threw for it. We retry from the oldest known version
* (1.0) and warn the caller; if even that fails we pass records through
* unchanged. (Versions at/above the current 1.4 never reach here they
* resolve to an empty chain and are passed through directly.) */
function bestEffortMigrations(): readonly WireMigration[] {
try {
return resolveWireMigrations('1.0');
@ -30,13 +31,15 @@ function bestEffortMigrations(): readonly WireMigration[] {
/** Read a single agent's `wire.jsonl`.
*
* Each record is returned as a `WireEntry` containing both the
* on-disk parsed form (`raw`) and the migrated current-protocol form
* (`data`). For wires that declare a protocol version `agent-core`
* does not recognise (historic 2.x labels, or truly future versions),
* the reader falls back to a best-effort path: records are run
* through the 1.0-onwards migration chain and a warning is added to
* `warnings[]` so the UI can surface the caveat. */
* Each record is returned as a `WireEntry` containing both the on-disk parsed
* form (`raw`) and the migrated current-protocol form (`data`). The reader
* never rejects a file over its `protocol_version`:
* - below-1.0 (or otherwise unrecognized-low) `resolveWireMigrations`
* throws, so records run through the 1.0-onwards best-effort chain and a
* warning is added to `warnings[]` so the UI can surface the caveat;
* - at/above the current 1.4 (including future versions) resolves to an
* empty chain, so records are passed through unchanged, with no migration
* and no warning. */
export async function readAgentWire(path: string): Promise<WireReadResult> {
const stream = createReadStream(path, { encoding: 'utf8' });
const rl = createInterface({ input: stream, crlfDelay: Infinity });

View file

@ -7,7 +7,7 @@ import { rehydrateWireEntries } from '../lib/blob-resolver';
import { readAgentWire } from '../lib/wire-reader';
import { projectContext } from '../lib/context-projector';
export function contextRoute(): Hono {
export function contextRoute(home: string = KIMI_CODE_HOME): Hono {
const r = new Hono();
r.get('/:id/context', async (c) => {
const id = c.req.param('id');
@ -15,7 +15,7 @@ export function contextRoute(): Hono {
if (!isSafeAgentId(agentId)) {
return c.json({ error: 'invalid agent id', code: 'BAD_REQUEST' }, 400);
}
const detail = await readSessionDetail(KIMI_CODE_HOME, id);
const detail = await readSessionDetail(home, id);
if (!detail) {
return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
}
@ -29,21 +29,24 @@ export function contextRoute(): Hono {
);
const baseUrl = new URL(c.req.url).origin;
rehydrateWireEntries(wire.records, id, agentId, baseUrl);
const proj = projectContext(wire.records);
// `?history=full` reconstructs the FULL pre-compaction/undo/clear history
// for debugging; the default mirrors the model's-eye post-compaction view.
const mode = c.req.query('history') === 'full' ? 'full' : 'model';
const proj = projectContext(wire.records, mode);
return c.json({
sessionId: id,
agentId,
messages: proj.messages,
usage: proj.usage,
contextTokens: proj.contextTokens,
config: proj.config,
permission: proj.permission,
planMode: proj.planMode,
goal: proj.goal,
swarm: proj.swarm,
});
} catch (err) {
const msg = (err as Error).message;
if (msg.toLowerCase().includes('unsupported protocol')) {
return c.json({ error: msg, code: 'UNSUPPORTED_PROTOCOL' }, 400);
}
return c.json({ error: msg, code: 'READ_ERROR' }, 500);
}
});

View file

@ -2,11 +2,11 @@ import { Hono } from 'hono';
import { KIMI_CODE_HOME } from '../config';
import { readSessionDetail } from '../lib/session-store';
export function sessionDetailRoute(): Hono {
export function sessionDetailRoute(home: string = KIMI_CODE_HOME): Hono {
const r = new Hono();
r.get('/:id', async (c) => {
const id = c.req.param('id');
const detail = await readSessionDetail(KIMI_CODE_HOME, id);
const detail = await readSessionDetail(home, id);
if (!detail) return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
return c.json(detail);
});

View file

@ -4,15 +4,15 @@ import { KIMI_CODE_HOME } from '../config';
import { revealInOs } from '../lib/reveal';
import { listSessions, readSessionDetail } from '../lib/session-store';
export function sessionsRoute(): Hono {
export function sessionsRoute(home: string = KIMI_CODE_HOME): Hono {
const r = new Hono();
r.get('/', async (c) => {
const sessions = await listSessions(KIMI_CODE_HOME);
const sessions = await listSessions(home);
return c.json({ sessions });
});
r.delete('/:id', async (c) => {
const id = c.req.param('id');
const all = await listSessions(KIMI_CODE_HOME);
const all = await listSessions(home);
const target = all.find((s) => s.sessionId === id);
if (!target) return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
await rm(target.sessionDir, { recursive: true, force: true });
@ -22,7 +22,7 @@ export function sessionsRoute(): Hono {
// opened on the SERVER host — only meaningful when vis runs locally.
r.post('/:id/reveal', async (c) => {
const id = c.req.param('id');
const detail = await readSessionDetail(KIMI_CODE_HOME, id);
const detail = await readSessionDetail(home, id);
if (!detail) return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
try {
await revealInOs(detail.sessionDir);

View file

@ -3,11 +3,11 @@ import { KIMI_CODE_HOME } from '../config';
import { readSessionDetail } from '../lib/session-store';
import { buildAgentTree } from '../lib/agent-tree';
export function subagentsRoute(): Hono {
export function subagentsRoute(home: string = KIMI_CODE_HOME): Hono {
const r = new Hono();
r.get('/:id/agents', async (c) => {
const id = c.req.param('id');
const detail = await readSessionDetail(KIMI_CODE_HOME, id);
const detail = await readSessionDetail(home, id);
if (!detail) {
return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
}

View file

@ -6,7 +6,7 @@ import { isSafeAgentId, readSessionDetail } from '../lib/session-store';
import { rehydrateWireEntries } from '../lib/blob-resolver';
import { readAgentWire } from '../lib/wire-reader';
export function wireRoute(): Hono {
export function wireRoute(home: string = KIMI_CODE_HOME): Hono {
const r = new Hono();
r.get('/:id/wire', async (c) => {
const id = c.req.param('id');
@ -14,7 +14,7 @@ export function wireRoute(): Hono {
if (!isSafeAgentId(agentId)) {
return c.json({ error: 'invalid agent id', code: 'BAD_REQUEST' }, 400);
}
const detail = await readSessionDetail(KIMI_CODE_HOME, id);
const detail = await readSessionDetail(home, id);
if (!detail) {
return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404);
}
@ -41,9 +41,6 @@ export function wireRoute(): Hono {
});
} catch (err) {
const msg = (err as Error).message;
if (msg.toLowerCase().includes('unsupported protocol')) {
return c.json({ error: msg, code: 'UNSUPPORTED_PROTOCOL' }, 400);
}
return c.json({ error: msg, code: 'READ_ERROR' }, 500);
}
});

View file

@ -0,0 +1,47 @@
import { serve } from '@hono/node-server';
import { createApp } from './app';
import { hostForUrl, resolveHost, resolveKimiCodeHome, resolvePort, resolveVisAuthToken } from './config';
import type { WebAsset } from './lib/web-asset';
export interface StartVisServerOptions {
/** Sessions home. Defaults to env KIMI_CODE_HOME, else ~/.kimi-code. */
readonly homeDir?: string;
/** Port; 0 = auto-pick a free port. Defaults to env PORT, else 3001. */
readonly port?: number;
readonly host?: string;
readonly authToken?: string;
readonly webAsset?: WebAsset;
}
export interface StartedVisServer {
readonly port: number;
readonly host: string;
readonly url: string;
readonly close: () => Promise<void>;
}
export async function startVisServer(
opts: StartVisServerOptions = {},
): Promise<StartedVisServer> {
const host = opts.host ?? resolveHost();
const authToken = opts.authToken ?? resolveVisAuthToken(host);
const homeDir = opts.homeDir ?? resolveKimiCodeHome();
const app = await createApp({ authToken, homeDir, webAsset: opts.webAsset });
const port = opts.port ?? resolvePort();
return new Promise<StartedVisServer>((resolveStarted, rejectStarted) => {
const server = serve({ fetch: app.fetch, hostname: host, port }, (info) => {
resolveStarted({
port: info.port,
host,
url: `http://${hostForUrl(host)}:${info.port}/`,
close: () =>
new Promise<void>((done, fail) => {
server.close((err?: Error) => (err ? fail(err) : done()));
}),
});
});
server.once('error', rejectStarted);
});
}

View file

@ -1,3 +1,5 @@
import { hostForUrl } from './config';
export interface StartupBannerOptions {
readonly authToken?: string;
readonly host: string;
@ -12,8 +14,3 @@ export function formatStartupBanner(options: StartupBannerOptions): string {
`(${authStatus}, KIMI_CODE_HOME=${options.kimiCodeHome})\n`
);
}
function hostForUrl(host: string): string {
if (host.includes(':') && !host.startsWith('[')) return `[${host}]`;
return host;
}

View file

@ -0,0 +1,5 @@
{"type":"metadata","protocol_version":"1.1","created_at":1779256791085}
{"type":"config.update","cwd":"/tmp/work","profileName":"agent","systemPrompt":"You are Kimi.","time":1779256791100}
{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"before compaction"}],"toolCalls":[]},"time":1779256800001}
{"type":"context.apply_compaction","summary":"compacted summary","compactedCount":1,"tokensBefore":100,"tokensAfter":30,"time":1779256800500}
{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"after compaction"}],"toolCalls":[]},"time":1779256801000}

View file

@ -0,0 +1,15 @@
{
"createdAt": "2026-05-20T05:59:51.085Z",
"updatedAt": "2026-05-21T03:12:08.000Z",
"title": "fixture: compaction",
"isCustomTitle": false,
"lastPrompt": "after compaction",
"agents": {
"main": {
"homedir": "<resolved-at-runtime>",
"type": "main",
"parentAgentId": null
}
},
"custom": {}
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildAgentTree } from '../../src/lib/agent-tree';
import { buildAgentTree, compareAgentIds } from '../../src/lib/agent-tree';
import type { AgentInfo } from '../../src/lib/agent-record-types';
function info(overrides: Partial<AgentInfo> & Pick<AgentInfo, 'agentId'>): AgentInfo {
@ -10,6 +10,7 @@ function info(overrides: Partial<AgentInfo> & Pick<AgentInfo, 'agentId'>): Agent
wireExists: true,
wireRecordCount: 0,
wireProtocolVersion: '1.1',
swarmItem: null,
...overrides,
};
}
@ -55,4 +56,35 @@ describe('agent-tree', () => {
]);
expect(tree[0]!.agentId).toBe('main');
});
it('orders agents by numeric suffix, main first (agent-2 before agent-10)', () => {
const mk = (id: string): AgentInfo => ({
agentId: id, type: id === 'main' ? 'main' : 'sub', parentAgentId: id === 'main' ? null : 'main',
homedir: '', wireExists: true, wireRecordCount: 0, wireProtocolVersion: null, swarmItem: null,
});
const tree = buildAgentTree([mk('main'), mk('agent-10'), mk('agent-2')]);
const order = [tree[0]!.agentId, ...tree[0]!.children.map((c) => c.agentId)];
expect(order).toEqual(['main', 'agent-2', 'agent-10']);
});
it('orders orphan ROOTS by numeric suffix (agent-2 before agent-10)', () => {
const tree = buildAgentTree([
info({ agentId: 'agent-10', type: 'sub', parentAgentId: 'missing' }),
info({ agentId: 'agent-2', type: 'sub', parentAgentId: 'missing' }),
]);
expect(tree.map((n) => n.agentId)).toEqual(['agent-2', 'agent-10']);
});
it('compareAgentIds is a deterministic total order when agent-N and foreign ids mix', () => {
// 'agent-1a' is a foreign/hand-edited id reachable via state.json keys or
// discoverAgentsFromDisk directory names — it does not match agent-N.
// Under the new rule: all agent-N ids sort numerically first, then any
// non-agent-N id by localeCompare. Sorting any permutation must yield the
// same order; the OLD comparator was intransitive and order-dependent here.
const forward = ['agent-2', 'agent-1a', 'agent-10'];
const reverse = [...forward].reverse();
const expected = ['agent-2', 'agent-10', 'agent-1a'];
expect([...forward].sort(compareAgentIds)).toEqual(expected);
expect([...reverse].sort(compareAgentIds)).toEqual(expected);
});
});

View file

@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { hostForUrl } from '../../src/config';
describe('hostForUrl', () => {
it('brackets a bare IPv6 literal for use in a URL', () => {
expect(hostForUrl('::1')).toBe('[::1]');
});
it('leaves an IPv4 literal unchanged', () => {
expect(hostForUrl('127.0.0.1')).toBe('127.0.0.1');
});
it('leaves a hostname unchanged', () => {
expect(hostForUrl('localhost')).toBe('localhost');
});
it('leaves an already-bracketed IPv6 literal unchanged', () => {
expect(hostForUrl('[::1]')).toBe('[::1]');
});
});

View file

@ -127,6 +127,123 @@ describe('context-projector', () => {
]);
});
// ---- Fix G: tool.result content must match what the model saw ---------------
// agent-core's `ContextMemory.appendLoopEvent` (`tool.result` case) stores
// `createToolMessage(toolCallId, toolResultOutputForModel(event.result))`, NOT
// the raw `event.result.output`. `toolResultOutputForModel`
// (`packages/agent-core/src/agent/context/index.ts` ~line 350) normalizes
// error / empty outputs with sentinel strings. The projector must replicate
// that normalization so the model-view shows the content the model actually
// received for failed / empty tool calls.
const TOOL_ERROR_STATUS = '<system>ERROR: Tool execution failed.</system>';
const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>';
const TOOL_EMPTY_ERROR_STATUS =
'<system>ERROR: Tool execution failed. Tool output is empty.</system>';
/** Build a minimal wire fixture: one assistant step with a tool call, then a
* `tool.result` loop event carrying `result`. Returns the projected tool
* message (last entry). */
const projectToolResult = (result: unknown) => {
const entries = [
{
lineNo: 1,
data: {
type: 'context.append_loop_event' as const,
event: { type: 'step.begin' as const, uuid: 's1', turnId: 't1', step: 0 },
},
raw: {},
},
{
lineNo: 2,
data: {
type: 'context.append_loop_event' as const,
event: {
type: 'tool.call' as const,
uuid: 'tc1', turnId: 't1', step: 0, stepUuid: 's1',
toolCallId: 'call_1', name: 'Bash', args: '{}',
},
},
raw: {},
},
{
lineNo: 3,
data: {
type: 'context.append_loop_event' as const,
event: { type: 'step.end' as const, uuid: 's1', turnId: 't1', step: 0 },
},
raw: {},
},
{
lineNo: 4,
data: {
type: 'context.append_loop_event' as const,
event: {
type: 'tool.result' as const,
parentUuid: 'tc1',
toolCallId: 'call_1',
result,
},
},
raw: {},
},
];
const proj = projectContext(entries as any);
return proj.messages.at(-1)!.message;
};
it('tool.result: error string output is prefixed with the error sentinel', () => {
const msg = projectToolResult({ output: 'boom: file not found', isError: true });
expect(msg.role).toBe('tool');
expect(msg.toolCallId).toBe('call_1');
expect(msg.isError).toBe(true);
expect(msg.content).toEqual([
{ type: 'text', text: `${TOOL_ERROR_STATUS}\nboom: file not found` },
]);
});
it('tool.result: error string already starting with <system>ERROR: is passed through (no double prefix)', () => {
const text = '<system>ERROR: already wrapped</system>\ndetails here';
const msg = projectToolResult({ output: text, isError: true });
expect(msg.content).toEqual([{ type: 'text', text }]);
});
it('tool.result: empty string output (non-error) becomes the empty sentinel', () => {
const msg = projectToolResult({ output: '' });
expect(msg.content).toEqual([{ type: 'text', text: TOOL_EMPTY_STATUS }]);
});
it('tool.result: empty string output with error becomes the empty-error sentinel', () => {
const msg = projectToolResult({ output: '', isError: true });
expect(msg.isError).toBe(true);
expect(msg.content).toEqual([{ type: 'text', text: TOOL_EMPTY_ERROR_STATUS }]);
});
it('tool.result: normal non-empty non-error string is unchanged', () => {
const msg = projectToolResult({ output: 'file1.txt\nfile2.txt' });
expect(msg.content).toEqual([{ type: 'text', text: 'file1.txt\nfile2.txt' }]);
});
it('tool.result: array output with error is prefixed with an error-sentinel part', () => {
const parts = [
{ type: 'text' as const, text: 'partial output' },
{ type: 'image_url' as const, imageUrl: { url: 'data:image/png;base64,AAAA' } },
];
const msg = projectToolResult({ output: parts, isError: true });
expect(msg.content).toEqual([{ type: 'text', text: TOOL_ERROR_STATUS }, ...parts]);
});
it('tool.result: empty array output (non-error) becomes a single empty-sentinel part', () => {
const msg = projectToolResult({ output: [] });
expect(msg.content).toEqual([{ type: 'text', text: TOOL_EMPTY_STATUS }]);
});
it('tool.result: non-error array output is passed through as-is', () => {
const parts = [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }];
const msg = projectToolResult({ output: parts });
expect(msg.content).toEqual(parts);
});
it('clears messages on context.clear', async () => {
const entries = [
{ lineNo: 2, data: { type: 'context.append_message' as const, message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'a' }], toolCalls: [] } }, raw: {} },
@ -153,4 +270,507 @@ describe('context-projector', () => {
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'old stuff' });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'new' });
});
it('apply_compaction keeps the post-compaction tail (slice(compactedCount))', () => {
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const,
message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'm0' }], toolCalls: [] } }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const,
message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'm1' }], toolCalls: [] } }, raw: {} },
{ lineNo: 3, data: { type: 'context.append_message' as const,
message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'm2 (kept)' }], toolCalls: [] } }, raw: {} },
{ lineNo: 4, data: { type: 'context.apply_compaction' as const,
summary: 'sum', compactedCount: 2, tokensBefore: 100, tokensAfter: 10 }, raw: {} },
];
const proj = projectContext(entries as any);
// [summary, m2] — m0 and m1 (the first compactedCount=2) are dropped, m2 kept.
expect(proj.messages).toHaveLength(2);
expect(proj.messages[0]!.source).toBe('compaction_summary');
expect(proj.messages[0]!.compaction).toEqual({ compactedCount: 2, tokensBefore: 100, tokensAfter: 10 });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'm2 (kept)' });
expect(proj.messages[1]!.lineNo).toBe(3);
});
// ---- Fix ④: UI-only markers must not offset agent-core history indices ------
// agent-core computes compactedCount (and the micro-compaction cutoff) as
// indices into _history, which NEVER contains the synthetic 'undo'/'clear'
// markers we push into our messages array. So index-based ops must count ONLY
// real history entries (append_message + compaction_summary), skipping
// 'undo'/'clear' markers.
it('apply_compaction slices by history index, skipping a preceding undo marker (model)', () => {
const userMsg = (text: string) => ({
role: 'user' as const, content: [{ type: 'text' as const, text }], toolCalls: [],
origin: { kind: 'user' as const },
});
// Step 1: append u1, u2 then undo(1) → removes u2, leaves [u1, <undo marker>].
// Step 2: append u3, u4 → array is [u1, <undo marker>, u3, u4].
// History entries (agent-core _history, which has NO marker) are the three
// real messages [u1, u3, u4]. A compaction with compactedCount=2 drops the
// first 2 HISTORY entries (u1, u3) — and the undo marker that sits within
// that compacted prefix is dropped with it — keeping exactly [summary, u4].
//
// The naive `messages.slice(compactedCount=2)` would instead cut the ARRAY at
// index 2, yielding [summary, u3, u4] — it WRONGLY retains the already-
// compacted u3 because the undo marker offset the index by one. This test
// pins the correct history-aware behaviour and FAILS against the naive slice.
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: userMsg('u1') }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const, message: userMsg('u2') }, raw: {} },
{ lineNo: 3, data: { type: 'context.undo' as const, count: 1 }, raw: {} },
{ lineNo: 4, data: { type: 'context.append_message' as const, message: userMsg('u3') }, raw: {} },
{ lineNo: 5, data: { type: 'context.append_message' as const, message: userMsg('u4') }, raw: {} },
{ lineNo: 6, data: { type: 'context.apply_compaction' as const,
summary: 'sum', compactedCount: 2, tokensBefore: 100, tokensAfter: 10 }, raw: {} },
];
const proj = projectContext(entries as any);
// Correct: [summary, u4]. The marker and the first 2 history entries are gone.
expect(proj.messages.map((m) => m.source)).toEqual(['compaction_summary', 'append_message']);
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'u4' });
});
it('micro-blanking uses the history index, skipping a preceding undo marker (model)', () => {
const bigText = 'x'.repeat(2000);
const toolMsg = (id: string, text: string) => ({
role: 'tool' as const, content: [{ type: 'text' as const, text }], toolCalls: [], toolCallId: id,
});
const userMsg = (text: string) => ({
role: 'user' as const, content: [{ type: 'text' as const, text }], toolCalls: [],
origin: { kind: 'user' as const },
});
// Step 1: append tool c0, user u1 then undo(1) → removes u1, leaves
// [c0, <undo marker>].
// Step 2: append tool c1 → array is [c0, <undo marker>, c1].
// History entries (no marker) are [c0, c1]. A micro cutoff=2 means "blank the
// first 2 HISTORY entries" → both c0 AND c1 must be blanked.
//
// The naive array-index pass (i < cutoff=2 over the messages array) would
// blank array index 0 (c0) and index 1 (the undo marker — a no-op since it is
// not a tool message), then STOP before reaching c1 at array index 2, leaving
// c1 WRONGLY un-blanked. This pins the history-aware behaviour and FAILS
// against the naive array-index pass.
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: toolMsg('c0', bigText) }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const, message: userMsg('u1') }, raw: {} },
{ lineNo: 3, data: { type: 'context.undo' as const, count: 1 }, raw: {} },
{ lineNo: 4, data: { type: 'context.append_message' as const, message: toolMsg('c1', bigText) }, raw: {} },
{ lineNo: 5, data: { type: 'micro_compaction.apply' as const, cutoff: 2 }, raw: {} },
];
const proj = projectContext(entries as any);
expect(proj.messages.map((m) => m.source)).toEqual(['append_message', 'undo', 'append_message']);
// Both real tool results are within the first 2 history entries → both blanked.
expect(proj.messages[0]!.message.content).toEqual([{ type: 'text', text: '[Old tool result content cleared]' }]);
expect(proj.messages[2]!.message.content).toEqual([{ type: 'text', text: '[Old tool result content cleared]' }]);
});
it('context.undo removes back to the Nth real user prompt and leaves an undo marker', () => {
const userMsg = (text: string) => ({
role: 'user' as const, content: [{ type: 'text' as const, text }], toolCalls: [],
origin: { kind: 'user' as const },
});
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: userMsg('u1') }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const,
message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'a1' }], toolCalls: [] } }, raw: {} },
{ lineNo: 3, data: { type: 'context.append_message' as const, message: userMsg('u2') }, raw: {} },
{ lineNo: 4, data: { type: 'context.undo' as const, count: 1 }, raw: {} },
];
const proj = projectContext(entries as any);
// count=1 removes u2 (the last real user prompt). u1 + a1 remain, then an undo marker.
expect(proj.messages.map((m) => m.source)).toEqual(['append_message', 'append_message', 'undo']);
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'u1' });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'a1' });
expect(proj.messages[2]!.undo).toEqual({ count: 1, removedMessageCount: 1 });
expect(proj.messages[2]!.lineNo).toBe(4);
});
it('context.undo keeps injection messages inside the undo window (skip, not remove)', () => {
const userMsg = (text: string) => ({
role: 'user' as const, content: [{ type: 'text' as const, text }], toolCalls: [],
origin: { kind: 'user' as const },
});
const injectionMsg = (text: string) => ({
role: 'user' as const, content: [{ type: 'text' as const, text }], toolCalls: [],
origin: { kind: 'injection' as const },
});
// Layout: [u1, a1, u2, INJECTION, a2]. undo(1) walks from the end:
// a2 → removed (non-injection)
// INJECTION → skipped (kept), NOT counted
// u2 → removed, real user prompt → count(1) reached → stop.
// The injection sits INSIDE the undo window (between the trailing real user
// prompt u2 and the cutoff) and must SURVIVE; u2 and a2 around it are gone.
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: userMsg('u1') }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const,
message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'a1' }], toolCalls: [] } }, raw: {} },
{ lineNo: 3, data: { type: 'context.append_message' as const, message: userMsg('u2') }, raw: {} },
{ lineNo: 4, data: { type: 'context.append_message' as const, message: injectionMsg('inj') }, raw: {} },
{ lineNo: 5, data: { type: 'context.append_message' as const,
message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'a2' }], toolCalls: [] } }, raw: {} },
{ lineNo: 6, data: { type: 'context.undo' as const, count: 1 }, raw: {} },
];
const proj = projectContext(entries as any);
// u1, a1 remain; the injection survives in place; u2 + a2 removed; undo marker last.
expect(proj.messages.map((m) => m.source)).toEqual([
'append_message', 'append_message', 'append_message', 'undo',
]);
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'u1' });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'a1' });
expect(proj.messages[2]!.message.origin).toEqual({ kind: 'injection' });
expect(proj.messages[2]!.message.content[0]).toMatchObject({ text: 'inj' });
// removedMessageCount counts only the removed (non-skipped) messages: u2 + a2 = 2.
expect(proj.messages[3]!.undo).toEqual({ count: 1, removedMessageCount: 2 });
});
it('micro_compaction.apply blanks tool-result content before the cutoff', () => {
const bigText = 'x'.repeat(2000); // comfortably above the 100-token min
const toolMsg = (id: string, text: string) => ({
role: 'tool' as const, content: [{ type: 'text' as const, text }], toolCalls: [], toolCallId: id,
});
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: toolMsg('c0', bigText) }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const, message: toolMsg('c1', bigText) }, raw: {} },
{ lineNo: 3, data: { type: 'micro_compaction.apply' as const, cutoff: 1 }, raw: {} },
];
const proj = projectContext(entries as any);
// index 0 < cutoff(1) and is a large tool message → blanked; index 1 kept.
expect(proj.messages[0]!.message.content).toEqual([{ type: 'text', text: '[Old tool result content cleared]' }]);
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: bigText });
});
it('micro_compaction.apply counts think parts toward the min-content gate', () => {
// A tool result dominated by a large `think` part (tiny text) must clear the
// min-content gate and be blanked — mirroring agent-core's token estimator,
// which counts both text and think parts.
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: {
role: 'tool' as const, toolCallId: 'c0', toolCalls: [],
content: [
{ type: 'text' as const, text: 'ok' },
{ type: 'think' as const, think: 'y'.repeat(2000) },
],
} }, raw: {} },
{ lineNo: 2, data: { type: 'micro_compaction.apply' as const, cutoff: 1 }, raw: {} },
];
const proj = projectContext(entries as any);
expect(proj.messages[0]!.message.content).toEqual([{ type: 'text', text: '[Old tool result content cleared]' }]);
});
it('micro_compaction.apply weights non-ASCII (CJK) chars as full tokens', () => {
// ~150 CJK chars. Under a naive chars/4 estimate this is ~38 tokens (< 100
// gate → NOT blanked, the bug). agent-core counts each non-ASCII char as a
// full token → ~150 tokens (>= gate → blanked). Assert it IS blanked, so a
// Chinese-heavy tool result diverges from agent-core no longer.
const cjk = '中'.repeat(150);
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: {
role: 'tool' as const, toolCallId: 'c0', toolCalls: [],
content: [{ type: 'text' as const, text: cjk }],
} }, raw: {} },
{ lineNo: 2, data: { type: 'micro_compaction.apply' as const, cutoff: 1 }, raw: {} },
];
const proj = projectContext(entries as any);
expect(proj.messages[0]!.message.content).toEqual([{ type: 'text', text: '[Old tool result content cleared]' }]);
});
it('context.clear resets the micro-compaction cutoff (no stale blanking)', () => {
const bigText = 'x'.repeat(2000);
const toolMsg = (id: string, text: string) => ({
role: 'tool' as const, content: [{ type: 'text' as const, text }], toolCalls: [], toolCallId: id,
});
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: toolMsg('c0', bigText) }, raw: {} },
{ lineNo: 2, data: { type: 'micro_compaction.apply' as const, cutoff: 1 }, raw: {} },
{ lineNo: 3, data: { type: 'context.clear' as const }, raw: {} },
{ lineNo: 4, data: { type: 'context.append_message' as const, message: toolMsg('n0', bigText) }, raw: {} },
{ lineNo: 5, data: { type: 'context.append_message' as const, message: toolMsg('n1', bigText) }, raw: {} },
];
const proj = projectContext(entries as any);
// clear() ran reset() → cutoff back to 0, so the new tool messages must NOT be blanked.
expect(proj.messages).toHaveLength(2);
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: bigText });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: bigText });
});
it('context.apply_compaction resets the micro-compaction cutoff', () => {
const bigText = 'x'.repeat(2000);
const toolMsg = (id: string, text: string) => ({
role: 'tool' as const, content: [{ type: 'text' as const, text }], toolCalls: [], toolCallId: id,
});
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: toolMsg('c0', bigText) }, raw: {} },
{ lineNo: 2, data: { type: 'micro_compaction.apply' as const, cutoff: 1 }, raw: {} },
{ lineNo: 3, data: { type: 'context.apply_compaction' as const,
summary: 'sum', compactedCount: 1, tokensBefore: 100, tokensAfter: 10 }, raw: {} },
{ lineNo: 4, data: { type: 'context.append_message' as const, message: toolMsg('n0', bigText) }, raw: {} },
];
const proj = projectContext(entries as any);
// applyCompaction() ran reset() → cutoff back to 0. Result: [summary, n0].
// n0 must NOT be blanked.
expect(proj.messages).toHaveLength(2);
expect(proj.messages[0]!.source).toBe('compaction_summary');
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: bigText });
});
it('context.undo clamps the micro-compaction cutoff to the post-undo length', () => {
const bigText = 'x'.repeat(2000);
const toolMsg = (id: string, text: string) => ({
role: 'tool' as const, content: [{ type: 'text' as const, text }], toolCalls: [], toolCallId: id,
});
const userMsg = (text: string) => ({
role: 'user' as const, content: [{ type: 'text' as const, text }], toolCalls: [],
origin: { kind: 'user' as const },
});
// Layout: [tool c0, user u1, tool c2]. cutoff=3 covers all three. undo(1)
// removes the trailing real user prompt u1 AND the messages after it (c2),
// walking from the end: c2 (removed, not a user prompt), u1 (removed, user
// prompt → count reached). Remaining: [c0, undo-marker]. The cutoff must be
// clamped to min(3, postLen) so a LATER appended tool message is not blanked
// by the stale large cutoff.
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: toolMsg('c0', bigText) }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const, message: userMsg('u1') }, raw: {} },
{ lineNo: 3, data: { type: 'context.append_message' as const, message: toolMsg('c2', bigText) }, raw: {} },
{ lineNo: 4, data: { type: 'micro_compaction.apply' as const, cutoff: 3 }, raw: {} },
{ lineNo: 5, data: { type: 'context.undo' as const, count: 1 }, raw: {} },
// appended AFTER undo: index 2 in the final list ([c0, undo-marker, n0]).
{ lineNo: 6, data: { type: 'context.append_message' as const, message: toolMsg('n0', bigText) }, raw: {} },
];
const proj = projectContext(entries as any);
// After undo: [c0, undo-marker]; then n0 appended → [c0, undo-marker, n0].
// Clamp made cutoff = min(3, 2) = 2, so n0 (index 2) is NOT blanked.
// c0 (index 0 < 2) IS still blanked (the still-valid prefix).
expect(proj.messages.map((m) => m.source)).toEqual(['append_message', 'undo', 'append_message']);
expect(proj.messages[0]!.message.content).toEqual([{ type: 'text', text: '[Old tool result content cleared]' }]);
expect(proj.messages[2]!.message.content[0]).toMatchObject({ text: bigText });
});
it('context.undo clamps the micro-compaction cutoff by history-entry count, not array length (surviving marker)', () => {
const bigText = 'x'.repeat(2000);
const toolMsg = (id: string, text: string) => ({
role: 'tool' as const, content: [{ type: 'text' as const, text }], toolCalls: [], toolCallId: id,
});
const userMsg = (text: string) => ({
role: 'user' as const, content: [{ type: 'text' as const, text }], toolCalls: [],
origin: { kind: 'user' as const },
});
// A PRIOR undo must leave a surviving marker so that, at a LATER undo's clamp,
// the array length exceeds the history-entry count by that marker. agent-core
// clamps against `_history.length` (NO markers); clamping against
// `messages.length` here would be one too high and wrongly blank a later
// tool result.
//
// Trace (model mode):
// 1. append u1, u2 → [u1, u2]
// 2. undo(1) removes u2 → [u1] then pushes marker → [u1, <undo#1>]
// 3. append u3 → [u1, <undo#1>, u3]
// 4. micro_compaction cutoff=5 (large) → microCutoff=5
// 5. undo(1) removes u3 (cutoff index 2); the <undo#1> marker at index 1
// SURVIVES (1 < 2) → [u1, <undo#1>]. Clamp:
// - buggy min(5, messages.length=2) = 2
// - fixed min(5, historyCount=1) = 1 (u1 is history; marker is not)
// then push <undo#2> → [u1, <undo#1>, <undo#2>]
// 6. append big tool n0 → [u1, <undo#1>, <undo#2>, n0]
//
// Final blanking iterates history entries only (markers skipped). n0 is at
// history index 1 (only u1 precedes it as a history entry). It is blanked iff
// historyIndex(1) < microCutoff:
// - buggy microCutoff=2 → 1 < 2 → n0 WRONGLY blanked
// - fixed microCutoff=1 → 1 >= 1 → pass breaks → n0 preserved
// So this is RED (n0 blanked) under the messages.length clamp and GREEN under
// the history-count clamp.
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: userMsg('u1') }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const, message: userMsg('u2') }, raw: {} },
{ lineNo: 3, data: { type: 'context.undo' as const, count: 1 }, raw: {} },
{ lineNo: 4, data: { type: 'context.append_message' as const, message: userMsg('u3') }, raw: {} },
{ lineNo: 5, data: { type: 'micro_compaction.apply' as const, cutoff: 5 }, raw: {} },
{ lineNo: 6, data: { type: 'context.undo' as const, count: 1 }, raw: {} },
// appended AFTER the second undo, at history index 1.
{ lineNo: 7, data: { type: 'context.append_message' as const, message: toolMsg('n0', bigText) }, raw: {} },
];
const proj = projectContext(entries as any);
expect(proj.messages.map((m) => m.source)).toEqual([
'append_message', 'undo', 'undo', 'append_message',
]);
// u1 (history index 0 < cutoff) is blanked-eligible but is a user message, so
// unchanged. n0 (history index 1) must NOT be blanked: its original content
// is preserved, not replaced by the cleared marker.
expect(proj.messages[3]!.message.content).toEqual([{ type: 'text', text: bigText }]);
});
it('accumulates goal state from goal.create/update and clears on goal.clear', () => {
const base = [
{ lineNo: 1, data: { type: 'goal.create' as const, goalId: 'g1', objective: 'ship it', completionCriterion: 'tests green' }, raw: {} },
{ lineNo: 2, data: { type: 'goal.update' as const, status: 'active', turnsUsed: 3, actor: 'model' }, raw: {} },
];
const proj = projectContext(base as any);
expect(proj.goal).toMatchObject({ goalId: 'g1', objective: 'ship it', status: 'active', turnsUsed: 3, actor: 'model' });
const cleared = projectContext([...base, { lineNo: 3, data: { type: 'goal.clear' as const }, raw: {} }] as any);
expect(cleared.goal).toBeNull();
});
it('tracks swarm mode enter/exit', () => {
const enter = projectContext([{ lineNo: 1, data: { type: 'swarm_mode.enter' as const, trigger: 'task' }, raw: {} }] as any);
expect(enter.swarm).toEqual({ active: true, trigger: 'task' });
const exit = projectContext([
{ lineNo: 1, data: { type: 'swarm_mode.enter' as const, trigger: 'task' }, raw: {} },
{ lineNo: 2, data: { type: 'swarm_mode.exit' as const }, raw: {} },
] as any);
expect(exit.swarm.active).toBe(false);
});
it('uses the latest step.end usage as the absolute context-token snapshot', () => {
const entries = [
{ lineNo: 1, data: { type: 'context.append_loop_event' as const,
event: { type: 'step.begin' as const, uuid: 's1', turnId: 't1', step: 0 } }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_loop_event' as const,
event: { type: 'step.end' as const, uuid: 's1', turnId: 't1', step: 0,
usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 3 } } }, raw: {} },
];
const proj = projectContext(entries as any);
expect(proj.contextTokens).toBe(20); // 10+5+2+3, absolute (not summed across usage.record)
});
// ---- Fix ②: contextTokens updates on clear / compaction lifecycle events ---
// agent-core ContextMemory sets _tokenCount on clear() (→ 0) and
// applyCompaction(result) (→ result.tokensAfter), not only on step.end. These
// are derived state, so they apply identically in both projection modes.
for (const mode of ['model', 'full'] as const) {
it(`resets contextTokens to 0 after a context.clear (mode=${mode})`, () => {
const entries = [
{ lineNo: 1, data: { type: 'context.append_loop_event' as const,
event: { type: 'step.begin' as const, uuid: 's1', turnId: 't1', step: 0 } }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_loop_event' as const,
event: { type: 'step.end' as const, uuid: 's1', turnId: 't1', step: 0,
usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 3 } } }, raw: {} },
// clear() is the last token-affecting event → contextTokens must be 0.
{ lineNo: 3, data: { type: 'context.clear' as const }, raw: {} },
];
const proj = projectContext(entries as any, mode);
expect(proj.contextTokens).toBe(0);
});
it(`sets contextTokens to tokensAfter after a context.apply_compaction (mode=${mode})`, () => {
const entries = [
{ lineNo: 1, data: { type: 'context.append_loop_event' as const,
event: { type: 'step.begin' as const, uuid: 's1', turnId: 't1', step: 0 } }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_loop_event' as const,
event: { type: 'step.end' as const, uuid: 's1', turnId: 't1', step: 0,
usage: { inputOther: 100, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } } }, raw: {} },
// applyCompaction is the last token-affecting event → contextTokens must
// be tokensAfter (30), not the pre-compaction step.end snapshot (100).
{ lineNo: 3, data: { type: 'context.apply_compaction' as const,
summary: 'sum', compactedCount: 0, tokensBefore: 100, tokensAfter: 30 }, raw: {} },
];
const proj = projectContext(entries as any, mode);
expect(proj.contextTokens).toBe(30);
});
}
// ---- Full-history mode (Unit 6) -------------------------------------------
// In 'full' mode the four destructive lifecycle events insert an inline
// marker but do NOT mutate/drop the surrounding message list. 'model' mode
// (the default) keeps the existing model's-eye behaviour byte-identical.
it("defaults to 'model' mode when no 2nd arg is passed (compaction drops the prefix)", () => {
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const,
message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'm0' }], toolCalls: [] } }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const,
message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'm1' }], toolCalls: [] } }, raw: {} },
{ lineNo: 3, data: { type: 'context.apply_compaction' as const,
summary: 'sum', compactedCount: 2, tokensBefore: 100, tokensAfter: 10 }, raw: {} },
];
// No 2nd arg → 'model' default: prefix dropped, only the summary remains.
const proj = projectContext(entries as any);
expect(proj.messages).toHaveLength(1);
expect(proj.messages[0]!.source).toBe('compaction_summary');
});
it("full mode keeps the pre-compaction messages plus the summary marker plus the tail", () => {
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const,
message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'm0' }], toolCalls: [] } }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const,
message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'm1' }], toolCalls: [] } }, raw: {} },
{ lineNo: 3, data: { type: 'context.apply_compaction' as const,
summary: 'sum', compactedCount: 2, tokensBefore: 100, tokensAfter: 10 }, raw: {} },
{ lineNo: 4, data: { type: 'context.append_message' as const,
message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'm3' }], toolCalls: [] } }, raw: {} },
];
const proj = projectContext(entries as any, 'full');
// m0, m1 are KEPT (not dropped), then the summary marker is appended inline,
// then the post-compaction tail (m3). Contrast the model-mode test above
// which drops the first compactedCount messages.
expect(proj.messages.map((m) => m.source)).toEqual([
'append_message', 'append_message', 'compaction_summary', 'append_message',
]);
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'm0' });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'm1' });
expect(proj.messages[2]!.compaction).toEqual({ compactedCount: 2, tokensBefore: 100, tokensAfter: 10 });
expect(proj.messages[2]!.message.origin).toEqual({ kind: 'compaction_summary' });
expect(proj.messages[3]!.message.content[0]).toMatchObject({ text: 'm3' });
});
it("full mode keeps the undone messages and only appends an undo marker (no splice)", () => {
const userMsg = (text: string) => ({
role: 'user' as const, content: [{ type: 'text' as const, text }], toolCalls: [],
origin: { kind: 'user' as const },
});
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: userMsg('u1') }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const,
message: { role: 'assistant' as const, content: [{ type: 'text' as const, text: 'a1' }], toolCalls: [] } }, raw: {} },
{ lineNo: 3, data: { type: 'context.append_message' as const, message: userMsg('u2') }, raw: {} },
{ lineNo: 4, data: { type: 'context.undo' as const, count: 1 }, raw: {} },
];
const proj = projectContext(entries as any, 'full');
// All three messages are KEPT, then an undo marker is appended. The
// removedMessageCount still reflects what WOULD have been removed (u2 → 1).
expect(proj.messages.map((m) => m.source)).toEqual([
'append_message', 'append_message', 'append_message', 'undo',
]);
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'u1' });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'a1' });
expect(proj.messages[2]!.message.content[0]).toMatchObject({ text: 'u2' });
expect(proj.messages[3]!.undo).toEqual({ count: 1, removedMessageCount: 1 });
expect(proj.messages[3]!.lineNo).toBe(4);
});
it("full mode keeps pre-clear messages and inserts a 'clear' marker (not emptied)", () => {
const entries = [
{ lineNo: 2, data: { type: 'context.append_message' as const,
message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'a' }], toolCalls: [] } }, raw: {} },
{ lineNo: 3, data: { type: 'context.clear' as const }, raw: {} },
{ lineNo: 4, data: { type: 'context.append_message' as const,
message: { role: 'user' as const, content: [{ type: 'text' as const, text: 'b' }], toolCalls: [] } }, raw: {} },
];
const proj = projectContext(entries as any, 'full');
// 'a' KEPT, then a 'clear' marker, then 'b' — not emptied.
expect(proj.messages.map((m) => m.source)).toEqual(['append_message', 'clear', 'append_message']);
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'a' });
expect(proj.messages[1]!.source).toBe('clear');
expect(proj.messages[1]!.lineNo).toBe(3);
expect(proj.messages[2]!.message.content[0]).toMatchObject({ text: 'b' });
});
it("full mode does NOT blank the tool result on micro-compaction (shows original content)", () => {
const bigText = 'x'.repeat(2000); // comfortably above the 100-token min
const toolMsg = (id: string, text: string) => ({
role: 'tool' as const, content: [{ type: 'text' as const, text }], toolCalls: [], toolCallId: id,
});
const entries = [
{ lineNo: 1, data: { type: 'context.append_message' as const, message: toolMsg('c0', bigText) }, raw: {} },
{ lineNo: 2, data: { type: 'context.append_message' as const, message: toolMsg('c1', bigText) }, raw: {} },
{ lineNo: 3, data: { type: 'micro_compaction.apply' as const, cutoff: 1 }, raw: {} },
];
const proj = projectContext(entries as any, 'full');
// In 'model' mode index 0 would be blanked; in 'full' mode the original
// content is preserved.
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: bigText });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: bigText });
});
});

View file

@ -233,4 +233,22 @@ describe('session-store', () => {
const sub = d!.agents.find((a) => a.agentId === 'agent-0')!;
expect(sub.parentAgentId).toBe('main');
});
it('surfaces swarmItem from state.json onto AgentInfo (null when absent)', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { readFile, writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const statePath = join(sessionDir, 'state.json');
const state = JSON.parse(await readFile(statePath, 'utf8'));
state.agents['agent-0'].swarmItem = 'task A';
await writeFile(statePath, JSON.stringify(state));
const d = await readSessionDetail(home, 'session_fixture');
expect(d).not.toBeNull();
const sub = d!.agents.find((a) => a.agentId === 'agent-0')!;
expect(sub.swarmItem).toBe('task A');
// main has no swarmItem in state.json → null, not undefined.
const main = d!.agents.find((a) => a.agentId === 'main')!;
expect(main.swarmItem).toBeNull();
});
});

View file

@ -0,0 +1,42 @@
import { describe, it, expect, afterEach } from 'vitest';
import { gzipSync } from 'node:zlib';
import { startVisServer } from '../../src/start';
let stop: (() => Promise<void>) | null = null;
afterEach(async () => { if (stop) await stop(); stop = null; });
describe('startVisServer', () => {
it('serves the embedded web asset and the API on an auto-picked port', async () => {
const html = '<!doctype html><title>vis</title>';
const server = await startVisServer({
port: 0, // auto-pick
homeDir: '/tmp/does-not-exist-home', // no sessions; API still responds
webAsset: { gzipped: new Uint8Array(gzipSync(Buffer.from(html))) },
});
stop = server.close;
expect(server.port).toBeGreaterThan(0);
const page = await fetch(`${server.url}`);
expect(page.status).toBe(200);
expect(page.headers.get('content-type')).toContain('text/html');
expect(await page.text()).toContain('<title>vis</title>'); // fetch auto-inflates gzip
const spa = await fetch(`${server.url}sessions/anything`);
expect(await spa.text()).toContain('<title>vis</title>'); // SPA fallback
const api = await fetch(`${server.url}api/sessions`);
expect(api.status).toBe(200); // empty list for a missing home, not a crash
});
it('rejects instead of hanging when the port is already bound', async () => {
const first = await startVisServer({ port: 0, homeDir: '/tmp/does-not-exist-home' });
stop = first.close;
const taken = first.port;
// A second bind on the same port must REJECT (EADDRINUSE), not hang
// forever or escape as an uncaughtException.
await expect(
startVisServer({ port: taken, homeDir: '/tmp/does-not-exist-home' }),
).rejects.toThrow();
});
});

View file

@ -0,0 +1,96 @@
import { describe, it, expect, afterEach } from 'vitest';
import { buildSessionFixture } from '../fixtures/build';
import { contextRoute } from '../../src/routes/context';
describe('context route', () => {
let cleanup: (() => Promise<void>) | null = null;
afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; });
it('echoes the new projection fields (contextTokens, goal, swarm)', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = contextRoute(home);
const res = await app.request('/session_fixture/context?agent=main');
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
// The route must pass these projection fields straight through (it used to
// cherry-pick only messages/usage/config/permission/planMode).
expect(body).toHaveProperty('contextTokens');
expect(body).toHaveProperty('goal');
expect(body).toHaveProperty('swarm');
// The sample fixture's only step.end carries usage 10+5 → contextTokens=15,
// and has no goal / swarm records.
expect(body['contextTokens']).toBe(15);
expect(body['goal']).toBeNull();
expect(body['swarm']).toEqual({ active: false });
});
it('still echoes the existing fields', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = contextRoute(home);
const res = await app.request('/session_fixture/context?agent=main');
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body['sessionId']).toBe('session_fixture');
expect(body['agentId']).toBe('main');
expect(body).toHaveProperty('messages');
expect(body).toHaveProperty('usage');
expect(body).toHaveProperty('config');
expect(body).toHaveProperty('permission');
expect(body).toHaveProperty('planMode');
});
it('returns 404 for missing session', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = contextRoute(home);
const res = await app.request('/no-such-session/context?agent=main');
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'NOT_FOUND' });
});
it('returns 400 for invalid agent id', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const app = contextRoute(home);
const res = await app.request('/session_fixture/context?agent=../escape');
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'BAD_REQUEST' });
});
it('?history=full returns the pre-compaction messages (full reconstructed history)', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-compaction');
cleanup = c;
const app = contextRoute(home);
// Default (model view): the pre-compaction message is dropped, leaving
// [summary, after-compaction].
const modelRes = await app.request('/session_fixture/context?agent=main');
expect(modelRes.status).toBe(200);
const modelBody = (await modelRes.json()) as {
messages: { source: string; message: { content: { type: string; text?: string }[] } }[];
};
expect(modelBody.messages.map((m) => m.source)).toEqual([
'compaction_summary', 'append_message',
]);
// Full history: the pre-compaction message is KEPT, then the summary marker,
// then the post-compaction tail.
const fullRes = await app.request('/session_fixture/context?agent=main&history=full');
expect(fullRes.status).toBe(200);
const fullBody = (await fullRes.json()) as {
messages: { source: string; message: { content: { type: string; text?: string }[] } }[];
};
expect(fullBody.messages.map((m) => m.source)).toEqual([
'append_message', 'compaction_summary', 'append_message',
]);
expect(fullBody.messages[0]!.message.content[0]).toMatchObject({ text: 'before compaction' });
expect(fullBody.messages[2]!.message.content[0]).toMatchObject({ text: 'after compaction' });
});
});

View file

@ -1,4 +1,4 @@
{
"extends": "../../../tsconfig.json",
"include": ["src", "test"]
"include": ["src", "test", "../../../packages/agent-core/src/prompt-modules.d.ts"]
}

View file

@ -34,6 +34,7 @@
"@vitejs/plugin-react": "^4.4.1",
"tailwindcss": "^4.1.4",
"typescript": "6.0.2",
"vite": "^6.3.3"
"vite": "^6.3.3",
"vite-plugin-singlefile": "^2.3.3"
}
}

View file

@ -103,8 +103,11 @@ export const api = {
getWire: (id: string, agentId: string) =>
get<WireResponse>(`/api/sessions/${enc(id)}/wire?agent=${enc(agentId)}`),
getContext: (id: string, agentId: string) =>
get<ContextResponse>(`/api/sessions/${enc(id)}/context?agent=${enc(agentId)}`),
getContext: (id: string, agentId: string, mode?: 'model' | 'full') =>
get<ContextResponse>(
`/api/sessions/${enc(id)}/context?agent=${enc(agentId)}` +
(mode === 'full' ? '&history=full' : ''),
),
getAgentTree: (id: string) =>
get<AgentTreeResponse>(`/api/sessions/${enc(id)}/agents`),

View file

@ -0,0 +1,26 @@
import type { ProjectedMessage } from '../../types';
interface ClearRibbonProps {
/** The synthetic clear-marker message emitted by the projector in
* full-history mode (`source === 'clear'`). */
message: ProjectedMessage;
}
/**
* Horizontal ribbon that marks where a `context.clear` record wiped the
* conversation. Only appears in full-history mode in the model view the
* messages before the clear are simply gone. Styled to match
* `CompactionRibbon` / `UndoRibbon` (flanking `h-px` rules + a centered mono
* uppercase label), using the warning tone.
*/
export function ClearRibbon({ message }: ClearRibbonProps) {
return (
<div className="my-3 flex items-center gap-3">
<span className="h-px flex-1 bg-[var(--color-sev-warning)] opacity-50" />
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--color-sev-warning)]">
context cleared · line {message.lineNo}
</span>
<span className="h-px flex-1 bg-[var(--color-sev-warning)] opacity-50" />
</div>
);
}

View file

@ -13,6 +13,7 @@ interface CompactionRibbonProps {
*/
export function CompactionRibbon({ message }: CompactionRibbonProps) {
const summary = extractSummary(message);
const stats = message.compaction;
return (
<div className="my-3 flex flex-col gap-2">
<div className="flex items-center gap-3">
@ -22,6 +23,12 @@ export function CompactionRibbon({ message }: CompactionRibbonProps) {
</span>
<span className="h-px flex-1 bg-[var(--color-compaction)] opacity-50" />
</div>
{stats ? (
<div className="text-center font-mono text-[10.5px] text-fg-3">
{stats.compactedCount} msgs · {stats.tokensBefore.toLocaleString()}
{stats.tokensAfter.toLocaleString()} tok
</div>
) : null}
{summary.length > 0 ? (
<pre className="whitespace-pre-wrap break-words font-mono text-[12px] text-fg-2">
{summary}

View file

@ -4,8 +4,10 @@ import { useContext } from '../../hooks/useContext';
import { useSession } from '../../hooks/useSession';
import type { TokenUsage } from '../../types';
import { Pill } from '../shared/Pill';
import { ClearRibbon } from './ClearRibbon';
import { CompactionRibbon } from './CompactionRibbon';
import { MessageBubble } from './MessageBubble';
import { UndoRibbon } from './UndoRibbon';
interface ContextTabProps {
sessionId: string;
@ -15,20 +17,26 @@ interface ContextTabProps {
export function ContextTab({ sessionId, initialAgentId = 'main' }: ContextTabProps) {
const [agentId, setAgentId] = useState<string>(initialAgentId);
const [history, setHistory] = useState<'model' | 'full'>('model');
// Re-sync on session OR agent id change — see WireTab for the same
// rationale (session navigation must reset a stale subagent pick).
useEffect(() => {
setAgentId(initialAgentId);
}, [sessionId, initialAgentId]);
const { data: detail } = useSession(sessionId);
const { data: ctx, isLoading, error } = useContext(sessionId, agentId);
const { data: ctx, isLoading, error } = useContext(sessionId, agentId, history);
const agents = detail?.agents ?? [];
const messages = ctx?.messages ?? [];
const session = ctx?.usage.byScope.session ?? EMPTY_USAGE;
// Live context-window fill (latest step.end usage), distinct from the
// cumulative `session` spend the 4-segment bar breaks down.
const contextTokens = ctx?.contextTokens ?? 0;
const config = ctx?.config ?? {};
const permissionMode = ctx?.permission.mode ?? null;
const planActive = ctx?.planMode.active ?? false;
const goal = ctx?.goal ?? null;
const swarmActive = ctx?.swarm.active ?? false;
return (
<div className="flex min-h-0 flex-1 flex-col">
@ -63,21 +71,85 @@ export function ContextTab({ sessionId, initialAgentId = 'main' }: ContextTabPro
</span>
) : null}
<div className="ml-auto flex items-center gap-2">
{/* History view toggle 'model' (post-compaction, what the model
sees) vs 'full' (full reconstructed history for debugging). */}
<div
role="group"
aria-label="history view"
className="flex items-center overflow-hidden border border-border"
>
<button
type="button"
aria-pressed={history === 'model'}
onClick={() => {
setHistory('model');
}}
className={[
'px-2 py-1 font-mono text-[11px] transition-colors',
history === 'model'
? 'bg-surface-2 text-fg-0'
: 'bg-surface-0 text-fg-3 hover:text-fg-1',
].join(' ')}
>
model
</button>
<button
type="button"
aria-pressed={history === 'full'}
onClick={() => {
setHistory('full');
}}
className={[
'border-l border-border px-2 py-1 font-mono text-[11px] transition-colors',
history === 'full'
? 'bg-surface-2 text-fg-0'
: 'bg-surface-0 text-fg-3 hover:text-fg-1',
].join(' ')}
>
full history
</button>
</div>
{permissionMode ? (
<Pill tone="approval" variant="outline">permission: {permissionMode}</Pill>
) : null}
{planActive ? (
<Pill tone="info" variant="solid">plan mode</Pill>
) : null}
{swarmActive ? (
<Pill tone="subagent" variant="solid">swarm mode</Pill>
) : null}
</div>
</div>
{/* 4-segment token bar pulled from byScope.session */}
<TokenBar usage={session} />
{/* Full-history hint clarifies that this is the reconstructed history
and the model itself only sees the compacted view. */}
{history === 'full' ? (
<div className="shrink-0 border-b border-border bg-surface-1 px-3 py-1 font-mono text-[10.5px] text-fg-3">
full reconstructed history the model actually sees the compacted view
</div>
) : null}
{/* Live context-window fill (contextTokens) + the 4-segment cumulative
session-usage breakdown. */}
<TokenBar usage={session} contextTokens={contextTokens} />
{/* Message stream */}
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col gap-3 px-3 py-4">
{goal ? (
<div className="rounded border border-[var(--color-cat-lifecycle)]/40 bg-surface-0 p-2">
<div className="mb-1 flex items-center gap-2">
<Pill tone="lifecycle" variant="soft">goal</Pill>
{goal.status ? <Pill tone="info" variant="outline">{goal.status}</Pill> : null}
</div>
<div className="font-mono text-[12px] text-fg-1">{goal.objective}</div>
{goal.completionCriterion ? (
<div className="mt-1 font-mono text-[11px] text-fg-3">
done when: {goal.completionCriterion}
</div>
) : null}
</div>
) : null}
{config.systemPrompt ? <SystemPromptBubble text={config.systemPrompt} /> : null}
{isLoading ? (
<div className="px-3 py-2 font-mono text-[12px] text-fg-3">loading context</div>
@ -94,6 +166,12 @@ export function ContextTab({ sessionId, initialAgentId = 'main' }: ContextTabPro
if (m.source === 'compaction_summary') {
return <CompactionRibbon key={m.lineNo} message={m} />;
}
if (m.source === 'undo') {
return <UndoRibbon key={m.lineNo} message={m} />;
}
if (m.source === 'clear') {
return <ClearRibbon key={m.lineNo} message={m} />;
}
return <MessageBubble key={m.lineNo} message={m} />;
})
)}
@ -123,51 +201,69 @@ const SEG_COLORS = {
inputCacheCreation: 'var(--color-sev-warning)',
} as const;
function TokenBar({ usage }: { usage: TokenUsage }) {
function TokenBar({ usage, contextTokens }: { usage: TokenUsage; contextTokens: number }) {
const total =
usage.inputOther + usage.output + usage.inputCacheRead + usage.inputCacheCreation;
if (total === 0) {
return <div className="h-[2px] shrink-0 bg-border" />;
}
const seg = (n: number) => (n / total) * 100;
return (
<div
className="flex h-[3px] w-full shrink-0"
title={
`cache_read ${usage.inputCacheRead.toLocaleString()} · ` +
`input ${usage.inputOther.toLocaleString()} · ` +
`output ${usage.output.toLocaleString()} · ` +
`cache_create ${usage.inputCacheCreation.toLocaleString()}`
}
>
{usage.inputCacheRead > 0 ? (
<div className="shrink-0">
{contextTokens > 0 ? (
<div className="flex items-center justify-end gap-1 border-b border-border bg-surface-1 px-3 py-1 font-mono text-[10px] text-fg-2">
<span className="text-fg-3">context</span>
<span className="tabular text-fg-0">{contextTokens.toLocaleString()}</span>
<span className="text-fg-3">tok</span>
</div>
) : null}
{total === 0 ? (
<div className="h-[2px] bg-border" />
) : (
<div
style={{
width: `${seg(usage.inputCacheRead)}%`,
backgroundColor: SEG_COLORS.inputCacheRead,
}}
/>
) : null}
{usage.inputOther > 0 ? (
<div
style={{ width: `${seg(usage.inputOther)}%`, backgroundColor: SEG_COLORS.inputOther }}
/>
) : null}
{usage.output > 0 ? (
<div style={{ width: `${seg(usage.output)}%`, backgroundColor: SEG_COLORS.output }} />
) : null}
{usage.inputCacheCreation > 0 ? (
<div
style={{
width: `${seg(usage.inputCacheCreation)}%`,
backgroundColor: SEG_COLORS.inputCacheCreation,
}}
/>
) : null}
className="flex h-[3px] w-full"
title={
`cache_read ${usage.inputCacheRead.toLocaleString()} · ` +
`input ${usage.inputOther.toLocaleString()} · ` +
`output ${usage.output.toLocaleString()} · ` +
`cache_create ${usage.inputCacheCreation.toLocaleString()}`
}
>
{usage.inputCacheRead > 0 ? (
<div
style={{
width: `${seg(usage.inputCacheRead, total)}%`,
backgroundColor: SEG_COLORS.inputCacheRead,
}}
/>
) : null}
{usage.inputOther > 0 ? (
<div
style={{
width: `${seg(usage.inputOther, total)}%`,
backgroundColor: SEG_COLORS.inputOther,
}}
/>
) : null}
{usage.output > 0 ? (
<div
style={{ width: `${seg(usage.output, total)}%`, backgroundColor: SEG_COLORS.output }}
/>
) : null}
{usage.inputCacheCreation > 0 ? (
<div
style={{
width: `${seg(usage.inputCacheCreation, total)}%`,
backgroundColor: SEG_COLORS.inputCacheCreation,
}}
/>
) : null}
</div>
)}
</div>
);
}
function seg(n: number, total: number): number {
return (n / total) * 100;
}
function SystemPromptBubble({ text }: { text: string }) {
const [open, setOpen] = useState(false);
return (

View file

@ -27,8 +27,10 @@ function baseClass(): string {
function UserBubble({ m }: { m: ProjectedMessage }) {
const origin = m.message.origin;
const originKind = origin?.kind;
const showsOriginBadge =
originKind === 'system_trigger' || originKind === 'injection' || originKind === 'hook_result';
// Badge every origin that is not a plain user prompt. This covers
// skill_activation, background_task, cron_job, cron_missed, retry,
// system_trigger, injection, hook_result, compaction_summary, etc.
const showsOriginBadge = originKind !== undefined && originKind !== 'user';
return (
<article className={baseClass()} style={{ borderLeftColor: 'var(--color-user)' }}>
<header className="mb-1 flex items-center gap-2">

View file

@ -0,0 +1,26 @@
import type { ProjectedMessage } from '../../types';
interface UndoRibbonProps {
/** The synthetic undo-marker message emitted by the projector
* (`source === 'undo'`). */
message: ProjectedMessage;
}
/**
* Horizontal ribbon that marks where a `context.undo` record spliced earlier
* prompts out of the conversation. Receives the `ProjectedMessage` whose
* `source === 'undo'` so we can show how many prompts / messages were removed.
*/
export function UndoRibbon({ message }: UndoRibbonProps) {
const count = message.undo?.count ?? 0;
const removed = message.undo?.removedMessageCount ?? 0;
return (
<div className="my-3 flex items-center gap-3">
<span className="h-px flex-1 bg-[var(--color-sev-warning)] opacity-50" />
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--color-sev-warning)]">
undid {count} prompt{count === 1 ? '' : 's'} · {removed} message{removed === 1 ? '' : 's'} removed · line {message.lineNo}
</span>
<span className="h-px flex-1 bg-[var(--color-sev-warning)] opacity-50" />
</div>
);
}

View file

@ -24,7 +24,6 @@ const HEALTH_OPTIONS: { value: HealthFilter; label: string }[] = [
{ value: 'broken_state', label: 'broken state' },
{ value: 'broken_main_wire', label: 'broken wire' },
{ value: 'missing_main_wire', label: 'no wire' },
{ value: 'unsupported_protocol', label: 'old proto' },
];
export function SessionFilter({

View file

@ -33,6 +33,11 @@ export function SubagentNode({ node, sessionId }: Props) {
{node.type}
</Pill>
<span className="font-mono text-[12px] text-fg-0">{node.agentId}</span>
{node.swarmItem ? (
<Pill tone="subagent" variant="outline" title={node.swarmItem}>
{node.swarmItem}
</Pill>
) : null}
{node.parentAgentId !== null ? (
<span className="font-mono text-[10.5px] text-fg-3">
{node.parentAgentId}

View file

@ -1,6 +1,6 @@
import type { AgentRecord } from '../../types';
import { Pill } from '../shared/Pill';
import { TYPE_LABEL, TYPE_TONE } from './typeMeta';
import { rendererFor } from './renderers';
type RecordType = AgentRecord['type'];
@ -9,8 +9,9 @@ interface TypeBadgeProps {
}
export function TypeBadge({ type }: TypeBadgeProps) {
const label = TYPE_LABEL[type] ?? type;
const tone = TYPE_TONE[type] ?? 'neutral';
const renderer = rendererFor(type);
const label = renderer?.label ?? type;
const tone = renderer?.tone ?? 'neutral';
return (
<Pill tone={tone} variant="soft" title={type}>
{label}

View file

@ -1,350 +1,17 @@
import type { ReactNode } from 'react';
import type { AgentRecord } from '../../types';
import { Dim, type HeadlineRender } from './parts';
import { rendererFor } from './renderers';
import type { AgentRecord, ContentPart, LoopRecordedEvent } from '../../types';
import { Pill } from '../shared/Pill';
import { formatBytes } from '../shared/SizePreview';
export type { HeadlineRender };
export interface HeadlineRender {
/** Main headline content — rendered in the flex-grow slot of the row */
main: ReactNode;
/** Right-side badges / pair refs */
right?: ReactNode;
}
function truncate(s: unknown, n: number): string {
let str: string;
if (s === null || s === undefined) str = '';
else if (typeof s === 'string') str = s;
else if (typeof s === 'number' || typeof s === 'boolean' || typeof s === 'bigint')
str = String(s);
else {
try {
str = JSON.stringify(s);
} catch {
return '[unserializable]';
}
}
if (str.length <= n) return str;
return str.slice(0, n) + '…';
}
/** Pull the first text segment from a ContentPart[] for one-line preview. */
function firstText(parts: readonly ContentPart[]): string {
for (const p of parts) {
if (p.type === 'text' && typeof p.text === 'string') return p.text;
}
return '(non-text)';
}
/** One-line description of the embedded LoopRecordedEvent. */
function loopEventSummary(ev: LoopRecordedEvent): string {
switch (ev.type) {
case 'step.begin':
return `step ${ev.step} (turn ${ev.turnId})`;
case 'step.end':
return `step ${ev.step}${ev.finishReason ?? '?'}`;
case 'content.part': {
const len =
ev.part.type === 'text'
? ev.part.text.length
: ev.part.type === 'think'
? ev.part.think.length
: 0;
return `${ev.part.type}${len ? ` (${len}b)` : ''}`;
}
case 'tool.call':
return `${ev.name}#${ev.toolCallId.slice(-8)}`;
case 'tool.result':
return `result#${ev.toolCallId.slice(-8)}${ev.result.isError === true ? ' (error)' : ''}`;
default: {
const exhaustive: never = ev;
return String((exhaustive as { type?: string }).type ?? 'unknown');
}
}
}
/** Render the collapsed-headline for a wire record. */
/** Render the collapsed-headline for a wire record. Thin dispatch to the
* per-kind registry; unknown runtime kinds (best-effort parse of a
* future/legacy/foreign protocol) get a generic fallback so the row never
* crashes the tab. */
export function renderHeadline(r: AgentRecord): HeadlineRender {
switch (r.type) {
case 'metadata':
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>protocol v{r.protocol_version}</Mono>
<Dim>·</Dim>
<Mono>created {new Date(r.created_at).toLocaleString()}</Mono>
</span>
),
};
case 'config.update': {
const parts: string[] = [];
if (r.profileName !== undefined) parts.push(`profile=${r.profileName}`);
if (r.modelAlias !== undefined) parts.push(`model=${r.modelAlias}`);
if (r.cwd !== undefined) parts.push(`cwd=${r.cwd}`);
if (r.thinkingLevel !== undefined) parts.push(`thinking=${r.thinkingLevel}`);
if (r.systemPrompt !== undefined) parts.push(`system(${r.systemPrompt.length}b)`);
return {
main: (
<span className="truncate text-fg-0">
{parts.length === 0 ? <Dim>(no fields)</Dim> : parts.join(' · ')}
</span>
),
};
}
case 'turn.prompt':
case 'turn.steer': {
const text = firstText(r.input);
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="turn" variant="soft">
{r.origin.kind}
</Pill>
<span className="truncate text-fg-1"> {truncate(text, 80)}</span>
</span>
),
};
}
case 'turn.cancel':
return {
main: <Mono>{r.turnId !== undefined ? `turn ${r.turnId}` : '(latest)'}</Mono>,
};
case 'context.append_message': {
const m = r.message;
const tc = m.toolCalls.length > 0 ? `${m.toolCalls.length} tool_call(s)` : '';
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill
tone={
m.role === 'user'
? 'user'
: m.role === 'assistant'
? 'assistant'
: m.role === 'tool'
? 'tool'
: 'meta'
}
variant="soft"
>
{m.role}
</Pill>
<Dim>({m.content.length} part{m.content.length === 1 ? '' : 's'})</Dim>
{tc ? <Dim>· {tc}</Dim> : null}
{m.origin?.kind ? <Dim>· origin={m.origin.kind}</Dim> : null}
</span>
),
right: m.isError === true ? (
<Pill tone="error" variant="solid">
error
</Pill>
) : undefined,
};
}
case 'context.append_loop_event':
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>{r.event.type}</Mono>
<Dim className="truncate">{loopEventSummary(r.event)}</Dim>
</span>
),
};
case 'context.clear':
return { main: <Dim>context cleared</Dim> };
case 'context.apply_compaction':
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="compaction" variant="soft">
compacted
</Pill>
<Dim>
summary {r.summary.length}b · {r.tokensBefore}{r.tokensAfter} tok · {r.compactedCount} msgs
</Dim>
</span>
),
};
case 'tools.set_active_tools': {
const head = r.names.slice(0, 3).join(', ');
const rest = r.names.length > 3 ? ` +${r.names.length - 3} more` : '';
return {
main: (
<Mono className="truncate">
{head}
{rest}
</Mono>
),
right: <Dim>{r.names.length} tools</Dim>,
};
}
case 'tools.register_user_tool':
return {
main: (
<span className="flex items-center gap-2">
<Mono className="text-[var(--color-cat-tools)]">+ {r.name}</Mono>
</span>
),
};
case 'tools.unregister_user_tool':
return {
main: (
<span className="flex items-center gap-2">
<Mono className="text-[var(--color-sev-warning)]">- {r.name}</Mono>
</span>
),
};
case 'tools.update_store': {
const valuePreview =
typeof r.value === 'object' && r.value !== null
? '(object)'
: truncate(String(r.value), 60);
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>{r.key}</Mono>
<Dim>= {valuePreview}</Dim>
</span>
),
};
}
case 'permission.set_mode':
return {
main: (
<span className="flex items-center gap-2">
<Dim>mode </Dim>
<Pill tone="approval" variant="soft">
{r.mode}
</Pill>
</span>
),
};
case 'permission.record_approval_result': {
const tone =
r.result.decision === 'approved'
? 'success'
: r.result.decision === 'rejected'
? 'error'
: 'neutral';
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>
{r.toolName}#{r.toolCallId.slice(-8)}
</Mono>
<Pill tone={tone} variant="soft">
{r.result.decision}
</Pill>
{r.result.scope ? <Dim>({r.result.scope})</Dim> : null}
</span>
),
};
}
case 'usage.record':
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>{r.model}</Mono>
<Dim>
in {r.usage.inputOther} / out {r.usage.output} / cache r{r.usage.inputCacheRead} w
{r.usage.inputCacheCreation}
</Dim>
</span>
),
right: r.usageScope ? (
<Pill tone="meta" variant="outline">
{r.usageScope}
</Pill>
) : undefined,
};
case 'full_compaction.begin':
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="compaction" variant="soft">
{r.source}
</Pill>
{r.instruction ? (
<Dim className="truncate">"{truncate(r.instruction, 40)}"</Dim>
) : null}
</span>
),
};
case 'full_compaction.cancel':
return { main: <Dim>cancelled</Dim> };
case 'full_compaction.complete':
return {
main: (
<span className="flex items-center gap-2">
<Dim>
{r.compactedCount} msgs · {r.tokensBefore}{r.tokensAfter} tok
</Dim>
<Dim>· summary {formatBytes(r.summary.length)}</Dim>
</span>
),
};
case 'plan_mode.enter':
return {
main: (
<span className="flex items-center gap-2">
<Pill tone="lifecycle" variant="soft">
enter
</Pill>
<Mono>{r.id}</Mono>
</span>
),
};
case 'plan_mode.cancel':
case 'plan_mode.exit':
return {
main: (
<span className="flex items-center gap-2">
<Pill
tone={r.type === 'plan_mode.exit' ? 'success' : 'warning'}
variant="soft"
>
{r.type === 'plan_mode.exit' ? 'exit' : 'cancel'}
</Pill>
<Mono>{r.id ?? '(latest)'}</Mono>
</span>
),
};
}
// `r` is `never` here under TypeScript exhaustiveness, but at runtime
// best-effort parsing of unknown/future protocols can deliver records
// whose `type` is outside the AgentRecord union. Without this fallback
// WireRow would dereference `undefined` and crash the whole tab.
const renderer = rendererFor(r.type);
if (renderer !== undefined) return renderer.headline(r);
return {
main: (
<Dim>(unknown record type: {(r as { type: string }).type})</Dim>
),
main: <Dim>(unknown record type: {(r as { type: string }).type})</Dim>,
};
}
// ─── tiny presentational helpers ───
function Mono({ children, className = '' }: { children: ReactNode; className?: string }) {
return <span className={`font-mono text-[12px] text-fg-0 ${className}`}>{children}</span>;
}
function Dim({ children, className = '' }: { children: ReactNode; className?: string }) {
return <span className={`font-mono text-[11px] text-fg-3 ${className}`}>{children}</span>;
}

View file

@ -1,17 +1,10 @@
import { useState } from 'react';
import type {
AgentRecord,
ContentPart,
ContextMessage,
LoopRecordedEvent,
ToolCall,
WireEntry,
} from '../../types';
import type { AgentRecord, WireEntry } from '../../types';
import { CopyButton } from '../shared/CopyButton';
import { ImagePreview } from '../shared/ImagePreview';
import { JsonViewer } from '../shared/JsonViewer';
import { SizePreview } from '../shared/SizePreview';
import { GenericDetail } from './parts';
import { rendererFor } from './renderers';
interface WireRowDetailProps {
entry: WireEntry;
@ -95,306 +88,10 @@ function sameJson(a: unknown, b: unknown): boolean {
}
}
/** Render the expanded detail for a wire record. Thin dispatch to the per-kind
* registry's `detail`; kinds without one fall back to a structured JSON dump. */
function renderFriendly(record: AgentRecord) {
switch (record.type) {
case 'context.append_message':
return <MessageDetail message={record.message} />;
case 'context.append_loop_event':
return <LoopEventDetail event={record.event} />;
case 'turn.prompt':
case 'turn.steer':
return (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="origin" wide>
<JsonViewer value={record.origin} defaultOpenDepth={2} />
</FieldRow>
</div>
<div>
<div className="mb-1 text-fg-2">
input ({record.input.length} part{record.input.length === 1 ? '' : 's'})
</div>
<div className="space-y-1">
{record.input.map((part, i) => (
<ContentPartView key={i} part={part} />
))}
</div>
</div>
</div>
);
case 'context.apply_compaction':
case 'full_compaction.complete':
return (
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="summary" wide>
<SizePreview label="summary" sizeBytes={record.summary.length} preview={record.summary}>
<pre className="whitespace-pre-wrap break-words text-fg-1">{record.summary}</pre>
</SizePreview>
</FieldRow>
<FieldRow label="compactedCount">
<span className="text-[var(--color-sev-info)]">{record.compactedCount}</span>
</FieldRow>
<FieldRow label="tokensBefore">
<span className="text-[var(--color-sev-info)]">{record.tokensBefore}</span>
</FieldRow>
<FieldRow label="tokensAfter">
<span className="text-[var(--color-sev-info)]">{record.tokensAfter}</span>
</FieldRow>
</div>
);
default:
return <JsonViewer value={record} defaultOpenDepth={2} />;
}
}
function MessageDetail({ message }: { message: ContextMessage }) {
return (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="role">
<span className="text-[var(--color-cat-ephemeral)]">"{message.role}"</span>
</FieldRow>
{message.toolCallId ? (
<FieldRow label="toolCallId">
<Mono>{message.toolCallId}</Mono>
</FieldRow>
) : null}
{message.origin ? (
<FieldRow label="origin" wide>
<JsonViewer value={message.origin} defaultOpenDepth={2} />
</FieldRow>
) : null}
{message.isError === true ? (
<FieldRow label="isError">
<span className="text-[var(--color-sev-error)]">true</span>
</FieldRow>
) : null}
{message.partial === true ? (
<FieldRow label="partial">
<span className="text-[var(--color-sev-warning)]">true</span>
</FieldRow>
) : null}
</div>
{message.content.length > 0 ? (
<div>
<div className="mb-1 text-fg-2">content ({message.content.length} part{message.content.length === 1 ? '' : 's'})</div>
<div className="space-y-1">
{message.content.map((part, i) => (
<ContentPartView key={i} part={part} />
))}
</div>
</div>
) : null}
{message.toolCalls.length > 0 ? (
<div>
<div className="mb-1 text-fg-2">
toolCalls ({message.toolCalls.length})
</div>
<div className="space-y-1">
{message.toolCalls.map((tc) => (
<ToolCallView key={tc.id} call={tc} />
))}
</div>
</div>
) : null}
</div>
);
}
function ContentPartView({ part }: { part: ContentPart }) {
switch (part.type) {
case 'text':
return (
<div className="border border-border bg-surface-0 p-2">
<div className="mb-1 text-fg-3">text · {part.text.length}b</div>
<pre className="whitespace-pre-wrap break-words text-fg-1">{part.text}</pre>
</div>
);
case 'think':
return (
<div className="border border-[var(--color-cat-config)]/40 bg-surface-0 p-2">
<div className="mb-1 text-[var(--color-cat-config)]">think · {part.think.length}b</div>
<pre className="whitespace-pre-wrap break-words text-fg-1">{part.think}</pre>
</div>
);
case 'image_url':
return <ImagePreview url={part.imageUrl.url} />;
case 'audio_url':
return (
<div className="border border-border bg-surface-0 p-2">
<div className="mb-1 text-fg-3">audio_url</div>
<Mono className="break-all">{part.audioUrl.url}</Mono>
</div>
);
case 'video_url':
return (
<div className="border border-border bg-surface-0 p-2">
<div className="mb-1 text-fg-3">video_url</div>
<Mono className="break-all">{part.videoUrl.url}</Mono>
</div>
);
default:
return <JsonViewer value={part} defaultOpenDepth={1} />;
}
}
function ToolCallView({ call }: { call: ToolCall }) {
const args = call.arguments ?? '';
let parsed: unknown = null;
if (typeof args === 'string' && args.length > 0) {
try {
parsed = JSON.parse(args);
} catch {
parsed = null;
}
}
return (
<div className="border border-[var(--color-cat-tools)]/40 bg-surface-0 p-2">
<div className="flex items-center justify-between gap-2">
<Mono className="text-[var(--color-cat-tools)]">{call.name}</Mono>
<Mono className="text-fg-3 text-[10px]">#{call.id}</Mono>
</div>
<div className="mt-1">
{parsed !== null ? (
<JsonViewer value={parsed} defaultOpenDepth={1} />
) : (
<pre className="whitespace-pre-wrap break-words text-fg-1">{args}</pre>
)}
</div>
</div>
);
}
function LoopEventDetail({ event }: { event: LoopRecordedEvent }) {
switch (event.type) {
case 'tool.call': {
let parsed: unknown = event.args;
if (typeof event.args === 'string') {
try {
parsed = JSON.parse(event.args);
} catch {
parsed = event.args;
}
}
return (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="name">
<Mono className="text-[var(--color-cat-tools)]">{event.name}</Mono>
</FieldRow>
<FieldRow label="toolCallId">
<Mono>{event.toolCallId}</Mono>
</FieldRow>
<FieldRow label="step">
<span className="text-[var(--color-sev-info)]">{event.step}</span>
</FieldRow>
<FieldRow label="turnId">
<Mono>{event.turnId}</Mono>
</FieldRow>
{event.description ? (
<FieldRow label="description" wide>
<pre className="whitespace-pre-wrap break-words text-fg-1">
{event.description}
</pre>
</FieldRow>
) : null}
</div>
<div>
<div className="mb-1 text-fg-2">args</div>
<JsonViewer value={parsed} defaultOpenDepth={2} />
</div>
{event.display ? (
<div>
<div className="mb-1 text-fg-2">display</div>
<JsonViewer value={event.display} defaultOpenDepth={1} />
</div>
) : null}
</div>
);
}
case 'tool.result': {
const isError = event.result.isError === true;
const output = event.result.output;
return (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="toolCallId">
<Mono>{event.toolCallId}</Mono>
</FieldRow>
<FieldRow label="parentUuid">
<Mono>{event.parentUuid}</Mono>
</FieldRow>
<FieldRow label="isError">
<span
className={
isError ? 'text-[var(--color-sev-error)]' : 'text-[var(--color-sev-success)]'
}
>
{String(isError)}
</span>
</FieldRow>
{event.result.message !== undefined ? (
<FieldRow label="message" wide>
<pre className="whitespace-pre-wrap break-words text-fg-1">
{event.result.message}
</pre>
</FieldRow>
) : null}
</div>
<div>
<div className="mb-1 text-fg-2">output</div>
{typeof output === 'string' ? (
<SizePreview label="output" sizeBytes={output.length} preview={output}>
<pre className="whitespace-pre-wrap break-words text-fg-1">{output}</pre>
</SizePreview>
) : (
<div className="space-y-1">
{output.map((p, i) => (
<ContentPartView key={i} part={p} />
))}
</div>
)}
</div>
</div>
);
}
case 'step.begin':
case 'step.end':
case 'content.part':
return <JsonViewer value={event} defaultOpenDepth={2} />;
default:
return <JsonViewer value={event} defaultOpenDepth={2} />;
}
}
function Mono({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <span className={`font-mono text-[12px] text-fg-0 ${className}`}>{children}</span>;
}
function FieldRow({
label,
children,
wide = false,
}: {
label: string;
children: React.ReactNode;
wide?: boolean;
}) {
if (wide) {
return (
<div className="col-span-2 flex items-baseline gap-3">
<span className="w-[140px] shrink-0 font-mono text-[11px] text-fg-2 text-right">
{label}
</span>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
return (
<>
<span className="font-mono text-[11px] text-fg-2 text-right">{label}</span>
<div className="font-mono text-[12px] text-fg-0 min-w-0 break-words">{children}</div>
</>
);
const renderer = rendererFor(record.type);
if (renderer?.detail !== undefined) return renderer.detail(record);
return <GenericDetail value={record} />;
}

View file

@ -0,0 +1,398 @@
// Shared presentational helpers for the wire renderer registry and its
// consumers. Extracted into a standalone module so that `renderers.tsx`
// (which holds the per-kind registry) and `WireHeadline.tsx` /
// `WireRowDetail.tsx` (the thin dispatchers) can all import these without
// forming an import cycle:
//
// parts.tsx ← renderers.tsx ← WireHeadline.tsx / WireRowDetail.tsx
//
// Every symbol below is defined exactly once, here.
import type { ReactNode } from 'react';
import type { ContentPart, ContextMessage, LoopRecordedEvent, ToolCall } from '../../types';
import { ImagePreview } from '../shared/ImagePreview';
import { JsonViewer } from '../shared/JsonViewer';
import { SizePreview } from '../shared/SizePreview';
export interface HeadlineRender {
/** Main headline content — rendered in the flex-grow slot of the row */
main: ReactNode;
/** Right-side badges / pair refs */
right?: ReactNode;
}
export function truncate(s: unknown, n: number): string {
let str: string;
if (s === null || s === undefined) str = '';
else if (typeof s === 'string') str = s;
else if (typeof s === 'number' || typeof s === 'boolean' || typeof s === 'bigint')
str = String(s);
else {
try {
str = JSON.stringify(s);
} catch {
return '[unserializable]';
}
}
if (str.length <= n) return str;
return str.slice(0, n) + '…';
}
/** Pull the first text segment from a ContentPart[] for one-line preview. */
export function firstText(parts: readonly ContentPart[]): string {
for (const p of parts) {
if (p.type === 'text' && typeof p.text === 'string') return p.text;
}
return '(non-text)';
}
/** One-line description of the embedded LoopRecordedEvent. */
export function loopEventSummary(ev: LoopRecordedEvent): string {
switch (ev.type) {
case 'step.begin':
return `step ${ev.step} (turn ${ev.turnId})`;
case 'step.end':
return `step ${ev.step}${ev.finishReason ?? '?'}`;
case 'content.part': {
const len =
ev.part.type === 'text'
? ev.part.text.length
: ev.part.type === 'think'
? ev.part.think.length
: 0;
return `${ev.part.type}${len ? ` (${len}b)` : ''}`;
}
case 'tool.call':
return `${ev.name}#${ev.toolCallId.slice(-8)}`;
case 'tool.result':
return `result#${ev.toolCallId.slice(-8)}${ev.result.isError === true ? ' (error)' : ''}`;
default: {
const exhaustive: never = ev;
return String((exhaustive as { type?: string }).type ?? 'unknown');
}
}
}
// ─── tiny presentational helpers ───
export function Mono({ children, className = '' }: { children: ReactNode; className?: string }) {
return <span className={`font-mono text-[12px] text-fg-0 ${className}`}>{children}</span>;
}
export function Dim({ children, className = '' }: { children: ReactNode; className?: string }) {
return <span className={`font-mono text-[11px] text-fg-3 ${className}`}>{children}</span>;
}
export function FieldRow({
label,
children,
wide = false,
}: {
label: string;
children: ReactNode;
wide?: boolean;
}) {
if (wide) {
return (
<div className="col-span-2 flex items-baseline gap-3">
<span className="w-[140px] shrink-0 font-mono text-[11px] text-fg-2 text-right">
{label}
</span>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
return (
<>
<span className="font-mono text-[11px] text-fg-2 text-right">{label}</span>
<div className="font-mono text-[12px] text-fg-0 min-w-0 break-words">{children}</div>
</>
);
}
export function ContentPartView({ part }: { part: ContentPart }) {
switch (part.type) {
case 'text':
return (
<div className="border border-border bg-surface-0 p-2">
<div className="mb-1 text-fg-3">text · {part.text.length}b</div>
<pre className="whitespace-pre-wrap break-words text-fg-1">{part.text}</pre>
</div>
);
case 'think':
return (
<div className="border border-[var(--color-cat-config)]/40 bg-surface-0 p-2">
<div className="mb-1 text-[var(--color-cat-config)]">think · {part.think.length}b</div>
<pre className="whitespace-pre-wrap break-words text-fg-1">{part.think}</pre>
</div>
);
case 'image_url':
return <ImagePreview url={part.imageUrl.url} />;
case 'audio_url':
return (
<div className="border border-border bg-surface-0 p-2">
<div className="mb-1 text-fg-3">audio_url</div>
<Mono className="break-all">{part.audioUrl.url}</Mono>
</div>
);
case 'video_url':
return (
<div className="border border-border bg-surface-0 p-2">
<div className="mb-1 text-fg-3">video_url</div>
<Mono className="break-all">{part.videoUrl.url}</Mono>
</div>
);
default:
return <JsonViewer value={part} defaultOpenDepth={1} />;
}
}
function ToolCallView({ call }: { call: ToolCall }) {
const args = call.arguments ?? '';
let parsed: unknown = null;
if (typeof args === 'string' && args.length > 0) {
try {
parsed = JSON.parse(args);
} catch {
parsed = null;
}
}
return (
<div className="border border-[var(--color-cat-tools)]/40 bg-surface-0 p-2">
<div className="flex items-center justify-between gap-2">
<Mono className="text-[var(--color-cat-tools)]">{call.name}</Mono>
<Mono className="text-fg-3 text-[10px]">#{call.id}</Mono>
</div>
<div className="mt-1">
{parsed !== null ? (
<JsonViewer value={parsed} defaultOpenDepth={1} />
) : (
<pre className="whitespace-pre-wrap break-words text-fg-1">{args}</pre>
)}
</div>
</div>
);
}
export function MessageDetail({ message }: { message: ContextMessage }) {
return (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="role">
<span className="text-[var(--color-cat-ephemeral)]">"{message.role}"</span>
</FieldRow>
{message.toolCallId ? (
<FieldRow label="toolCallId">
<Mono>{message.toolCallId}</Mono>
</FieldRow>
) : null}
{message.origin ? (
<FieldRow label="origin" wide>
<JsonViewer value={message.origin} defaultOpenDepth={2} />
</FieldRow>
) : null}
{message.isError === true ? (
<FieldRow label="isError">
<span className="text-[var(--color-sev-error)]">true</span>
</FieldRow>
) : null}
{message.partial === true ? (
<FieldRow label="partial">
<span className="text-[var(--color-sev-warning)]">true</span>
</FieldRow>
) : null}
</div>
{message.content.length > 0 ? (
<div>
<div className="mb-1 text-fg-2">content ({message.content.length} part{message.content.length === 1 ? '' : 's'})</div>
<div className="space-y-1">
{message.content.map((part, i) => (
<ContentPartView key={i} part={part} />
))}
</div>
</div>
) : null}
{message.toolCalls.length > 0 ? (
<div>
<div className="mb-1 text-fg-2">
toolCalls ({message.toolCalls.length})
</div>
<div className="space-y-1">
{message.toolCalls.map((tc) => (
<ToolCallView key={tc.id} call={tc} />
))}
</div>
</div>
) : null}
</div>
);
}
export function LoopEventDetail({ event }: { event: LoopRecordedEvent }) {
switch (event.type) {
case 'tool.call': {
let parsed: unknown = event.args;
if (typeof event.args === 'string') {
try {
parsed = JSON.parse(event.args);
} catch {
parsed = event.args;
}
}
return (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="name">
<Mono className="text-[var(--color-cat-tools)]">{event.name}</Mono>
</FieldRow>
<FieldRow label="toolCallId">
<Mono>{event.toolCallId}</Mono>
</FieldRow>
<FieldRow label="step">
<span className="text-[var(--color-sev-info)]">{event.step}</span>
</FieldRow>
<FieldRow label="turnId">
<Mono>{event.turnId}</Mono>
</FieldRow>
{event.description ? (
<FieldRow label="description" wide>
<pre className="whitespace-pre-wrap break-words text-fg-1">
{event.description}
</pre>
</FieldRow>
) : null}
</div>
<div>
<div className="mb-1 text-fg-2">args</div>
<JsonViewer value={parsed} defaultOpenDepth={2} />
</div>
{event.display ? (
<div>
<div className="mb-1 text-fg-2">display</div>
<JsonViewer value={event.display} defaultOpenDepth={1} />
</div>
) : null}
</div>
);
}
case 'tool.result': {
const isError = event.result.isError === true;
const output = event.result.output;
return (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="toolCallId">
<Mono>{event.toolCallId}</Mono>
</FieldRow>
<FieldRow label="parentUuid">
<Mono>{event.parentUuid}</Mono>
</FieldRow>
<FieldRow label="isError">
<span
className={
isError ? 'text-[var(--color-sev-error)]' : 'text-[var(--color-sev-success)]'
}
>
{String(isError)}
</span>
</FieldRow>
{event.result.message !== undefined ? (
<FieldRow label="message" wide>
<pre className="whitespace-pre-wrap break-words text-fg-1">
{event.result.message}
</pre>
</FieldRow>
) : null}
</div>
<div>
<div className="mb-1 text-fg-2">output</div>
{typeof output === 'string' ? (
<SizePreview label="output" sizeBytes={output.length} preview={output}>
<pre className="whitespace-pre-wrap break-words text-fg-1">{output}</pre>
</SizePreview>
) : (
<div className="space-y-1">
{output.map((p, i) => (
<ContentPartView key={i} part={p} />
))}
</div>
)}
</div>
</div>
);
}
case 'step.end': {
const usage = event.usage;
return (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="step">
<span className="text-[var(--color-sev-info)]">{event.step}</span>
</FieldRow>
<FieldRow label="turnId">
<Mono>{event.turnId}</Mono>
</FieldRow>
{event.finishReason !== undefined ? (
<FieldRow label="finishReason">
<Mono>{event.finishReason}</Mono>
</FieldRow>
) : null}
{event.providerFinishReason !== undefined ? (
<FieldRow label="providerFinishReason">
<Mono>{event.providerFinishReason}</Mono>
</FieldRow>
) : null}
{event.rawFinishReason !== undefined ? (
<FieldRow label="rawFinishReason">
<Mono>{event.rawFinishReason}</Mono>
</FieldRow>
) : null}
{event.llmFirstTokenLatencyMs !== undefined ? (
<FieldRow label="firstToken">
<span className="text-fg-1">{event.llmFirstTokenLatencyMs} ms</span>
</FieldRow>
) : null}
{event.llmStreamDurationMs !== undefined ? (
<FieldRow label="streamDuration">
<span className="text-fg-1">{event.llmStreamDurationMs} ms</span>
</FieldRow>
) : null}
</div>
{usage !== undefined ? (
<div>
<div className="mb-1 text-fg-2">usage</div>
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="inputOther">
<span className="text-[var(--color-sev-info)]">{usage.inputOther}</span>
</FieldRow>
<FieldRow label="output">
<span className="text-[var(--color-sev-info)]">{usage.output}</span>
</FieldRow>
<FieldRow label="inputCacheRead">
<span className="text-[var(--color-sev-info)]">{usage.inputCacheRead}</span>
</FieldRow>
<FieldRow label="inputCacheCreation">
<span className="text-[var(--color-sev-info)]">{usage.inputCacheCreation}</span>
</FieldRow>
</div>
</div>
) : null}
</div>
);
}
case 'step.begin':
case 'content.part':
return <JsonViewer value={event} defaultOpenDepth={2} />;
default:
return <JsonViewer value={event} defaultOpenDepth={2} />;
}
}
/** Fallback detail for any kind without a dedicated renderer: a full
* structured JSON dump of the record (type + time + payload). */
export function GenericDetail({ value }: { value: unknown }) {
return <JsonViewer value={value} defaultOpenDepth={2} />;
}

View file

@ -0,0 +1,616 @@
// The single wire-renderer registry. Co-locates tone + label + headline +
// detail for every record kind. Because `WIRE_RENDERERS` is typed as a mapped
// type over the FULL `RecordType` union, TypeScript REQUIRES an entry for each
// kind: adding a kind upstream in agent-core fails
// `pnpm --filter @moonshot-ai/vis-web typecheck` here until a renderer is
// added. This is the anti-rot guarantee that keeps vis from silently falling
// behind the wire protocol.
import type { ReactNode } from 'react';
import type { AgentRecord, AgentRecordOf } from '../../types';
import type { PillTone } from '../shared/Pill';
import { Pill } from '../shared/Pill';
import {
Dim,
type HeadlineRender,
LoopEventDetail,
MessageDetail,
Mono,
ContentPartView,
FieldRow,
firstText,
truncate,
loopEventSummary,
} from './parts';
import { SizePreview } from '../shared/SizePreview';
import { JsonViewer } from '../shared/JsonViewer';
export type RecordType = AgentRecord['type'];
export interface WireRenderer<K extends RecordType> {
tone: PillTone;
/** Compact badge label. */
label: string;
/** One-line collapsed summary. */
headline: (r: AgentRecordOf<K>) => HeadlineRender;
/** Expanded detail. Omit to fall back to a full structured JSON dump. */
detail?: (r: AgentRecordOf<K>) => ReactNode;
}
/** A registry entry for every record kind. The value type is a mapped type
* over the full `RecordType` union, so TypeScript forces an entry per kind. */
type RendererMap = { [K in RecordType]: WireRenderer<K> };
export const WIRE_RENDERERS: RendererMap = {
metadata: {
tone: 'meta',
label: 'meta',
headline: (r) => ({
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>protocol v{r.protocol_version}</Mono>
<Dim>·</Dim>
<Mono>created {new Date(r.created_at).toLocaleString()}</Mono>
</span>
),
}),
},
forked: {
tone: 'lifecycle',
label: 'fork',
headline: () => ({ main: <Dim>session forked</Dim> }),
},
'config.update': {
tone: 'config',
label: 'config',
headline: (r) => {
const parts: string[] = [];
if (r.profileName !== undefined) parts.push(`profile=${r.profileName}`);
if (r.modelAlias !== undefined) parts.push(`model=${r.modelAlias}`);
if (r.cwd !== undefined) parts.push(`cwd=${r.cwd}`);
if (r.thinkingLevel !== undefined) parts.push(`thinking=${r.thinkingLevel}`);
if (r.systemPrompt !== undefined) parts.push(`system(${r.systemPrompt.length}b)`);
return {
main: (
<span className="truncate text-fg-0">
{parts.length === 0 ? <Dim>(no fields)</Dim> : parts.join(' · ')}
</span>
),
};
},
},
'turn.prompt': {
tone: 'turn',
label: 'prompt',
headline: (r) => {
const text = firstText(r.input);
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="turn" variant="soft">
{r.origin.kind}
</Pill>
<span className="truncate text-fg-1"> {truncate(text, 80)}</span>
</span>
),
};
},
detail: (r) => (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="origin" wide>
<JsonViewer value={r.origin} defaultOpenDepth={2} />
</FieldRow>
</div>
<div>
<div className="mb-1 text-fg-2">
input ({r.input.length} part{r.input.length === 1 ? '' : 's'})
</div>
<div className="space-y-1">
{r.input.map((part, i) => (
<ContentPartView key={i} part={part} />
))}
</div>
</div>
</div>
),
},
'turn.steer': {
tone: 'turn',
label: 'steer',
headline: (r) => {
const text = firstText(r.input);
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="turn" variant="soft">
{r.origin.kind}
</Pill>
<span className="truncate text-fg-1"> {truncate(text, 80)}</span>
</span>
),
};
},
detail: (r) => (
<div className="space-y-2">
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="origin" wide>
<JsonViewer value={r.origin} defaultOpenDepth={2} />
</FieldRow>
</div>
<div>
<div className="mb-1 text-fg-2">
input ({r.input.length} part{r.input.length === 1 ? '' : 's'})
</div>
<div className="space-y-1">
{r.input.map((part, i) => (
<ContentPartView key={i} part={part} />
))}
</div>
</div>
</div>
),
},
'turn.cancel': {
tone: 'warning',
label: 'cancel',
headline: (r) => ({
main: <Mono>{r.turnId !== undefined ? `turn ${r.turnId}` : '(latest)'}</Mono>,
}),
},
'context.append_message': {
tone: 'assistant',
label: 'message',
headline: (r) => {
const m = r.message;
const tc = m.toolCalls.length > 0 ? `${m.toolCalls.length} tool_call(s)` : '';
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill
tone={
m.role === 'user'
? 'user'
: m.role === 'assistant'
? 'assistant'
: m.role === 'tool'
? 'tool'
: 'meta'
}
variant="soft"
>
{m.role}
</Pill>
<Dim>({m.content.length} part{m.content.length === 1 ? '' : 's'})</Dim>
{tc ? <Dim>· {tc}</Dim> : null}
{m.origin?.kind ? <Dim>· origin={m.origin.kind}</Dim> : null}
</span>
),
right: m.isError === true ? (
<Pill tone="error" variant="solid">
error
</Pill>
) : undefined,
};
},
detail: (r) => <MessageDetail message={r.message} />,
},
'context.append_loop_event': {
tone: 'meta',
label: 'loop',
headline: (r) => ({
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>{r.event.type}</Mono>
<Dim className="truncate">{loopEventSummary(r.event)}</Dim>
</span>
),
}),
detail: (r) => <LoopEventDetail event={r.event} />,
},
'context.clear': {
tone: 'warning',
label: 'clear',
headline: () => ({ main: <Dim>context cleared</Dim> }),
},
'context.apply_compaction': {
tone: 'compaction',
label: 'compacted',
headline: (r) => ({
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="compaction" variant="soft">
compacted
</Pill>
<Dim>
summary {r.summary.length}b · {r.tokensBefore}{r.tokensAfter} tok · {r.compactedCount}{' '}
msgs
</Dim>
</span>
),
}),
detail: (r) => (
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="summary" wide>
<SizePreview label="summary" sizeBytes={r.summary.length} preview={r.summary}>
<pre className="whitespace-pre-wrap break-words text-fg-1">{r.summary}</pre>
</SizePreview>
</FieldRow>
<FieldRow label="compactedCount">
<span className="text-[var(--color-sev-info)]">{r.compactedCount}</span>
</FieldRow>
<FieldRow label="tokensBefore">
<span className="text-[var(--color-sev-info)]">{r.tokensBefore}</span>
</FieldRow>
<FieldRow label="tokensAfter">
<span className="text-[var(--color-sev-info)]">{r.tokensAfter}</span>
</FieldRow>
</div>
),
},
'context.undo': {
tone: 'warning',
label: 'undo',
headline: (r) => ({
main: (
<span className="flex items-center gap-2">
<Pill tone="warning" variant="soft">
undo
</Pill>
<Dim>
{r.count} prompt{r.count === 1 ? '' : 's'}
</Dim>
</span>
),
}),
},
'tools.register_user_tool': {
tone: 'tools',
label: 'tool+',
headline: (r) => ({
main: (
<span className="flex items-center gap-2">
<Mono className="text-[var(--color-cat-tools)]">+ {r.name}</Mono>
</span>
),
}),
},
'tools.unregister_user_tool': {
tone: 'tools',
label: 'tool-',
headline: (r) => ({
main: (
<span className="flex items-center gap-2">
<Mono className="text-[var(--color-sev-warning)]">- {r.name}</Mono>
</span>
),
}),
},
'tools.set_active_tools': {
tone: 'tools',
label: 'tools',
headline: (r) => {
const head = r.names.slice(0, 3).join(', ');
const rest = r.names.length > 3 ? ` +${r.names.length - 3} more` : '';
return {
main: (
<Mono className="truncate">
{head}
{rest}
</Mono>
),
right: <Dim>{r.names.length} tools</Dim>,
};
},
},
'tools.update_store': {
tone: 'meta',
label: 'store',
headline: (r) => {
const valuePreview =
typeof r.value === 'object' && r.value !== null
? '(object)'
: truncate(String(r.value), 60);
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>{r.key}</Mono>
<Dim>= {valuePreview}</Dim>
</span>
),
};
},
},
'permission.set_mode': {
tone: 'approval',
label: 'perm',
headline: (r) => ({
main: (
<span className="flex items-center gap-2">
<Dim>mode </Dim>
<Pill tone="approval" variant="soft">
{r.mode}
</Pill>
</span>
),
}),
},
'permission.record_approval_result': {
tone: 'approval',
label: 'approval',
headline: (r) => {
const tone =
r.result.decision === 'approved'
? 'success'
: r.result.decision === 'rejected'
? 'error'
: 'neutral';
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>
{r.toolName}#{r.toolCallId.slice(-8)}
</Mono>
<Pill tone={tone} variant="soft">
{r.result.decision}
</Pill>
{r.result.scope ? <Dim>({r.result.scope})</Dim> : null}
</span>
),
};
},
detail: (r) => (
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="toolName">
<Mono>{r.toolName}</Mono>
</FieldRow>
<FieldRow label="toolCallId">
<Mono>{r.toolCallId}</Mono>
</FieldRow>
<FieldRow label="action">
<Mono>{r.action}</Mono>
</FieldRow>
<FieldRow label="turnId">
<span className="text-[var(--color-sev-info)]">{r.turnId}</span>
</FieldRow>
<FieldRow label="decision">
<span className="text-fg-0">{r.result.decision}</span>
</FieldRow>
{r.result.scope !== undefined ? (
<FieldRow label="scope">
<Mono>{r.result.scope}</Mono>
</FieldRow>
) : null}
{r.sessionApprovalRule !== undefined ? (
<FieldRow label="sessionApprovalRule" wide>
<Mono className="break-all">{r.sessionApprovalRule}</Mono>
</FieldRow>
) : null}
{r.result.selectedLabel !== undefined ? (
<FieldRow label="selectedLabel" wide>
<Mono className="break-all">{r.result.selectedLabel}</Mono>
</FieldRow>
) : null}
{r.result.feedback !== undefined ? (
<FieldRow label="feedback" wide>
<pre className="whitespace-pre-wrap break-words text-fg-1">{r.result.feedback}</pre>
</FieldRow>
) : null}
</div>
),
},
'usage.record': {
tone: 'meta',
label: 'usage',
headline: (r) => ({
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>{r.model}</Mono>
<Dim>
in {r.usage.inputOther} / out {r.usage.output} / cache r{r.usage.inputCacheRead} w
{r.usage.inputCacheCreation}
</Dim>
</span>
),
right: r.usageScope ? (
<Pill tone="meta" variant="outline">
{r.usageScope}
</Pill>
) : undefined,
}),
},
'full_compaction.begin': {
tone: 'compaction',
label: 'compact↻',
headline: (r) => ({
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="compaction" variant="soft">
{r.source}
</Pill>
{r.instruction ? (
<Dim className="truncate">"{truncate(r.instruction, 40)}"</Dim>
) : null}
</span>
),
}),
},
'full_compaction.cancel': {
tone: 'warning',
label: 'compact×',
headline: () => ({ main: <Dim>cancelled</Dim> }),
},
// `full_compaction.complete` has an EMPTY payload (`{}`). The previous code
// read `r.summary` / `r.compactedCount` / `r.tokensBefore` / `r.tokensAfter`,
// none of which exist on this record — a runtime crash. Those fields belong
// to `context.apply_compaction` (its own entry above). This is a static,
// payload-free renderer; the generic JSON dump shows type + time only.
'full_compaction.complete': {
tone: 'success',
label: 'compact✓',
headline: () => ({ main: <Dim>compaction complete</Dim> }),
},
'micro_compaction.apply': {
tone: 'compaction',
label: 'µcompact',
headline: (r) => ({
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="compaction" variant="soft">
micro
</Pill>
<Dim>cutoff {r.cutoff}</Dim>
</span>
),
}),
},
'plan_mode.enter': {
tone: 'lifecycle',
label: 'plan↻',
headline: (r) => ({
main: (
<span className="flex items-center gap-2">
<Pill tone="lifecycle" variant="soft">
enter
</Pill>
<Mono>{r.id}</Mono>
</span>
),
}),
},
'plan_mode.cancel': {
tone: 'warning',
label: 'plan×',
headline: (r) => ({
main: (
<span className="flex items-center gap-2">
<Pill tone="warning" variant="soft">
cancel
</Pill>
<Mono>{r.id ?? '(latest)'}</Mono>
</span>
),
}),
},
'plan_mode.exit': {
tone: 'success',
label: 'plan✓',
headline: (r) => ({
main: (
<span className="flex items-center gap-2">
<Pill tone="success" variant="soft">
exit
</Pill>
<Mono>{r.id ?? '(latest)'}</Mono>
</span>
),
}),
},
'swarm_mode.enter': {
tone: 'subagent',
label: 'swarm↻',
headline: (r) => ({
main: (
<span className="flex items-center gap-2">
<Pill tone="subagent" variant="soft">
enter
</Pill>
<Mono>{r.trigger}</Mono>
</span>
),
}),
},
'swarm_mode.exit': {
tone: 'subagent',
label: 'swarm✓',
headline: () => ({ main: <Dim>swarm mode exited</Dim> }),
},
'goal.create': {
tone: 'lifecycle',
label: 'goal+',
headline: (r) => ({
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="lifecycle" variant="soft">
goal
</Pill>
<span className="truncate text-fg-1">{r.objective}</span>
</span>
),
}),
},
'goal.update': {
tone: 'lifecycle',
label: 'goal',
headline: (r) => {
const parts: string[] = [];
if (r.status !== undefined) parts.push(`status=${r.status}`);
if (r.actor !== undefined) parts.push(`by=${r.actor}`);
if (r.turnsUsed !== undefined) parts.push(`turns=${r.turnsUsed}`);
if (r.tokensUsed !== undefined) parts.push(`tok=${r.tokensUsed}`);
return {
main: (
<span className="truncate text-fg-1">
{parts.length === 0 ? <Dim>(no change)</Dim> : parts.join(' · ')}
</span>
),
};
},
},
'goal.clear': {
tone: 'warning',
label: 'goal×',
headline: () => ({ main: <Dim>goal cleared</Dim> }),
},
};
/** Look up a renderer by a runtime `type` string. Returns `undefined` for kinds
* outside the known union (best-effort parse of a future/legacy/foreign
* protocol), which the callers render via the generic fallback.
*
* Legacy/foreign runtime-only kinds (e.g. `goal.account_usage`,
* `goal.continuation`, `background.stop`) are intentionally NOT given registry
* entries: they sit outside the typed `AgentRecord` union, so this returns
* `undefined` and they fall back to a readable generic render `TypeBadge`
* shows the raw `type` string (neutral tone, `title` tooltip), the headline
* shows `(unknown record type: …)`, and the detail shows the full JSON. That is
* legible enough; no friendly-label map is warranted.
*
* The `as unknown as` widening is the one place we sidestep TypeScript's
* correlated-union limitation: each entry's `headline`/`detail` is narrowed to
* its own kind, but at dispatch time we only have the union, so we widen the
* value to `WireRenderer<RecordType>` (callable with any `AgentRecord`). Safe
* because we only ever call it with the matching record. */
export function rendererFor(type: string): WireRenderer<RecordType> | undefined {
return (WIRE_RENDERERS as unknown as Record<string, WireRenderer<RecordType>>)[type];
}

View file

@ -1,56 +0,0 @@
import type { AgentRecord } from '../../types';
import type { PillTone } from '../shared/Pill';
type RecordType = AgentRecord['type'];
/** Visual tone for each record type. */
export const TYPE_TONE: Record<RecordType, PillTone> = {
'metadata': 'meta',
'config.update': 'config',
'turn.prompt': 'turn',
'turn.steer': 'turn',
'turn.cancel': 'warning',
'context.append_message': 'assistant',
'context.append_loop_event': 'meta',
'context.clear': 'warning',
'context.apply_compaction': 'compaction',
'tools.register_user_tool': 'tools',
'tools.unregister_user_tool': 'tools',
'tools.set_active_tools': 'tools',
'tools.update_store': 'meta',
'permission.set_mode': 'approval',
'permission.record_approval_result': 'approval',
'usage.record': 'meta',
'full_compaction.begin': 'compaction',
'full_compaction.cancel': 'warning',
'full_compaction.complete': 'success',
'plan_mode.enter': 'lifecycle',
'plan_mode.cancel': 'warning',
'plan_mode.exit': 'success',
};
/** Compact human label for each record type (used in the type badge). */
export const TYPE_LABEL: Record<RecordType, string> = {
'metadata': 'meta',
'config.update': 'config',
'turn.prompt': 'prompt',
'turn.steer': 'steer',
'turn.cancel': 'cancel',
'context.append_message': 'message',
'context.append_loop_event': 'loop',
'context.clear': 'clear',
'context.apply_compaction': 'compacted',
'tools.register_user_tool': 'tool+',
'tools.unregister_user_tool': 'tool-',
'tools.set_active_tools': 'tools',
'tools.update_store': 'store',
'permission.set_mode': 'perm',
'permission.record_approval_result': 'approval',
'usage.record': 'usage',
'full_compaction.begin': 'compact↻',
'full_compaction.cancel': 'compact×',
'full_compaction.complete': 'compact✓',
'plan_mode.enter': 'plan↻',
'plan_mode.cancel': 'plan×',
'plan_mode.exit': 'plan✓',
};

View file

@ -5,14 +5,24 @@ import { api } from '../api';
* Fetch the projected context for a given agent in a session.
*
* The `/api/sessions/:id/context?agent=<agentId>` route returns the
* full `ContextProjection` (messages, usage totals, config snapshot,
* permission mode, plan mode). Defaults to `main` when no agent id
* is provided, but callers should pass an explicit id for clarity.
* full `ContextProjection` (messages, usage totals, contextTokens,
* config snapshot, permission mode, plan mode, goal, swarm). Defaults
* to `main` when no agent id is provided, but callers should pass an
* explicit id for clarity.
*
* `mode` selects the projection view: `'model'` (default) mirrors what
* the model currently sees (post-compaction/undo/clear), while `'full'`
* requests the full reconstructed history for debugging. Both modes are
* cached independently (the mode is part of the React Query key).
*/
export function useContext(sessionId: string, agentId: string) {
export function useContext(
sessionId: string,
agentId: string,
mode: 'model' | 'full' = 'model',
) {
return useQuery({
queryKey: ['context', sessionId, agentId] as const,
queryFn: () => api.getContext(sessionId, agentId),
queryKey: ['context', sessionId, agentId, mode] as const,
queryFn: () => api.getContext(sessionId, agentId, mode),
enabled: sessionId.length > 0 && agentId.length > 0,
});
}

View file

@ -13,6 +13,7 @@ export type {
WireEntry,
ApiError,
AgentRecord,
AgentRecordOf,
ContextMessage,
PromptOrigin,
TokenUsage,
@ -28,6 +29,7 @@ export type {
UsageTotals,
ConfigSnapshot,
ContextProjection,
GoalSnapshot,
} from '../../server/src/lib/context-projector';
export interface DeleteSessionResponse {
@ -46,7 +48,10 @@ export interface ContextResponse {
agentId: string;
messages: import('../../server/src/lib/context-projector').ProjectedMessage[];
usage: import('../../server/src/lib/context-projector').UsageTotals;
contextTokens: number;
config: import('../../server/src/lib/context-projector').ConfigSnapshot;
permission: { mode: import('../../server/src/lib/agent-record-types').PermissionMode | null };
planMode: { active: boolean; id?: string };
goal: import('../../server/src/lib/context-projector').GoalSnapshot | null;
swarm: { active: boolean; trigger?: string };
}

View file

@ -13,8 +13,10 @@
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
// Unused-locals/params are intentionally NOT enforced here: this package
// type-checks agent-core's source (consumed via source `exports`), so these
// flags would surface dead code inside agent-core. Matches the repo norm
// (root tsconfig and other packages do not set them); oxlint covers unused.
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,

View file

@ -1,12 +1,29 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { viteSingleFile } from 'vite-plugin-singlefile';
const apiPort = Number(process.env.PORT) || 5174;
const webPort = Number(process.env.WEB_PORT) || 5173;
// When set, build a single self-contained index.html (JS+CSS inlined) into
// `dist-single/` so it can be embedded into the kimi CLI. The normal `dist/`
// build is unaffected.
const singlefile = process.env.VIS_SINGLEFILE === '1';
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
...(singlefile
? [
viteSingleFile({
useRecommendedBuildConfig: true,
deleteInlinedFiles: true,
}),
]
: []),
],
server: {
port: webPort,
strictPort: false,
@ -18,7 +35,7 @@ export default defineConfig({
},
},
build: {
outDir: 'dist',
outDir: singlefile ? 'dist-single' : 'dist',
emptyOutDir: true,
target: 'es2022',
},

View file

@ -142,7 +142,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-XwkLwxWZtOaw1N1GKR9G3z0yhXO/lDB5+O+VKtgxKWo=";
hash = "sha256-X0ujM9le14IecKMOo8tqfU9YYWWkZzSkMbXMOk+r6/8=";
};
nativeBuildInputs = [

View file

@ -11,7 +11,7 @@
"build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace",
"vis": "pnpm -C apps/vis run dev",
"dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev",
"typecheck": "pnpm run build:packages && pnpm -r --filter './packages/*' run typecheck && pnpm --filter @moonshot-ai/kimi-code run typecheck",
"typecheck": "pnpm run build:packages && pnpm -r --filter './packages/*' run typecheck && pnpm --filter @moonshot-ai/kimi-code run typecheck && pnpm --filter @moonshot-ai/vis-server run typecheck && pnpm --filter @moonshot-ai/vis-web run typecheck",
"lint": "oxlint --type-aware",
"lint:fix": "pnpm run lint --fix",
"lint:pkg": "pnpm -r --filter '!@moonshot-ai/monorepo' exec publint && pnpm -r --filter './packages/*' exec attw --pack . --profile node16",

29
pnpm-lock.yaml generated
View file

@ -84,6 +84,12 @@ importers:
'@moonshot-ai/migration-legacy':
specifier: workspace:^
version: link:../../packages/migration-legacy
'@moonshot-ai/vis-server':
specifier: workspace:^
version: link:../vis/server
'@moonshot-ai/vis-web':
specifier: workspace:*
version: link:../vis/web
'@types/semver':
specifier: ^7.7.0
version: 7.7.1
@ -142,6 +148,9 @@ importers:
'@moonshot-ai/agent-core':
specifier: workspace:^
version: link:../../../packages/agent-core
'@moonshot-ai/kosong':
specifier: workspace:^
version: link:../../../packages/kosong
hono:
specifier: ^4.7.7
version: 4.12.14
@ -192,6 +201,9 @@ importers:
vite:
specifier: ^6.3.3
version: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-singlefile:
specifier: ^2.3.3
version: 2.3.3(rollup@4.60.2)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
docs:
dependencies:
@ -5256,6 +5268,16 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite-plugin-singlefile@2.3.3:
resolution: {integrity: sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==}
engines: {node: '>18.0.0'}
peerDependencies:
rollup: ^4.59.0
vite: ^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
rollup:
optional: true
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -10475,6 +10497,13 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-singlefile@2.3.3(rollup@4.60.2)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
micromatch: 4.0.8
vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)
optionalDependencies:
rollup: 4.60.2
vite@5.4.21(@types/node@22.19.17)(lightningcss@1.32.0):
dependencies:
esbuild: 0.21.5