diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index b5fb0fd50d..800d0c3d79 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -21,7 +21,7 @@ All other `@moonshot-ai/*` packages are treated as internal packages, including 4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, list `@moonshot-ai/kimi-code`. - **Web app (`@moonshot-ai/kimi-web`) changes always enter the CLI bundle.** `@moonshot-ai/kimi-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@moonshot-ai/kimi-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@moonshot-ai/kimi-code` so the CLI release carries the bundled `dist-web` output. 5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump. -6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. +6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. `@moonshot-ai/kimi-inspect` (a private dev app that never ships) is likewise ignored and must never appear in a changeset frontmatter. ## Workflow diff --git a/.changeset/config.json b/.changeset/config.json index 0f9da8d75c..9aaa6ce298 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,7 +9,8 @@ "ignore": [ "@moonshot-ai/vis", "@moonshot-ai/vis-server", - "@moonshot-ai/vis-web" + "@moonshot-ai/vis-web", + "@moonshot-ai/kimi-inspect" ], "snapshot": { "useCalculatedVersion": true, diff --git a/.changeset/debug-rpc-surface.md b/.changeset/debug-rpc-surface.md new file mode 100644 index 0000000000..0162b8a9ea --- /dev/null +++ b/.changeset/debug-rpc-surface.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Mount the dev-only /api/v1/debug RPC surface behind the --debug-endpoints flag, exposing every scoped service for local debugging on loopback binds. Pass --debug-endpoints to kimi server run to enable it. diff --git a/AGENTS.md b/AGENTS.md index df1b117c71..6715558877 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,13 +17,14 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. +- `apps/kimi-inspect`: web inspector for the v2 (kap-server) `/api/v2` surface — workspace/session browser, per-session chat, and live Service panels (data + trigger buttons) for the Session and Agent scopes. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v2`, `WsChannel` over the shared `/api/v2/ws` socket for events), typed by `agent-core-v2` Service interfaces; `GET {rpcBasePath}/channels` loads every wire protocol 1:1 — probing `/api/v1/debug` first (dev, whitelist-free) and falling back to `/api/v2`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. -- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. With `--debug-endpoints` on a loopback bind it additionally mounts `/api/v1/debug/*` — the same reflection dispatcher as `/api/v2` but without the channel whitelist (every scoped Service callable, `src/transport/registerDebugRoutes.ts`); internal only, repo dev scripts pass the flag. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/http|ipc|memory`); all three return the same `Klient`. The package also hosts the e2e suites: dual-backend session/agent suites (`test/e2e/dual/`, in-memory + in-process server), `/api/v2` wire tests (`test/e2e/v2/`), the legacy `/api/v1` live suites (`test/e2e/legacy/`), and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. ## Environment Requirements diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 7b8a06914d..9fb63d05ac 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -63,9 +63,9 @@ "test:native:smoke": "node scripts/native/smoke.mjs", "dev": "node scripts/dev.mjs", "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", - "dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground", - "dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground", - "dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground", + "dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints", + "dev:kap-server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints", + "dev:kap-server:multi": "KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run --foreground --debug-endpoints", "dev:server:restart": "node scripts/dev-server-restart.mjs", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", diff --git a/apps/kimi-inspect/index.html b/apps/kimi-inspect/index.html new file mode 100644 index 0000000000..3a9b3b5e59 --- /dev/null +++ b/apps/kimi-inspect/index.html @@ -0,0 +1,12 @@ + + + + + + Kimi Inspect + + +
+ + + diff --git a/apps/kimi-inspect/package.json b/apps/kimi-inspect/package.json new file mode 100644 index 0000000000..63acb6f06c --- /dev/null +++ b/apps/kimi-inspect/package.json @@ -0,0 +1,40 @@ +{ + "name": "@moonshot-ai/kimi-inspect", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "imports": { + "#/*": { + "types": [ + "./src/*.ts", + "./src/*.tsx", + "./src/*/index.ts", + "./src/*/index.tsx" + ], + "default": "./src/*" + } + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@moonshot-ai/agent-core-v2": "workspace:^", + "@tanstack/react-query": "^5.74.4", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "tailwindcss": "^4.1.4", + "typescript": "6.0.2", + "vite": "^6.3.3", + "vitest": "4.1.4" + } +} diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx new file mode 100644 index 0000000000..57a5c3dcc4 --- /dev/null +++ b/apps/kimi-inspect/src/App.tsx @@ -0,0 +1,126 @@ +/** + * App shell — selection state, session resume, and the three WS event + * subscriptions that feed the live bus: + * core `events` — process-wide domain events + * session `interactions` — pending approvals / questions + * agent `events` — the active agent's live event stream + * Layout: header / left sidebar (workspaces + sessions) / chat / inspector. + */ + +import { useEffect, useRef, useState } from 'react'; + +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; + +import { ChatView } from './components/ChatView'; +import { Inspector } from './components/Inspector'; +import { ServerSwitcher } from './components/ServerSwitcher'; +import { Sidebar } from './components/Sidebar'; +import { useConnection } from './connection'; +import { LiveBusProvider, type Emit, type LiveEvent } from './live'; +import { Badge, errorMessage } from './ui'; + +export function App() { + const { klient, wsState, baseUrl, disconnect } = useConnection(); + const [sessionId, setSessionId] = useState(null); + const [agentId, setAgentId] = useState('main'); + const [ready, setReady] = useState(false); + const [resumeError, setResumeError] = useState(null); + const emitRef = useRef(null); + const publish = (source: LiveEvent['source'], data: unknown) => + emitRef.current?.({ source, data, at: Date.now() }); + + // Core events — for the lifetime of the connection. + useEffect(() => { + const sub = klient.ws().listen('events', (data) => publish('core', data)); + return () => sub.dispose(); + }, [klient]); + + // Session interactions — re-subscribe when the session changes. + useEffect(() => { + if (sessionId === null || !ready) return; + const session = klient.ws().session(sessionId); + const a = session.listen('interactions', (data) => publish('session', data)); + const b = session.listen('interactions:resolved', (data) => publish('session', data)); + return () => { + a.dispose(); + b.dispose(); + }; + }, [klient, sessionId, ready]); + + // Agent events — re-subscribe when session or agent changes. + useEffect(() => { + if (sessionId === null || !ready) return; + const sub = klient + .ws() + .session(sessionId) + .agent(agentId) + .listen('events', (data) => publish('agent', data)); + return () => sub.dispose(); + }, [klient, sessionId, agentId, ready]); + + // Resume (materialize) the session on the server when it is selected, so + // session / agent scoped Services become reachable. + useEffect(() => { + if (sessionId === null) return; + let cancelled = false; + setReady(false); + setResumeError(null); + klient + .core(ISessionLifecycleService) + .resume(sessionId) + .then(() => { + if (!cancelled) setReady(true); + }) + .catch((error: unknown) => { + if (!cancelled) setResumeError(error); + }); + return () => { + cancelled = true; + }; + }, [klient, sessionId]); + + // Switching servers invalidates every session/agent selection: sessions + // belong to the server they were listed from. The client and its WS + // subscriptions rebuild from the new config on their own. + useEffect(() => { + setSessionId(null); + setAgentId('main'); + }, [baseUrl]); + + return ( + +
+
+ KIMI INSPECT + + + ws: {wsState} + +
+ +
+
+ + {resumeError !== null ? ( +
+ Failed to open session: {errorMessage(resumeError)} +
+ ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/kimi-inspect/src/channel/channel.test.ts b/apps/kimi-inspect/src/channel/channel.test.ts new file mode 100644 index 0000000000..fa1b249665 --- /dev/null +++ b/apps/kimi-inspect/src/channel/channel.test.ts @@ -0,0 +1,195 @@ +/** + * Channel layer unit tests — `ProxyChannel` URL/envelope semantics, `makeProxy` + * routing, and `WsChannel`'s ref-counted `listen`. The WS wire protocol itself + * is covered by the kap-server contract and klient's e2e suites. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import type { Event, IChannel } from './channel'; +import { RPCError } from './errors'; +import { makeProxy } from './proxy'; +import { ProxyChannel } from './proxyChannel'; +import { WsChannel } from './wsChannel'; +import type { WsSocket } from './wsSocket'; + +const ok = (data: unknown) => ({ code: 0, msg: 'success', data, request_id: 'r1' }); + +function fakeFetch(envelope: unknown) { + const calls: { url: string; init?: RequestInit }[] = []; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return { json: async () => envelope }; + }) as unknown as typeof fetch; + return { calls, fetchImpl }; +} + +function stubSocket() { + const state = { + listens: 0, + disposed: 0, + handler: undefined as ((data: unknown) => void) | undefined, + }; + const raw = { + call: vi.fn(async () => 'ws-ret'), + listen: ( + _scope: string, + _event: string, + _ids: unknown, + handler: (data: unknown) => void, + _service?: string, + ) => { + state.listens += 1; + state.handler = handler; + return { + dispose: () => { + state.disposed += 1; + }, + }; + }, + }; + return { raw, state, socket: raw as unknown as WsSocket }; +} + +describe('ProxyChannel.call', () => { + it('POSTs the command to the service base URL; no body and no header without args/token', async () => { + const { calls, fetchImpl } = fakeFetch(ok({ id: 's1' })); + const channel = new ProxyChannel({ + baseUrl: 'http://h:1/api/v2/session/s%201/agent/main/agentRPCService', + fetch: fetchImpl, + }); + const result = await channel.call('getModel', []); + expect(result).toEqual({ id: 's1' }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('http://h:1/api/v2/session/s%201/agent/main/agentRPCService/getModel'); + expect(calls[0]!.init?.method).toBe('POST'); + expect(calls[0]!.init?.body).toBeUndefined(); + }); + + it('sends the complete argument array as the JSON body, plus the bearer token', async () => { + const { calls, fetchImpl } = fakeFetch(ok(null)); + const channel = new ProxyChannel({ + baseUrl: 'http://h:2/api/v2/configService', + token: 'tok', + fetch: fetchImpl, + }); + await channel.call('set', ['workspace', { theme: 'dark' }]); + expect(calls[0]!.init?.body).toBe(JSON.stringify(['workspace', { theme: 'dark' }])); + expect(calls[0]!.init?.headers).toEqual({ + 'content-type': 'application/json', + authorization: 'Bearer tok', + }); + }); + + it('unwraps the envelope and throws RPCError on a non-zero code', async () => { + const { fetchImpl } = fakeFetch({ + code: 40401, + msg: 'session not found', + data: null, + request_id: 'r2', + details: { id: 's9' }, + }); + const channel = new ProxyChannel({ baseUrl: 'http://h:3/api/v2/sessionIndex', fetch: fetchImpl }); + const err: unknown = await channel.call('get', ['s9']).catch((error: unknown) => error); + expect(err).toBeInstanceOf(RPCError); + expect((err as RPCError).code).toBe(40401); + expect((err as RPCError).message).toBe('session not found'); + expect((err as RPCError).details).toEqual({ id: 's9' }); + }); +}); + +describe('makeProxy', () => { + interface DemoService { + read(id: string, n: number): Promise; + onDidChangeMetadata: Event<{ title: string }>; + } + + it('routes methods to call and onXxx members to listen', async () => { + const seen = { calls: [] as [string, unknown[]][], listens: [] as string[] }; + const channel: IChannel = { + call: async (command: string, args?: unknown[]): Promise => { + seen.calls.push([command, args ?? []]); + return 'ret' as T; + }, + listen: (event: string): Event => { + seen.listens.push(event); + return () => ({ dispose: () => {} }); + }, + }; + const svc = makeProxy(channel); + await expect(svc.read('a', 1)).resolves.toBe('ret'); + expect(seen.calls).toEqual([['read', ['a', 1]]]); + const d = svc.onDidChangeMetadata(() => {}); + d.dispose(); + expect(seen.listens).toEqual(['onDidChangeMetadata']); + }); +}); + +describe('WsChannel', () => { + it('forwards calls over the socket with scope + service + ids', async () => { + const { raw, socket } = stubSocket(); + const channel = new WsChannel({ + socket, + scope: 'agent', + service: 'agentRPCService', + sessionId: 's1', + agentId: 'main', + }); + const result = await channel.call('getModel', [{}]); + expect(result).toBe('ws-ret'); + expect(raw.call).toHaveBeenCalledWith('agent', 'agentRPCService', 'getModel', [{}], { + sessionId: 's1', + agentId: 'main', + }); + }); + + it('multiplexes local listeners onto one remote subscription', () => { + const { state, socket } = stubSocket(); + const channel = new WsChannel({ + socket, + scope: 'session', + service: 'sessionMetadata', + sessionId: 's1', + }); + const onDidChange = channel.listen('onDidChangeMetadata'); + const seen: unknown[] = []; + const sub1 = onDidChange((e) => seen.push(['l1', e])); + const sub2 = onDidChange((e) => seen.push(['l2', e])); + expect(state.listens).toBe(1); + state.handler!({ title: 't' }); + expect(seen).toEqual([ + ['l1', { title: 't' }], + ['l2', { title: 't' }], + ]); + sub1.dispose(); + expect(state.disposed).toBe(0); + sub2.dispose(); + expect(state.disposed).toBe(1); + }); +}); + +describe('ProxyChannel.listen', () => { + it('throws without a WS binding', () => { + const channel = new ProxyChannel({ + baseUrl: 'http://h:4/api/v2/configService', + fetch: fakeFetch(ok(null)).fetchImpl, + }); + expect(() => channel.listen('onDidChangeConfiguration')).toThrow(/events are not supported/); + }); + + it('delegates to one lazily-created WsChannel when a WS binding is provided', () => { + const { state, socket } = stubSocket(); + const factory = vi.fn(() => new WsChannel({ socket, scope: 'core', service: 'configService' })); + const channel = new ProxyChannel( + { baseUrl: 'http://h:5/api/v2/configService', fetch: fakeFetch(ok(null)).fetchImpl }, + factory, + ); + const sub = channel.listen('onDidChangeConfiguration')(() => {}); + expect(factory).toHaveBeenCalledTimes(1); + expect(state.listens).toBe(1); + channel.listen('onDidSectionChange'); + expect(factory).toHaveBeenCalledTimes(1); + sub.dispose(); + expect(state.disposed).toBe(1); + }); +}); diff --git a/apps/kimi-inspect/src/channel/channel.ts b/apps/kimi-inspect/src/channel/channel.ts new file mode 100644 index 0000000000..fe87242509 --- /dev/null +++ b/apps/kimi-inspect/src/channel/channel.ts @@ -0,0 +1,42 @@ +/** + * Transport-agnostic channel contract for the `/api/v2` client — the + * old-klient / VS Code `ProxyChannel` model: the channel is bound to one + * Service (the URL carries the scope + the Service's decorator id) and + * `command` is the method name, invoked by reflection on the server. + * `listen` is for the Service's `onXxx` emitter events over the persistent + * `/api/v2/ws` transport. + */ + +import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; + +export interface IDisposable { + dispose(): void; +} + +export interface Event { + (listener: (event: T) => unknown, thisArg?: unknown, disposables?: IDisposable[]): IDisposable; +} + +/** The client-facing channel contract. Calls always carry the complete argument array. */ +export interface IChannel { + call(command: string, args?: unknown[]): Promise; + listen(event: string, arg?: unknown): Event; +} + +/** A wire Service reference: a DI decorator (stringifies to the wire channel + * name) or the raw channel name as a string. */ +export type ServiceRef = ServiceIdentifier | string; + +/** + * Remote view of a Service contract: every method becomes an async wire call; + * `onXxx` event members (`Event` — callables returning `IDisposable`) stay + * subscribable events; plain non-function members become zero-arg property + * reads (the `/api/v2` dispatcher returns non-function members as-is). + */ +export type ServiceProxy = { + [K in keyof T]: T[K] extends (...args: infer A) => infer R + ? R extends IDisposable + ? T[K] + : (...args: A) => Promise> + : () => Promise>; +}; diff --git a/apps/kimi-inspect/src/channel/channels.ts b/apps/kimi-inspect/src/channel/channels.ts new file mode 100644 index 0000000000..1810f6c830 --- /dev/null +++ b/apps/kimi-inspect/src/channel/channels.ts @@ -0,0 +1,112 @@ +/** + * Protocol loading — the server's `{rpcBasePath}/channels` endpoint is its + * self-description of every wire-callable Service (name, scope, domain, + * methods + properties). On dev servers that's the whitelist-free + * `/api/v1/debug`; older/production servers fall back to the `/api/v2` + * whitelist set (`probeRpcBasePath` decides). Paired with `serviceByName`, + * each descriptor materializes 1:1 into a typed proxy of the channel layer: + * same channel name, same scope route, methods invoked by reflection. + */ + +import { createDecorator } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; + +import { RPCError } from './errors'; +import type { InspectClient } from './client'; +import type { ServiceProxy } from './channel'; + +/** Wire scope kinds reported by the channels endpoint (`app` ≡ the core route). */ +export type ChannelScope = 'app' | 'session' | 'agent'; + +/** Mirror of `ChannelDescriptor` in kap-server (`GET /api/v2/channels`). */ +export interface ChannelDescriptor { + readonly name: string; + readonly scope: ChannelScope; + readonly domain: string; + readonly methods: readonly { + readonly name: string; + readonly kind: 'method' | 'property'; + readonly arity: number; + readonly params: string; + }[]; +} + +/** Fetch the dynamic channel list (unwrapped from the project envelope), + * from whichever RPC surface the connection probed (`rpcBasePath`). */ +export async function fetchChannelDescriptors( + client: InspectClient, +): Promise { + const headers: Record = {}; + if (client.token !== undefined && client.token !== '') { + headers['authorization'] = `Bearer ${client.token}`; + } + const res = await fetch(`${client.baseUrl}${client.rpcBasePath}/channels`, { headers }); + const envelope = (await res.json()) as { + code: number; + msg: string; + data: readonly ChannelDescriptor[]; + }; + if (envelope.code !== 0) throw new RPCError(envelope.code, envelope.msg); + return envelope.data; +} + +/** The dev server's whitelist-free debug surface (`--debug-endpoints`). */ +export const DEBUG_RPC_BASE = '/api/v1/debug' as const; +/** The stable whitelist RPC surface — fallback when debug is not mounted. */ +export const V2_RPC_BASE = '/api/v2' as const; + +export type RpcBasePath = typeof DEBUG_RPC_BASE | typeof V2_RPC_BASE; + +/** + * Probe which RPC surface a server offers: dev servers started with + * `--debug-endpoints` answer `/api/v1/debug/channels`; older/production + * servers only the whitelisted `/api/v2`. Always resolves (fallback `/api/v2`). + */ +export async function probeRpcBasePath(options: { + readonly baseUrl: string; + readonly token?: string; +}): Promise { + try { + const headers: Record = {}; + if (options.token !== undefined && options.token !== '') { + headers['authorization'] = `Bearer ${options.token}`; + } + const res = await fetch( + `${options.baseUrl.replace(/\/$/, '')}${DEBUG_RPC_BASE}/channels`, + { headers }, + ); + if (res.ok) { + const envelope = (await res.json()) as { code?: number }; + if (envelope.code === 0) return DEBUG_RPC_BASE; + } + } catch { + // fall through to the v2 fallback + } + return V2_RPC_BASE; +} + +export interface ServiceTarget { + readonly scope: ChannelScope; + readonly sessionId?: string; + readonly agentId?: string; +} + +/** + * Resolve a Service proxy by wire channel name. The DI decorator registry keys + * identifiers by name, so re-creating the decorator resolves to the same token + * the server channel registry created — the name is the wire channel, which is + * all the proxy uses. Returns `undefined` when the target scope needs a + * session/agent id that isn't available. + */ +export function serviceByName( + client: InspectClient, + name: string, + target: ServiceTarget, +): ServiceProxy | undefined { + const id = createDecorator(name); + if (target.scope === 'app') return client.core(id); + if (target.sessionId === undefined) return undefined; + const base = client.session(target.sessionId); + if (target.scope === 'session') return base.service(id); + if (target.agentId === undefined) return undefined; + return base.agent(target.agentId).service(id); +} diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts new file mode 100644 index 0000000000..2f1149dd23 --- /dev/null +++ b/apps/kimi-inspect/src/channel/client.ts @@ -0,0 +1,149 @@ +/** + * Inspect client — the app's `/api/v2` entry point, in the old-klient VS Code + * `ProxyChannel` model: a three-level scope entry (`core` / `session` / + * `agent`) whose every Service handle is a `makeProxy`-materialized typed + * proxy over a service-bound channel, plus the one shared `/api/v2/ws` socket + * for scope event streams and connection state. + * + * const client = createInspectClient({ url: 'http://127.0.0.1:58627' }); + * await client.core(ISessionIndex).list({}); + * await client.session('s1').service(ISessionMetadata).read(); + * await client.session('s1').agent('main').service(IAgentRPCService).getModel(); + * + * The `agent-core-v2` service token is the whole key: its type parameter `T` + * types the returned proxy, and its decorator id (`String(id)`) is the channel + * name in the URL. Calls ride HTTP (`ProxyChannel`); the proxy's `onXxx` + * emitter events and the scope streams (`events` / `interactions` / + * `interactions:resolved`) ride the one shared `WsSocket`. + */ + +import type { ServiceProxy, ServiceRef } from './channel'; +import { makeProxy } from './proxy'; +import { ProxyChannel } from './proxyChannel'; +import { WsChannel } from './wsChannel'; +import { + WsSocket, + type WsScopeIds, + type WsScopeKind, + type WsSocketState, + type WsSubscription, +} from './wsSocket'; + +export interface InspectAgentHandle { + service(id: ServiceRef): ServiceProxy; +} + +export interface InspectSessionHandle extends InspectAgentHandle { + agent(agentId: string): InspectAgentHandle; +} + +export interface InspectWsAgent { + listen(stream: string, handler: (data: unknown) => void): WsSubscription; +} + +export interface InspectWsSession extends InspectWsAgent { + agent(agentId: string): InspectWsAgent; +} + +/** The one owned WebSocket: connection state plus per-scope stream subscriptions. */ +export interface InspectWs { + readonly state: WsSocketState; + onDidChangeState(listener: (state: WsSocketState) => void): WsSubscription; + listen(stream: string, handler: (data: unknown) => void): WsSubscription; + session(sessionId: string): InspectWsSession; + close(): void; +} + +export interface InspectClient { + /** Absolute server base URL, e.g. `http://127.0.0.1:58627`. */ + readonly baseUrl: string; + /** Bearer token in use, when any. */ + readonly token?: string; + /** RPC base path for calls and `/channels`: `/api/v1/debug` on dev servers + * (whitelist-free), `/api/v2` otherwise. Resolved by the connection probe. */ + readonly rpcBasePath: string; + core(id: ServiceRef): ServiceProxy; + session(sessionId: string): InspectSessionHandle; + ws(): InspectWs; +} + +export interface InspectClientOptions { + /** Base URL of the server, e.g. `http://127.0.0.1:58627`. */ + readonly url: string; + /** Optional bearer token. */ + readonly token?: string; + /** RPC base path for service calls + `/channels` introspection. Default + * `/api/v2`; the connection layer probes and passes `/api/v1/debug` when + * the server mounts the dev debug surface (`--debug-endpoints`). */ + readonly rpcBasePath?: string; +} + +export function createInspectClient(options: InspectClientOptions): InspectClient { + const url = options.url.replace(/\/$/, ''); + const rpcBasePath = options.rpcBasePath ?? '/api/v2'; + const socket = new WsSocket(options); + + /** Materialize a typed proxy for one Service on one scope binding. */ + function proxy( + scopePath: string, + scope: WsScopeKind, + ids: WsScopeIds, + id: ServiceRef, + ): ServiceProxy { + const service = String(id); + return makeProxy( + new ProxyChannel( + { baseUrl: `${url}${rpcBasePath}${scopePath}/${service}`, token: options.token }, + () => new WsChannel({ socket, scope, service, ...ids }), + ), + ); + } + + function wsListen(ids: WsScopeIds): InspectWsAgent { + return { + listen: (stream, handler) => + socket.listen( + ids.agentId !== undefined ? 'agent' : ids.sessionId !== undefined ? 'session' : 'core', + stream, + ids, + handler, + ), + }; + } + + const ws: InspectWs = { + get state() { + return socket.currentState; + }, + onDidChangeState: (listener) => socket.onDidChangeState(listener), + ...wsListen({}), + session: (sessionId) => ({ + ...wsListen({ sessionId }), + agent: (agentId) => wsListen({ sessionId, agentId }), + }), + close: () => { + socket.close(); + }, + }; + + return { + baseUrl: url, + token: options.token, + rpcBasePath, + core: (id) => proxy('', 'core', {}, id), + session: (sessionId) => { + const scopePath = `/session/${encodeURIComponent(sessionId)}`; + return { + service: (id) => proxy(scopePath, 'session', { sessionId }, id), + agent: (agentId) => ({ + service: (subId) => + proxy(`${scopePath}/agent/${encodeURIComponent(agentId)}`, 'agent', { + sessionId, + agentId, + }, subId), + }), + }; + }, + ws: () => ws, + }; +} diff --git a/apps/kimi-inspect/src/channel/errors.ts b/apps/kimi-inspect/src/channel/errors.ts new file mode 100644 index 0000000000..b655802de5 --- /dev/null +++ b/apps/kimi-inspect/src/channel/errors.ts @@ -0,0 +1,15 @@ +/** + * Client-side RPC error surfaced when the `/api/v2` envelope carries a non-zero + * `code`. Mirrors the server envelope (`{ code, msg, data, request_id }`) — the + * numeric `code` is the stable branch key across the wire, not `instanceof`. + */ +export class RPCError extends Error { + constructor( + readonly code: number, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = 'RPCError'; + } +} diff --git a/apps/kimi-inspect/src/channel/index.ts b/apps/kimi-inspect/src/channel/index.ts new file mode 100644 index 0000000000..bcb9186399 --- /dev/null +++ b/apps/kimi-inspect/src/channel/index.ts @@ -0,0 +1,8 @@ +export * from './channel'; +export * from './channels'; +export * from './client'; +export * from './errors'; +export * from './proxy'; +export * from './proxyChannel'; +export * from './wsChannel'; +export * from './wsSocket'; diff --git a/apps/kimi-inspect/src/channel/proxy.ts b/apps/kimi-inspect/src/channel/proxy.ts new file mode 100644 index 0000000000..5a0eb75187 --- /dev/null +++ b/apps/kimi-inspect/src/channel/proxy.ts @@ -0,0 +1,21 @@ +/** + * Typed proxy turning an `IChannel` (bound to one Service) into a value + * satisfying that Service's interface `T` — VS Code's `ProxyChannel.toService`. + * + * Members named `onUpperCase` become channel events; every other property access + * becomes a function forwarding its complete argument array to `channel.call` + * (the dispatcher also answers property reads this way). The shared interface + * `T` is the whole contract, with no per-method allowlist or renaming. + */ + +import type { IChannel, ServiceProxy } from './channel'; + +export function makeProxy(channel: IChannel): ServiceProxy { + return new Proxy({} as ServiceProxy, { + get(_target, prop) { + if (typeof prop !== 'string') return undefined; + if (/^on[A-Z]/.test(prop)) return channel.listen(prop); + return (...args: unknown[]) => channel.call(prop, args); + }, + }); +} diff --git a/apps/kimi-inspect/src/channel/proxyChannel.ts b/apps/kimi-inspect/src/channel/proxyChannel.ts new file mode 100644 index 0000000000..e88143f8eb --- /dev/null +++ b/apps/kimi-inspect/src/channel/proxyChannel.ts @@ -0,0 +1,81 @@ +/** + * `ProxyChannel` — an `IChannel` bound to one Service, routing `call`s to + * kap-server's `/api/v2` HTTP surface. Every call `POST`s the method name to + * the Service base URL with the complete argument array as the JSON body, + * then unwraps the project envelope: a non-zero `code` throws `RPCError`, + * otherwise `data` is returned. Non-function members answer as property reads + * through the same route (the dispatcher returns them as-is). + * + * `listen` cannot be served by HTTP; when the client supplies a WS binding + * (`events` factory) it is delegated to a lazily-created `WsChannel` bound to + * the same scope + Service, so the Service's `onXxx` emitter events work 1:1. + * Without a factory `listen` throws, matching the old HTTP-only channel. + */ + +import type { Event, IChannel } from './channel'; +import { RPCError } from './errors'; +import type { WsChannel } from './wsChannel'; + +interface Envelope { + readonly code: number; + readonly msg: string; + readonly data: T; + readonly request_id: string; + readonly details?: unknown; +} + +export interface ProxyChannelOptions { + /** Service base URL, e.g. `http://127.0.0.1:58627/api/v2[/session/:sid[/agent/:aid]]/:service`. */ + readonly baseUrl: string; + /** Optional bearer token. */ + readonly token?: string; + /** `fetch` implementation; defaults to the global `fetch`. */ + readonly fetch?: typeof fetch; +} + +export class ProxyChannel implements IChannel { + private readonly baseUrl: string; + private readonly token?: string; + private readonly fetchImpl: typeof fetch; + private readonly eventsFactory?: () => WsChannel; + private eventsChannel: WsChannel | undefined; + + constructor(opts: ProxyChannelOptions, events?: () => WsChannel) { + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); + this.token = opts.token; + // Bind the global fetch: browsers throw "Illegal invocation" when the + // native function is invoked with a non-Window receiver. + this.fetchImpl = opts.fetch ?? fetch.bind(globalThis); + this.eventsFactory = events; + } + + async call(command: string, args: unknown[] = []): Promise { + const headers: Record = {}; + let body: string | undefined; + if (args.length > 0) { + headers['content-type'] = 'application/json'; + body = JSON.stringify(args); + } + if (this.token !== undefined) { + headers['authorization'] = `Bearer ${this.token}`; + } + const res = await this.fetchImpl(`${this.baseUrl}/${command}`, { + method: 'POST', + headers, + body, + }); + const envelope = (await res.json()) as Envelope; + if (envelope.code !== 0) { + throw new RPCError(envelope.code, envelope.msg, envelope.details); + } + return envelope.data; + } + + listen(event: string): Event { + if (this.eventsFactory === undefined) { + throw new Error('events are not supported on this channel (no WS binding)'); + } + this.eventsChannel ??= this.eventsFactory(); + return this.eventsChannel.listen(event); + } +} diff --git a/apps/kimi-inspect/src/channel/wsChannel.ts b/apps/kimi-inspect/src/channel/wsChannel.ts new file mode 100644 index 0000000000..7166a1ee87 --- /dev/null +++ b/apps/kimi-inspect/src/channel/wsChannel.ts @@ -0,0 +1,79 @@ +/** + * `WsChannel` — an `IChannel` bound to one Service that forwards `call`s and + * `listen`s over the shared `/api/v2/ws` socket instead of HTTP. Same VS Code + * shape as `ProxyChannel` (the URL equivalent is the `{scope, service, ids}` + * triple the socket puts on each frame). `listen` multiplexes local listeners + * onto one remote subscription: the first local listener opens it, the last + * `dispose()` tears it down, and it survives reconnects until then. + */ + +import type { Event, IChannel } from './channel'; +import type { WsScopeIds, WsScopeKind, WsSocket } from './wsSocket'; + +export interface WsChannelOptions { + readonly socket: WsSocket; + readonly scope: WsScopeKind; + /** Service channel name (the decorator id, `String(id)`). */ + readonly service: string; + readonly sessionId?: string; + readonly agentId?: string; +} + +interface SharedEvent { + readonly listeners: Set<{ listener: (data: unknown) => unknown; thisArg: unknown }>; + remote?: { dispose(): void }; +} + +export class WsChannel implements IChannel { + private readonly socket: WsSocket; + private readonly scope: WsScopeKind; + private readonly service: string; + private readonly ids: WsScopeIds; + private readonly events = new Map(); + + constructor(opts: WsChannelOptions) { + this.socket = opts.socket; + this.scope = opts.scope; + this.service = opts.service; + this.ids = { sessionId: opts.sessionId, agentId: opts.agentId }; + } + + call(command: string, args: unknown[] = []): Promise { + return this.socket.call(this.scope, this.service, command, args, this.ids); + } + + listen(event: string): Event { + let shared = this.events.get(event); + if (shared === undefined) { + shared = { listeners: new Set() }; + this.events.set(event, shared); + } + return (listener, thisArg, disposables) => { + const entry = { listener: listener as (data: unknown) => unknown, thisArg }; + shared.listeners.add(entry); + shared.remote ??= this.socket.listen( + this.scope, + event, + this.ids, + (data) => { + for (const current of shared.listeners) current.listener.call(current.thisArg, data); + }, + this.service, + ); + let disposed = false; + const subscription = { + dispose: (): void => { + if (disposed) return; + disposed = true; + shared.listeners.delete(entry); + if (shared.listeners.size === 0) { + shared.remote?.dispose(); + shared.remote = undefined; + } + }, + }; + disposables?.push(subscription); + return subscription; + }; + } +} diff --git a/apps/kimi-inspect/src/channel/wsSocket.ts b/apps/kimi-inspect/src/channel/wsSocket.ts new file mode 100644 index 0000000000..98eca01650 --- /dev/null +++ b/apps/kimi-inspect/src/channel/wsSocket.ts @@ -0,0 +1,456 @@ +/** + * `/api/v2/ws` socket — the persistent WebSocket transport behind the inspect + * client's `WsChannel` (Service emitter `listen`s) and scope event streams. + * + * Speaks the kap-server v2 JSON protocol: one socket multiplexes RPC `call`s + * and event `listen`s, correlated by client-chosen ids. Adds the client-side + * safety features a long-lived devtool connection needs: `hello` handshake, + * `ping`→`pong` heartbeat answers, per-call timeouts, and opt-out automatic + * reconnect (active `listen`s are re-subscribed after a reconnect; in-flight + * calls reject on close — the server cannot resume them). + * + * The bearer token is presented at the upgrade through the + * `kimi-code.bearer.` subprotocol (the only credential channel a browser + * WebSocket has) and again in the `hello` frame for the present-only handshake + * check. Works against the DOM WebSocket (browsers, Node ≥ 21); any compatible + * implementation can be injected for tests. + */ + +import { RPCError } from './errors'; + +/** Wire scope kinds, mirroring kap-server's `ScopeKind`. */ +export type WsScopeKind = 'core' | 'session' | 'agent'; + +/** Scope coordinates carried on `call` / `listen` frames. */ +export interface WsScopeIds { + readonly sessionId?: string; + readonly agentId?: string; +} + +export type WsSocketState = 'connecting' | 'open' | 'closed'; + +export interface WsSubscription { + dispose(): void; +} + +/** Minimal DOM-compatible WebSocket surface this module codes against. */ +export interface WsLike { + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: never) => void): void; +} + +export interface WsLikeCtor { + new (url: string, protocols?: string | string[]): WsLike; + readonly OPEN: number; +} + +export interface WsSocketOptions { + /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v2/ws` URL. */ + readonly url: string; + /** Optional bearer token. */ + readonly token?: string; + /** WebSocket implementation; defaults to the global `WebSocket`. */ + readonly WebSocketImpl?: WsLikeCtor; + /** Reconnect after an unexpected close. Default `true`. */ + readonly autoReconnect?: boolean; + /** Base delay (ms) for the reconnect backoff. Default `500`. */ + readonly reconnectDelayMs?: number; + /** Per-call deadline (ms). Default `30000`. */ + readonly callTimeoutMs?: number; +} + +interface PendingCall { + readonly resolve: (data: unknown) => void; + readonly reject: (err: Error) => void; + readonly timer: ReturnType | undefined; +} + +interface ActiveListen { + readonly scope: WsScopeKind; + readonly service?: string; + readonly event: string; + readonly ids: WsScopeIds; + readonly handler: (data: unknown) => void; + readonly onError?: (error: Error) => void; + acknowledged: boolean; +} + +export interface WsListenError { + readonly scope: WsScopeKind; + readonly service?: string; + readonly event: string; + readonly error: Error; +} + +interface ServerFrame { + readonly type: string; + readonly id?: string; + readonly data?: unknown; + readonly code?: number; + readonly msg?: string; + readonly eventId?: string; +} + +const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; +const DEFAULT_CALL_TIMEOUT_MS = 30_000; + +export class WsSocket { + private readonly wsUrl: string; + private readonly token?: string; + private readonly WsCtor: WsLikeCtor; + private readonly autoReconnect: boolean; + private readonly reconnectDelayMs: number; + private readonly callTimeoutMs: number; + + private ws: WsLike | undefined; + private state: WsSocketState = 'connecting'; + private manualClose = false; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | undefined; + private readyWaiters: { resolve: () => void; reject: (err: Error) => void }[] = []; + + private readonly pending = new Map(); + private readonly listens = new Map(); + private readonly eventControllers = new Map(); + private readonly stateListeners = new Set<(state: WsSocketState) => void>(); + private readonly listenErrorListeners = new Set<(event: WsListenError) => void>(); + + private seq = 0; + private readonly idPrefix = `k${Date.now().toString(36)}`; + + constructor(opts: WsSocketOptions) { + this.wsUrl = toWsUrl(opts.url); + this.token = opts.token; + const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined); + if (ctor === undefined) { + throw new Error('no WebSocket implementation available; pass WebSocketImpl'); + } + this.WsCtor = ctor; + this.autoReconnect = opts.autoReconnect ?? true; + this.reconnectDelayMs = opts.reconnectDelayMs ?? 500; + this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS; + this.connect(); + } + + get currentState(): WsSocketState { + return this.state; + } + + onDidChangeState(listener: (state: WsSocketState) => void): WsSubscription { + this.stateListeners.add(listener); + return { dispose: () => this.stateListeners.delete(listener) }; + } + + onDidListenError(listener: (event: WsListenError) => void): WsSubscription { + this.listenErrorListeners.add(listener); + return { dispose: () => this.listenErrorListeners.delete(listener) }; + } + + /** RPC call over the socket; rejects on `error` frame, timeout, or close. */ + async call( + scope: WsScopeKind, + service: string, + method: string, + arg?: unknown, + ids?: WsScopeIds, + ): Promise { + await this.whenReady(); + // The socket may have dropped between `whenReady` resolving and this + // continuation running; never register a call we cannot send. + if (this.state !== 'open') { + throw new Error('ws closed'); + } + const id = this.nextId(); + const promise = new Promise((resolve, reject) => { + const timer = + this.callTimeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new RPCError(50001, `call timed out after ${this.callTimeoutMs}ms`)); + }, this.callTimeoutMs) + : undefined; + this.pending.set(id, { + resolve: resolve as (data: unknown) => void, + reject, + timer, + }); + }); + this.send({ type: 'call', id, scope, service, method, arg, ...ids }); + return promise; + } + + /** + * Subscribe to a scope event stream. The subscription survives reconnects + * (re-sent after each reconnect) until `dispose()`d. + */ + listen( + scope: WsScopeKind, + event: string, + ids: WsScopeIds, + handler: (data: unknown) => void, + service?: string, + onError?: (error: Error) => void, + ): WsSubscription { + const id = this.nextId(); + this.listens.set(id, { scope, service, event, ids, handler, onError, acknowledged: false }); + if (this.state === 'open') { + this.send({ type: 'listen', id, scope, service, event, ...ids }); + } + return { + dispose: () => { + if (!this.listens.delete(id)) return; + if (this.state === 'open') { + this.send({ type: 'unlisten', id }); + } + }, + }; + } + + /** Tear the socket down permanently; rejects in-flight calls. */ + close(): void { + this.manualClose = true; + if (this.reconnectTimer !== undefined) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + this.setState('closed'); + this.ws?.close(); + this.ws = undefined; + this.failAll(new Error('ws closed')); + this.rejectReadyWaiters(new Error('ws closed')); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private nextId(): string { + this.seq += 1; + return `${this.idPrefix}_${this.seq}`; + } + + private connect(): void { + this.setState('connecting'); + const protocols = + this.token !== undefined && this.token.length > 0 + ? [`${WS_BEARER_PROTOCOL_PREFIX}${this.token}`] + : undefined; + let ws: WsLike; + try { + ws = new this.WsCtor(this.wsUrl, protocols); + } catch (error) { + this.scheduleReconnect(error); + return; + } + this.ws = ws; + ws.addEventListener('open', () => { + this.onOpen(); + }); + ws.addEventListener('message', (event: { data: unknown }) => { + this.onMessage(event.data); + }); + ws.addEventListener('close', () => { + this.onClose(); + }); + ws.addEventListener('error', () => { + // The 'close' event always follows 'error'; reconnect logic lives there. + }); + } + + private onOpen(): void { + this.reconnectAttempt = 0; + this.setState('open'); + this.send({ type: 'hello', token: this.token }); + for (const [id, sub] of this.listens) { + this.send({ + type: 'listen', + id, + scope: sub.scope, + service: sub.service, + event: sub.event, + ...sub.ids, + }); + } + const waiters = this.readyWaiters; + this.readyWaiters = []; + for (const w of waiters) w.resolve(); + } + + private onMessage(raw: unknown): void { + let frame: ServerFrame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame; + } catch { + return; + } + switch (frame.type) { + case 'ready': + case 'server_hello': + return; + case 'ping': + this.send({ type: 'pong' }); + return; + case 'result': { + const p = this.take(frame.id); + p?.resolve(frame.data); + return; + } + case 'error': { + const p = this.take(frame.id); + if (p !== undefined) { + p.reject(new RPCError(frame.code ?? 50001, frame.msg ?? 'error')); + } else { + const sub = this.listens.get(frame.id ?? ''); + if (sub !== undefined) { + this.listens.delete(frame.id ?? ''); + const error = new RPCError(frame.code ?? 50001, frame.msg ?? 'error'); + sub.onError?.(error); + queueMicrotask(() => { + for (const listener of this.listenErrorListeners) { + listener({ scope: sub.scope, service: sub.service, event: sub.event, error }); + } + }); + } + } + return; + } + case 'listen_result': { + const sub = this.listens.get(frame.id ?? ''); + if (sub !== undefined) sub.acknowledged = true; + return; + } + case 'event': { + const sub = this.listens.get(frame.id ?? ''); + if (sub === undefined) return; + if (frame.eventId === undefined) { + sub.handler(frame.data); + return; + } + const controller = new AbortController(); + const eventKey = `${frame.id}:${frame.eventId}`; + this.eventControllers.set(eventKey, controller); + const waits: Promise[] = []; + const data = { + ...(frame.data as object), + signal: controller.signal, + waitUntil: (promise: Promise) => waits.push(promise), + }; + try { + sub.handler(data); + } catch { + // Listener failures are fail-open for the server-side event. + } + void Promise.allSettled(waits).finally(() => { + if (!this.eventControllers.delete(eventKey)) return; + this.send({ type: 'event_result', id: frame.id, eventId: frame.eventId }); + }); + return; + } + case 'event_cancel': { + const key = `${frame.id}:${frame.eventId}`; + const controller = this.eventControllers.get(key); + if (controller !== undefined) { + this.eventControllers.delete(key); + controller.abort(); + } + return; + } + } + } + + private onClose(): void { + this.ws = undefined; + for (const controller of this.eventControllers.values()) controller.abort(); + this.eventControllers.clear(); + this.failAll(new Error('ws closed')); + if (this.manualClose || !this.autoReconnect) { + this.setState('closed'); + this.rejectReadyWaiters(new Error('ws closed')); + return; + } + // Transient drop: queued calls keep waiting for the reconnect. + this.scheduleReconnect(undefined); + } + + private scheduleReconnect(_cause: unknown): void { + if (this.manualClose || !this.autoReconnect) { + this.setState('closed'); + return; + } + this.reconnectAttempt += 1; + const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000); + this.setState('connecting'); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.connect(); + }, delay); + this.reconnectTimer.unref?.(); + } + + private whenReady(): Promise { + if (this.state === 'open') return Promise.resolve(); + if (this.state === 'closed' && this.manualClose) { + return Promise.reject(new Error('ws closed')); + } + return new Promise((resolve, reject) => { + this.readyWaiters.push({ resolve, reject }); + }); + } + + private rejectReadyWaiters(err: Error): void { + const waiters = this.readyWaiters; + this.readyWaiters = []; + for (const w of waiters) w.reject(err); + } + + private take(id: string | undefined): PendingCall | undefined { + const p = this.pending.get(id ?? ''); + if (p !== undefined) { + this.pending.delete(id ?? ''); + if (p.timer !== undefined) clearTimeout(p.timer); + } + return p; + } + + private failAll(err: Error): void { + for (const p of this.pending.values()) { + if (p.timer !== undefined) clearTimeout(p.timer); + p.reject(err); + } + this.pending.clear(); + } + + private send(frame: Record): void { + const ws = this.ws; + if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return; + try { + ws.send(JSON.stringify(frame)); + } catch { + // best-effort; the close handler handles teardown + } + } + + private setState(next: WsSocketState): void { + if (this.state === next) return; + this.state = next; + for (const listener of this.stateListeners) listener(next); + } +} + +/** Derive the `/api/v2/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ +function toWsUrl(base: string): string { + const url = new URL(base); + if (url.protocol === 'http:') url.protocol = 'ws:'; + else if (url.protocol === 'https:') url.protocol = 'wss:'; + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error(`unsupported URL scheme for WS transport: ${base}`); + } + if (!url.pathname.endsWith('/api/v2/ws')) { + url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v2/ws`; + } + url.search = ''; + url.hash = ''; + return url.toString(); +} diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx new file mode 100644 index 0000000000..ac6169b8a5 --- /dev/null +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -0,0 +1,333 @@ +/** + * Main view — the conversation of the active session + agent. + * + * History comes from `IAgentContextMemoryService.get()` (the authoritative + * context); live turns stream in through the agent `events` subscription + * (`assistant.delta` / `thinking.delta` / `tool.*`) as ephemeral entries. On + * `turn.ended` the history is refetched and the ephemeral layer is dropped, so + * the view always converges to the authoritative context. Prompts go out via + * `IAgentRPCService.prompt`; `cancel` aborts the running turn. + */ + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; +import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; + +import { useConnection } from '../connection'; +import { eventType, payloadField, useLiveEvent } from '../live'; +import { ActionButton, Badge, ErrorLine } from '../ui'; + +interface ChatEntry { + readonly id: string; + readonly kind: 'user' | 'assistant' | 'thinking' | 'tool' | 'error'; + text: string; + name?: string; + args?: string; + output?: string; + isError?: boolean; +} + +interface HistoryMessage { + readonly role?: string; + readonly content?: readonly { type?: string; text?: string; think?: string }[]; + readonly toolCalls?: readonly { id?: string; name?: string; arguments?: string | null }[]; + readonly isError?: boolean; +} + +function mapHistory(messages: readonly HistoryMessage[]): ChatEntry[] { + const entries: ChatEntry[] = []; + messages.forEach((m, i) => { + const parts = m.content ?? []; + const text = parts + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .join('\n'); + const think = parts + .filter((p) => p.type === 'think') + .map((p) => p.think ?? p.text ?? '') + .join('\n'); + if (m.role === 'user') { + entries.push({ id: `h${i}`, kind: 'user', text: text || '[non-text content]' }); + } else if (m.role === 'assistant') { + if (think !== '') entries.push({ id: `h${i}t`, kind: 'thinking', text: think }); + if (text !== '') entries.push({ id: `h${i}a`, kind: 'assistant', text }); + for (const call of m.toolCalls ?? []) { + entries.push({ + id: `h${i}c${call.id ?? ''}`, + kind: 'tool', + text: '', + name: call.name ?? 'tool', + args: call.arguments ?? undefined, + }); + } + } else if (m.role === 'tool') { + entries.push({ + id: `h${i}r`, + kind: 'tool', + text: '', + name: 'result', + output: text, + isError: m.isError, + }); + } + // system / developer messages (the system prompt) are not shown. + }); + return entries; +} + +export function ChatView({ + sessionId, + agentId, + ready, +}: { + sessionId: string | null; + agentId: string; + ready: boolean; +}) { + const { klient } = useConnection(); + const queryClient = useQueryClient(); + const [stream, setStream] = useState([]); + const [input, setInput] = useState(''); + const [running, setRunning] = useState(false); + const [sendError, setSendError] = useState(null); + const bottomRef = useRef(null); + + const enabled = sessionId !== null && ready; + const history = useQuery({ + queryKey: ['history', sessionId, agentId], + queryFn: () => + klient + .session(sessionId as string) + .agent(agentId) + .service(IAgentContextMemoryService) + .get(), + enabled, + }); + + const refetchHistory = useRef | undefined>(undefined); + const scheduleRefetch = () => { + if (refetchHistory.current !== undefined) clearTimeout(refetchHistory.current); + refetchHistory.current = setTimeout(() => { + void queryClient.invalidateQueries({ queryKey: ['history', sessionId, agentId] }); + setStream([]); + setRunning(false); + }, 500); + }; + + useLiveEvent((event) => { + if (event.source !== 'agent') return; + const type = eventType(event); + const data = event.data as Record; + switch (type) { + case 'turn.started': + setRunning(true); + return; + case 'assistant.delta': + case 'thinking.delta': { + const turnId = payloadField(data, 'turnId', '?'); + const kind = type === 'assistant.delta' ? 'assistant' : 'thinking'; + const id = `stream:${turnId}:${kind}`; + const delta = payloadField(data, 'delta', ''); + setStream((prev) => { + const found = prev.find((e) => e.id === id); + if (found === undefined) return [...prev, { id, kind, text: delta }]; + return prev.map((e) => (e.id === id ? { ...e, text: e.text + delta } : e)); + }); + return; + } + case 'tool.call.started': { + const callId = payloadField(data, 'toolCallId', String(Math.random())); + setStream((prev) => [ + ...prev, + { + id: `stream:tool:${callId}`, + kind: 'tool', + text: '', + name: payloadField(data, 'name', 'tool'), + args: typeof data['args'] === 'string' ? data['args'] : JSON.stringify(data['args'] ?? ''), + }, + ]); + return; + } + case 'tool.result': { + const callId = payloadField(data, 'toolCallId', ''); + const output = typeof data['output'] === 'string' ? data['output'] : JSON.stringify(data['output']); + const id = `stream:tool:${callId}`; + setStream((prev) => { + const found = prev.find((e) => e.id === id); + if (found === undefined) { + return [...prev, { id, kind: 'tool', text: '', name: 'result', output, isError: Boolean(data['isError']) }]; + } + return prev.map((e) => (e.id === id ? { ...e, output, isError: Boolean(data['isError']) } : e)); + }); + return; + } + case 'turn.ended': + case 'prompt.completed': + case 'prompt.aborted': + case 'compaction.completed': + scheduleRefetch(); + return; + case 'error': { + const message = + typeof data['message'] === 'string' + ? data['message'] + : JSON.stringify(data).slice(0, 300); + setStream((prev) => [ + ...prev, + { id: `stream:err:${Date.now()}`, kind: 'error', text: message }, + ]); + setRunning(false); + return; + } + } + }); + + const entries = useMemo( + () => [...mapHistory((history.data ?? []) as readonly HistoryMessage[]), ...stream], + [history.data, stream], + ); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ block: 'end' }); + }, [entries.length, stream]); + + const send = async () => { + if (sessionId === null || input.trim() === '' || running) return; + const text = input.trim(); + setInput(''); + setSendError(null); + setStream((prev) => [...prev, { id: `optimistic:${Date.now()}`, kind: 'user', text }]); + try { + await klient + .session(sessionId) + .agent(agentId) + .service(IAgentRPCService) + .prompt({ input: [{ type: 'text', text }] }); + } catch (error) { + setSendError(error); + } + }; + + const cancel = async () => { + if (sessionId === null) return; + try { + await klient.session(sessionId).agent(agentId).service(IAgentRPCService).cancel({}); + } catch (error) { + setSendError(error); + } + }; + + if (sessionId === null) { + return ( +
+ Select a session on the left to open its conversation. +
+ ); + } + if (!ready) { + return ( +
+ Loading session… +
+ ); + } + + return ( +
+
+ {sessionId} + agent: {agentId} + {running ? turn running : idle} +
+ +
+ {history.isError ? : null} + {entries.length === 0 && !history.isLoading ? ( +
Empty context — send a prompt below.
+ ) : null} + {entries.map((entry) => ( + + ))} +
+
+ +
+ {sendError !== null ?
: null} +
+