mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-16 20:25:36 +00:00
* fix: stop advertising Claude thinking efforts for non-Claude models Models served over the Anthropic protocol whose names carry no Claude marker (e.g. a catalog-imported Kimi K3) no longer inherit the latest Opus effort list, so the model selector stops offering levels the model does not accept. The models.dev catalog import now also parses reasoning_options and records the declared effort levels on the model alias, so K3 offers its real levels (low / high / max). * fix: consume deprecated, override, and input-limit metadata from the models.dev catalog - Models declared status=deprecated in the catalog are no longer offered for import. - Per-model provider overrides on gateway providers (an npm package targeting an Anthropic SDK plus a usable endpoint) now land as alias protocol and base_url, so those models are served over the right protocol and endpoint; overrides without a usable URL are skipped. - A declared limit.input now sizes the context budget instead of the larger total context window (e.g. gpt-5: 272k instead of 400k). The model alias schema gains an optional base_url field (not accepted in overrides) that Anthropic wire resolution prefers over the provider-level base URL. * fix: honor thinking-disable semantics and the OpenAI-compatible fallback in catalog imports - reasoning_options 'none' is the model's off encoding: off_effort flows from the catalog through the model alias to the OpenAI wire providers, so turning thinking off sends 'none' instead of omitting the effort field; models with effort levels but no way to disable thinking are imported as always_thinking and no longer offer an Off option. - Bare Claude family aliases (e.g. sonnet-latest) recover the inferred Anthropic effort profile; v2 comment conventions restored. - Providers whose SDK the catalog does not type now fall back to the OpenAI-compatible wire (with a visible "guessed" note) instead of being refused; imports lacking a usable endpoint ask for one (--base-url on the CLI, a prompt in the TUI). Proprietary SDKs (Amazon Bedrock), unrecognized explicit types, and env-placeholder URLs are refused with a clear reason. * fix: align catalog imports with the reference models.dev consumer - A JSON null tier in declared effort values is now read as the 'none' off-encoding (previously such models were wrongly imported as always-thinking with no way to turn reasoning off). - Alpha-status models are filtered out alongside deprecated ones. - Models whose per-model provider override targets a wire that cannot be expressed per-model (e.g. Claude on google-vertex, whose wire here is Gemini-mode Vertex, or gpt entries on an Anthropic provider) are skipped instead of being imported under the silently wrong protocol. - interleaved: true no longer pins reasoning_content: the provider's default three-field scan is wider and the pinned key only narrowed reasoning parsing for gateways answering with another field name. * fix: require endpoints for Anthropic-compatible catalog imports and honor --base-url - catalogProviderNeedsBaseUrl now covers the Anthropic wire: a non-official Anthropic-compatible vendor without a concrete catalog endpoint (e.g. google-vertex-anthropic) must supply --base-url / the TUI prompt instead of silently falling back to the default Anthropic endpoint. - --base-url now takes precedence over the catalog-declared endpoint, and an empty --base-url is rejected instead of persisting a blank endpoint. * fix: enforce always-on thinking on every wire and refuse Cohere at import A model that declares always_thinking (e.g. a catalog-imported gpt-5) no longer resolves to a dishonest off state via thinking.enabled=false or an SDK/ACP off request: resolution clamps to the model's default effort on every wire instead of letting upstream keep reasoning while the UI reports Off. The Anthropic warn-and-send path for unlisted effort levels is unchanged. Cohere's proprietary SDK joins Amazon Bedrock on the import-refusal list instead of being guessed as OpenAI-compatible. * fix: harden catalog import edge cases - An explicit but unrecognized catalog type is now refused before npm/id inference, so a future catalog protocol is never silently miswired through the OpenAI fallback. - User-supplied --base-url values for Anthropic-wire providers get the same trailing-/v1 normalization as catalog endpoints, avoiding /v1/v1/messages requests. - The TUI import prompt rejects env-placeholder base URLs like the CLI does. * fix: await the floating assertion promise in the catalog add CLI test * refactor: unify catalog import resolution into a single decision function Wire-type inference, the OpenAI-compatible fallback, proprietary-SDK refusal, endpoint adaptation, and the base-URL requirement are now produced together by resolveCatalogImport, one pure resolver consumed by both the CLI and the TUI — replacing the cooperating predicates (inferWireType, isGuessedWireType, catalogProviderNeedsBaseUrl) whose permutations kept producing edge cases. No behavior change. * fix: close configured-off clamp hole, keep inferWireType compat, carry same-wire override endpoints - A configured thinking.effort = "off" no longer bypasses the always-on clamp: it is treated as absent and the model default applies, mirrored on both engines. - The previously public inferWireType stays as a deprecated compatibility wrapper over resolveCatalogImport so existing SDK consumers do not break on a patch release. - Catalog model overrides that stay on the provider's wire but declare their own endpoint now persist it on the alias (and the v1 OpenAI wire branches honor alias-level base URLs like the Anthropic branch). * fix: split total window from input cap and close override/endpoint gaps - max_context_tokens once again means the total context window (used by completion budgeting); a model's declared input limit is tracked as max_input_tokens, which compaction, context-splice and usage-ratio checks prefer — fixing the over-clamping introduced when the input cap was stored as the context budget. - A catalog endpoint declared only as an env placeholder now always produces needs-base-url (official SDK included), so credentials are never sent to the public vendor host by default. - api-only per-model overrides are honored as same-wire endpoint changes; overrides targeting another known but inexpressible wire (e.g. google-genai on an OpenAI gateway) are skipped; same-wire models whose declared endpoint is an unusable placeholder are skipped instead of silently rerouted. * style: drop a function-level comment from the v2 thinking resolver * chore: consolidate the PR's changesets into two user-facing entries
608 lines
20 KiB
TypeScript
608 lines
20 KiB
TypeScript
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'pathe';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
createRPC,
|
|
KimiCore,
|
|
type CoreAPI,
|
|
type SDKAPI,
|
|
type TelemetryClient,
|
|
} from '../../src';
|
|
import {
|
|
__resetRootLoggerForTest,
|
|
getRootLogger,
|
|
} from '../../src/logging/logger';
|
|
import { resolveLoggingConfig } from '../../src/logging/resolve-config';
|
|
import {
|
|
recordingContextTelemetry,
|
|
type TelemetryContextRecord,
|
|
} from '../fixtures/telemetry';
|
|
|
|
const CONFIG = `
|
|
default_model = "kimi-code/kimi-for-coding"
|
|
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
api_key = "test-key"
|
|
base_url = "https://api.example/v1"
|
|
|
|
[models."kimi-code/kimi-for-coding"]
|
|
provider = "managed:kimi-code"
|
|
model = "kimi-for-coding"
|
|
max_context_size = 1000000
|
|
capabilities = ["thinking"]
|
|
support_efforts = ["low", "medium", "high"]
|
|
default_effort = "high"
|
|
`;
|
|
|
|
describe('HarnessAPI session model aliases', () => {
|
|
let tmp: string;
|
|
let homeDir: string;
|
|
let workDir: string;
|
|
let configPath: string;
|
|
|
|
beforeEach(async () => {
|
|
tmp = await mkdtemp(join(tmpdir(), 'kimi-model-alias-'));
|
|
homeDir = join(tmp, 'home');
|
|
workDir = join(tmp, 'work');
|
|
configPath = join(tmp, 'config.toml');
|
|
await mkdir(workDir, { recursive: true });
|
|
await writeFile(configPath, CONFIG);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await __resetRootLoggerForTest();
|
|
await rm(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
const compatibleConfig = (supportEfforts: string, defaultEffort: string) => `
|
|
default_model = "compatible/model"
|
|
|
|
[providers.compatible]
|
|
type = "kimi"
|
|
api_key = "test-key"
|
|
base_url = "https://api.example.test"
|
|
|
|
[models."compatible/model"]
|
|
provider = "compatible"
|
|
model = "compatible-model"
|
|
protocol = "anthropic"
|
|
max_context_size = 128000
|
|
capabilities = ["thinking"]
|
|
support_efforts = [${supportEfforts}]
|
|
default_effort = "${defaultEffort}"
|
|
`;
|
|
|
|
async function createEffortReplaySession(): Promise<string> {
|
|
await writeFile(configPath, compatibleConfig('"high", "max"', 'high'));
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({ workDir, model: 'compatible/model' });
|
|
await rpc.setThinking({ sessionId: created.id, agentId: 'main', effort: 'max' });
|
|
await rpc.closeSession({ sessionId: created.id });
|
|
await writeFile(configPath, compatibleConfig('"max"', 'max'));
|
|
return created.id;
|
|
}
|
|
|
|
it('keeps the configured alias separate from the provider model across create, setModel, and resume', async () => {
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({
|
|
workDir,
|
|
model: 'kimi-code/kimi-for-coding',
|
|
});
|
|
|
|
expect(await rpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(
|
|
'kimi-code/kimi-for-coding',
|
|
);
|
|
|
|
const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' });
|
|
expect(config.modelAlias).toBe('kimi-code/kimi-for-coding');
|
|
expect(config.provider?.model).toBe('kimi-for-coding');
|
|
expect(config.modelCapabilities?.max_context_tokens).toBe(1_000_000);
|
|
|
|
await rpc.setModel({
|
|
sessionId: created.id,
|
|
agentId: 'main',
|
|
model: 'kimi-code/kimi-for-coding',
|
|
});
|
|
|
|
const freshRpc = await createTestRpc();
|
|
await freshRpc.resumeSession({ sessionId: created.id });
|
|
|
|
expect(await freshRpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(
|
|
'kimi-code/kimi-for-coding',
|
|
);
|
|
});
|
|
|
|
it('resolves the initial effort with provider context for an Anthropic-typed provider', async () => {
|
|
// The model name is unknown to the Anthropic profile matrix but still
|
|
// carries a Claude marker, and the alias declares no protocol/capabilities
|
|
// itself; the provider's `type = "anthropic"` must still route the
|
|
// default resolution through the inferred profile (default effort
|
|
// "high"), not fall back to "off".
|
|
await writeFile(
|
|
configPath,
|
|
`
|
|
default_model = "compat/custom"
|
|
|
|
[providers.compat]
|
|
type = "anthropic"
|
|
api_key = "test-key"
|
|
base_url = "https://api.example.test"
|
|
|
|
[models."compat/custom"]
|
|
provider = "compat"
|
|
model = "joint-claude-0714-vibe"
|
|
max_context_size = 200000
|
|
`,
|
|
);
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({ workDir });
|
|
|
|
const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' });
|
|
expect(config.thinkingEffort).toBe('high');
|
|
|
|
// The recorded bootstrap effort must survive resume unchanged.
|
|
await rpc.closeSession({ sessionId: created.id });
|
|
const freshRpc = await createTestRpc();
|
|
await freshRpc.resumeSession({ sessionId: created.id });
|
|
const restored = await freshRpc.getConfig({ sessionId: created.id, agentId: 'main' });
|
|
expect(restored.thinkingEffort).toBe('high');
|
|
});
|
|
|
|
it('honors an explicit session effort for an Anthropic-typed provider', async () => {
|
|
await writeFile(
|
|
configPath,
|
|
`
|
|
default_model = "compat/custom"
|
|
|
|
[providers.compat]
|
|
type = "anthropic"
|
|
api_key = "test-key"
|
|
base_url = "https://api.example.test"
|
|
|
|
[models."compat/custom"]
|
|
provider = "compat"
|
|
model = "joint-model-0714-vibe"
|
|
max_context_size = 200000
|
|
`,
|
|
);
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({ workDir, thinking: 'low' });
|
|
|
|
const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' });
|
|
expect(config.thinkingEffort).toBe('low');
|
|
});
|
|
|
|
it('restores the final effort after replaying an earlier unlisted Anthropic effort', async () => {
|
|
const sessionId = await createEffortReplaySession();
|
|
|
|
// The current catalog no longer lists the earlier `high` state. Replay
|
|
// must continue to the following `max` record instead of validating and
|
|
// aborting at the transient state.
|
|
const events: Array<Parameters<SDKAPI['emitEvent']>[0]> = [];
|
|
const freshRpc = await createTestRpc({ emitEvent: (event) => events.push(event) });
|
|
|
|
await expect(freshRpc.resumeSession({ sessionId })).resolves.toBeDefined();
|
|
expect(events).toContainEqual({
|
|
sessionId,
|
|
agentId: 'main',
|
|
type: 'warning',
|
|
code: 'anthropic-thinking-effort-not-listed',
|
|
message:
|
|
'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.',
|
|
});
|
|
const restored = await freshRpc.getConfig({ sessionId, agentId: 'main' });
|
|
expect(restored.modelAlias).toBe('compatible/model');
|
|
expect(restored.thinkingEffort).toBe('max');
|
|
});
|
|
|
|
it('does not block resume when the warning sink fails', async () => {
|
|
const sessionId = await createEffortReplaySession();
|
|
|
|
const throwingRpc = await createTestRpc({
|
|
emitEvent: () => {
|
|
throw new Error('warning sink failed');
|
|
},
|
|
});
|
|
await expect(throwingRpc.resumeSession({ sessionId })).resolves.toBeDefined();
|
|
});
|
|
|
|
it('re-bootstraps profile and model when resuming a session whose wire has no config.update', async () => {
|
|
// A migrated session ships a wire.jsonl with only `metadata` and message
|
|
// records — none of the `config.update` / `tools.set_active_tools`
|
|
// bootstrap events a natively-created session writes. Resuming it must
|
|
// still yield a usable agent (model + system prompt), not an empty config.
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({
|
|
workDir,
|
|
model: 'kimi-code/kimi-for-coding',
|
|
});
|
|
await rpc.closeSession({ sessionId: created.id });
|
|
|
|
const wirePath = await findWireFile(homeDir);
|
|
const kept = (await readFile(wirePath, 'utf-8'))
|
|
.split('\n')
|
|
.filter((line) => line.trim().length > 0)
|
|
.filter((line) => {
|
|
const type = (JSON.parse(line) as { type?: string }).type;
|
|
return type !== 'config.update' && type !== 'tools.set_active_tools';
|
|
});
|
|
await writeFile(wirePath, `${kept.join('\n')}\n`);
|
|
|
|
const freshRpc = await createTestRpc();
|
|
await freshRpc.resumeSession({ sessionId: created.id });
|
|
|
|
expect(await freshRpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(
|
|
'kimi-code/kimi-for-coding',
|
|
);
|
|
const config = await freshRpc.getConfig({ sessionId: created.id, agentId: 'main' });
|
|
expect(config.modelAlias).toBe('kimi-code/kimi-for-coding');
|
|
expect(config.systemPrompt.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('applies RPC config updates to later agent model changes', async () => {
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({
|
|
workDir,
|
|
model: 'kimi-code/kimi-for-coding',
|
|
});
|
|
|
|
const updatedConfig = await rpc.setKimiConfig({
|
|
defaultModel: 'gpt-alias',
|
|
providers: {
|
|
openai: {
|
|
type: 'openai',
|
|
apiKey: 'sk-openai',
|
|
baseUrl: 'https://openai.example/v1',
|
|
},
|
|
},
|
|
models: {
|
|
'gpt-alias': {
|
|
provider: 'openai',
|
|
model: 'gpt-runtime',
|
|
maxContextSize: 200000,
|
|
capabilities: ['tool_use'],
|
|
},
|
|
},
|
|
});
|
|
expect(updatedConfig.defaultModel).toBe('gpt-alias');
|
|
|
|
await expect(
|
|
rpc.setModel({
|
|
sessionId: created.id,
|
|
agentId: 'main',
|
|
model: 'gpt-alias',
|
|
}),
|
|
).resolves.toEqual({
|
|
model: 'gpt-alias',
|
|
providerName: 'openai',
|
|
});
|
|
|
|
const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' });
|
|
expect(config.modelAlias).toBe('gpt-alias');
|
|
expect(config.provider).toMatchObject({
|
|
type: 'openai',
|
|
model: 'gpt-runtime',
|
|
apiKey: 'sk-openai',
|
|
baseUrl: 'https://openai.example/v1',
|
|
});
|
|
expect(config.modelCapabilities).toMatchObject({
|
|
tool_use: true,
|
|
max_context_tokens: 200000,
|
|
});
|
|
});
|
|
|
|
it('can create an unconfigured session when no model is selected', async () => {
|
|
await writeFile(configPath, '');
|
|
const rpc = await createTestRpc();
|
|
|
|
const created = await rpc.createSession({ workDir });
|
|
|
|
expect(created.id.startsWith('session_')).toBe(true);
|
|
expect(await rpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe('');
|
|
});
|
|
|
|
it('loads configured permission rules into created and resumed sessions', async () => {
|
|
await writeFile(
|
|
configPath,
|
|
`${CONFIG}
|
|
|
|
[[permission.deny]]
|
|
tool = "Bash"
|
|
match = "rm *"
|
|
reason = "no rm"
|
|
`,
|
|
);
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({ workDir });
|
|
|
|
await expect(rpc.getPermission({ sessionId: created.id, agentId: 'main' })).resolves.toEqual({
|
|
mode: 'manual',
|
|
rules: [
|
|
{
|
|
decision: 'deny',
|
|
scope: 'user',
|
|
pattern: 'Bash(rm *)',
|
|
reason: 'no rm',
|
|
},
|
|
],
|
|
});
|
|
|
|
const freshRpc = await createTestRpc();
|
|
await freshRpc.resumeSession({ sessionId: created.id });
|
|
await expect(
|
|
freshRpc.getPermission({ sessionId: created.id, agentId: 'main' }),
|
|
).resolves.toEqual({
|
|
mode: 'manual',
|
|
rules: [
|
|
{
|
|
decision: 'deny',
|
|
scope: 'user',
|
|
pattern: 'Bash(rm *)',
|
|
reason: 'no rm',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('uses configured default permission mode for fresh sessions', async () => {
|
|
await writeFile(
|
|
configPath,
|
|
CONFIG.replace(
|
|
'default_model = "kimi-code/kimi-for-coding"',
|
|
'default_model = "kimi-code/kimi-for-coding"\ndefault_permission_mode = "auto"',
|
|
),
|
|
);
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({ workDir });
|
|
|
|
await expect(rpc.getPermission({ sessionId: created.id, agentId: 'main' })).resolves.toEqual({
|
|
mode: 'auto',
|
|
rules: [],
|
|
});
|
|
|
|
const explicit = await rpc.createSession({ workDir, permission: 'manual' });
|
|
await expect(rpc.getPermission({ sessionId: explicit.id, agentId: 'main' })).resolves.toEqual({
|
|
mode: 'manual',
|
|
rules: [],
|
|
});
|
|
});
|
|
|
|
it('does not expose raw provider switching through the core RPC surface', async () => {
|
|
const rpc = (await createTestRpc()) as unknown as Record<string, unknown>;
|
|
|
|
expect(rpc['setProvider']).toBeUndefined();
|
|
});
|
|
|
|
it('exposes the core package version as read-only metadata', async () => {
|
|
const rpc = await createTestRpc();
|
|
const pkg = JSON.parse(
|
|
await readFile(new URL('../../package.json', import.meta.url), 'utf-8'),
|
|
) as { version: string };
|
|
|
|
await expect(rpc.getCoreInfo({})).resolves.toEqual({
|
|
version: pkg.version,
|
|
});
|
|
expect((rpc as unknown as Record<string, unknown>)['setVersion']).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the resumed model alias visible when it no longer resolves', async () => {
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({ workDir, model: 'kimi-code/kimi-for-coding' });
|
|
await rpc.closeSession({ sessionId: created.id });
|
|
|
|
// The config now has no models and no default model — the alias replayed
|
|
// from the session is invalid and there is no fallback to resolve.
|
|
await writeFile(configPath, '');
|
|
|
|
const freshRpc = await createTestRpc();
|
|
await freshRpc.resumeSession({ sessionId: created.id });
|
|
|
|
// The stale alias stays visible so the UI can surface which model the
|
|
// user had selected. The next prompt will raise MODEL_NOT_CONFIGURED.
|
|
expect(await freshRpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(
|
|
'kimi-code/kimi-for-coding',
|
|
);
|
|
});
|
|
|
|
it('logs app_version when resuming a session', async () => {
|
|
await getRootLogger().configure(resolveLoggingConfig({ homeDir }));
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({ workDir, model: 'kimi-code/kimi-for-coding' });
|
|
await rpc.closeSession({ sessionId: created.id });
|
|
|
|
const freshRpc = await createTestRpc({ appVersion: '1.2.3-test' });
|
|
await freshRpc.resumeSession({ sessionId: created.id });
|
|
await getRootLogger().flushSession(created.id);
|
|
|
|
const logText = await readFile(join(created.sessionDir, 'logs', 'kimi-code.log'), 'utf-8');
|
|
expect(logText).toContain('session resume');
|
|
expect(logText).toContain('app_version=1.2.3-test');
|
|
});
|
|
|
|
it('surfaces a config error when a resumed model is configured but unresolvable', async () => {
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({ workDir, model: 'kimi-code/kimi-for-coding' });
|
|
await rpc.closeSession({ sessionId: created.id });
|
|
|
|
// The model alias is still in config, but it now references a provider
|
|
// that does not exist. That is an actionable config error — resume must
|
|
// surface it, not silently fall back to another model or clear it.
|
|
await writeFile(
|
|
configPath,
|
|
`
|
|
default_model = "kimi-code/kimi-for-coding"
|
|
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
api_key = "test-key"
|
|
base_url = "https://api.example/v1"
|
|
|
|
[models."kimi-code/kimi-for-coding"]
|
|
provider = "ghost-provider"
|
|
model = "kimi-for-coding"
|
|
max_context_size = 1000000
|
|
`,
|
|
);
|
|
|
|
const freshRpc = await createTestRpc();
|
|
await expect(freshRpc.resumeSession({ sessionId: created.id })).rejects.toThrow();
|
|
});
|
|
|
|
it('scopes agent telemetry events to the owning session', async () => {
|
|
const createRecords: TelemetryContextRecord[] = [];
|
|
const createRpc = await createTestRpc({ telemetry: recordingContextTelemetry(createRecords) });
|
|
const created = await createRpc.createSession({
|
|
workDir,
|
|
model: 'kimi-code/kimi-for-coding',
|
|
});
|
|
|
|
await createRpc.setPermission({ sessionId: created.id, agentId: 'main', mode: 'yolo' });
|
|
|
|
expect(createRecords).toContainEqual({
|
|
event: 'yolo_toggle',
|
|
sessionId: created.id,
|
|
properties: { enabled: true, agent_id: 'main' },
|
|
});
|
|
|
|
await createRpc.setPermission({ sessionId: created.id, agentId: 'main', mode: 'auto' });
|
|
|
|
expect(createRecords).toContainEqual({
|
|
event: 'afk_toggle',
|
|
sessionId: created.id,
|
|
properties: { enabled: true, agent_id: 'main' },
|
|
});
|
|
|
|
await createRpc.setKimiConfig({
|
|
defaultModel: 'gpt-alias',
|
|
providers: {
|
|
openai: {
|
|
type: 'openai',
|
|
apiKey: 'sk-openai',
|
|
baseUrl: 'https://openai.example/v1',
|
|
},
|
|
},
|
|
models: {
|
|
'gpt-alias': {
|
|
provider: 'openai',
|
|
model: 'gpt-runtime',
|
|
maxContextSize: 200000,
|
|
},
|
|
},
|
|
});
|
|
await createRpc.setModel({
|
|
sessionId: created.id,
|
|
agentId: 'main',
|
|
model: 'gpt-alias',
|
|
});
|
|
|
|
expect(createRecords).toContainEqual({
|
|
event: 'model_switch',
|
|
sessionId: created.id,
|
|
properties: { model: 'gpt-alias', agent_id: 'main' },
|
|
});
|
|
|
|
const resumeRecords: TelemetryContextRecord[] = [];
|
|
const resumeRpc = await createTestRpc({ telemetry: recordingContextTelemetry(resumeRecords) });
|
|
await resumeRpc.resumeSession({ sessionId: created.id });
|
|
await resumeRpc.setThinking({ sessionId: created.id, agentId: 'main', effort: 'off' });
|
|
|
|
expect(resumeRecords).toContainEqual({
|
|
event: 'thinking_toggle',
|
|
sessionId: created.id,
|
|
properties: { enabled: false, effort: 'off', from: 'high', agent_id: 'main' },
|
|
});
|
|
});
|
|
|
|
it('tracks session_load_failed with the attempted session context', async () => {
|
|
const rpc = await createTestRpc();
|
|
const created = await rpc.createSession({ workDir });
|
|
await writeFile(join(created.sessionDir, 'state.json'), '{bad json', 'utf-8');
|
|
|
|
const records: TelemetryContextRecord[] = [];
|
|
const freshRpc = await createTestRpc({ telemetry: recordingContextTelemetry(records) });
|
|
|
|
await expect(freshRpc.resumeSession({ sessionId: created.id })).rejects.toThrow();
|
|
expect(records).toContainEqual({
|
|
event: 'session_load_failed',
|
|
sessionId: created.id,
|
|
properties: { reason: 'SyntaxError' },
|
|
});
|
|
});
|
|
|
|
it('adds web client metadata to new-session telemetry', async () => {
|
|
const records: TelemetryContextRecord[] = [];
|
|
const rpc = await createTestRpc({ telemetry: recordingContextTelemetry(records) });
|
|
const created = await rpc.createSession({
|
|
workDir,
|
|
client: {
|
|
id: 'web_test_client',
|
|
name: 'kimi-code-web',
|
|
version: '0.1.1',
|
|
uiMode: 'web',
|
|
},
|
|
});
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: created.id,
|
|
properties: {
|
|
client_id: 'web_test_client',
|
|
client_name: 'kimi-code-web',
|
|
client_version: '0.1.1',
|
|
ui_mode: 'web',
|
|
resumed: false,
|
|
},
|
|
});
|
|
|
|
await rpc.setPermission({ sessionId: created.id, agentId: 'main', mode: 'yolo' });
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'yolo_toggle',
|
|
sessionId: created.id,
|
|
properties: {
|
|
client_id: 'web_test_client',
|
|
client_name: 'kimi-code-web',
|
|
client_version: '0.1.1',
|
|
ui_mode: 'web',
|
|
enabled: true,
|
|
agent_id: 'main',
|
|
},
|
|
});
|
|
});
|
|
|
|
async function findWireFile(root: string): Promise<string> {
|
|
const suffix = join('agents', 'main', 'wire.jsonl');
|
|
const entries = await readdir(root, { recursive: true });
|
|
const match = entries.find((entry) => entry.replaceAll('\\', '/').endsWith(suffix));
|
|
if (match === undefined) {
|
|
throw new Error('wire.jsonl not found under session home');
|
|
}
|
|
return join(root, match);
|
|
}
|
|
|
|
async function createTestRpc(
|
|
options: {
|
|
readonly appVersion?: string;
|
|
readonly emitEvent?: (event: Parameters<SDKAPI['emitEvent']>[0]) => void;
|
|
readonly telemetry?: TelemetryClient;
|
|
} = {},
|
|
) {
|
|
const [coreRpc, sdkRpc] = createRPC<CoreAPI, SDKAPI>();
|
|
void new KimiCore(coreRpc, {
|
|
homeDir,
|
|
configPath,
|
|
appVersion: options.appVersion,
|
|
telemetry: options.telemetry,
|
|
});
|
|
return sdkRpc({
|
|
emitEvent: options.emitEvent ?? vi.fn(),
|
|
requestApproval: vi.fn(async () => ({ decision: 'rejected' as const })),
|
|
requestQuestion: vi.fn(async () => null),
|
|
toolCall: vi.fn(async () => ({ output: '' })),
|
|
});
|
|
}
|
|
});
|