mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 13:45:28 +00:00
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
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:
parent
7b5b818815
commit
efdf8a1b2d
60 changed files with 3197 additions and 909 deletions
5
.changeset/add-kimi-vis-command.md
Normal file
5
.changeset/add-kimi-vis-command.md
Normal 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
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
dist-single/
|
||||
dist-native/
|
||||
.tmp-api-extractor/
|
||||
coverage/
|
||||
|
|
|
|||
6
apps/kimi-code/.gitignore
vendored
6
apps/kimi-code/.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
54
apps/kimi-code/scripts/build-vis-asset.mjs
Normal file
54
apps/kimi-code/scripts/build-vis-asset.mjs
Normal 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)`);
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
158
apps/kimi-code/src/cli/sub/vis.ts
Normal file
158
apps/kimi-code/src/cli/sub/vis.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
1
apps/kimi-code/src/generated/vis-web-asset.d.ts
vendored
Normal file
1
apps/kimi-code/src/generated/vis-web-asset.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export declare const VIS_WEB_GZIP_B64: string;
|
||||
|
|
@ -347,6 +347,7 @@ describe('CLI options parsing', () => {
|
|||
'acp',
|
||||
'login',
|
||||
'doctor',
|
||||
'vis',
|
||||
'migrate',
|
||||
'upgrade',
|
||||
]);
|
||||
|
|
|
|||
114
apps/kimi-code/test/cli/vis.test.ts
Normal file
114
apps/kimi-code/test/cli/vis.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
17
apps/vis/server/src/lib/web-asset.ts
Normal file
17
apps/vis/server/src/lib/web-asset.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
47
apps/vis/server/src/start.ts
Normal file
47
apps/vis/server/src/start.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
5
apps/vis/server/test/fixtures/sessions/sample-compaction/agents/main/wire.jsonl
vendored
Normal file
5
apps/vis/server/test/fixtures/sessions/sample-compaction/agents/main/wire.jsonl
vendored
Normal 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}
|
||||
15
apps/vis/server/test/fixtures/sessions/sample-compaction/state.json
vendored
Normal file
15
apps/vis/server/test/fixtures/sessions/sample-compaction/state.json
vendored
Normal 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": {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
20
apps/vis/server/test/lib/config.test.ts
Normal file
20
apps/vis/server/test/lib/config.test.ts
Normal 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]');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
42
apps/vis/server/test/lib/start.test.ts
Normal file
42
apps/vis/server/test/lib/start.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
96
apps/vis/server/test/routes/context.test.ts
Normal file
96
apps/vis/server/test/routes/context.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["src", "test"]
|
||||
"include": ["src", "test", "../../../packages/agent-core/src/prompt-modules.d.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
26
apps/vis/web/src/components/context/ClearRibbon.tsx
Normal file
26
apps/vis/web/src/components/context/ClearRibbon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
26
apps/vis/web/src/components/context/UndoRibbon.tsx
Normal file
26
apps/vis/web/src/components/context/UndoRibbon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
398
apps/vis/web/src/components/wire/parts.tsx
Normal file
398
apps/vis/web/src/components/wire/parts.tsx
Normal 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} />;
|
||||
}
|
||||
616
apps/vis/web/src/components/wire/renderers.tsx
Normal file
616
apps/vis/web/src/components/wire/renderers.tsx
Normal 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];
|
||||
}
|
||||
|
|
@ -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✓',
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
29
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue