|
| 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