diff --git a/.changeset/tui-honors-agent-file.md b/.changeset/tui-honors-agent-file.md new file mode 100644 index 0000000000..4810ab9825 --- /dev/null +++ b/.changeset/tui-honors-agent-file.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": minor +--- + +Bind the agent selected by `--agent` or `--agent-file` to the startup session of the interactive TUI, which previously started on the default agent without reporting that the flag was dropped. An `--agent-file` is registered for the whole launch while the flag itself still binds only the startup session, so a file named after a built-in agent keeps replacing that built-in for sessions created later in the same launch. A requested name absent from the workspace agent catalog is now rejected before the session is created instead of after. The SDK gains a matching `agentFiles` harness option. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 3d6c741ceb..eb3985041c 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -68,6 +68,11 @@ export async function runShell( homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), skillDirs: opts.skillsDirs, + // `--agent-file` registers the file for this launch; the harness is built + // before any session exists, and the v2 engine reads explicit agent files + // from its own bootstrap arguments, so the registration has to happen here. + // Selecting the profile it defines stays per-session (`agentProfile`). + agentFiles: opts.agentFiles, telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index c4e95c1d1b..3a02ee984f 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -1,4 +1,7 @@ import { execSync } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -156,11 +159,16 @@ vi.mock('node:child_process', () => ({ })); describe('runShell', () => { + const tempDirs: string[] = []; + beforeEach(() => { vi.stubEnv('KIMI_CODE_LEGACY_FLAG', '1'); }); - afterEach(() => { + afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } vi.clearAllMocks(); vi.unstubAllEnvs(); mocks.harnessGetConfig.mockResolvedValue({ @@ -398,6 +406,39 @@ describe('runShell', () => { ); }); + // `--agent-file` registers the file for the whole launch, and the v2 engine + // reads explicit agent files from the process bootstrap the harness sets up — + // so the flag has to reach the harness, not only the startup session. Runs on + // the default (v2) route, which is the one that reads the option. + it('forwards agentFiles from CLI options to the v2 harness', async () => { + stubTuiStartup(); + const agentDir = await mkdtemp(join(tmpdir(), 'kimi-code-agent-')); + tempDirs.push(agentDir); + const agentFile = join(agentDir, 'reviewer.md'); + await writeFile( + agentFile, + '---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview the requested change.\n', + 'utf-8', + ); + + await withEnv( + { KIMI_CODE_LEGACY_FLAG: undefined, KIMI_CODE_EXPERIMENTAL_FLAG: undefined }, + async () => { + await runShell({ ...minimalCliOptions, agentFiles: [agentFile] }, '1.2.3-test'); + }, + ); + + expect(mocks.kimiHarnessV2Constructor).toHaveBeenCalledWith( + expect.objectContaining({ agentFiles: [agentFile] }), + ); + // The startup session still selects the profile that file defines. + expect(mocks.kimiTuiConstructor).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ agentProfile: 'reviewer' }), + ); + }); + it('tracks first launch when device id creation reports first launch', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 36480081d2..48552b76a4 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -105,7 +105,7 @@ kimi --agent reviewer kimi -p --agent reviewer "Review the changes on this branch" ``` -`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, and `--agent` and `--agent-file` are mutually exclusive. Both flags only apply when starting a new session — neither can be combined with `--session`/`--continue`, because the agent is bound at session creation and resuming restores the bound agent automatically. The selection is fixed at the session's first bind and cannot be switched later; in the TUI the flags bind only the startup session, and a session created later in the same process (for example via `/new`) starts with the default agent. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. +`--agent-file` registers a single agent file at the highest priority for this launch only and selects it; the flag cannot be repeated, and `--agent` and `--agent-file` are mutually exclusive. Both flags only apply when starting a new session — neither can be combined with `--session`/`--continue`, because the agent is bound at session creation and resuming restores the bound agent automatically. The selection is fixed at the session's first bind and cannot be switched later; in the TUI the flags bind only the startup session, and a session created later in the same process (for example via `/new`) starts with the default agent. On the default `agent-core-v2` engine, the file `--agent-file` points at stays in the agent catalog for the rest of the launch: sessions created later start on the default agent, but the file's agent remains available for delegation as a sub-agent, subject to the active agent's sub-agent allowlist — and if the file is named after a built-in agent, it keeps replacing that built-in for those sessions too. The legacy engine selected with `KIMI_CODE_LEGACY_FLAG=1` registers the file for the startup session only. See [Agents and Sub-Agents](../customization/agents.md#custom-agents) for the agent file format and discovery directories. ## Non-Interactive Execution diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 642026c799..460aa7458a 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -105,7 +105,7 @@ kimi --agent reviewer kimi -p --agent reviewer "审查这个分支上的改动" ``` -`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥。两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合,因为 Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent。选择在会话首次绑定后即固定,之后不可切换;在 TUI 中,这些 flag 只绑定启动时的会话,之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 +`--agent-file` 以最高优先级注册单个 Agent 文件(仅本次启动)并选中它;该 flag 不可重复传入,`--agent` 与 `--agent-file` 互斥。两个 flag 都仅在新建会话时有效——都不能与 `--session`/`--continue` 组合,因为 Agent 在会话创建时绑定,恢复会话时会自动还原已绑定的 Agent。选择在会话首次绑定后即固定,之后不可切换;在 TUI 中,这些 flag 只绑定启动时的会话,之后在同一进程内新建的会话(例如通过 `/new`)使用默认 Agent。在默认的 `agent-core-v2` 引擎下,`--agent-file` 指向的文件在本次启动的剩余时间内一直留在 Agent 清单中:之后新建的会话虽然以默认 Agent 启动,该文件定义的 Agent 仍可作为子 Agent 派发,但要受当前 Agent 的子 Agent 允许列表限制;如果该文件与某个内置 Agent 同名,它对这些会话也会继续替换那个内置 Agent。设置 `KIMI_CODE_LEGACY_FLAG=1` 选择旧版引擎时,该文件只为启动时的会话注册。Agent 文件格式与发现目录详见 [Agent 与子 Agent](../customization/agents.md#自定义-agent)。 ## 非交互执行 diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index d723c41063..a7e4c13dc8 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -171,6 +171,7 @@ import { IAgentLoopService, IAgentPermissionModeService, IAgentPermissionRulesService, + IAgentProfileRegistry, IAgentProfileService, IAgentRPCService, IAgentSkillService, @@ -180,10 +181,14 @@ import { IBootstrapService, IConfigService, IEventService, + IExplicitAgentProfileLoader, + IExtraAgentProfileLoader, IHostEnvironment, IHostFileSystem, + ILogService, IModelCatalog, IModelService, + IPluginAgentProfileLoader, IProviderService, ISessionBtwService, ISessionContext, @@ -198,7 +203,10 @@ import { ISessionSkillCatalog, ISessionWorkspaceContext, ITelemetryService, + IUserAgentProfileLoader, + IWorkspaceAgentProfileLoader, IWorkspaceAliases, + IWorkspaceContext, IWorkspaceDirs, IWorkspaceMcpService, ISessionActivityView, @@ -230,6 +238,7 @@ import { type IAgentScopeHandle, type IDisposable, type ISessionScopeHandle, + type IWorkspaceScopeHandle, type Scope, type SecondaryModelConfig, type ServicesAccessor, @@ -337,6 +346,19 @@ export interface SDKRpcClientV2Options { * source. Passed into the engine through `BootstrapInput.args.skillDirs`. */ readonly skillDirs?: readonly string[]; + /** + * Explicit agent files (the CLI's `--agent-file`): loaded at the highest + * catalog priority for every workspace this client hosts, on top of the + * discovered user / project / plugin agent roots. Passed into the engine + * through `BootstrapInput.args.agentFiles`, which is where the + * workspace-scoped explicit loader reads them from — a session selects one + * of the profiles they define with `createSession`'s `agentProfile`. + * + * The scope is this client's own engine bootstrap, not the OS process: a + * second client in the same process bootstraps its own engine and sees none + * of these files. + */ + readonly agentFiles?: readonly string[]; readonly telemetry?: TelemetryClient; readonly onOAuthRefresh?: (outcome: OAuthRefreshOutcome) => void; readonly uiMode?: string; @@ -444,6 +466,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // `--skills-dir` (v1 parity): explicit skill dirs replace default // user / project discovery for every session this client hosts. skillDirs: options.skillDirs, + // `--agent-file` (v1 parity): explicit agent definition files, added + // to every workspace catalog at the highest priority. Passed through + // unresolved — the engine expands `~` and resolves relative paths + // against the workspace root, mirroring `skillDirs`. + agentFiles: options.agentFiles, }, }, [...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env }))], @@ -818,11 +845,32 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return handle; } + /** + * v1's rejection for a `createSession` profile name the catalog does not + * have (`resolveMainAgentProfile`): a `KimiError` carrying + * `agent.not_found`. The v2 engine raises its own `ProfileError` with + * `profile.unknown` instead, and that class is not exported from this + * package — `isKimiError` is an `instanceof KimiError` test, so a consumer + * that switches engines would stop recognizing the failure entirely. Both + * routes by which {@link createSession} can learn the name is unusable (the + * pre-create catalog check, and a bind that loses the race behind it) answer + * with this instead. Only that SDK surface is translated: the klient facade + * and kap-server keep speaking the engine's own error vocabulary. + */ + private static agentProfileNotFound(profileName: string, available: string): KimiError { + return new KimiError( + ErrorCodes.AGENT_NOT_FOUND, + `Agent profile "${profileName}" was not found. Available profiles: ${available}`, + { details: { profile: profileName, available } }, + ); + } + /** * Attach the event/interaction wiring to a freshly materialized session - * (idempotent). Unwiring needs no call site of its own: every close path - * goes through the engine's lifecycle close, whose `onDidCloseSession` - * subscription (constructor) drops the wiring. + * (idempotent). Unwiring needs one call site of its own — {@link + * createSession}'s rollback, which unwires ahead of its delete; every other + * close path goes through the engine's lifecycle close, whose + * `onDidCloseSession` subscription (constructor) drops the wiring. */ private wireSession(handle: ISessionScopeHandle): void { if (this.sessionWirings.has(handle.id)) return; @@ -1049,6 +1097,14 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * deliberately NOT `strictThinking`, and the v2-only create-time rejections * that still leak through (unknown alias → `config.invalid`, no configured * default model → `model.not_configured`) are pinned in the parity tests. + * + * `agentProfile` (`--agent`, and the profile an `--agent-file` defines) joins + * that same bind rather than the engine's own `mainAgentBinding`, because + * binding inside `create` would run before the session is wired and drop the + * warnings the bind publishes. Binding that late is also why the name is + * checked against the workspace catalog first (see + * {@link assertProfileInWorkspaceCatalog}) and why a bind that still fails + * takes the session with it. */ override async createSession(input: CreateSessionOptions): Promise { const workDir = normalizeRequiredWorkDir('createSession', input.workDir); @@ -1066,23 +1122,91 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { const handler = await this.engineAccessor .get(IWorkspaceLifecycleService) .handlerFor({ root: workDir }); - const handle = await handler.accessor.get(ISessionLifecycleService).create({ + const lifecycle = handler.accessor.get(ISessionLifecycleService); + if (input.agentProfile !== undefined) { + await this.assertProfileInWorkspaceCatalog(handler, input.agentProfile); + } + const handle = await lifecycle.create({ sessionId: input.id, workDir, additionalDirs: input.additionalDirs, }); - // Wired before the optional main-agent materialization so a profile-bind - // warning (oversized AGENTS.md) reaches the listeners like v1's create. + // Wired before the main-agent materialization so the warnings the bind + // publishes — an oversized AGENTS.md, and the tool patterns a profile's + // `tools` / `disallowedTools` match nothing with — reach the listeners + // like v1's create. The agent event bus has no replay, and the + // tool-pattern warning is emitted once per pattern, so a bind that runs + // before this line loses them for good. this.wireSession(handle); if ( input.model !== undefined || input.thinking !== undefined || - input.permission !== undefined + input.permission !== undefined || + input.agentProfile !== undefined ) { - const agent = await this.materializeMainAgent(handle, { - model: input.model, - thinking: input.thinking, - }); + let agent: IAgentScopeHandle; + try { + agent = await this.materializeMainAgent(handle, { + model: input.model, + thinking: input.thinking, + profile: input.agentProfile, + }); + } catch (error) { + // An explicit `agentProfile` states what this session is FOR, so any + // bind that fails to honor it takes the session with it — the unknown + // name that raced the check above, an unknown model alias + // (`config.invalid`), a home with no configured model at all + // (`model.not_configured`). Without a profile those two keep the + // session they have always left behind: that is pre-existing behavior + // the parity tests pin, and widening the rollback to it is a separate + // change from this one. + if (input.agentProfile !== undefined) { + // Ahead of the delete: `close` drops the handle from the engine's + // session map before disposal fires `onDidCloseSession`, so a delete + // that throws midway would otherwise strand this session's wiring + // here for good. + this.unwireSession(handle.id); + const log = this.engineAccessor.get(ILogService); + // `delete` resolves by id, and the id may no longer be ours: two + // concurrent creates for the SAME explicit id both succeed (the + // engine has no duplicate-id guard and the SESSION_ALREADY_EXISTS + // check above is not atomic), leaving the last one registered. + // Deleting then would take the other session, not the one that just + // failed. Roll back only while the id still resolves to the handle + // this call created — the underlying race predates this rollback and + // is the engine's to fix; this only keeps the rollback out of it. + if (lifecycle.get(handle.id) === handle) { + await lifecycle.delete(handle.id).catch((rollbackError: unknown) => { + // The bind failure stays the caller's error; a rollback that + // itself fails leaves a session on disk and in the index, which + // is only ever visible in the log. + log.error( + `rollback of session "${handle.id}" after a failed "${input.agentProfile}" profile bind failed`, + { error: String(rollbackError) }, + ); + }); + } else { + log.error( + `session "${handle.id}" was replaced by a concurrent create; leaving it in place rather than rolling back another session`, + ); + } + // The name passed the pre-create check and was gone by the time the + // bind ran (a catalog reload in between). Rare, but it is the same + // failure the caller would have got a moment earlier, so it must not + // arrive wearing a different type — translate it like the check does. + if ( + error instanceof ProfileError && + error.code === ProfileErrors.codes.PROFILE_UNKNOWN + ) { + const available = error.details?.['available']; + throw SDKRpcClientV2.agentProfileNotFound( + input.agentProfile, + typeof available === 'string' ? available : '', + ); + } + } + throw error; + } if (input.permission !== undefined) { agent.accessor.get(IAgentPermissionModeService).setMode(input.permission); } @@ -1095,6 +1219,69 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return { ...(await this.liveSessionSummary(handle)), metadata: input.metadata }; } + /** + * Reject a main-agent profile the workspace cannot serve BEFORE any session + * exists. `ISessionLifecycleService.create` announces the finished session as + * its last step — the `SessionStart` external hook and the `session_started` + * fact — so a profile rejected by the later bind would have already run the + * user's hook commands and recorded a start for a session about to be rolled + * back (and the rollback then runs `SessionEnd` on top). v1 resolves the + * requested profile against its catalog before its store write for the same + * reason. + * + * The v2 merged catalog only exists at Session scope, so the question is + * asked one level up: await the five workspace profile loaders (exactly what + * session materialization awaits), then fold the App-scope registry down to + * the records this workspace sees. Presence of the NAME is the whole test — + * the Session catalog's merge only ever chooses between same-name candidates + * (its one skip, a non-`override` file shadowing a builtin, falls back to + * that builtin), so a name any visible record contributes always resolves to + * some profile there. The bind stays authoritative: this only moves the + * failure earlier, and {@link createSession}'s rollback still covers the + * check→bind race. + * + * INVARIANT this rests on: every source of agent profiles is registry-visible + * by the time a session is created. True of all six loaders today. A future + * contribution point that registers lazily, or without the `workspaceKey` + * this fold filters on, would be invisible here and make the check reject a + * profile the bind would have accepted — extend the fold with it rather than + * leaving the rejection to be discovered at runtime. + */ + private async assertProfileInWorkspaceCatalog( + handler: IWorkspaceScopeHandle, + profileName: string, + ): Promise { + try { + await Promise.all([ + handler.accessor.get(IWorkspaceAgentProfileLoader).ready, + handler.accessor.get(IExtraAgentProfileLoader).ready, + handler.accessor.get(IExplicitAgentProfileLoader).ready, + handler.accessor.get(IUserAgentProfileLoader).ready, + handler.accessor.get(IPluginAgentProfileLoader).ready, + ]); + } catch { + // A loader that failed is the engine's error to raise, not this check's: + // `create` awaits the same five and rejects with it, and its own failure + // path reloads the explicit loader so the next attempt re-reads the file. + // Reporting "unknown profile" off a half-loaded catalog would be a lie. + return; + } + const workspaceKey = handler.accessor.get(IWorkspaceContext).workspaceId; + const available = new Set(); + for (const entry of this.engineAccessor.get(IAgentProfileRegistry).entries()) { + // Global records (builtin) plus this workspace's own; another + // workspace's user/project/explicit files are not in this catalog. + if (entry.workspaceKey !== undefined && entry.workspaceKey !== workspaceKey) continue; + for (const profile of entry.contribution.profiles) available.add(profile.name); + } + if (available.has(profileName)) return; + // v1's shape, not the engine's — see {@link agentProfileNotFound}. The + // `available` list is a human-readable hint rather than a contract, and its + // order does differ from the engine's (registry order here, merged-map + // order there, which puts builtins first). + throw SDKRpcClientV2.agentProfileNotFound(profileName, [...available].join(', ')); + } + /** * v1 renames through the live session when there is one and at the store * level otherwise. The v2 metadata service is session-scoped (and the @@ -1310,14 +1497,19 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * The session's materialized main agent with v1's eager default binding * applied: a freshly created agent whose profile is still unbound gets the * default profile + configured default model (the same bind kap-server's - * prompt route performs on first use). A home with no configured model - * leaves the agent unbound instead of failing — v1's model-less session - * reads (`model: undefined`, `'off'` thinking, zero capabilities) map onto - * the unbound state exactly. + * prompt route performs on first use). With no binding requested, a home + * with no configured model leaves the agent unbound instead of failing — + * v1's model-less session reads (`model: undefined`, `'off'` thinking, zero + * capabilities) map onto the unbound state exactly. A caller that DID + * request one gets the rejection instead, since there is no unbound state + * that honors it: `binding.profile` selects a profile (`--agent` / + * `--agent-file`) and an unknown name raises the catalog's own + * `profile.unknown` — which {@link createSession} restates as v1's + * `agent.not_found` before it reaches an SDK caller. */ private async materializeMainAgent( session: ISessionScopeHandle, - binding?: { readonly model?: string; readonly thinking?: string }, + binding?: { readonly model?: string; readonly thinking?: string; readonly profile?: string }, ): Promise { await this.modelReady; const agent = await ensureMainAgent(session); @@ -1325,7 +1517,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (binding !== undefined || profile.data().profileName === undefined) { try { await profile.bind({ - profile: DEFAULT_AGENT_PROFILE_NAME, + profile: binding?.profile ?? DEFAULT_AGENT_PROFILE_NAME, model: binding?.model, thinking: binding?.thinking, }); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 8ce05461d4..6b00b25427 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -109,6 +109,19 @@ export interface KimiHarnessOptions { readonly autoLoadConfig?: boolean | undefined; readonly uiMode?: string; readonly skillDirs?: readonly string[]; + /** + * Explicit agent files (`--agent-file`) registered at the highest catalog + * priority. Registration is per harness, not per session: a session selects + * one of the profiles they define through + * {@link CreateSessionOptions.agentProfile}, and the profiles stay in the + * catalog for every later session this harness creates. Two harnesses in one + * process therefore have separate catalogs. + * + * `createKimiHarnessV2` only: the legacy harness ignores this option and + * takes explicit files per session through + * {@link CreateSessionOptions.agentFiles} instead. + */ + readonly agentFiles?: readonly string[]; readonly telemetry?: TelemetryClient | undefined; readonly onOAuthRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; readonly sessionStartedProperties?: TelemetryProperties; @@ -127,12 +140,22 @@ export interface CreateSessionOptions { readonly additionalDirs?: readonly string[]; /** * Main-agent profile name (`--agent`): a builtin profile or one defined by - * an agentfile discovered from the user/project agent directories. + * an agent file discovered from the user/project agent directories. + * + * Naming one is explicit intent, so unlike a session that takes whatever + * default it is given, it never degrades: a name the workspace catalog does + * not have is rejected before anything is created — as a `KimiError` with + * `agent.not_found`, on both engines — and a home with no configured model + * rejects with `model.not_configured` rather than leaving the main agent + * unbound the way a profile-less create does. */ readonly agentProfile?: string; /** - * Explicit agentfiles (`--agent-file`) loaded for this session with the - * highest precedence; an invalid file fails session creation. + * Explicit agent files (`--agent-file`) loaded for this session with the + * highest precedence; an invalid file fails session creation. The v2 engine + * registers explicit files per harness rather than per session, so hosts on + * that engine pass them through {@link KimiHarnessOptions.agentFiles} and + * select the resulting profile here with {@link agentProfile}. */ readonly agentFiles?: readonly string[]; readonly sessionStartedProperties?: TelemetryProperties; diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 5c6bad93a4..4c17f23fce 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -26,7 +26,11 @@ import { foldAgentWireReplay } from '#/v2/resume-replay'; import { drainQueryStoreDisposals, drainSessionIndexMirror, + ensureMainAgent, + getLiveSessionById, + IAgentProfileService, IHostRequestHeaders, + type ProfileData, } from '@moonshot-ai/agent-core-v2'; import { McpOAuthService } from '../../agent-core/src/mcp/oauth/service'; @@ -226,6 +230,267 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { } }); + it('binds the requested agentProfile on the session it creates', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + // Discoverable through the project agent root, so only the selection + // (`--agent`) is under test here, not the explicit-file registration. + await writeReviewerAgent(join(workDir, '.kimi-code', 'agents')); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const summary = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'reviewer', + }); + expect(await mainAgentProfile(rpc, summary.id)).toMatchObject({ + profileName: 'reviewer', + }); + } finally { + await rpc.close(); + } + }); + + it('accepts an agentProfile discovered from the user agent root', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + // The user loader, rather than the project or explicit one the other tests + // use: the pre-create catalog check has to see every profile source, so + // each one it can reject over is worth exercising. + await writeReviewerAgent(join(homeDir, 'agents')); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + const summary = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'reviewer', + }); + expect(await mainAgentProfile(rpc, summary.id)).toMatchObject({ + profileName: 'reviewer', + }); + } finally { + await rpc.close(); + } + }); + + it('creates nothing at all for an unknown agentProfile', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + + const records: TelemetryRecord[] = []; + const rpc = new SDKRpcClientV2({ + homeDir, + identity: TEST_IDENTITY, + telemetry: recordingTelemetry(records), + }); + const started = (): number => records.filter((r) => r.event === 'session_started').length; + try { + // A control session first, so the absences asserted below are real ones + // and not a telemetry client that never receives anything. + await rpc.createSession({ id: 'session_control', workDir, model: TEST_MODEL }); + expect(started()).toBe(1); + + // The v1 contract for this option, pinned in create-session-transport's + // "does not persist a session record when the requested agent profile is + // missing": a KimiError with `agent.not_found`. The v2 engine's own + // ProfileError is not exported from this package and `isKimiError` is an + // instanceof test, so surfacing it raw would strand SDK consumers. + await expect( + rpc.createSession({ + id: 'session_missing_agent_profile', + workDir, + model: TEST_MODEL, + agentProfile: 'missing-agent', + }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: ErrorCodes.AGENT_NOT_FOUND, + message: expect.stringContaining('missing-agent'), + }); + + // Session creation announces the finished session as its last step — the + // SessionStart hook and this fact — so a name rejected only once the bind + // runs would have fired both for a session that never survived the call. + expect(started()).toBe(1); + const listed = await rpc.listSessions({ workDir }); + expect(listed.map((session) => session.id)).toEqual(['session_control']); + // Nothing on disk either, so the id is free. + await expect( + rpc.createSession({ id: 'session_missing_agent_profile', workDir, model: TEST_MODEL }), + ).resolves.toMatchObject({ id: 'session_missing_agent_profile' }); + } finally { + await rpc.close(); + } + }); + + it('rolls the session back when an explicitly selected profile cannot be bound', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeTestModelConfig(homeDir); + await writeReviewerAgent(join(workDir, '.kimi-code', 'agents')); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + // The profile resolves, so the catalog check passes and the bind is + // reached — and then fails on the model alias. The session the caller + // asked to run `reviewer` on must not outlive that failure. + await expect( + rpc.createSession({ + id: 'session_reviewer_bad_model', + workDir, + model: 'no-such-model', + agentProfile: 'reviewer', + }), + ).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); + expect(await rpc.listSessions({ workDir })).toEqual([]); + } finally { + await rpc.close(); + } + }); + + it('rejects an explicitly selected profile on a home with no model configured', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + // No config.toml at all. Without a profile the main agent is left unbound + // (v1's model-less session, pinned in the parity suite); an explicit + // `agentProfile` has no such degraded state to fall back to, so it fails. + await writeReviewerAgent(join(workDir, '.kimi-code', 'agents')); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + await expect( + rpc.createSession({ + id: 'session_reviewer_no_model', + workDir, + agentProfile: 'reviewer', + }), + ).rejects.toMatchObject({ code: ErrorCodes.MODEL_NOT_CONFIGURED }); + expect(await rpc.listSessions({ workDir })).toEqual([]); + } finally { + await rpc.close(); + } + }); + + it('registers agentFiles (--agent-file) from outside every discovery root', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const explicitDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-explicit-')); + tempDirs.push(explicitDir); + await writeTestModelConfig(homeDir); + // Outside every discovery root: the profile can only reach the catalog + // through the explicit `agentFiles` registration. + const agentFile = await writeReviewerAgent(explicitDir); + + const rpc = new SDKRpcClientV2({ + homeDir, + identity: TEST_IDENTITY, + agentFiles: [agentFile], + }); + try { + const summary = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'reviewer', + }); + const profile = await mainAgentProfile(rpc, summary.id); + expect(profile.profileName).toBe('reviewer'); + // The file's body is what the bound profile renders as its prompt. + expect(profile.systemPrompt).toContain('Review the requested change.'); + } finally { + await rpc.close(); + } + }); + + it('shadows a same-name builtin profile with the explicit agent file, for later sessions too', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const explicitDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-explicit-')); + tempDirs.push(explicitDir); + await writeTestModelConfig(homeDir); + // Deliberately named after the builtin default profile: `--agent-file` is + // explicit launch intent and outranks every other source, so it replaces + // the builtin for the whole launch — including sessions created later, + // which bind that same name as their default. + const agentFile = join(explicitDir, 'agent.md'); + await writeFile( + agentFile, + '---\nname: agent\ndescription: Overrides the builtin default.\n---\n\nShadowed default prompt.\n', + 'utf-8', + ); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY, agentFiles: [agentFile] }); + try { + const first = await rpc.createSession({ + workDir, + model: TEST_MODEL, + agentProfile: 'agent', + }); + expect((await mainAgentProfile(rpc, first.id)).systemPrompt).toContain( + 'Shadowed default prompt.', + ); + // A session created later selects no profile at all, so it binds the + // default by name — which the explicit file now owns. + const later = await rpc.createSession({ workDir, model: TEST_MODEL }); + expect((await mainAgentProfile(rpc, later.id)).systemPrompt).toContain( + 'Shadowed default prompt.', + ); + } finally { + await rpc.close(); + } + }); + + it('delivers the profile-bind warnings of an explicitly selected agent file', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const explicitDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-explicit-')); + tempDirs.push(explicitDir); + await writeTestModelConfig(homeDir); + // `Reed` is a typo no registered tool answers to, so binding this profile + // publishes a `tool-pattern-no-match` warning. The agent event bus has no + // replay and the warning fires once per pattern, so it only reaches a + // listener if the bind runs after the session is wired. + const agentFile = join(explicitDir, 'typo-tools.md'); + await writeFile( + agentFile, + '---\nname: typo-tools\ndescription: Has a misspelled tool.\ntools:\n - Reed\n---\n\nBody.\n', + 'utf-8', + ); + + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY, agentFiles: [agentFile] }); + const warnings: string[] = []; + const unsubscribe = rpc.onEvent((event) => { + if (event.type === 'warning') warnings.push(event.message); + }); + try { + await rpc.createSession({ workDir, model: TEST_MODEL, agentProfile: 'typo-tools' }); + expect(warnings.some((message) => message.includes('Reed'))).toBe(true); + } finally { + unsubscribe(); + await rpc.close(); + } + }); + it('serves the plugin catalog from the v2 engine on an empty home', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); @@ -539,3 +804,43 @@ async function writeSkill(dir: string, name: string): Promise { await mkd 'utf-8', ); } + +const TEST_MODEL = 'kimi-test-model'; + +async function writeTestModelConfig(homeDir: string): Promise { + await writeFile( + join(homeDir, 'config.toml'), + ` +[providers.local] +type = "kimi" +base_url = "https://example.test/v1" +api_key = "sk-test" + +[models."${TEST_MODEL}"] +provider = "local" +model = "${TEST_MODEL}" +max_context_size = 1000 +`, + 'utf-8', + ); +} + +/** Writes the `reviewer` agent file into `dir` and returns its path. */ +async function writeReviewerAgent(dir: string): Promise { + await mkdir(dir, { recursive: true }); + const path = join(dir, 'reviewer.md'); + await writeFile( + path, + '---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview the requested change.\n', + 'utf-8', + ); + return path; +} + +/** The profile the session's main agent is actually bound to. */ +async function mainAgentProfile(rpc: SDKRpcClientV2, sessionId: string): Promise { + const session = getLiveSessionById(rpc.engineAccessor, sessionId); + expect(session).toBeDefined(); + const agent = await ensureMainAgent(session!); + return agent.accessor.get(IAgentProfileService).data(); +}