Skip to content

Commit a160915

Browse files
authored
test(klient): add real-server smoke coverage (#1713)
Add transport and persisted-session smoke scripts for HTTP, WebSocket, and scoped service calls. Type-check all examples and document remote-server usage and side effects.
1 parent a74ab44 commit a160915

7 files changed

Lines changed: 640 additions & 3 deletions

File tree

packages/klient/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,38 @@ automatically after an unexpected close (active `listen`s are re-subscribed;
5252
in-flight calls reject). The bearer token rides the
5353
`kimi-code.bearer.<token>` subprotocol, so the transport works unchanged in
5454
browsers.
55+
56+
## Real-server smoke checks
57+
58+
Run the transport smoke against a real server (the model phase is opt-in). It
59+
creates and archives a fixture session, and therefore touches the selected
60+
workspace's persisted metadata:
61+
62+
```sh
63+
KIMI_SERVER_URL=http://127.0.0.1:58627 \
64+
KIMI_SERVER_TOKEN=YOUR_SERVER_TOKEN \
65+
pnpm smoke
66+
67+
KIMI_SMOKE_MODEL=YOUR_MODEL pnpm smoke
68+
```
69+
70+
The history smoke checks persisted sessions before warming one, including the
71+
cold-session regression where an indexed session is unavailable through the v2
72+
session scope. It sends no explicit mutation request. When `KIMI_SMOKE_MARKER`
73+
is set, the v1 message read resumes the session and may persist server-side
74+
legacy metadata migrations:
75+
76+
```sh
77+
KIMI_SERVER_URL=http://127.0.0.1:58627 \
78+
KIMI_SERVER_TOKEN=YOUR_SERVER_TOKEN \
79+
KIMI_SMOKE_EXPECT_SESSION_ID=YOUR_SESSION_ID \
80+
KIMI_SMOKE_MARKER=YOUR_MARKER \
81+
KIMI_SMOKE_REQUIRE_HISTORY=1 \
82+
pnpm smoke:history
83+
```
84+
85+
`KIMI_SMOKE_EXPECT_CWD` can select a session by working directory instead of
86+
`KIMI_SMOKE_EXPECT_SESSION_ID`. The transport smoke creates its fixture in the
87+
first registered workspace; set `KIMI_SMOKE_CWD` when a different server-local
88+
folder is required. Omit `KIMI_SERVER_TOKEN` only for a server started with
89+
authentication bypassed.

packages/klient/examples/basic.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { HttpChannel, Klient, SessionIndexClient } from '@moonshot-ai/klient';
1111
import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex';
1212
import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
1313

14-
const BASE = process.env.KIMI_SERVER_URL ?? 'http://127.0.0.1:58627';
14+
const BASE = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627';
1515

1616
async function createSessionViaV1(cwd: string): Promise<string> {
1717
const res = await fetch(`${BASE}/api/v1/sessions`, {

packages/klient/examples/inspect-init.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ console.log('agents in metadata:', agentIds);
3131
for (const agentId of ['main', ...agentIds.filter((id) => id !== 'main')]) {
3232
const agent = client.session(sid).agent(agentId);
3333
try {
34-
const mode = await agent.service(IAgentPermissionModeService).mode();
34+
// Klient maps property reads to zero-argument RPC calls at runtime.
35+
const mode = await (
36+
agent.service(IAgentPermissionModeService) as unknown as { mode(): Promise<string> }
37+
).mode();
3538
// `get()` is sync in the shared interface but async over the wire.
3639
const context = await Promise.resolve(agent.service(IAgentContextMemoryService).get());
3740
console.log(`\n== agent ${agentId} == mode=${mode} messages=${context.length}`);
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import assert from 'node:assert/strict';
2+
3+
import { Klient } from '@moonshot-ai/klient';
4+
import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory';
5+
import {
6+
ISessionIndex,
7+
type SessionSummary,
8+
} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex';
9+
import {
10+
IWorkspaceRegistry,
11+
type Workspace,
12+
} from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry';
13+
import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
14+
15+
interface Envelope<T> {
16+
readonly code: number;
17+
readonly msg: string;
18+
readonly data: T;
19+
}
20+
21+
interface V1Workspace {
22+
readonly id: string;
23+
readonly root: string;
24+
readonly session_count: number;
25+
}
26+
27+
interface V1Session {
28+
readonly id: string;
29+
readonly workspace_id: string;
30+
readonly metadata?: { readonly cwd?: string };
31+
readonly archived?: boolean;
32+
}
33+
34+
interface V1Message {
35+
readonly id: string;
36+
readonly role: string;
37+
readonly content: readonly unknown[];
38+
}
39+
40+
function optionalEnv(name: string): string | undefined {
41+
const value = process.env[name];
42+
return value === undefined || value === '' ? undefined : value;
43+
}
44+
45+
const baseUrl = (process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627').replace(/\/$/, '');
46+
const token = optionalEnv('KIMI_SERVER_TOKEN');
47+
const expectedSessionId = optionalEnv('KIMI_SMOKE_EXPECT_SESSION_ID');
48+
const expectedCwd = optionalEnv('KIMI_SMOKE_EXPECT_CWD');
49+
const marker = optionalEnv('KIMI_SMOKE_MARKER');
50+
const requireHistory = /^(1|true|yes)$/i.test(process.env['KIMI_SMOKE_REQUIRE_HISTORY'] ?? 'false');
51+
52+
function authHeaders(): Headers {
53+
const result = new Headers();
54+
if (token !== undefined) result.set('authorization', `Bearer ${token}`);
55+
return result;
56+
}
57+
58+
async function v1<T>(path: string): Promise<T> {
59+
const response = await fetch(`${baseUrl}/api/v1${path}`, { headers: authHeaders() });
60+
const envelope = (await response.json()) as Envelope<T>;
61+
assert.equal(response.ok, true, `v1 ${path} returned HTTP ${String(response.status)}`);
62+
assert.equal(envelope.code, 0, `v1 ${path}: ${String(envelope.code)} ${envelope.msg}`);
63+
return envelope.data;
64+
}
65+
66+
function sortedStrings(values: Iterable<string>): readonly string[] {
67+
return [...values].toSorted((a, b) => a.localeCompare(b));
68+
}
69+
70+
function canonicalPath(value: string): string {
71+
const slashed = value.replaceAll('\\', '/').replace(/\/+$/, '');
72+
const rooted = /^[A-Za-z]:$/.test(slashed) ? `${slashed}/` : slashed;
73+
return /^(?:[A-Za-z]:\/|\/\/)/.test(rooted) ? rooted.toLowerCase() : rooted;
74+
}
75+
76+
function duplicates(values: readonly string[]): readonly string[] {
77+
const counts = new Map<string, number>();
78+
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
79+
return sortedStrings([...counts].filter(([, count]) => count > 1).map(([value]) => value));
80+
}
81+
82+
function diagnoseAliases(values: readonly string[]): readonly string[] {
83+
const groups = new Map<string, Set<string>>();
84+
for (const value of values) {
85+
const canonical = canonicalPath(value);
86+
const group = groups.get(canonical) ?? new Set<string>();
87+
group.add(value);
88+
groups.set(canonical, group);
89+
}
90+
return sortedStrings(
91+
[...groups.values()]
92+
.filter((group) => group.size > 1)
93+
.map((group) => sortedStrings(group).join(' <> ')),
94+
);
95+
}
96+
97+
function text(value: unknown): string {
98+
if (typeof value === 'string') return value;
99+
if (Array.isArray(value)) return value.map(text).join('\n');
100+
if (value !== null && typeof value === 'object') {
101+
const record = value as Record<string, unknown>;
102+
if (typeof record['text'] === 'string') return record['text'];
103+
return Object.values(record).map(text).join('\n');
104+
}
105+
return '';
106+
}
107+
108+
async function listAllV1Sessions(): Promise<readonly V1Session[]> {
109+
const items: V1Session[] = [];
110+
let beforeId: string | undefined;
111+
for (;;) {
112+
const query = new URLSearchParams({ include_archive: 'true', page_size: '100' });
113+
if (beforeId !== undefined) query.set('before_id', beforeId);
114+
const page = await v1<{ readonly items: readonly V1Session[]; readonly has_more: boolean }>(
115+
`/sessions?${query.toString()}`,
116+
);
117+
items.push(...page.items);
118+
if (!page.has_more) return items;
119+
const next = page.items.at(-1)?.id;
120+
assert.ok(next !== undefined && next !== beforeId, 'v1 session pagination did not advance');
121+
beforeId = next;
122+
}
123+
}
124+
125+
async function listAllV1Messages(sessionId: string): Promise<readonly V1Message[]> {
126+
const items: V1Message[] = [];
127+
let beforeId: string | undefined;
128+
for (;;) {
129+
const query = new URLSearchParams({ page_size: '100' });
130+
if (beforeId !== undefined) query.set('before_id', beforeId);
131+
const page = await v1<{ readonly items: readonly V1Message[]; readonly has_more: boolean }>(
132+
`/sessions/${encodeURIComponent(sessionId)}/messages?${query.toString()}`,
133+
);
134+
items.push(...page.items);
135+
if (!page.has_more) return items;
136+
const next = page.items.at(-1)?.id;
137+
assert.ok(next !== undefined && next !== beforeId, 'v1 message pagination did not advance');
138+
beforeId = next;
139+
}
140+
}
141+
142+
function selectColdSession(sessions: readonly SessionSummary[]): SessionSummary | undefined {
143+
if (expectedSessionId !== undefined) {
144+
const selected = sessions.find((session) => session.id === expectedSessionId);
145+
assert.ok(selected, `expected session ${expectedSessionId} is absent from the global index`);
146+
return selected;
147+
}
148+
if (expectedCwd !== undefined) {
149+
const canonicalExpected = canonicalPath(expectedCwd);
150+
const matches = sessions.filter(
151+
(session) => session.cwd !== undefined && canonicalPath(session.cwd) === canonicalExpected,
152+
);
153+
assert.equal(matches.length, 1, `expected cwd must identify exactly one session; found ${String(matches.length)}`);
154+
return matches[0];
155+
}
156+
return sessions[0];
157+
}
158+
159+
function report(label: string, values: readonly string[]): void {
160+
console.log(`${label}: ${values.length === 0 ? 'none' : values.join(', ')}`);
161+
}
162+
163+
async function main(): Promise<void> {
164+
console.log(`server: ${baseUrl}`);
165+
const client = new Klient({ url: baseUrl, token });
166+
const index = client.core(ISessionIndex);
167+
168+
// This must be the first session operation: capture the durable global index
169+
// before any session-scoped call can materialize a cold session.
170+
const globalPage = await index.list({ includeArchived: true });
171+
const sessions = globalPage.items;
172+
assert.ok(Array.isArray(sessions), 'global ISessionIndex.list did not return items');
173+
if (requireHistory || expectedSessionId !== undefined || expectedCwd !== undefined || marker !== undefined) {
174+
assert.ok(sessions.length > 0, 'historical sessions are required but the global index is empty');
175+
}
176+
console.log(`global index before warm-up: ${String(sessions.length)} sessions`);
177+
const failures: string[] = [];
178+
179+
// Regression probe: this intentionally fails while the v2 dispatcher only
180+
// looks up live scopes instead of resuming an indexed cold session.
181+
const cold = selectColdSession(sessions);
182+
if (cold !== undefined) {
183+
try {
184+
const metadata = await client.session(cold.id).service(ISessionMetadata).read();
185+
assert.equal(metadata.id, cold.id, 'cold ISessionMetadata.read returned the wrong session');
186+
assert.equal(metadata.cwd, cold.cwd, 'cold metadata cwd differs from the index');
187+
assert.equal(metadata.archived, cold.archived, 'cold metadata archived flag differs from the index');
188+
console.log(`PASS cold ISessionMetadata.read (${cold.id})`);
189+
} catch (error) {
190+
const message = `cold session ${cold.id} is globally indexed but unavailable through session scope: ${error instanceof Error ? error.message : String(error)}`;
191+
failures.push(message);
192+
console.error(`FAIL ${message}`);
193+
}
194+
} else {
195+
console.log('SKIP cold metadata read (no history found)');
196+
}
197+
198+
const registry = client.core(IWorkspaceRegistry);
199+
const workspaces = await registry.list();
200+
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
201+
const workspaceIds = new Set([
202+
...workspaces.map((workspace) => workspace.id),
203+
...sessions.map((session) => session.workspaceId),
204+
]);
205+
206+
for (const workspaceId of workspaceIds) {
207+
const filtered = await index.list({ workspaceId, includeArchived: true });
208+
const workspaceSessions: readonly SessionSummary[] = sessions.filter(
209+
(session: SessionSummary) => session.workspaceId === workspaceId,
210+
);
211+
assert.deepEqual(
212+
filtered.items.map((session) => session.id).toSorted((a, b) => a.localeCompare(b)),
213+
workspaceSessions.map((session: SessionSummary) => session.id).toSorted((a, b) => a.localeCompare(b)),
214+
`workspace-filtered index mismatch for ${workspaceId}`,
215+
);
216+
const active = workspaceSessions.filter((session: SessionSummary) => !session.archived).length;
217+
assert.equal(await index.countActive(workspaceId), active, `countActive mismatch for ${workspaceId}`);
218+
}
219+
console.log(`PASS workspace-filtered list/countActive (${String(workspaceIds.size)} workspace ids)`);
220+
221+
const [v1WorkspacePage, v1Sessions] = await Promise.all([
222+
v1<{ readonly items: readonly V1Workspace[] }>('/workspaces'),
223+
listAllV1Sessions(),
224+
]);
225+
const v1Workspaces = v1WorkspacePage.items;
226+
assert.deepEqual(
227+
v1Workspaces.map((workspace) => workspace.id).toSorted((a, b) => a.localeCompare(b)),
228+
workspaces.map((workspace) => workspace.id).toSorted((a, b) => a.localeCompare(b)),
229+
'v1 and IWorkspaceRegistry workspace ids differ',
230+
);
231+
for (const workspace of v1Workspaces) {
232+
assert.equal(workspace.root, workspaceById.get(workspace.id)?.root, `v1 root mismatch for ${workspace.id}`);
233+
assert.equal(
234+
workspace.session_count,
235+
sessions.filter((session) => session.workspaceId === workspace.id).length,
236+
`v1 session_count mismatch for ${workspace.id}`,
237+
);
238+
}
239+
assert.deepEqual(
240+
v1Sessions.map((session) => session.id).toSorted((a, b) => a.localeCompare(b)),
241+
sessions.map((session) => session.id).toSorted((a, b) => a.localeCompare(b)),
242+
'v1 and ISessionIndex session ids differ',
243+
);
244+
console.log('PASS v1 workspace/session cross-check');
245+
246+
const orphanIds = sessions
247+
.filter((session) => !workspaceById.has(session.workspaceId))
248+
.map((session) => session.id)
249+
.toSorted((a, b) => a.localeCompare(b));
250+
const allPaths = [
251+
...workspaces.map((workspace: Workspace) => workspace.root),
252+
...sessions.flatMap((session) => (session.cwd === undefined ? [] : [session.cwd])),
253+
];
254+
const pathAliases = diagnoseAliases(allPaths);
255+
const duplicateIndexIds = duplicates(sessions.map((session) => session.id));
256+
const duplicateV1Ids = duplicates(v1Sessions.map((session) => session.id));
257+
const duplicateWorkspaceIds = duplicates(workspaces.map((workspace) => workspace.id));
258+
const duplicateWorkspaceRoots = duplicates(
259+
workspaces.map((workspace) => canonicalPath(workspace.root)),
260+
);
261+
report('orphan sessions', orphanIds);
262+
report('Windows/canonical path aliases', pathAliases);
263+
report('duplicate index session ids', duplicateIndexIds);
264+
report('duplicate v1 session ids', duplicateV1Ids);
265+
report('duplicate workspace ids', duplicateWorkspaceIds);
266+
report('duplicate canonical workspace roots', duplicateWorkspaceRoots);
267+
if (orphanIds.length > 0) failures.push(`orphan sessions: ${orphanIds.join(', ')}`);
268+
if (pathAliases.length > 0) failures.push(`Windows/canonical path aliases: ${pathAliases.join(', ')}`);
269+
if (duplicateIndexIds.length > 0) failures.push(`duplicate index session ids: ${duplicateIndexIds.join(', ')}`);
270+
if (duplicateV1Ids.length > 0) failures.push(`duplicate v1 session ids: ${duplicateV1Ids.join(', ')}`);
271+
if (duplicateWorkspaceIds.length > 0) failures.push(`duplicate workspace ids: ${duplicateWorkspaceIds.join(', ')}`);
272+
if (duplicateWorkspaceRoots.length > 0) {
273+
failures.push(`duplicate canonical workspace roots: ${duplicateWorkspaceRoots.join(', ')}`);
274+
}
275+
276+
if (marker !== undefined) {
277+
assert.ok(cold, 'KIMI_SMOKE_MARKER requires a selected historical session');
278+
// The v1 read resumes the session first; only then query the materialized
279+
// agent scope. This avoids racing a cold v2 scope lookup against resume.
280+
const v1Messages = await listAllV1Messages(cold.id);
281+
const context = await Promise.resolve(
282+
client.session(cold.id).agent('main').service(IAgentContextMemoryService).get(),
283+
);
284+
const v1Matches = v1Messages.filter((message) => text(message.content).includes(marker));
285+
const contextMatches = context.filter((message) => text(message.content).includes(marker));
286+
assert.ok(v1Matches.length > 0, `marker ${JSON.stringify(marker)} absent from v1 messages`);
287+
assert.ok(contextMatches.length > 0, `marker ${JSON.stringify(marker)} absent from agent context`);
288+
assert.deepEqual(
289+
sortedStrings(v1Matches.map((message) => message.role)),
290+
sortedStrings(contextMatches.map((message) => message.role)),
291+
'marker message roles differ between v1 messages and agent context',
292+
);
293+
console.log(`PASS marker cross-check (${String(v1Matches.length)} matching messages)`);
294+
}
295+
296+
if (failures.length > 0) {
297+
throw new Error(`history audit found ${String(failures.length)} issue(s):\n- ${failures.join('\n- ')}`);
298+
}
299+
console.log('HISTORY SMOKE PASSED');
300+
}
301+
302+
try {
303+
await main();
304+
} catch (error) {
305+
console.error('HISTORY SMOKE FAILED:', error instanceof Error ? error.stack ?? error.message : error);
306+
process.exitCode = 1;
307+
}

0 commit comments

Comments
 (0)