diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..def8a9213fee03c9621ea48cb255280464d23eb7 --- /dev/null +++ b/apps/kimi-inspect/src/App.tsx @@ -0,0 +1,166 @@ +/** + * App shell — selection state and session resume. There is no live event + * push anymore: the v2 socket (`/api/v2/ws`) that fed the core/session/agent + * event streams was removed server-side, so Service panels and the pending + * interactions card fetch on demand and the sidebar polls. + * Layout: header / icon rail / view. The `chat` view is a strip of the + * left sidebar (a workspace → session tree), the chat column, and the + * right dock (`RightPanel`) merging the transcript audit, the agent + * inspector, and the session pane under Audit / Agent / State / Session + * tabs; + * the `models` view is the full-width model catalog; the `services` view is + * the full-width app-scope Service reflection (`AppServicesView`); the + * `workspace` view is the workspace-scope counterpart + * (`WorkspaceServicesView`, with a workspace picker on top); the + * `bash` view is the full-width `IBashParserService` playground + * (`BashParserView`); the `di` view is the engine's Service × Effect × DI + * debug surface (`DiInspectionView`); the `search` view is the full-width + * global message search (`SearchView`) whose hits navigate back into the + * chat timeline. + */ + +import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager'; +import { useEffect, useState } from 'react'; + +import type { AuditTrail } from './audit/trail'; +import { AppServicesView } from './components/AppServicesView'; +import { BashParserView } from './components/BashParserView'; +import { ChatView, type ChatJump } from './components/ChatView'; +import { DiInspectionView } from './components/DiInspectionView'; +import { FsSuggestView } from './components/FsSuggestView'; +import { ModelCatalogView } from './components/ModelCatalogView'; +import { NavRail, type AppView } from './components/NavRail'; +import { RightPanel } from './components/RightPanel'; +import { SearchView } from './components/SearchView'; +import { ServerSwitcher } from './components/ServerSwitcher'; +import { Sidebar } from './components/Sidebar'; +import { WorkspaceServicesView } from './components/WorkspaceServicesView'; +import { useConnection } from './connection'; +import type { SearchHit } from './search/api'; +import { errorMessage } from './ui'; + +export function App() { + const { klient, baseUrl, disconnect } = useConnection(); + const [sessionId, setSessionId] = useState(null); + const [agentId, setAgentId] = useState('main'); + const [view, setView] = useState('chat'); + const [ready, setReady] = useState(false); + const [resumeError, setResumeError] = useState(null); + /** Audit trail of the chat view's transcript channel, rendered in the right dock. */ + const [trail, setTrail] = useState(null); + /** Pending chat navigation requested from another view (search result click). */ + const [jump, setJump] = useState(null); + + // Resume (materialize) the session on the server when it is selected, so + // session / agent scoped Services become reachable. Session lifecycle lives + // on the workspace handler (Workspace scope): the index yields the + // session's workspaceId, then the handler resumes it. + useEffect(() => { + if (sessionId === null) return; + let cancelled = false; + setReady(false); + setResumeError(null); + klient + .core(ISessionManager) + .resume(sessionId) + .then((session) => { + if (session === undefined) throw new Error(`session ${sessionId} does not exist`); + }) + .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. + useEffect(() => { + setSessionId(null); + setAgentId('main'); + setJump(null); + }, [baseUrl]); + + // A search hit opens the chat view at its session / agent / turn / step. + // Title hits belong to the session (agent '') and carry no turn: switch + // over without a scroll target. + const openSearchHit = (hit: SearchHit): void => { + setSessionId(hit.sessionId); + setAgentId(hit.agentId === '' ? 'main' : hit.agentId); + setView('chat'); + setJump({ + turnId: hit.turn === undefined ? undefined : `t${hit.turn}`, + stepId: hit.stepId, + nonce: Date.now(), + }); + }; + + return ( +
+
+ KIMI INSPECT + +
+ +
+
+ + {view === 'services' ? ( + + ) : view === 'workspace' ? ( + + ) : view === 'suggest' ? ( + + ) : view === 'bash' ? ( + + ) : view === 'di' ? ( + + ) : view === 'models' ? ( + { + setSessionId(id); + setView('chat'); + }} + /> + ) : view === 'search' ? ( + + ) : ( + <> + + {resumeError !== null ? ( +
+ Failed to open session: {errorMessage(resumeError)} +
+ ) : ( + setJump(null)} + onOpenSearchHit={openSearchHit} + /> + )} + + + )} +
+
+ ); +} diff --git a/apps/kimi-inspect/src/activity/di.ts b/apps/kimi-inspect/src/activity/di.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed5b7e3f007b6e18ca43c856dd8fda9edcec14b4 --- /dev/null +++ b/apps/kimi-inspect/src/activity/di.ts @@ -0,0 +1,53 @@ +/** + * DI debug feed — a dedicated global-events socket for the DI view. The + * session activity hub (`useSessionActivities`) lives with the chat Sidebar, + * which unmounts when the DI view is active, so the DI view owns its own + * `GlobalEventsWs` (only one of the two is ever connected at a time). + * + * Every `event.di.unit_changed` frame invalidates the `['di']` query prefix + * (the same pattern as `event.session.created` invalidating `['sessions']`), + * so all DI panels refetch on unit transitions instead of waiting out their + * poll interval. Bursts (a cascade flipping many units at once) are coalesced + * with a short trailing throttle; a reconnect invalidates immediately, since + * live transitions were missed while the socket was down. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +import { useConnection } from '../connection'; +import { GlobalEventsWs } from './ws'; + +const INVALIDATE_THROTTLE_MS = 250; + +export function useDiQueryInvalidation(): void { + const { baseUrl, config } = useConnection(); + const queryClient = useQueryClient(); + const token = config.token.trim(); + + useEffect(() => { + let timer: ReturnType | undefined; + const invalidate = () => { + if (timer !== undefined) return; + timer = setTimeout(() => { + timer = undefined; + void queryClient.invalidateQueries({ queryKey: ['di'] }); + }, INVALIDATE_THROTTLE_MS); + }; + const ws = new GlobalEventsWs({ + url: baseUrl, + token: token === '' ? undefined : token, + handlers: { + onWorkChanged: () => {}, + onSessionCreated: () => {}, + onMetaUpdated: () => {}, + onDiUnitChanged: invalidate, + onReconnected: invalidate, + }, + }); + return () => { + if (timer !== undefined) clearTimeout(timer); + ws.close(); + }; + }, [baseUrl, token, queryClient]); +} diff --git a/apps/kimi-inspect/src/activity/store.test.ts b/apps/kimi-inspect/src/activity/store.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e600fe8122fe4a32e5f5e81b526493c4a181000 --- /dev/null +++ b/apps/kimi-inspect/src/activity/store.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { WsLike, WsLikeCtor } from '../channel/wsLike'; +import { SessionActivityHub, SessionActivityStore, type SessionWorkFacts } from './store'; + +function facts(partial: Partial = {}): SessionWorkFacts { + return { + busy: false, + mainTurnActive: false, + pendingInteraction: 'none', + lastTurnReason: undefined, + ...partial, + }; +} + +class FakeWs implements WsLike { + static readonly OPEN = 1; + readyState = 1; + sent: string[] = []; + closed = false; + private readonly listeners = new Map void>>(); + + send(data: string): void { + this.sent.push(data); + } + close(): void { + this.closed = true; + } + addEventListener(type: string, listener: (event: never) => void): void { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + emit(type: 'open' | 'message' | 'close' | 'error', event?: unknown): void { + for (const listener of this.listeners.get(type) ?? []) listener(event as never); + } + emitFrame(frame: Record): void { + this.emit('message', { data: JSON.stringify(frame) }); + } +} + +function makeFakeWsCtor(): { ctor: WsLikeCtor; instances: FakeWs[] } { + const instances: FakeWs[] = []; + const ctor = class { + static readonly OPEN = 1; + constructor(_url: string, _protocols?: string | string[]) { + const ws = new FakeWs(); + instances.push(ws); + return ws; + } + } as unknown as WsLikeCtor; + return { ctor, instances }; +} + +function seedFetch(items: Record[]): typeof fetch { + return vi.fn(async () => ({ + json: async () => ({ code: 0, data: { items, has_more: false } }), + })) as unknown as typeof fetch; +} + +describe('SessionActivityStore', () => { + it('applies work facts and notifies with a version bump', () => { + const store = new SessionActivityStore(); + const listener = vi.fn(); + store.subscribe(listener); + + store.applyWorkChanged('s1', facts({ busy: true, mainTurnActive: true })); + + expect(store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true })); + expect(store.getVersion()).toBe(1); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('ignores identical facts (no bump, no notify)', () => { + const store = new SessionActivityStore(); + const listener = vi.fn(); + store.applyWorkChanged('s1', facts({ busy: true })); + store.subscribe(listener); + + store.applyWorkChanged('s1', facts({ busy: true })); + + expect(store.getVersion()).toBe(1); + expect(listener).not.toHaveBeenCalled(); + }); + + it('seed replaces the whole map', () => { + const store = new SessionActivityStore(); + store.applyWorkChanged('stale', facts({ busy: true })); + + store.seed([['s1', facts({ pendingInteraction: 'approval' })]]); + + expect(store.get('stale')).toBeUndefined(); + expect(store.get('s1')?.pendingInteraction).toBe('approval'); + }); +}); + +describe('SessionActivityHub', () => { + it('seeds the store from the REST session list when the socket opens', async () => { + const { ctor, instances } = makeFakeWsCtor(); + const hub = new SessionActivityHub({ + url: 'http://127.0.0.1:58627', + onListChanged: () => {}, + WebSocketImpl: ctor, + fetchImpl: seedFetch([ + { id: 's1', busy: true, main_turn_active: true, pending_interaction: 'none' }, + { id: 's2', busy: false, main_turn_active: false, pending_interaction: 'approval' }, + ]), + }); + + instances[0]!.emit('open'); + await vi.waitFor(() => expect(hub.store.get('s1')).toBeDefined()); + + expect(hub.store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true })); + expect(hub.store.get('s2')?.pendingInteraction).toBe('approval'); + // The hello goes out with no subscriptions — global facts flow regardless. + const hello = JSON.parse(instances[0]!.sent[0]!) as { + type: string; + payload: { subscriptions: string[] }; + }; + expect(hello.type).toBe('client_hello'); + expect(hello.payload.subscriptions).toEqual([]); + hub.close(); + }); + + it('applies live work_changed frames by session id', () => { + const { ctor, instances } = makeFakeWsCtor(); + const hub = new SessionActivityHub({ + url: 'http://127.0.0.1:58627', + onListChanged: () => {}, + WebSocketImpl: ctor, + fetchImpl: seedFetch([]), + }); + instances[0]!.emit('open'); + + instances[0]!.emitFrame({ + type: 'event.session.work_changed', + session_id: 's1', + payload: { + type: 'event.session.work_changed', + busy: true, + main_turn_active: true, + pending_interaction: 'question', + last_turn_reason: null, + }, + }); + + expect(hub.store.get('s1')).toEqual( + facts({ busy: true, mainTurnActive: true, pendingInteraction: 'question' }), + ); + hub.close(); + }); + + it('forwards created and meta updates as list-level signals', () => { + const { ctor, instances } = makeFakeWsCtor(); + const onListChanged = vi.fn(); + const hub = new SessionActivityHub({ + url: 'http://127.0.0.1:58627', + onListChanged, + WebSocketImpl: ctor, + fetchImpl: seedFetch([]), + }); + instances[0]!.emit('open'); + + instances[0]!.emitFrame({ type: 'event.session.created', session_id: 's1', payload: {} }); + instances[0]!.emitFrame({ type: 'session.meta.updated', session_id: 's1', payload: {} }); + // Agent-grained frames are ignored even if they somehow arrive. + instances[0]!.emitFrame({ type: 'turn.started', session_id: 's1', payload: {} }); + + expect(onListChanged).toHaveBeenCalledTimes(2); + expect(hub.store.get('s1')).toBeUndefined(); + hub.close(); + }); + + it('forwards archived and workspace frames as list-level signals and drops archived facts', () => { + const { ctor, instances } = makeFakeWsCtor(); + const onListChanged = vi.fn(); + const hub = new SessionActivityHub({ + url: 'http://127.0.0.1:58627', + onListChanged, + WebSocketImpl: ctor, + fetchImpl: seedFetch([]), + }); + instances[0]!.emit('open'); + + instances[0]!.emitFrame({ + type: 'event.session.work_changed', + session_id: 's1', + payload: { type: 'event.session.work_changed', busy: true }, + }); + expect(hub.store.get('s1')).toBeDefined(); + + // Global-dispatched frames carry the __global__ watermark; the real + // session id rides in the payload. + instances[0]!.emitFrame({ + type: 'event.session.archived', + session_id: '__global__', + payload: { type: 'event.session.archived', sessionId: 's1', workspace_id: 'wd_1' }, + }); + expect(hub.store.get('s1')).toBeUndefined(); + expect(onListChanged).toHaveBeenCalledTimes(1); + + instances[0]!.emitFrame({ + type: 'event.session.work_changed', + session_id: 's2', + payload: { type: 'event.session.work_changed', busy: true }, + }); + expect(hub.store.get('s2')).toBeDefined(); + + instances[0]!.emitFrame({ + type: 'event.session.deleted', + session_id: '__global__', + payload: { type: 'event.session.deleted', sessionId: 's2', workspace_id: 'wd_1' }, + }); + expect(hub.store.get('s2')).toBeUndefined(); + expect(onListChanged).toHaveBeenCalledTimes(2); + + for (const type of [ + 'event.workspace.created', + 'event.workspace.updated', + 'event.workspace.deleted', + ]) { + instances[0]!.emitFrame({ type, session_id: '__global__', payload: {} }); + } + expect(onListChanged).toHaveBeenCalledTimes(5); + hub.close(); + }); +}); diff --git a/apps/kimi-inspect/src/activity/store.ts b/apps/kimi-inspect/src/activity/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..af77ad6c0626cb0a65ee04e7933e0edbd510cd7a --- /dev/null +++ b/apps/kimi-inspect/src/activity/store.ts @@ -0,0 +1,160 @@ +/** + * Session activity hub — owns the global-events socket and the per-session + * coarse activity map behind the Sidebar's status badges. + * + * Two data sources converge into one store: the initial / reconnect + * baseline comes from a single `GET /api/v1/sessions` page (every wire + * session carries `busy` / `main_turn_active` / `pending_interaction` / + * `last_turn_reason`), and live updates arrive as + * `event.session.work_changed` frames over the global WS channel (no + * subscription needed server-side). List-level facts (session created / + * retitled) are forwarded to the consumer as `onListChanged` so the + * react-query session list invalidates instead of waiting out its slow poll. + * The store is a plain subscribe/version store so React binds through + * `useSyncExternalStore`. + */ + +import type { WsLikeCtor } from '../channel/wsLike'; +import { GlobalEventsWs, type SessionWorkFacts } from './ws'; + +export type { SessionWorkFacts }; + +export class SessionActivityStore { + private activities = new Map(); + private readonly listeners = new Set<() => void>(); + private version = 0; + + get(sessionId: string): SessionWorkFacts | undefined { + return this.activities.get(sessionId); + } + + getVersion(): number { + return this.version; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + applyWorkChanged(sessionId: string, facts: SessionWorkFacts): void { + const previous = this.activities.get(sessionId); + if ( + previous !== undefined && + previous.busy === facts.busy && + previous.mainTurnActive === facts.mainTurnActive && + previous.pendingInteraction === facts.pendingInteraction && + previous.lastTurnReason === facts.lastTurnReason + ) { + return; + } + this.activities.set(sessionId, facts); + this.bump(); + } + + /** Replace the whole map with a REST baseline (initial load / re-seed). */ + seed(entries: Iterable): void { + this.activities = new Map(entries); + this.bump(); + } + + /** Drop one session's live facts (e.g. it was archived — no further + * work_changed frames will arrive to correct a stale badge). */ + remove(sessionId: string): void { + if (this.activities.delete(sessionId)) this.bump(); + } + + private bump(): void { + this.version += 1; + for (const listener of this.listeners) listener(); + } +} + +export interface SessionActivityHubOptions { + /** Server base URL (`http(s)://host:port`). */ + readonly url: string; + readonly token?: string | undefined; + /** List-level signal (session created / retitled) — invalidate the list. */ + readonly onListChanged: () => void; + readonly WebSocketImpl?: WsLikeCtor; + readonly fetchImpl?: typeof fetch; +} + +export class SessionActivityHub { + readonly store = new SessionActivityStore(); + private readonly ws: GlobalEventsWs; + private readonly baseUrl: string; + private readonly token?: string; + private readonly fetchImpl: typeof fetch; + + constructor(opts: SessionActivityHubOptions) { + this.baseUrl = opts.url.replace(/\/$/, ''); + this.token = opts.token; + // Bind the default: `this.fetchImpl(...)` is a member call, and the + // browser's `fetch` throws Illegal invocation when its receiver is not + // the global object (Node's undici fetch does not care). + this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis); + this.ws = new GlobalEventsWs({ + url: opts.url, + token: opts.token, + WebSocketImpl: opts.WebSocketImpl, + handlers: { + onWorkChanged: (sessionId, facts) => this.store.applyWorkChanged(sessionId, facts), + onSessionCreated: () => opts.onListChanged(), + onMetaUpdated: () => opts.onListChanged(), + onSessionArchived: (sessionId) => { + this.store.remove(sessionId); + opts.onListChanged(); + }, + onSessionDeleted: (sessionId) => { + this.store.remove(sessionId); + opts.onListChanged(); + }, + onWorkspaceChanged: () => opts.onListChanged(), + onReconnected: () => void this.seed(), + }, + }); + } + + close(): void { + this.ws.close(); + } + + private async seed(): Promise { + const headers: Record = {}; + if (this.token !== undefined && this.token.length > 0) { + headers['authorization'] = `Bearer ${this.token}`; + } + try { + const res = await this.fetchImpl(`${this.baseUrl}/api/v1/sessions`, { headers }); + const envelope = (await res.json()) as { + code: number; + data?: { items?: Record[] }; + }; + if (envelope.code !== 0 || envelope.data?.items === undefined) return; + const entries: [string, SessionWorkFacts][] = []; + for (const item of envelope.data.items) { + const id = item['id']; + if (typeof id !== 'string' || typeof item['busy'] !== 'boolean') continue; + const pending = item['pending_interaction']; + const reason = item['last_turn_reason']; + entries.push([ + id, + { + busy: item['busy'], + mainTurnActive: item['main_turn_active'] === true, + pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none', + lastTurnReason: + reason === 'completed' || reason === 'cancelled' || reason === 'failed' + ? reason + : undefined, + }, + ]); + } + this.store.seed(entries); + } catch { + // Seed is best-effort: live frames keep flowing, and the next reconnect + // re-seeds. A dead server surfaces through the connection layer anyway. + } + } +} diff --git a/apps/kimi-inspect/src/activity/useSessionActivity.ts b/apps/kimi-inspect/src/activity/useSessionActivity.ts new file mode 100644 index 0000000000000000000000000000000000000000..651cb6db8422769a5087c6857c8d69653ce464a6 --- /dev/null +++ b/apps/kimi-inspect/src/activity/useSessionActivity.ts @@ -0,0 +1,52 @@ +/** + * React binding for the session activity hub: one hub per (server, token), + * torn down on server switch or unmount. Consumers read per-session coarse + * activity (`get(sessionId)`) and re-render on every store bump; list-level + * signals invalidate the `['sessions']` / `['v2-sessions']` react-query lists + * directly. + * + * The hub is created inside `useEffect` (not `useMemo`): under StrictMode + * the mount → cleanup → re-mount cycle runs the cleanup of the FIRST mount, + * and a memo-created hub would stay closed for the rest of the page's life. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState, useSyncExternalStore } from 'react'; + +import { useConnection } from '../connection'; +import { SessionActivityHub, SessionActivityStore, type SessionWorkFacts } from './store'; + +const EMPTY_STORE = new SessionActivityStore(); + +export function useSessionActivities(): { + get(sessionId: string): SessionWorkFacts | undefined; +} { + const { baseUrl, config } = useConnection(); + const queryClient = useQueryClient(); + const token = config.token.trim(); + const [hub, setHub] = useState(null); + + useEffect(() => { + const created = new SessionActivityHub({ + url: baseUrl, + token: token === '' ? undefined : token, + onListChanged: () => { + void queryClient.invalidateQueries({ queryKey: ['sessions'] }); + void queryClient.invalidateQueries({ queryKey: ['v2-sessions'] }); + void queryClient.invalidateQueries({ queryKey: ['workspaces'] }); + }, + }); + setHub(created); + return () => { + setHub(null); + created.close(); + }; + }, [baseUrl, token, queryClient]); + + const store = hub?.store ?? EMPTY_STORE; + useSyncExternalStore( + (listener) => store.subscribe(listener), + () => store.getVersion(), + ); + return store; +} diff --git a/apps/kimi-inspect/src/activity/ws.ts b/apps/kimi-inspect/src/activity/ws.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a1887f05c488a037f3f6563b6b645878e466f5c --- /dev/null +++ b/apps/kimi-inspect/src/activity/ws.ts @@ -0,0 +1,308 @@ +/** + * Minimal `/api/v1/ws` client for GLOBAL session facts — no subscriptions. + * + * The server pushes every global event (`event.session.*` / + * `session.meta.updated` / `event.workspace.*` / `event.config.*`) to every + * established connection, so this client subscribes to nothing: it sends a + * `client_hello` with an empty subscription list (etiquette only — the + * delivery set does not depend on it) and dispatches the coarse per-session + * facts to the consumer: + * + * - `event.session.work_changed` → `{busy, main_turn_active, + * pending_interaction, last_turn_reason}` for one session; + * - `event.session.created` / `session.meta.updated` → list-level signals + * (a session appeared / retitled), forwarded for list invalidation; + * - `event.session.archived` (live or cold) / `event.workspace.*` → + * list-level signals, forwarded for list invalidation; + * - `event.di.unit_changed` → one DI unit state transition of the engine's + * scope tree (the debug-surface feed), forwarded for `['di']` + * invalidation. Global like the rest: it carries the `__global__` + * session watermark and fans out to every connection. + * + * Session/agent-grained events never arrive here (they stay subscribe-gated + * server-side); the transcript chat channel has its own socket. Global + * frames are live-only — a drop loses whatever fired meanwhile, so the + * consumer answers `onReconnected` with a REST re-seed. + * + * The bearer token is presented at the upgrade through the + * `kimi-code.bearer.` subprotocol (the only credential channel a + * browser WebSocket has). + */ + +import type { WsLike, WsLikeCtor } from '../channel/wsLike'; + +export type SessionPendingInteraction = 'none' | 'approval' | 'question'; +export type SessionTurnOutcome = 'completed' | 'cancelled' | 'failed'; + +export interface SessionWorkFacts { + readonly busy: boolean; + readonly mainTurnActive: boolean; + readonly pendingInteraction: SessionPendingInteraction; + readonly lastTurnReason?: SessionTurnOutcome | undefined; +} + +export type DiUnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; + +/** Wire payload of the `event.di.unit_changed` global event. */ +export interface DiUnitChangedPayload { + /** Scope path of the container owning the unit (`app` / `app/workspace:` / …). */ + readonly scope: string; + readonly token: string; + readonly state: DiUnitState; + /** Serialized sticky failure, present only on a Failed transition. */ + readonly error?: string | undefined; +} + +export interface GlobalEventsWsHandlers { + /** Coarse work-fact tuple for one session changed. */ + onWorkChanged: (sessionId: string, facts: SessionWorkFacts) => void; + /** A session was created (list-level signal). */ + onSessionCreated: (sessionId: string) => void; + /** A session's title/patch changed (list-level signal). */ + onMetaUpdated: (sessionId: string) => void; + /** A session was archived, live or cold (list-level signal). The envelope + * carries the `__global__` watermark; the real session id rides in the + * payload. */ + onSessionArchived?: ((sessionId: string) => void) | undefined; + /** A session was permanently deleted (list-level signal). Same envelope + * shape as `event.session.archived`: the real session id rides in the + * payload. */ + onSessionDeleted?: (sessionId: string) => void; + /** A workspace was created / updated / deleted (list-level signal). */ + onWorkspaceChanged?: (() => void) | undefined; + /** A DI unit of the engine's scope tree changed state (debug feed). */ + onDiUnitChanged?: ((payload: DiUnitChangedPayload) => void) | undefined; + /** Socket established (initial connect and every reconnect) — the consumer + * answers with a REST re-seed, since live facts are missed while down. */ + onReconnected: () => void; +} + +export interface GlobalEventsWsOptions { + /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v1/ws` URL. */ + readonly url: string; + readonly token?: string | undefined; + readonly handlers: GlobalEventsWsHandlers; + /** WebSocket implementation; defaults to the global `WebSocket`. */ + readonly WebSocketImpl?: WsLikeCtor; + /** Base delay (ms) for the reconnect backoff. Default `500`. */ + readonly reconnectDelayMs?: number; +} + +interface ServerFrame { + readonly type: string; + readonly id?: string; + readonly session_id?: string; + readonly payload?: unknown; +} + +const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; + +export class GlobalEventsWs { + private readonly wsUrl: string; + private readonly token?: string; + private readonly handlers: GlobalEventsWsHandlers; + private readonly WsCtor: WsLikeCtor; + private readonly reconnectDelayMs: number; + + private ws: WsLike | undefined; + private manualClose = false; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | undefined; + + constructor(opts: GlobalEventsWsOptions) { + this.wsUrl = toWsUrl(opts.url); + this.token = opts.token; + this.handlers = opts.handlers; + 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.reconnectDelayMs = opts.reconnectDelayMs ?? 500; + this.connect(); + } + + /** Tear the socket down permanently. */ + close(): void { + this.manualClose = true; + if (this.reconnectTimer !== undefined) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + const ws = this.ws; + this.ws = undefined; + ws?.close(); + } + + private connect(): void { + 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 { + this.scheduleReconnect(); + return; + } + this.ws = ws; + ws.addEventListener('open', () => { + this.reconnectAttempt = 0; + this.send({ + type: 'client_hello', + id: `kimi-inspect-global-${Date.now().toString(36)}`, + payload: { client_id: 'kimi-inspect', subscriptions: [] }, + }); + // Established (first connect and every reconnect alike): live facts may + // have been missed — the consumer re-seeds from REST. + this.handlers.onReconnected(); + }); + ws.addEventListener('message', (event: { data: unknown }) => { + this.onMessage(event.data); + }); + ws.addEventListener('close', () => { + // Stale socket (a manual close already cleared `this.ws`). + if (this.ws !== ws) return; + this.ws = undefined; + if (!this.manualClose) this.scheduleReconnect(); + }); + ws.addEventListener('error', () => { + // The 'close' event always follows 'error'; reconnect logic lives there. + }); + } + + private onMessage(raw: unknown): void { + let frame: ServerFrame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame; + } catch { + return; + } + const sessionId = frame.session_id; + if (typeof sessionId !== 'string' || sessionId === '') return; + switch (frame.type) { + case 'event.session.work_changed': { + const facts = parseWorkFacts(frame.payload); + if (facts !== undefined) this.handlers.onWorkChanged(sessionId, facts); + return; + } + case 'event.session.created': { + this.handlers.onSessionCreated(sessionId); + return; + } + case 'event.session.archived': { + const payload = frame.payload as { sessionId?: unknown } | undefined; + const archivedId = payload?.sessionId; + if (typeof archivedId === 'string' && archivedId !== '') { + this.handlers.onSessionArchived?.(archivedId); + } + return; + } + case 'event.session.deleted': { + const payload = frame.payload as { sessionId?: unknown } | undefined; + const deletedId = payload?.sessionId; + if (typeof deletedId === 'string' && deletedId !== '') { + this.handlers.onSessionDeleted?.(deletedId); + } + return; + } + case 'event.workspace.created': + case 'event.workspace.updated': + case 'event.workspace.deleted': { + this.handlers.onWorkspaceChanged?.(); + return; + } + case 'session.meta.updated': { + this.handlers.onMetaUpdated(sessionId); + return; + } + case 'event.di.unit_changed': { + const payload = parseDiUnitChangedPayload(frame.payload); + if (payload !== undefined) this.handlers.onDiUnitChanged?.(payload); + return; + } + case 'ping': { + const nonce = (frame.payload as { nonce?: unknown } | undefined)?.nonce; + this.send({ type: 'pong', payload: { nonce } }); + return; + } + default: + return; + } + } + + private scheduleReconnect(): void { + if (this.manualClose) return; + this.reconnectAttempt += 1; + const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.connect(); + }, delay); + this.reconnectTimer.unref?.(); + } + + 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 + } + } +} + +function parseWorkFacts(payload: unknown): SessionWorkFacts | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const p = payload as Record; + if (typeof p['busy'] !== 'boolean') return undefined; + const pending = p['pending_interaction']; + const reason = p['last_turn_reason']; + return { + busy: p['busy'], + mainTurnActive: p['main_turn_active'] === true, + pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none', + lastTurnReason: + reason === 'completed' || reason === 'cancelled' || reason === 'failed' ? reason : undefined, + }; +} + +const DI_UNIT_STATES: ReadonlySet = new Set([ + 'Pending', + 'Activating', + 'Active', + 'Unloading', + 'Failed', +]); + +function parseDiUnitChangedPayload(payload: unknown): DiUnitChangedPayload | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const p = payload as Record; + if (typeof p['scope'] !== 'string' || typeof p['token'] !== 'string') return undefined; + const state = p['state']; + if (typeof state !== 'string' || !DI_UNIT_STATES.has(state)) return undefined; + return { + scope: p['scope'], + token: p['token'], + state: state as DiUnitState, + error: typeof p['error'] === 'string' ? p['error'] : undefined, + }; +} + +/** Derive the `/api/v1/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/v1/ws')) { + url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/ws`; + } + url.search = ''; + url.hash = ''; + return url.toString(); +} diff --git a/apps/kimi-inspect/src/audit/audit.test.ts b/apps/kimi-inspect/src/audit/audit.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..45c586c95c524b47018eb048738175d919322583 --- /dev/null +++ b/apps/kimi-inspect/src/audit/audit.test.ts @@ -0,0 +1,266 @@ +/** + * Audit-layer tests: the trail recorder, the structural diff, serialization, + * and tail-preserving truncation used by the chat view's audit panel. + */ + +import type { StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol'; +import { describe, expect, it } from 'vitest'; + +import { EMPTY_CHAT_STATE, type ChatState } from '../transcript/store'; +import { diffValue, type DiffNode } from './diff'; +import { serializeState } from './serialize'; +import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail'; +import { tailTrunc } from './truncate'; + +const T0 = Date.parse('2026-01-01T00:00:00.000Z'); +let tick = 0; + +function ts(): number { + tick += 1; + return T0 + tick * 1000; +} + +function turnMsg(n: number, status: 'running' | 'completed' = 'completed'): TurnMessage { + return { + type: 'turn', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + turn_id: `t${n}`, + ordinal: n, + status, + origin: { kind: 'user' }, + }; +} + +function stepMsg(stepId: string, status: 'running' | 'completed'): StepMessage { + return { + type: 'step', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + step_id: stepId, + turn_id: stepId.split('.')[0] ?? 't1', + ordinal: Number(stepId.split('.')[1] ?? '1'), + status, + }; +} + +function stateWithTimeline(items: readonly (TurnMessage | StepMessage)[]): ChatState { + return { + ...EMPTY_CHAT_STATE, + entries: items.map((message) => ({ + key: message.type === 'turn' ? `turn:${message.turn_id}` : `step:${message.step_id}`, + message, + })), + }; +} + +// ---------------------------------------------------------------- diff + +describe('diffValue', () => { + it('collapses reference-equal subtrees to unchanged without children', () => { + const shared = { a: 1, b: { c: 'x' } }; + const node = diffValue({ v: shared }, { v: shared }); + expect(node.status).toBe('unchanged'); + expect(node.children?.get('v')?.children).toBeUndefined(); + }); + + it('marks added, removed, and modified object keys', () => { + const node = diffValue( + { keep: 1, gone: 'x', changed: 'a' }, + { keep: 1, fresh: true, changed: 'b' }, + ); + expect(node.status).toBe('modified'); + expect(node.children?.get('keep')?.status).toBe('unchanged'); + expect(node.children?.get('fresh')?.status).toBe('added'); + expect(node.children?.get('gone')).toMatchObject({ status: 'removed', prev: 'x' }); + expect(node.children?.get('changed')).toMatchObject({ + status: 'modified', + prev: 'a', + value: 'b', + }); + }); + + it('matches entity arrays by id instead of index', () => { + const t1 = turnMsg(1); + const t2 = turnMsg(2); + const prev = [t1, t2]; + const next = [t1, { ...t2, status: 'running' as const }, turnMsg(3)]; + const node = diffValue(prev, next); + expect(node.children?.get('t1')?.status).toBe('unchanged'); + expect(node.children?.get('t2')?.status).toBe('modified'); + expect(node.children?.get('t2')?.children?.get('status')).toMatchObject({ + status: 'modified', + prev: 'completed', + value: 'running', + }); + expect(node.children?.get('t3')?.status).toBe('added'); + }); + + it('keys steps by step_id (not their shared turn_id) so siblings never collide', () => { + const done = stepMsg('t1.1', 'completed'); + const node = diffValue( + [done, stepMsg('t1.2', 'completed')], + [done, stepMsg('t1.2', 'running')], + ); + expect([...(node.children?.keys() ?? [])]).toEqual(['t1.1', 't1.2']); + expect(node.children?.get('t1.1')?.status).toBe('unchanged'); + expect(node.children?.get('t1.2')?.status).toBe('modified'); + }); + + it('marks removed array elements by id', () => { + const t2 = turnMsg(2); + const node = diffValue([turnMsg(1), t2], [t2]); + expect(node.children?.get('t1')).toMatchObject({ status: 'removed' }); + expect(node.children?.get('t2')?.status).toBe('unchanged'); + }); + + it('marks whole-subtree adds/removes without descending', () => { + const added = diffValue(undefined, { nested: { deep: 1 } }); + expect(added.status).toBe('added'); + expect(added.children).toBeUndefined(); + const removed = diffValue({ nested: 1 }, undefined); + expect(removed.status).toBe('removed'); + expect(removed.children).toBeUndefined(); + }); + + it('treats type changes as leaf modifications', () => { + expect(diffValue('1', 1).status).toBe('modified'); + expect(diffValue(null, {}).status).toBe('modified'); + expect(diffValue([1], { 0: 1 }).status).toBe('modified'); + }); + + it('diffs two serialized states with session.state changes visible', () => { + const base = stateWithTimeline([turnMsg(1)]); + const prev = serializeState(base); + const nextState: ChatState = { + ...base, + sessionState: { + type: 'session.state', + session_id: 's1', + timestamp: ts(), + status: 'running', + goal: { objective: 'ship it', status: 'active' }, + modes: { plan: { review_path: '/tmp/plan.md' } }, + }, + }; + const node: DiffNode = diffValue(prev, serializeState(nextState)); + expect(node.children?.get('timeline')?.status).toBe('unchanged'); + const sessionState = node.children?.get('sessionState'); + expect(sessionState?.status).toBe('added'); + expect(sessionState?.children).toBeUndefined(); + }); +}); + +// ---------------------------------------------------------------- serialize + +describe('serializeState', () => { + it('turns maps into sorted plain objects and flattens the timeline', () => { + const state: ChatState = { + ...EMPTY_CHAT_STATE, + entries: stateWithTimeline([turnMsg(1)]).entries, + tasks: new Map([ + [ + 'b-task', + { + type: 'task', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + task_id: 'b-task', + kind: 'shell', + status: 'running', + detached: false, + output_tail: '', + }, + ], + [ + 'a-task', + { + type: 'task', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + task_id: 'a-task', + kind: 'tool', + status: 'completed', + detached: false, + output_tail: '', + }, + ], + ]), + }; + const out = serializeState(state); + expect(Object.keys(out.tasks)).toEqual(['a-task', 'b-task']); + expect(out.timeline.map((m) => (m.type === 'turn' ? m.turn_id : ''))).toEqual(['t1']); + expect(out.hasMoreOlder).toBe(false); + }); +}); + +// ---------------------------------------------------------------- truncate + +describe('tailTrunc', () => { + it('returns short strings unchanged', () => { + expect(tailTrunc('hello')).toBe('hello'); + expect(tailTrunc('x'.repeat(500))).toBe('x'.repeat(500)); + }); + + it('keeps the tail of long strings and reports the total length', () => { + const value = 'head-padding'.repeat(100) + 'THE-TAIL'; + const out = tailTrunc(value, 50); + expect(out).toContain(`${value.length} chars total`); + expect(out.endsWith('THE-TAIL')).toBe(true); + expect(out).not.toContain('head-padding'.repeat(10)); + }); +}); + +// ---------------------------------------------------------------- trail + +describe('AuditTrail', () => { + it('records entries with increasing indices, timestamps, and state references', () => { + const trail = new AuditTrail(); + const s1 = stateWithTimeline([turnMsg(1)]); + const s2 = stateWithTimeline([turnMsg(1), turnMsg(2)]); + trail.recordRest({ pageSize: 500 }, 'replace', 1, { turn_id: 't1', step_id: 't1.1' }, s1); + trail.recordWs(turnMsg(2, 'running'), s2); + trail.recordEvent('prompt', 'hello', s2); + + const entries = trail.getEntries(); + expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ws', 'event']); + expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2]); + expect(entries[0]!.state).toBe(s1); + expect(entries[1]!.state).toBe(s2); + expect(entries[0]).toMatchObject({ mode: 'replace', messageCount: 1 }); + expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' }); + expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe( + true, + ); + expect(entries.every((entry) => entry.summary.length > 0)).toBe(true); + }); + + it('notifies subscribers on each record', () => { + const trail = new AuditTrail(); + let notified = 0; + const unsubscribe = trail.subscribe(() => { + notified += 1; + }); + trail.recordEvent('cancel', undefined, EMPTY_CHAT_STATE); + trail.recordEvent('ack', undefined, EMPTY_CHAT_STATE); + expect(notified).toBe(2); + unsubscribe(); + trail.recordEvent('reconnect', undefined, EMPTY_CHAT_STATE); + expect(notified).toBe(2); + }); + + it('drops the oldest entries beyond the cap while indices keep increasing', () => { + const trail = new AuditTrail(); + for (let i = 0; i < AUDIT_TRAIL_MAX_ENTRIES + 10; i += 1) { + trail.recordEvent('prompt', `p${i}`, EMPTY_CHAT_STATE); + } + const entries = trail.getEntries(); + expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES); + expect(entries[0]!.index).toBe(10); + expect(entries.at(-1)!.index).toBe(AUDIT_TRAIL_MAX_ENTRIES + 9); + }); +}); diff --git a/apps/kimi-inspect/src/audit/diff.ts b/apps/kimi-inspect/src/audit/diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..13c1af72e1b41cac2e72048c061baea5522c9527 --- /dev/null +++ b/apps/kimi-inspect/src/audit/diff.ts @@ -0,0 +1,122 @@ +/** + * Structural diff over serialized `ChatState` values (see `serialize.ts`). + * + * The audit panel diffs two adjacent, immutable states. Because the store + * is copy-on-write, untouched subtrees share references — the reference + * equality fast path below collapses them to `unchanged` without walking. + * + * Arrays of protocol entities are matched by their id field (turn_id, + * step_id, message_id, tool_call_id, …) rather than by index, so an upsert + * in the middle of the timeline does not turn into a cascade of spurious + * modifications. + */ + +export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified'; + +export interface DiffNode { + readonly status: DiffStatus; + /** Current value (`undefined` when removed). */ + readonly value: unknown; + /** Previous value (`undefined` when added). */ + readonly prev: unknown; + /** + * Object/array children — key is the object key, the entity id, or + * `#` for plain arrays. Absent on leaves and on whole-subtree + * added/removed nodes (the renderer colors the subtree as one block). + */ + readonly children?: ReadonlyMap; +} + +/** + * Id fields checked in priority order — MOST SPECIFIC FIRST. An interaction + * carries both `interaction_id` and `tool_call_id`, a tool call can carry + * `task_id` / `todo_id` alongside its `tool_call_id`, and every timeline + * entity carries `turn_id`; matching the wrong one mislabels the node and, + * worse, collides siblings in the children map (two tool calls of one task + * both keyed by that task id). + */ +const ID_FIELDS = [ + 'message_id', + 'interaction_id', + 'tool_call_id', + 'task_id', + 'todo_id', + 'system_id', + 'step_id', + 'turn_id', +] as const; + +function elementId(element: unknown): string | undefined { + if (typeof element !== 'object' || element === null) return undefined; + for (const field of ID_FIELDS) { + const value = (element as Record)[field]; + if (typeof value === 'string') return value; + } + return undefined; +} + +/** Public for the audit UI: same id-priority keying used to match array elements. */ +export { elementId }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function containerStatus(children: ReadonlyMap): DiffStatus { + for (const child of children.values()) { + if (child.status !== 'unchanged') return 'modified'; + } + return 'unchanged'; +} + +function diffObjects(prev: Record, next: Record): DiffNode { + const children = new Map(); + for (const key of Object.keys(next)) { + children.set(key, diffValue(prev[key], next[key])); + } + for (const key of Object.keys(prev)) { + if (!(key in next)) { + children.set(key, { status: 'removed', value: undefined, prev: prev[key] }); + } + } + return { status: containerStatus(children), value: next, prev, children }; +} + +function diffArrays(prev: readonly unknown[], next: readonly unknown[]): DiffNode { + const children = new Map(); + const keyed = + prev.every((el) => elementId(el) !== undefined) && + next.every((el) => elementId(el) !== undefined); + + if (keyed) { + const prevById = new Map(); + for (const el of prev) prevById.set(elementId(el) as string, el); + const nextIds = new Set(); + for (const el of next) { + const id = elementId(el) as string; + nextIds.add(id); + children.set(id, diffValue(prevById.get(id), el)); + } + for (const el of prev) { + const id = elementId(el) as string; + if (!nextIds.has(id)) children.set(id, { status: 'removed', value: undefined, prev: el }); + } + } else { + for (let i = 0; i < next.length; i += 1) { + children.set(`#${i}`, diffValue(prev[i], next[i])); + } + for (let i = next.length; i < prev.length; i += 1) { + children.set(`#${i}`, { status: 'removed', value: undefined, prev: prev[i] }); + } + } + return { status: containerStatus(children), value: next, prev, children }; +} + +export function diffValue(prev: unknown, next: unknown): DiffNode { + if (prev === next) return { status: 'unchanged', value: next, prev }; + if (prev === undefined) return { status: 'added', value: next, prev: undefined }; + if (next === undefined) return { status: 'removed', value: undefined, prev }; + if (isPlainObject(prev) && isPlainObject(next)) return diffObjects(prev, next); + if (Array.isArray(prev) && Array.isArray(next)) return diffArrays(prev, next); + return { status: 'modified', value: next, prev }; +} diff --git a/apps/kimi-inspect/src/audit/serialize.ts b/apps/kimi-inspect/src/audit/serialize.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b2768b52e65ebd45d86b83ff2aa99e4edf780d5 --- /dev/null +++ b/apps/kimi-inspect/src/audit/serialize.ts @@ -0,0 +1,43 @@ +/** + * Serialize a `ChatState` into a plain, JSON-shaped object for the audit + * panel's state tree and structural diff. Maps become key-sorted plain + * objects (stable display order); everything else is passed through by + * reference (state is immutable, so sharing is safe and keeps the + * reference-equality fast path in `diffValue` useful). + */ + +import type { + InteractionMessage, + SessionStateMessage, + TaskMessage, + TodoMessage, +} from '@moonshot-ai/kap-server/protocol'; + +import type { ChatState, TimelineMessage } from '../transcript/store'; + +/** Plain-object view of a `ChatState` (Maps unwrapped). */ +export interface SerializedChatState { + readonly timeline: readonly TimelineMessage[]; + readonly interactions: Record; + readonly tasks: Record; + readonly todos: Record; + readonly sessionState: SessionStateMessage | undefined; + readonly hasMoreOlder: boolean; +} + +function mapToSortedObject(map: ReadonlyMap): Record { + const out: Record = {}; + for (const key of [...map.keys()].toSorted()) out[key] = map.get(key) as V; + return out; +} + +export function serializeState(state: ChatState): SerializedChatState { + return { + timeline: state.entries.map((entry) => entry.message), + interactions: mapToSortedObject(state.interactions), + tasks: mapToSortedObject(state.tasks), + todos: mapToSortedObject(state.todos), + sessionState: state.sessionState, + hasMoreOlder: state.hasMoreOlder, + }; +} diff --git a/apps/kimi-inspect/src/audit/trail.ts b/apps/kimi-inspect/src/audit/trail.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d69159bb7bdd7829be1f5603cff26be77b3ce24 --- /dev/null +++ b/apps/kimi-inspect/src/audit/trail.ts @@ -0,0 +1,198 @@ +/** + * Audit trail for the chat view's message-protocol channel. + * + * A pure observer: the chat pipeline (REST history loads, WS messages, user + * actions) calls the `record*` methods AFTER applying each step to the real + * `ChatStore`, passing the resulting immutable `ChatState` reference. + * Replaying the trail is therefore free — every entry already holds the + * exact state the store had at that point, ready for the timeline slider + * and the structural diff. + */ + +import type { ServerMessage } from '@moonshot-ai/kap-server/protocol'; + +import type { ChatState } from '../transcript/store'; + +export const AUDIT_TRAIL_MAX_ENTRIES = 5000; + +interface AuditEntryBase { + /** Position in the trail (stable even when old entries are dropped). */ + readonly index: number; + /** Local record time (ISO). */ + readonly at: string; + /** Store state right after this entry was applied (immutable reference). */ + readonly state: ChatState; + /** One-line summary for the timeline list. */ + readonly summary: string; +} + +export interface RestAuditEntry extends AuditEntryBase { + readonly kind: 'rest'; + readonly request: { + readonly beforeTurn?: string | undefined; + readonly afterStep?: string | undefined; + readonly pageSize: number; + }; + /** replace = newest page (initial/refresh); prepend = older page; tail = after_step catch-up. */ + readonly mode: 'replace' | 'prepend' | 'tail'; + readonly messageCount: number; + readonly inFlight?: { turn_id: string; step_id: string } | undefined; +} + +export interface WsAuditEntry extends AuditEntryBase { + readonly kind: 'ws'; + /** The raw server message as applied to the store (entity, delta, or state). */ + readonly message: ServerMessage; +} + +export interface EventAuditEntry extends AuditEntryBase { + readonly kind: 'event'; + readonly event: + | 'ack' + | 'ack-error' + | 'reconnect' + | 'catchup-refresh' + | 'protocol-error' + | 'invalid-frame' + | 'prompt' + | 'cancel' + | 'older-error'; + readonly detail?: string | undefined; +} + +export type AuditEntry = RestAuditEntry | WsAuditEntry | EventAuditEntry; + +type DistributiveOmit = T extends unknown ? Omit : never; + +/** Entry payload accepted by `push` (index/at are filled in there). */ +type AuditEntryInput = DistributiveOmit; + +export class AuditTrail { + private entryList: AuditEntry[] = []; + private nextIndex = 0; + private readonly listeners = new Set<() => void>(); + + /** `useSyncExternalStore`-compatible subscribe. */ + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getEntries(): readonly AuditEntry[] { + return this.entryList; + } + + recordRest( + request: RestAuditEntry['request'], + mode: RestAuditEntry['mode'], + messageCount: number, + inFlight: RestAuditEntry['inFlight'], + state: ChatState, + ): void { + const cursor = + request.beforeTurn !== undefined + ? `?before_turn=${request.beforeTurn}` + : request.afterStep !== undefined + ? `?after_step=${request.afterStep}` + : ''; + const flight = inFlight !== undefined ? ` (in_flight ${inFlight.step_id})` : ''; + this.push({ + kind: 'rest', + request, + mode, + messageCount, + inFlight, + state, + summary: `GET history${cursor} → ${messageCount} messages (${mode})${flight}`, + }); + } + + recordWs(message: ServerMessage, state: ChatState): void { + this.push({ + kind: 'ws', + message, + state, + summary: summarizeMessage(message), + }); + } + + recordEvent( + event: EventAuditEntry['event'], + detail: string | undefined, + state: ChatState, + ): void { + const label = + event === 'ack' + ? 'subscribe ack → after_step catch-up' + : event === 'ack-error' + ? 'subscribe ack error' + : event === 'reconnect' + ? 'socket dropped → reconnecting' + : event === 'catchup-refresh' + ? 'catch-up anchor gone → full refresh' + : event === 'protocol-error' + ? 'protocol error frame' + : event === 'invalid-frame' + ? 'invalid frame (server bug)' + : event === 'prompt' + ? 'prompt sent' + : event === 'cancel' + ? 'cancel sent' + : 'older-page load failed'; + this.push({ + kind: 'event', + event, + detail, + state, + summary: detail !== undefined && detail !== '' ? `${label}: ${detail}` : label, + }); + } + + private push(entry: AuditEntryInput): void { + const full = { ...entry, index: this.nextIndex, at: new Date().toISOString() } as AuditEntry; + this.nextIndex += 1; + const kept = + this.entryList.length >= AUDIT_TRAIL_MAX_ENTRIES + ? this.entryList.slice(this.entryList.length - AUDIT_TRAIL_MAX_ENTRIES + 1) + : this.entryList; + this.entryList = [...kept, full]; + for (const listener of this.listeners) listener(); + } +} + +function summarizeMessage(message: ServerMessage): string { + switch (message.type) { + case 'turn': + return `turn ${message.turn_id} (${message.status})`; + case 'step': + return `step ${message.step_id} (${message.status})`; + case 'user': + return `user ${message.message_id}`; + case 'assistant': + case 'thinking': + return `${message.type} ${message.message_id} (${message.status})`; + case 'assistant.delta': + case 'thinking.delta': + return `${message.type} ${message.message_id} +${message.text.length}ch`; + case 'tool_call': + return `tool_call ${message.name} ${message.tool_call_id} (${message.status})`; + case 'tool_call.delta': + return `tool_call.delta ${message.tool_call_id} +${message.input_text.length}ch`; + case 'tool.progress': + return `tool.progress ${message.tool_call_id} (${message.progress.kind})`; + case 'system': + return `system(${message.subtype}) ${message.system_id}`; + case 'interaction': + return `interaction ${message.interaction_id} (${message.kind}/${message.status})`; + case 'task': + return `task ${message.task_id} (${message.kind}/${message.status})`; + case 'todo': + return `todo ${message.todo_id} (${message.items.length} items)`; + case 'session.state': + return `session.state (${message.status})`; + default: + return message.type; + } +} diff --git a/apps/kimi-inspect/src/audit/truncate.ts b/apps/kimi-inspect/src/audit/truncate.ts new file mode 100644 index 0000000000000000000000000000000000000000..69a7c137c7bce5418dce52df8bbf1bf57d102f33 --- /dev/null +++ b/apps/kimi-inspect/src/audit/truncate.ts @@ -0,0 +1,14 @@ +/** + * Tail-preserving string truncation for the audit panel: long values are + * rendered with their total length plus the LAST `keep` characters (the + * tail is where streaming text, tool output, and prompts carry the newest + * information). Rendering-only — the underlying state is never truncated, + * and no field is ever dropped. + */ + +export const TRUNCATE_KEEP = 500; + +export function tailTrunc(value: string, keep: number = TRUNCATE_KEEP): string { + if (value.length <= keep) return value; + return `… [${value.length} chars total, showing last ${keep}]\n${value.slice(-keep)}`; +} 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 0000000000000000000000000000000000000000..fdf2de26c57eb3e577be95096edb957a2ba06cec --- /dev/null +++ b/apps/kimi-inspect/src/channel/channel.test.ts @@ -0,0 +1,199 @@ +/** + * Channel layer unit tests — `ProxyChannel` URL/envelope semantics, + * `makeProxy` routing, the HTTP-only `listen` failure, and the debug-surface + * probe (`/api/v1/debug` is the only RPC surface; there is no v2 fallback). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Event, IChannel } from './channel'; +import { probeDebugSurface } from './channels'; +import { createInspectClient } from './client'; +import { RPCError } from './errors'; +import { + fetchAgentRuntimeBinding, + fetchSessionWorkspaceAssociation, + fetchWorkspaceSnapshot, +} from '../snapshots/api'; +import { makeProxy } from './proxy'; +import { ProxyChannel } from './proxyChannel'; + +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 }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +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/v1/debug/session/s%201/agent/main/agentLoopService', + 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/v1/debug/session/s%201/agent/main/agentLoopService/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/v1/debug/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/v1/debug/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('ProxyChannel.listen', () => { + it('throws: the debug surface is HTTP-only, there is no event transport', () => { + const channel = new ProxyChannel({ + baseUrl: 'http://h:4/api/v1/debug/configService', + fetch: fakeFetch(ok(null)).fetchImpl, + }); + expect(() => channel.listen('onDidChangeConfiguration')).toThrow(/events are not supported/); + }); +}); + +describe('business snapshots', () => { + it('uses explicit workspace, session association, and agent binding routes', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', async (url: string | URL) => { + const value = String(url); + calls.push(value); + if (value.endsWith('/workspace/w%201/snapshot')) { + return { json: async () => ok({ metadata: { id: 'w 1' } }) }; + } + if (value.endsWith('/session/s%201/association')) { + return { json: async () => ok({ sessionId: 's 1', workspaceId: 'w 1', cwd: '/work' }) }; + } + return { + json: async () => ok({ + binding: { workspaceId: 'w 1', runtimeId: 'remote' }, + available: true, + runtime: { runtimeId: 'remote', generation: 'g2', status: 'ready', capabilities: ['process'] }, + }), + }; + }); + const client = createInspectClient({ url: 'http://h:9', token: 'tok' }); + + await expect(fetchWorkspaceSnapshot(client, 'w 1')).resolves.toMatchObject({ metadata: { id: 'w 1' } }); + await expect(fetchSessionWorkspaceAssociation(client, 's 1')).resolves.toMatchObject({ workspaceId: 'w 1' }); + await expect(fetchAgentRuntimeBinding(client, 's 1', 'main')).resolves.toMatchObject({ + binding: { runtimeId: 'remote' }, + runtime: { generation: 'g2' }, + }); + expect(calls).toEqual([ + 'http://h:9/api/v1/debug/workspace/w%201/snapshot', + 'http://h:9/api/v1/debug/session/s%201/association', + 'http://h:9/api/v1/debug/session/s%201/agent/main/runtime-binding', + ]); + }); +}); + +describe('probeDebugSurface', () => { + function stubProbeFetch(impl: (url: string, init?: RequestInit) => unknown) { + const calls: { url: string; init?: RequestInit }[] = []; + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return impl(String(url), init); + }); + return calls; + } + + it('resolves when /api/v1/debug/channels answers a zero-code envelope (with bearer header)', async () => { + const calls = stubProbeFetch(() => ({ ok: true, json: async () => ({ code: 0 }) })); + await expect( + probeDebugSurface({ baseUrl: 'http://h:5/', token: 'tok' }), + ).resolves.toBeUndefined(); + expect(calls[0]!.url).toBe('http://h:5/api/v1/debug/channels'); + expect(calls[0]!.init?.headers).toEqual({ authorization: 'Bearer tok' }); + }); + + it('throws a --debug-endpoints hint when the surface is not mounted (HTTP 404)', async () => { + stubProbeFetch(() => ({ ok: false, status: 404 })); + await expect(probeDebugSurface({ baseUrl: 'http://h:6' })).rejects.toThrow(/--debug-endpoints/); + }); + + it('throws an unreachable-server error when fetch itself fails', async () => { + vi.stubGlobal('fetch', async () => { + throw new Error('ECONNREFUSED'); + }); + await expect(probeDebugSurface({ baseUrl: 'http://h:7' })).rejects.toThrow(/cannot reach/); + }); + + it('throws a token hint when the envelope carries a non-zero code', async () => { + stubProbeFetch(() => ({ + ok: true, + json: async () => ({ code: 40101, msg: 'unauthorized' }), + })); + await expect(probeDebugSurface({ baseUrl: 'http://h:8' })).rejects.toThrow(/bearer token/); + }); +}); diff --git a/apps/kimi-inspect/src/channel/channel.ts b/apps/kimi-inspect/src/channel/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d71e28912df2505b4dbdc49db080dde562ef501 --- /dev/null +++ b/apps/kimi-inspect/src/channel/channel.ts @@ -0,0 +1,45 @@ +/** + * Transport-agnostic channel contract for the debug-RPC 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 kept for contract completeness (Service `onXxx` emitters map to + * it), but the `/api/v1/debug` surface is HTTP-only — the v2 event socket + * (`/api/v2/ws`) that used to serve it was removed — so the HTTP channel's + * `listen` throws and panels fetch on demand instead. + */ + +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 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 0000000000000000000000000000000000000000..c97405f0815a752a2c4d143bf4c05e236b3d018d --- /dev/null +++ b/apps/kimi-inspect/src/channel/channels.ts @@ -0,0 +1,120 @@ +/** + * Protocol loading — the debug surface's `GET /api/v1/debug/channels` + * endpoint is the server's self-description of every wire-callable Service + * (name, scope, domain, methods + properties), whitelist-free. 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. + * + * `/api/v1/debug` is the ONLY RPC surface this app talks to (mounted by + * kap-server with `--debug-endpoints` on a loopback bind); the v2 surface + * (`/api/v2` + `/api/v2/ws`) was removed server-side, so there is no + * fallback — `probeDebugSurface` fails the connection with a clear error. + */ + +import { createDecorator } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; + +import type { ServiceProxy } from './channel'; +import { DEBUG_RPC_BASE, type InspectClient } from './client'; +import { RPCError } from './errors'; + +/** 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/v1/debug/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). */ +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}${DEBUG_RPC_BASE}/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; +} + +/** + * Verify the server mounts the debug RPC surface before the client is built. + * Resolves silently when `GET /api/v1/debug/channels` answers with a + * zero-code envelope; otherwise throws an `Error` whose message tells the + * user exactly what is wrong (unreachable server, surface not mounted → + * start kap-server with `--debug-endpoints`, or a rejected probe → check the + * token). + */ +export async function probeDebugSurface(options: { + readonly baseUrl: string; + readonly token?: string; +}): Promise { + const headers: Record = {}; + if (options.token !== undefined && options.token !== '') { + headers['authorization'] = `Bearer ${options.token}`; + } + const url = `${options.baseUrl.replace(/\/$/, '')}${DEBUG_RPC_BASE}/channels`; + let res: Response; + try { + res = await fetch(url, { headers }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`cannot reach ${options.baseUrl} — is kap-server running? (${reason})`); + } + if (!res.ok) { + throw new Error( + `GET ${DEBUG_RPC_BASE}/channels answered HTTP ${res.status} — this server does not ` + + 'mount the debug RPC surface. Start kap-server with --debug-endpoints on a loopback bind.', + ); + } + const envelope = (await res.json()) as { code?: number; msg?: string }; + if (envelope.code !== 0) { + throw new Error( + `the debug surface rejected the probe (code ${envelope.code ?? '?'}: ${ + envelope.msg ?? 'no message' + }) — check the bearer token.`, + ); + } +} + +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 + * workspace/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 0000000000000000000000000000000000000000..9f81265a7d395ac8906cbff84df8fe0483968301 --- /dev/null +++ b/apps/kimi-inspect/src/channel/client.ts @@ -0,0 +1,81 @@ +/** + * Inspect client — the app's `/api/v1/debug` entry point, in the old-klient + * VS Code `ProxyChannel` model: a multi-level scope entry (`core` / + * `workspace` / `session` / `agent`) whose every Service handle is a + * `makeProxy`-materialized typed proxy over a service-bound HTTP channel. + * + * const client = createInspectClient({ url: 'http://127.0.0.1:58627' }); + * await client.core(ISessionIndex).listRecent({}); + * await client.workspace('wd_1').service(ISessionLifecycleService).resume('s1'); + * await client.session('s1').service(ISessionMetadata).read(); + * await client.session('s1').agent('main').service(IAgentLoopService).cancel(undefined); + * + * 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`). There is no event + * transport: the v2 socket (`/api/v2/ws`) that used to carry Service `onXxx` + * emitters and scope event streams was removed server-side, so the UI reads + * Service state on demand. (The transcript's own `/api/v1/ws` delta channel + * lives in `src/transcript/` and is unrelated to this client.) + */ + +import type { ServiceProxy, ServiceRef } from './channel'; +import { makeProxy } from './proxy'; +import { ProxyChannel } from './proxyChannel'; + +/** The dev server's whitelist-free debug surface (`--debug-endpoints` + loopback). */ +export const DEBUG_RPC_BASE = '/api/v1/debug' as const; + +export interface InspectAgentHandle { + service(id: ServiceRef): ServiceProxy; +} + +export interface InspectSessionHandle extends InspectAgentHandle { + agent(agentId: string): InspectAgentHandle; +} + +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; + core(id: ServiceRef): ServiceProxy; + session(sessionId: string): InspectSessionHandle; +} + +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; +} + +export function createInspectClient(options: InspectClientOptions): InspectClient { + const url = options.url.replace(/\/$/, ''); + + /** Materialize a typed proxy for one Service on one scope binding. */ + function proxy(scopePath: string, id: ServiceRef): ServiceProxy { + const service = String(id); + return makeProxy( + new ProxyChannel({ + baseUrl: `${url}${DEBUG_RPC_BASE}${scopePath}/${service}`, + token: options.token, + }), + ); + } + + return { + baseUrl: url, + token: options.token, + core: (id) => proxy('', id), + session: (sessionId) => { + const scopePath = `/session/${encodeURIComponent(sessionId)}`; + return { + service: (id) => proxy(scopePath, id), + agent: (agentId) => ({ + service: (subId) => proxy(`${scopePath}/agent/${encodeURIComponent(agentId)}`, subId), + }), + }; + }, + }; +} diff --git a/apps/kimi-inspect/src/channel/errors.ts b/apps/kimi-inspect/src/channel/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..1748e9ac59fc9c9abc93c1e962eadd371d1c587e --- /dev/null +++ b/apps/kimi-inspect/src/channel/errors.ts @@ -0,0 +1,16 @@ +/** + * Client-side RPC error surfaced when the debug-RPC 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 0000000000000000000000000000000000000000..4dadf528b823e2e054d04c25d4ecb6f03ff61db5 --- /dev/null +++ b/apps/kimi-inspect/src/channel/index.ts @@ -0,0 +1,7 @@ +export * from './channel'; +export * from './channels'; +export * from './client'; +export * from './errors'; +export * from './proxy'; +export * from './proxyChannel'; +export * from './wsLike'; diff --git a/apps/kimi-inspect/src/channel/proxy.ts b/apps/kimi-inspect/src/channel/proxy.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a0eb75187dcdd1fa70987f0661fd6a7653d6966 --- /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 0000000000000000000000000000000000000000..8ab9795d51cee4bc3512408825c5c5e2e8a4e380 --- /dev/null +++ b/apps/kimi-inspect/src/channel/proxyChannel.ts @@ -0,0 +1,74 @@ +/** + * `ProxyChannel` — an `IChannel` bound to one Service, routing `call`s to + * kap-server's `/api/v1/debug` 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: the v2 event socket (`/api/v2/ws`) that + * used to back Service emitter events was removed, so `listen` throws and + * the UI fetches Service state on demand instead. + */ + +import type { Event, IChannel } from './channel'; +import { RPCError } from './errors'; + +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/v1/debug[/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; + + constructor(opts: ProxyChannelOptions) { + 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); + } + + 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 { + throw new Error( + 'events are not supported on this channel (HTTP-only; the /api/v2/ws event socket was removed)', + ); + } +} diff --git a/apps/kimi-inspect/src/channel/wsLike.ts b/apps/kimi-inspect/src/channel/wsLike.ts new file mode 100644 index 0000000000000000000000000000000000000000..440a9e8c77f11d54f50954eb478a57aa1e176e81 --- /dev/null +++ b/apps/kimi-inspect/src/channel/wsLike.ts @@ -0,0 +1,20 @@ +/** + * Minimal DOM-compatible WebSocket surface shared by the app's socket + * clients (today only the transcript `/api/v1/ws` client). Coding against + * this structural type keeps the clients testable with an injected fake; + * the default is the global `WebSocket` (browsers, Node ≥ 21). + */ +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; +} diff --git a/apps/kimi-inspect/src/components/AppServicesView.tsx b/apps/kimi-inspect/src/components/AppServicesView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..98ae9aa3403df3933f7c1ac7d4720e6ddfbdab13 --- /dev/null +++ b/apps/kimi-inspect/src/components/AppServicesView.tsx @@ -0,0 +1,27 @@ +/** + * App Services view — the app-scope (server-level) Service reflection as a + * standalone rail view. Postman-style three-pane layout + * (`ScopePanelsScrollspy`): the Service list on the left, every Service's + * methods expanded in one continuously scrolling column in the middle + * (scroll position and left-side highlight kept in sync), and a + * request/response call history on the right. The proxies resolve on the + * `core` route, so this view works before any session is selected. + */ + +import { useCallback } from 'react'; + +import { serviceByName } from '../channel'; +import { useConnection } from '../connection'; +import type { AnyService } from '../panels'; +import { ScopePanelsScrollspy } from './ServicePanels'; + +export function AppServicesView() { + const { klient } = useConnection(); + const proxyFor = useCallback( + (name: string): AnyService | null => + serviceByName(klient, name, { scope: 'app' }) ?? null, + [klient], + ); + + return ; +} diff --git a/apps/kimi-inspect/src/components/BashParserView.tsx b/apps/kimi-inspect/src/components/BashParserView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1532705722b42b83ed286a1a779f57075c6edf60 --- /dev/null +++ b/apps/kimi-inspect/src/components/BashParserView.tsx @@ -0,0 +1,279 @@ +/** + * Bash Parser view — a playground for the App-scope `IBashParserService` + * (the `bashParser` domain, a thin adapter over `@moonshot-ai/tree-sitter-bash`). + * + * left: the bash source textarea plus the parse budget (timeoutMs / + * maxNodes, empty = package default); the `examples…` dropdown + * fills the textarea with curated snippets from the parser's own + * differential fixtures; + * right: the parse result — status badges (hasError / aborted / node + * count) and the syntax tree, one row per node with its type, + * UTF-16 range and (for leaves) the source text. Anonymous tokens + * are dimmed; rows expand/collapse. + * + * Parsing is debounced off the textarea and rides the same `/api/v1/debug` + * channel as every other panel (`klient.core(IBashParserService).parse`) — + * the budgeted parse never throws, `{ ok: false }` means budget exhaustion. + */ + +import { useEffect, useState } from 'react'; + +import { + IBashParserService, + type BashParseResult, + type BashSyntaxNode, +} from '@moonshot-ai/agent-core-v2/app/bashParser/bashParser'; + +import { useConnection } from '../connection'; +import { Badge, errorMessage } from '../ui'; + +const DEFAULT_SOURCE = `if [ -f config.sh ]; then + source config.sh && echo "loaded" | tee -a setup.log +else + echo "missing" >&2; exit 1 +fi +`; + +const PARSE_DEBOUNCE_MS = 300; + +/** + * Quick-fill examples, adapted from the parser's own differential fixtures + * (`packages/tree-sitter-bash/test/fixtures/differential/*.txt`) — each one + * exercises a distinct area of the grammar. The last three probe the + * non-happy paths: deep nesting (a left-associative arithmetic chain, the + * case that once overflowed the DTO conversion) and the error-recovery + * paths that set `hasError`. + */ +const EXAMPLES: readonly { readonly name: string; readonly source: string }[] = [ + { + name: 'deep arithmetic (1000 operands)', + // A thousand left-nested binary_expression levels. Deeper chains parse + // fine in-process, but past ~2500 levels the JSON RPC transport itself + // cannot serialize the tree (V8 call-stack limit in JSON.stringify). + source: `echo $((${'1+'.repeat(1000)}1))`, + }, + { + name: 'pipeline & redirects', + source: `git log --oneline | head -20 | tee /tmp/log.txt +find . -name '*.ts' -print0 2>/dev/null | xargs -0 grep -l TODO +cmd <<< "$input" >out.txt 2>&1 +`, + }, + { + name: 'case statement', + source: `case $x in + a) echo A ;; + b|c) echo BC ;& + foo*|bar) echo match ;; + [a-z]) echo lower ;; + *) echo other ;; +esac +`, + }, + { + name: 'heredoc', + source: `foo() { cat < sum + countNodes(child), 0); +} + +export function BashParserView() { + const { klient } = useConnection(); + const [source, setSource] = useState(DEFAULT_SOURCE); + const [timeoutMs, setTimeoutMs] = useState(''); + const [maxNodes, setMaxNodes] = useState(''); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const handle = setTimeout(() => { + klient + .core(IBashParserService) + .parse(source, { + timeoutMs: timeoutMs === '' ? undefined : Number(timeoutMs), + maxNodes: maxNodes === '' ? undefined : Number(maxNodes), + }) + .then(setResult, (e: unknown) => { + setResult(null); + setError(errorMessage(e)); + }); + }, PARSE_DEBOUNCE_MS); + return () => { + clearTimeout(handle); + }; + }, [klient, source, timeoutMs, maxNodes]); + + const nodeCount = result !== null && result.ok ? countNodes(result.root) : null; + + return ( +
+
+
+ bash source + +
+ + +
+