diff --git a/.changeset/kap-server-meta-experimental-flags.md b/.changeset/kap-server-meta-experimental-flags.md new file mode 100644 index 0000000000..ac946e9449 --- /dev/null +++ b/.changeset/kap-server-meta-experimental-flags.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kap-server": patch +--- + +Expose the effective experimental-flag map as `experimental_flags` on `GET /api/v1/meta`. diff --git a/packages/kap-server/src/protocol/rest-meta.ts b/packages/kap-server/src/protocol/rest-meta.ts index 090712e27e..1dda5f3aaf 100644 --- a/packages/kap-server/src/protocol/rest-meta.ts +++ b/packages/kap-server/src/protocol/rest-meta.ts @@ -32,6 +32,17 @@ export const metaResponseSchema = z.object({ * credential. Defaults to false on hardened boots. */ dangerous_bypass_auth: z.boolean(), + /** + * Effective experimental-flag state (flag id → enabled), resolved by the + * FlagService from every source (master env, per-flag env, the + * `[experimental]` config section, defaults). This is the *effective* state, + * not the persisted config section: a flag enabled via + * `KIMI_CODE_EXPERIMENTAL_*` env appears here but not in `GET /config`'s + * `experimental` map. Computed per request — config-section writes flip it + * live. Clients use it to gate experimental UI; older servers omit it, so + * treat absence as "no flags enabled". + */ + experimental_flags: z.record(z.string(), z.boolean()).optional(), /** * Backend engine generation serving this API. `'v2'` is the DI × Scope * engine (`@moonshot-ai/kap-server` / `agent-core-v2`); older servers omit diff --git a/packages/kap-server/src/routes/meta.ts b/packages/kap-server/src/routes/meta.ts index 58f2799148..932cbd231a 100644 --- a/packages/kap-server/src/routes/meta.ts +++ b/packages/kap-server/src/routes/meta.ts @@ -11,7 +11,10 @@ * must treat unbacked capabilities as not-yet-available until the corresponding * routes are wired. * - * **No DI**: pure server-self info; the payload is frozen at registration time. + * **No DI for the static fields**: pure server-self info; that part of the + * payload is frozen at registration time. `experimental_flags` is the + * exception — flag state flips live when the `[experimental]` config section + * changes, so it is resolved per request through the injected getter. */ import { okEnvelope } from '../envelope'; @@ -39,10 +42,17 @@ export interface MetaRouteOptions { * the web UI can skip the token prompt and connect without a credential. */ readonly dangerousBypassAuth: boolean; + /** + * Resolves the effective experimental-flag map (flag id → enabled) at + * request time. Backed by `IFlagService.snapshot()` in production; tests may + * stub it. May return a promise — the handler awaits it, so flag state + * always reflects the fully loaded config (never pre-load defaults). + */ + readonly getExperimentalFlags: () => Record | Promise>; } export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void { - const data: MetaResponse = Object.freeze({ + const staticData = Object.freeze({ server_version: opts.serverVersion, capabilities: Object.freeze({ websocket: true as const, @@ -68,6 +78,10 @@ export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void tags: ['meta'], }, async (req, reply) => { + const data: MetaResponse = { + ...staticData, + experimental_flags: await opts.getExperimentalFlags(), + }; reply.send(okEnvelope(data, req.id)); }, ); diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 36adaa3e44..047d206c76 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -9,7 +9,8 @@ * folder picker, the session filesystem, terminals, connections, shutdown). */ -import type { Scope } from '@moonshot-ai/agent-core-v2'; +import { IConfigService, type Scope } from '@moonshot-ai/agent-core-v2'; +import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag'; import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; import { ulid } from 'ulid'; @@ -105,6 +106,15 @@ export async function registerApiV1Routes( serverId: ulid(), startedAt: new Date().toISOString(), dangerousBypassAuth: opts.dangerousBypassAuth === true, + getExperimentalFlags: async () => { + // Same edge-facade contract as the config route: never project + // config-derived state before the initial load settles — an early + // /meta hit would otherwise advertise default/env-only flags and + // hide config-enabled features until the FlagService's change + // watcher catches up. + await core.accessor.get(IConfigService).ready; + return core.accessor.get(IFlagService).snapshot(); + }, }); registerAuthRoute(apiV1 as unknown as Parameters[0], core); diff --git a/packages/kap-server/test/meta.test.ts b/packages/kap-server/test/meta.test.ts new file mode 100644 index 0000000000..95e5e61934 --- /dev/null +++ b/packages/kap-server/test/meta.test.ts @@ -0,0 +1,126 @@ +/** + * `/api/v1/meta` tests — the static server-self fields are covered by + * `boot.test.ts`; these focus on `experimental_flags`, the effective + * experimental-flag map resolved per request from every flag source (env, + * master env, the `[experimental]` config section, defaults). + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authedFetch } from './helpers/auth'; + +interface MetaBody { + code: number; + data: { experimental_flags?: Record }; +} + +describe('/api/v1/meta experimental_flags', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + beforeEach(() => { + // Neutralize flag env vars leaking from the developer shell (e.g. a + // globally exported KIMI_CODE_EXPERIMENTAL_FLAG=1) so each test starts + // from the default-off baseline. The master env can be pinned to '0' (it + // only forces ON), but the per-flag env must be fully ABSENT — an + // explicit '0' is an env override that outranks the config section. + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', undefined); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + // The query-store cache can still be flushing while we clean up; retry + // instead of flaking on ENOTEMPTY. + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + home = undefined; + } + }); + + async function boot(toml?: string): Promise { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-meta-')); + if (toml !== undefined) { + await writeFile(join(home, 'config.toml'), toml, 'utf-8'); + } + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + return `http://127.0.0.1:${server.port}`; + } + + async function getMetaFlags(base: string): Promise> { + const res = await authedFetch(server as RunningServer, base, '/api/v1/meta'); + expect(res.status).toBe(200); + const body = (await res.json()) as MetaBody; + expect(body.code).toBe(0); + expect(body.data.experimental_flags).toBeDefined(); + return body.data.experimental_flags as Record; + } + + it('reports registered flags as off by default', async () => { + const base = await boot(); + const flags = await getMetaFlags(base); + expect(flags['secondary-model']).toBe(false); + }); + + it('reports a config-enabled flag from the very first response', async () => { + // Regression for the startup race: FlagService reads the `[experimental]` + // section from a config that loads asynchronously, so the handler awaits + // IConfigService.ready before snapshotting — a persisted flag must be + // visible even to the earliest request. + const base = await boot('[experimental]\nsecondary-model = true\n'); + const flags = await getMetaFlags(base); + expect(flags['secondary-model']).toBe(true); + }); + + it('reflects a flag enabled via its KIMI_CODE_EXPERIMENTAL_* env var', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1'); + const base = await boot(); + const flags = await getMetaFlags(base); + expect(flags['secondary-model']).toBe(true); + }); + + it('flips live when the [experimental] config section is written via POST /config', async () => { + const base = await boot(); + expect((await getMetaFlags(base))['secondary-model']).toBe(false); + + const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ experimental: { 'secondary-model': true } }), + }); + expect(res.status).toBe(200); + + expect((await getMetaFlags(base))['secondary-model']).toBe(true); + }); + + it('keeps an env-forced flag on when the config section disables it', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1'); + const base = await boot(); + + const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ experimental: { 'secondary-model': false } }), + }); + expect(res.status).toBe(200); + + // Env outranks the config section in FlagService resolution. + expect((await getMetaFlags(base))['secondary-model']).toBe(true); + }); +});