mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 00:27:32 +00:00
Some checks are pending
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* refactor(agent-core-v2): featurize todo/cron/interaction and migrate goal to agent runtime * refactor(agent-core-v2): move cron scheduling helpers into features/cron/internal
887 lines
30 KiB
TypeScript
887 lines
30 KiB
TypeScript
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import {
|
|
AgentGoal,
|
|
agentContextOf,
|
|
ErrorCodes,
|
|
IAgentActivityView,
|
|
IAgentLifecycleService,
|
|
IAgentPluginCommandService,
|
|
IAgentPromptService,
|
|
IAgentRuntimeBindingService,
|
|
IAgentShellCommandService,
|
|
IAppendLogStore,
|
|
IDebugEventsService,
|
|
IEventService,
|
|
IInstantiationService,
|
|
IPluginService,
|
|
ISessionIndex,
|
|
ISessionManager,
|
|
ISessionMetadata,
|
|
IWorkspaceInstanceManager,
|
|
IWorkspaceService,
|
|
getLiveSessionById,
|
|
} from '@moonshot-ai/agent-core-v2';
|
|
import type {
|
|
AgentRuntimeBindingSnapshot,
|
|
ServiceIdentifier,
|
|
SessionWorkspaceAssociationSnapshot,
|
|
WorkspaceInstanceSnapshot,
|
|
} from '@moonshot-ai/agent-core-v2';
|
|
import { FakeRuntime } from '@moonshot-ai/agent-core-v2/runtime/fakeRuntime';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { type RunningServer, startServer } from '../src/start';
|
|
import { TEST_HOST_IDENTITY } from './helpers/hostIdentity';
|
|
import { authHeaders } from './helpers/auth';
|
|
|
|
interface Envelope<T> {
|
|
code: number;
|
|
msg: string;
|
|
data: T;
|
|
request_id: string;
|
|
details?: { path: string; message: string }[];
|
|
}
|
|
|
|
interface SessionMetaWire {
|
|
id: string;
|
|
title?: string;
|
|
lastPrompt?: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
archived: boolean;
|
|
}
|
|
|
|
function rpc(
|
|
scope: 'core' | 'session' | 'agent',
|
|
service: ServiceIdentifier<unknown>,
|
|
method: string,
|
|
ids: { sid?: string; aid?: string } = {},
|
|
): string {
|
|
if (scope === 'core') return `/api/v1/debug/${String(service)}/${method}`;
|
|
if (scope === 'session') return `/api/v1/debug/session/${ids.sid}/${String(service)}/${method}`;
|
|
return `/api/v1/debug/session/${ids.sid}/agent/${ids.aid}/${String(service)}/${method}`;
|
|
}
|
|
|
|
describe('server-v2 /api/v1/debug RPC', () => {
|
|
let server: RunningServer | undefined;
|
|
let home: string | undefined;
|
|
let base: string;
|
|
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-'));
|
|
server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', debugEndpoints: true });
|
|
base = `http://127.0.0.1:${server.port}`;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (server !== undefined) {
|
|
await server.close();
|
|
server = undefined;
|
|
}
|
|
if (home !== undefined) {
|
|
await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never);
|
|
home = undefined;
|
|
}
|
|
});
|
|
|
|
async function call<T>(
|
|
method: 'GET' | 'POST',
|
|
path: string,
|
|
arg?: unknown,
|
|
token?: string,
|
|
): Promise<{ status: number; body: Envelope<T> }> {
|
|
const headers: Record<string, string> = {};
|
|
let url = `${base}${path}`;
|
|
const init: { method: string; headers: Record<string, string>; body?: string } = {
|
|
method,
|
|
headers,
|
|
};
|
|
if (method === 'GET') {
|
|
if (arg !== undefined) url += `?arg=${encodeURIComponent(JSON.stringify(arg))}`;
|
|
} else if (arg !== undefined) {
|
|
headers['content-type'] = 'application/json';
|
|
init.body = JSON.stringify(arg);
|
|
}
|
|
const credential = token ?? (server as RunningServer).authTokenService.getToken();
|
|
headers['authorization'] = `Bearer ${credential}`;
|
|
const res = await fetch(url, init);
|
|
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
|
}
|
|
|
|
async function createSession(cwd: string): Promise<string> {
|
|
const res = await fetch(`${base}/api/v1/sessions`, {
|
|
method: 'POST',
|
|
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
|
|
body: JSON.stringify({ metadata: { cwd } }),
|
|
} as never);
|
|
const body = (await res.json()) as Envelope<{ id: string }>;
|
|
expect(body.code).toBe(0);
|
|
return body.data.id;
|
|
}
|
|
|
|
async function createMainAgent(sessionId: string): Promise<void> {
|
|
const session = getLiveSessionById(server!.core.accessor, sessionId);
|
|
if (session === undefined) throw new Error(`session ${sessionId} not found`);
|
|
await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
|
|
}
|
|
|
|
async function createSubagent(sessionId: string, agentId: string): Promise<void> {
|
|
const session = getLiveSessionById(server!.core.accessor, sessionId);
|
|
if (session === undefined) throw new Error(`session ${sessionId} not found`);
|
|
await session.accessor.get(IAgentLifecycleService).create({ agentId });
|
|
}
|
|
|
|
function goalFacade(sessionId: string, agentId = 'main') {
|
|
const session = getLiveSessionById(server!.core.accessor, sessionId);
|
|
if (session === undefined) throw new Error(`session ${sessionId} not found`);
|
|
const manager = session.accessor.get(IAgentLifecycleService);
|
|
const handle = manager.handleOf(agentId);
|
|
if (handle === undefined) throw new Error(`agent ${agentId} not found`);
|
|
return manager.resolve(agentContextOf(handle), AgentGoal);
|
|
}
|
|
|
|
it('describes all channels via GET /api/v1/debug/channels', async () => {
|
|
const { status, body } = await call<
|
|
readonly {
|
|
name: string;
|
|
scope: 'app' | 'session' | 'agent';
|
|
methods: readonly {
|
|
name: string;
|
|
kind: 'method' | 'property';
|
|
arity: number;
|
|
params: string;
|
|
}[];
|
|
}[]
|
|
>('GET', '/api/v1/debug/channels');
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(0);
|
|
|
|
const byName = new Map(body.data.map((c) => [c.name, c]));
|
|
expect(byName.get('sessionIndex')?.scope).toBe('app');
|
|
expect(byName.get('sessionMetadata')?.scope).toBe('session');
|
|
expect(byName.get('agentPromptService')?.scope).toBe('agent');
|
|
|
|
const meta = byName.get('sessionMetadata');
|
|
expect(meta?.methods.map((m) => m.name)).toEqual(
|
|
expect.arrayContaining(['read', 'setTitle', 'setArchived']),
|
|
);
|
|
expect(meta?.methods.find((m) => m.name === 'read')).toMatchObject({
|
|
kind: 'method',
|
|
arity: 0,
|
|
params: '',
|
|
});
|
|
expect(meta?.methods.find((m) => m.name === 'setTitle')).toMatchObject({
|
|
arity: 1,
|
|
params: 'title',
|
|
});
|
|
expect(meta?.methods.map((m) => m.name)).not.toContain('dispose');
|
|
|
|
const prompts = byName.get('agentPromptService');
|
|
expect(prompts?.methods.map((m) => m.name)).toContain('enqueue');
|
|
expect(prompts?.methods.map((m) => m.name)).not.toContain('reserve');
|
|
});
|
|
|
|
it('reaches a runtime-contributed Service absent from /channels (decorator-name fallback)', async () => {
|
|
const channels = await call<readonly { name: string }[]>(
|
|
'GET',
|
|
'/api/v1/debug/channels',
|
|
);
|
|
expect(channels.body.data.some((c) => c.name === String(IDebugEventsService))).toBe(false);
|
|
|
|
const { status, body } = await call<{
|
|
subscriptions: unknown[];
|
|
buses: unknown[];
|
|
globalListeners?: number;
|
|
}>('GET', rpc('core', IDebugEventsService, 'subscriptions'));
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(0);
|
|
expect(Array.isArray(body.data.subscriptions)).toBe(true);
|
|
expect(Array.isArray(body.data.buses)).toBe(true);
|
|
expect(typeof body.data.globalListeners).toBe('number');
|
|
});
|
|
|
|
it('rejects kernel tokens registered neither statically nor by a feature (40001)', async () => {
|
|
const { body } = await call<null>('POST', rpc('core', IInstantiationService, 'dispose'));
|
|
expect(body.code).toBe(40001);
|
|
});
|
|
|
|
it('lists sessions via GET', async () => {
|
|
const { body } = await call<{ items: unknown[]; has_more: boolean }>(
|
|
'GET',
|
|
rpc('core', ISessionIndex, 'listRecent'),
|
|
{},
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(Array.isArray(body.data.items)).toBe(true);
|
|
});
|
|
|
|
it('creates a workspace and reads it back', async () => {
|
|
const cwd = home as string;
|
|
const created = await call<{ id: string; root: string }>(
|
|
'POST',
|
|
rpc('core', IWorkspaceService, 'createOrTouch'),
|
|
cwd,
|
|
);
|
|
expect(created.body.code).toBe(0);
|
|
expect(created.body.data.root).toBe(cwd);
|
|
|
|
const got = await call<{ id: string; root: string }>(
|
|
'GET',
|
|
rpc('core', IWorkspaceService, 'get'),
|
|
created.body.data.id,
|
|
);
|
|
expect(got.body.code).toBe(0);
|
|
expect(got.body.data.root).toBe(cwd);
|
|
});
|
|
|
|
it('exposes workspace, session association, and agent binding business snapshots', async () => {
|
|
const sessionId = await createSession(home as string);
|
|
await createMainAgent(sessionId);
|
|
const summary = await server!.core.accessor.get(ISessionIndex).get(sessionId);
|
|
expect(summary).toBeDefined();
|
|
const workspaceId = summary!.workspaceId;
|
|
|
|
const workspace = await call<WorkspaceInstanceSnapshot>(
|
|
'GET',
|
|
`/api/v1/debug/workspace/${workspaceId}/snapshot`,
|
|
);
|
|
expect(workspace.body.data).toMatchObject({
|
|
metadata: { id: workspaceId, root: home },
|
|
lifecycle: 'active',
|
|
program: {
|
|
binding: { workspaceId, runtimeId: 'local' },
|
|
},
|
|
runtimes: {
|
|
workspaceId,
|
|
runtimes: [{ runtimeId: 'local', status: 'ready' }],
|
|
},
|
|
});
|
|
expect(workspace.body.data).not.toHaveProperty('accessor');
|
|
expect(workspace.body.data).not.toHaveProperty('container');
|
|
|
|
const association = await call<SessionWorkspaceAssociationSnapshot>(
|
|
'GET',
|
|
`/api/v1/debug/session/${sessionId}/association`,
|
|
);
|
|
expect(association.body.data).toEqual({
|
|
sessionId,
|
|
workspaceId,
|
|
cwd: home,
|
|
});
|
|
|
|
const binding = await call<AgentRuntimeBindingSnapshot>(
|
|
'GET',
|
|
`/api/v1/debug/session/${sessionId}/agent/main/runtime-binding`,
|
|
);
|
|
expect(binding.body.data).toMatchObject({
|
|
binding: { workspaceId, runtimeId: 'local' },
|
|
available: true,
|
|
runtime: { runtimeId: 'local', status: 'ready' },
|
|
});
|
|
|
|
const legacy = await fetch(
|
|
`${base}/api/v1/debug/workspace/${workspaceId}/workspaceTrust/get`,
|
|
{ headers: authHeaders(server as RunningServer) },
|
|
);
|
|
expect(legacy.status).toBe(404);
|
|
});
|
|
|
|
it('rejects createOrTouch for a missing root directory (40409)', async () => {
|
|
const missing = join(home as string, 'never-created');
|
|
const { body } = await call<null>(
|
|
'POST',
|
|
rpc('core', IWorkspaceService, 'createOrTouch'),
|
|
missing,
|
|
);
|
|
expect(body.code).toBe(40409);
|
|
});
|
|
|
|
it('renames a workspace via update', async () => {
|
|
const cwd = home as string;
|
|
const created = await call<{ id: string; name: string }>(
|
|
'POST',
|
|
rpc('core', IWorkspaceService, 'createOrTouch'),
|
|
cwd,
|
|
);
|
|
const id = created.body.data.id;
|
|
|
|
const updated = await call<{ id: string; name: string }>(
|
|
'POST',
|
|
rpc('core', IWorkspaceService, 'update'),
|
|
[id, { name: 'renamed' }],
|
|
);
|
|
expect(updated.body.code).toBe(0);
|
|
expect(updated.body.data.name).toBe('renamed');
|
|
|
|
const got = await call<{ id: string; name: string }>(
|
|
'GET',
|
|
rpc('core', IWorkspaceService, 'get'),
|
|
id,
|
|
);
|
|
expect(got.body.data.name).toBe('renamed');
|
|
});
|
|
|
|
it('counts active sessions', async () => {
|
|
const cwd = home as string;
|
|
const created = await call<{ id: string }>('POST', rpc('core', IWorkspaceService, 'createOrTouch'), cwd);
|
|
await createSession(cwd);
|
|
const { body } = await call<number>(
|
|
'POST',
|
|
rpc('core', ISessionIndex, 'count'),
|
|
[{ workspaceIds: [created.body.data.id] }],
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('reads and updates session metadata', async () => {
|
|
const id = await createSession(home as string);
|
|
|
|
const read = await call<SessionMetaWire>('POST', rpc('session', ISessionMetadata, 'read', { sid: id }));
|
|
expect(read.body.code).toBe(0);
|
|
expect(read.body.data.id).toBe(id);
|
|
|
|
const set = await call<null>('POST', rpc('session', ISessionMetadata, 'setTitle', { sid: id }), 'renamed');
|
|
expect(set.body.code).toBe(0);
|
|
|
|
const read2 = await call<SessionMetaWire>('POST', rpc('session', ISessionMetadata, 'read', { sid: id }));
|
|
expect(read2.body.data.title).toBe('renamed');
|
|
});
|
|
|
|
it('reads agent activity state', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
const { body } = await call<{ lifecycle: string }>(
|
|
'POST',
|
|
rpc('agent', IAgentActivityView, 'state', { sid: id, aid: 'main' }),
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.lifecycle).toBe('ready');
|
|
});
|
|
|
|
it('exposes runtime binding through REST and debug dispatcher contracts', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const dispatched = await call<{ workspaceId: string; runtimeId: string }>(
|
|
'POST',
|
|
rpc('agent', IAgentRuntimeBindingService, 'get', { sid: id, aid: 'main' }),
|
|
);
|
|
expect(dispatched.body.data.runtimeId).toBe('local');
|
|
|
|
const current = await call<{ workspace_id: string; runtime_id: string }>(
|
|
'GET',
|
|
`/api/v1/sessions/${id}/runtime`,
|
|
);
|
|
expect(current.body.data).toMatchObject({ runtime_id: 'local' });
|
|
|
|
const invalid = await call<null>(
|
|
'POST',
|
|
`/api/v1/sessions/${id}/runtime`,
|
|
{ runtime_id: 'missing-runtime' },
|
|
);
|
|
expect(invalid.body.code).toBe(40420);
|
|
|
|
const unchanged = await call<{ workspace_id: string; runtime_id: string }>(
|
|
'GET',
|
|
`/api/v1/sessions/${id}/runtime`,
|
|
);
|
|
expect(unchanged.body.data).toEqual(current.body.data);
|
|
|
|
const provider = await server!.core.accessor.get(IWorkspaceInstanceManager).addProvider({
|
|
id: 'debug-remote-provider',
|
|
imports: { root: [], imports: [], local: [] },
|
|
attach: async (context, host) => {
|
|
host.registerRuntime(new FakeRuntime({
|
|
workspaceId: context.id,
|
|
runtimeId: 'remote',
|
|
generation: 'remote-two',
|
|
}));
|
|
return { dispose: () => {} };
|
|
},
|
|
});
|
|
try {
|
|
const switched = await call<{ workspace_id: string; runtime_id: string }>(
|
|
'POST',
|
|
`/api/v1/sessions/${id}/runtime`,
|
|
{ runtime_id: 'remote' },
|
|
);
|
|
expect(switched.body.data.runtime_id).toBe('remote');
|
|
const snapshot = await call<AgentRuntimeBindingSnapshot>(
|
|
'GET',
|
|
`/api/v1/debug/session/${id}/agent/main/runtime-binding`,
|
|
);
|
|
expect(snapshot.body.data).toMatchObject({
|
|
binding: { workspaceId: current.body.data.workspace_id, runtimeId: 'remote' },
|
|
available: true,
|
|
runtime: { runtimeId: 'remote', generation: 'remote-two', status: 'ready' },
|
|
});
|
|
} finally {
|
|
await provider.dispose();
|
|
}
|
|
});
|
|
|
|
it('archives a session', async () => {
|
|
const id = await createSession(home as string);
|
|
const { body } = await call<null>('POST', rpc('core', ISessionManager, 'archive'), id);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data).toBeNull();
|
|
});
|
|
|
|
it('submits a prompt and returns the turn id', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const { body } = await call<{ turn_id: number }>(
|
|
'POST',
|
|
rpc('agent', IAgentPromptService, 'submit', { sid: id, aid: 'main' }),
|
|
{ input: [{ type: 'text', text: 'hello' }] },
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.turn_id).toBe(0);
|
|
});
|
|
|
|
it('maps a duplicate promptId to 40927 before metadata changes', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
const path = rpc('agent', IAgentPromptService, 'submit', { sid: id, aid: 'main' });
|
|
|
|
const first = await call<{ turn_id: number }>('POST', path, {
|
|
input: [{ type: 'text', text: 'first prompt' }],
|
|
promptId: 'submission-1',
|
|
});
|
|
expect(first.body.code).toBe(0);
|
|
|
|
const duplicate = await call<null>('POST', path, {
|
|
input: [{ type: 'text', text: 'must not become metadata' }],
|
|
promptId: 'submission-1',
|
|
});
|
|
expect(duplicate.body.code).toBe(40927);
|
|
|
|
const metadata = await call<SessionMetaWire>(
|
|
'POST',
|
|
rpc('session', ISessionMetadata, 'read', { sid: id }),
|
|
);
|
|
expect(metadata.body.data.lastPrompt).toBe('first prompt');
|
|
});
|
|
|
|
it('rejects disabledTools before bind without mutating prompt metadata', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const { body } = await call<null>(
|
|
'POST',
|
|
rpc('agent', IAgentPromptService, 'submit', { sid: id, aid: 'main' }),
|
|
{
|
|
input: [{ type: 'text', text: 'must not become metadata' }],
|
|
disabledTools: ['Bash'],
|
|
},
|
|
);
|
|
expect(body.code).toBe(40001);
|
|
|
|
const metadata = await call<SessionMetaWire>(
|
|
'POST',
|
|
rpc('session', ISessionMetadata, 'read', { sid: id }),
|
|
);
|
|
expect(metadata.body.data.title).toBeUndefined();
|
|
expect(metadata.body.data.lastPrompt).toBeUndefined();
|
|
});
|
|
|
|
it('derives the session title and lastPrompt from the first prompt', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const events: { type: string; payload: unknown }[] = [];
|
|
const sub = (server as RunningServer).core.accessor
|
|
.get(IEventService)
|
|
.subscribe((event) => events.push(event as unknown as { type: string; payload: unknown }));
|
|
|
|
const { body } = await call<{ turn_id: number }>(
|
|
'POST',
|
|
rpc('agent', IAgentPromptService, 'submit', { sid: id, aid: 'main' }),
|
|
{ input: [{ type: 'text', text: 'hello title' }] },
|
|
);
|
|
expect(body.code).toBe(0);
|
|
sub.dispose();
|
|
|
|
const meta = await call<SessionMetaWire>('POST', rpc('session', ISessionMetadata, 'read', { sid: id }));
|
|
expect(meta.body.code).toBe(0);
|
|
expect(meta.body.data.title).toBe('hello title');
|
|
expect(meta.body.data.lastPrompt).toBe('hello title');
|
|
|
|
const updated = events.find((e) => e.type === 'session.meta.updated');
|
|
expect(updated).toBeDefined();
|
|
const payload = updated?.payload as
|
|
| { title?: string; patch?: { lastPrompt?: string } }
|
|
| undefined;
|
|
expect(payload?.title).toBe('hello title');
|
|
expect(payload?.patch?.lastPrompt).toBe('hello title');
|
|
});
|
|
|
|
it('keeps a custom title and only refreshes lastPrompt on a later prompt', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const renamed = await call<null>('POST', rpc('session', ISessionMetadata, 'setTitle', { sid: id }), 'keep-me');
|
|
expect(renamed.body.code).toBe(0);
|
|
|
|
const { body } = await call<{ turn_id: number }>(
|
|
'POST',
|
|
rpc('agent', IAgentPromptService, 'submit', { sid: id, aid: 'main' }),
|
|
{ input: [{ type: 'text', text: 'should not become the title' }] },
|
|
);
|
|
expect(body.code).toBe(0);
|
|
|
|
const meta = await call<SessionMetaWire>('POST', rpc('session', ISessionMetadata, 'read', { sid: id }));
|
|
expect(meta.body.code).toBe(0);
|
|
expect(meta.body.data.title).toBe('keep-me');
|
|
expect(meta.body.data.lastPrompt).toBe('should not become the title');
|
|
});
|
|
|
|
it('runs a shell command through the shell command service', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const { body } = await call<{ stdout: string; stderr: string; isError?: boolean }>(
|
|
'POST',
|
|
rpc('agent', IAgentShellCommandService, 'run', { sid: id, aid: 'main' }),
|
|
{ command: 'printf hello' },
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.stdout).toBe('hello');
|
|
expect(body.data.stderr).toBe('');
|
|
expect(body.data.isError).not.toBe(true);
|
|
});
|
|
|
|
it('controls goals through the goal runtime facade', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const goal = goalFacade(id);
|
|
const created = await goal.createGoal({ objective: 'finish the migration' });
|
|
expect(created).toMatchObject({
|
|
objective: 'finish the migration',
|
|
status: 'active',
|
|
});
|
|
|
|
expect(goal.getGoal().goal).toMatchObject({
|
|
objective: 'finish the migration',
|
|
status: 'active',
|
|
});
|
|
|
|
const paused = await goal.pauseGoal({});
|
|
expect(paused.status).toBe('paused');
|
|
|
|
const resumed = await goal.resumeGoal({});
|
|
expect(resumed.status).toBe('active');
|
|
|
|
const cancelled = await goal.cancelGoal({});
|
|
expect(cancelled.status).toBe('active');
|
|
|
|
expect(goal.getGoal().goal).toBeNull();
|
|
});
|
|
|
|
it('rejects a duplicate goal through the goal runtime facade', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const goal = goalFacade(id);
|
|
await goal.createGoal({ objective: 'first' });
|
|
await expect(goal.createGoal({ objective: 'second' })).rejects.toMatchObject({
|
|
code: ErrorCodes.GOAL_ALREADY_EXISTS,
|
|
});
|
|
});
|
|
|
|
it('rejects goal access for subagents', async () => {
|
|
const id = await createSession(home as string);
|
|
await createSubagent(id, 'sub-1');
|
|
|
|
await expect(goalFacade(id, 'sub-1').createGoal({ objective: 'sub' })).rejects.toMatchObject({
|
|
code: ErrorCodes.GOAL_UNSUPPORTED_AGENT,
|
|
});
|
|
});
|
|
|
|
it('lists and installs plugins through RPC', async () => {
|
|
const pluginRoot = await mkdtemp(join(tmpdir(), 'server-v2-plugin-source-'));
|
|
try {
|
|
await writeFile(join(pluginRoot, 'deploy.md'), '---\ndescription: Deploy\n---\n\nDeploy body', 'utf8');
|
|
await writeFile(
|
|
join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({ name: 'rpc-plugin', commands: ['./deploy.md'] }),
|
|
'utf8',
|
|
);
|
|
|
|
const installed = await call<{ id: string }>('POST', rpc('core', IPluginService, 'installPlugin'), { source: pluginRoot });
|
|
expect(installed.body.code).toBe(0);
|
|
expect(installed.body.data.id).toBe('rpc-plugin');
|
|
|
|
const listed = await call<readonly { id: string; state: string }[]>('GET', rpc('core', IPluginService, 'listPlugins'));
|
|
expect(listed.body.code).toBe(0);
|
|
expect(listed.body.data).toEqual([
|
|
expect.objectContaining({ id: 'rpc-plugin', state: 'ok' }),
|
|
]);
|
|
|
|
const info = await call<{ id: string }>('POST', rpc('core', IPluginService, 'getPluginInfo'), { id: 'rpc-plugin' });
|
|
expect(info.body.code).toBe(0);
|
|
expect(info.body.data.id).toBe('rpc-plugin');
|
|
|
|
const commands = await call<readonly { pluginId: string; name: string }[]>(
|
|
'GET',
|
|
rpc('core', IPluginService, 'listPluginCommands'),
|
|
);
|
|
expect(commands.body.code).toBe(0);
|
|
expect(commands.body.data).toEqual([
|
|
expect.objectContaining({ pluginId: 'rpc-plugin', name: 'deploy' }),
|
|
]);
|
|
|
|
const sessionId = await createSession(home as string);
|
|
await createMainAgent(sessionId);
|
|
const activated = await call<null>(
|
|
'POST',
|
|
rpc('agent', IAgentPluginCommandService, 'activate', { sid: sessionId, aid: 'main' }),
|
|
{ pluginId: 'rpc-plugin', commandName: 'deploy', args: 'prod' },
|
|
);
|
|
expect(activated.body.code).toBe(0);
|
|
} finally {
|
|
await rm(pluginRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns 40401 when the agent does not exist', async () => {
|
|
const id = await createSession(home as string);
|
|
const { body } = await call<null>(
|
|
'POST',
|
|
rpc('agent', IAgentPromptService, 'submit', { sid: id, aid: 'does-not-exist' }),
|
|
{ input: [{ type: 'text', text: 'hello' }] },
|
|
);
|
|
expect(body.code).toBe(40401);
|
|
expect(body.msg).toBe(`agent does-not-exist not found in session ${id}`);
|
|
});
|
|
|
|
it('routes core / session / agent scopes by channel name', async () => {
|
|
const cwd = home as string;
|
|
await createSession(cwd);
|
|
|
|
const listed = await call<{ items: { id: string }[] }>('POST', rpc('core', ISessionIndex, 'listRecent'), {});
|
|
expect(listed.body.code).toBe(0);
|
|
expect(listed.body.data.items.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const id = listed.body.data.items[0]!.id;
|
|
const read = await call<SessionMetaWire>('POST', rpc('session', ISessionMetadata, 'read', { sid: id }));
|
|
expect(read.body.code).toBe(0);
|
|
expect(read.body.data.id).toBe(id);
|
|
});
|
|
|
|
it('rejects unknown method (40001)', async () => {
|
|
const { body } = await call<null>('POST', rpc('core', ISessionIndex, 'nope'));
|
|
expect(body.code).toBe(40001);
|
|
});
|
|
|
|
it('rejects unknown service (40001)', async () => {
|
|
const { body } = await call<null>('POST', '/api/v1/debug/does-not-exist/list');
|
|
expect(body.code).toBe(40001);
|
|
});
|
|
|
|
it('does not serve a missing method segment', async () => {
|
|
const { status, body } = await call<null>('POST', '/api/v1/debug/sessionIndex');
|
|
expect(status === 404 || body.code !== 0).toBe(true);
|
|
});
|
|
|
|
it('rejects unknown session (40401)', async () => {
|
|
const { body } = await call<null>('POST', rpc('session', ISessionMetadata, 'read', { sid: 'nope' }));
|
|
expect(body.code).toBe(40401);
|
|
});
|
|
|
|
it('rejects oversized body', async () => {
|
|
const huge = 'x'.repeat(2 * 1024 * 1024);
|
|
const token = (server as RunningServer).authTokenService.getToken();
|
|
let rejected = false;
|
|
let code: number | undefined;
|
|
try {
|
|
const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ big: huge }),
|
|
});
|
|
const body = (await res.json()) as Envelope<null>;
|
|
rejected = res.status === 413 || body.code !== 0;
|
|
code = body.code;
|
|
} catch {
|
|
rejected = true;
|
|
}
|
|
expect(rejected).toBe(true);
|
|
expect(code).not.toBe(0);
|
|
});
|
|
|
|
it('surfaces the originating stack trace on error', async () => {
|
|
const { body } = await call<null>('POST', rpc('session', ISessionMetadata, 'read', { sid: 'nope' }));
|
|
const json = JSON.stringify(body);
|
|
expect(json).toContain('"stack"');
|
|
expect(json).toContain('dispatch');
|
|
});
|
|
});
|
|
|
|
describe('server-v2 /api/v1/debug RPC auth', () => {
|
|
let server: RunningServer | undefined;
|
|
let home: string | undefined;
|
|
let base: string;
|
|
const token = 'test-secret-token';
|
|
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-auth-'));
|
|
server = await startServer({
|
|
hostIdentity: TEST_HOST_IDENTITY,
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
homeDir: home,
|
|
logLevel: 'silent',
|
|
rpcToken: token, debugEndpoints: true,
|
|
});
|
|
base = `http://127.0.0.1:${server.port}`;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (server !== undefined) {
|
|
await server.close();
|
|
server = undefined;
|
|
}
|
|
if (home !== undefined) {
|
|
await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never);
|
|
home = undefined;
|
|
}
|
|
});
|
|
|
|
it('rejects calls without a token (40101)', async () => {
|
|
const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { method: 'POST' });
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as Envelope<null>;
|
|
expect(body.code).toBe(40101);
|
|
});
|
|
|
|
it('accepts calls with the correct rpcToken', async () => {
|
|
const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, {
|
|
method: 'POST',
|
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const body = (await res.json()) as Envelope<{ items: unknown[] }>;
|
|
expect(body.code).toBe(0);
|
|
});
|
|
|
|
it('accepts the persistent token on /api/v1/debug', async () => {
|
|
const persistent = (server as RunningServer).authTokenService.getToken();
|
|
const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, {
|
|
method: 'POST',
|
|
headers: { authorization: `Bearer ${persistent}`, 'content-type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const body = (await res.json()) as Envelope<{ items: unknown[] }>;
|
|
expect(body.code).toBe(0);
|
|
});
|
|
|
|
it('rejects a wrong token (40101)', async () => {
|
|
const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, {
|
|
method: 'POST',
|
|
headers: { authorization: 'Bearer wrong' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as Envelope<null>;
|
|
expect(body.code).toBe(40101);
|
|
});
|
|
});
|
|
|
|
describe('server-v2 /api/v1/debug RPC (dev-only, whitelist-free)', () => {
|
|
let server: RunningServer | undefined;
|
|
let home: string | undefined;
|
|
let base: string;
|
|
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-debug-rpc-'));
|
|
server = await startServer({
|
|
hostIdentity: TEST_HOST_IDENTITY,
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
homeDir: home,
|
|
logLevel: 'silent',
|
|
debugEndpoints: true,
|
|
});
|
|
base = `http://127.0.0.1:${server.port}`;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (server !== undefined) {
|
|
await server.close();
|
|
server = undefined;
|
|
}
|
|
if (home !== undefined) {
|
|
await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never);
|
|
home = undefined;
|
|
}
|
|
});
|
|
|
|
async function call<T>(
|
|
method: 'GET' | 'POST',
|
|
path: string,
|
|
arg?: unknown,
|
|
): Promise<{ status: number; body: Envelope<T> }> {
|
|
const headers: Record<string, string> = {
|
|
authorization: `Bearer ${(server as RunningServer).authTokenService.getToken()}`,
|
|
};
|
|
const init: { method: string; headers: Record<string, string>; body?: string } = {
|
|
method,
|
|
headers,
|
|
};
|
|
if (arg !== undefined) {
|
|
headers['content-type'] = 'application/json';
|
|
init.body = JSON.stringify(arg);
|
|
}
|
|
const res = await fetch(`${base}${path}`, init);
|
|
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
|
}
|
|
|
|
it('describes every scoped Service via GET /api/v1/debug/channels', async () => {
|
|
const { status, body } = await call<readonly { name: string; scope: string }[]>(
|
|
'GET',
|
|
'/api/v1/debug/channels',
|
|
);
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.length).toBeGreaterThan(50);
|
|
const names = body.data.map((c) => c.name);
|
|
expect(names).toContain(String(IAppendLogStore));
|
|
expect(names).toContain(String(ISessionIndex));
|
|
});
|
|
|
|
it('calls a non-whitelisted Service method', async () => {
|
|
const { status, body } = await call(
|
|
'POST',
|
|
`/api/v1/debug/${String(IAppendLogStore)}/flush`,
|
|
[],
|
|
);
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(0);
|
|
});
|
|
|
|
it('also reaches whitelisted Services by the same wire names', async () => {
|
|
const { body } = await call<{ items: unknown[] }>(
|
|
'POST',
|
|
`/api/v1/debug/${String(ISessionIndex)}/listRecent`,
|
|
[{ limit: 1 }],
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(Array.isArray(body.data.items)).toBe(true);
|
|
});
|
|
|
|
it('rejects an unknown service with 40001', async () => {
|
|
const { body } = await call('POST', '/api/v1/debug/noSuchService/whatever', []);
|
|
expect(body.code).toBe(40001);
|
|
});
|
|
|
|
it('is gated by the same bearer auth as the rest of /api/*', async () => {
|
|
const res = await fetch(`${base}/api/v1/debug/channels`);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|