diff --git a/.changeset/reload-plugin-session-start.md b/.changeset/reload-plugin-session-start.md new file mode 100644 index 000000000..186311de6 --- /dev/null +++ b/.changeset/reload-plugin-session-start.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +`/reload` now refreshes the assistant's view of plugin skills, so plugin changes take effect in the current session instead of requiring a new one. diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a8700d95e..f4dca0489 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -16,7 +16,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const session = host.session; if (session !== undefined) { - await session.reloadSession(); + await session.reloadSession({ forcePluginSessionStartReminder: true }); await host.reloadCurrentSessionView(session, 'Session reloaded.'); } diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index ab54a221b..76ffb0a7b 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -72,7 +72,9 @@ auto_install = false await handleReloadCommand(host); - expect(session.reloadSession).toHaveBeenCalledOnce(); + expect(session.reloadSession).toHaveBeenCalledWith({ + forcePluginSessionStartReminder: true, + }); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( session, 'Session reloaded.', diff --git a/packages/agent-core/src/agent/injection/plugin-session-start.ts b/packages/agent-core/src/agent/injection/plugin-session-start.ts index 16d41489b..b7bafe412 100644 --- a/packages/agent-core/src/agent/injection/plugin-session-start.ts +++ b/packages/agent-core/src/agent/injection/plugin-session-start.ts @@ -3,6 +3,47 @@ import type { SkillDefinition } from '../../skill'; import { escapeXmlAttr } from '../../utils/xml-escape'; import { DynamicInjector } from './injector'; +export interface RenderPluginSessionStartReminderInput { + readonly sessionStarts: readonly EnabledPluginSessionStart[]; + readonly registry: + | { + getPluginSkill(pluginId: string, name: string): SkillDefinition | undefined; + renderSkillPrompt(skill: SkillDefinition, args: string): string; + } + | undefined; + readonly log?: { warn(message: string, payload?: unknown): void }; +} + +/** + * Renders the `` reminder blocks for the currently enabled + * plugin session starts. Returns `undefined` when there is nothing to render + * (no session starts, no registry, or no resolvable skills). + * + * Shared by the turn-loop injector (which dedups against history) and the + * explicit `/reload` flow (which force-appends a fresh reminder). + */ +export function renderPluginSessionStartReminder( + input: RenderPluginSessionStartReminderInput, +): string | undefined { + const { sessionStarts, registry, log } = input; + if (sessionStarts.length === 0) return undefined; + if (registry === undefined) return undefined; + const blocks: string[] = []; + for (const sessionStart of sessionStarts) { + const skill = registry.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); + if (skill === undefined) { + log?.warn('plugin sessionStart skill not found', { + pluginId: sessionStart.pluginId, + skillName: sessionStart.skillName, + }); + continue; + } + blocks.push(renderSessionStartBlock(sessionStart, skill, registry.renderSkillPrompt(skill, ''))); + } + if (blocks.length === 0) return undefined; + return blocks.join('\n'); +} + export class PluginSessionStartInjector extends DynamicInjector { protected override readonly injectionVariant = 'plugin_session_start'; @@ -17,24 +58,11 @@ export class PluginSessionStartInjector extends DynamicInjector { this.injectedAt = replayedAt; return undefined; } - const sessionStarts = this.agent.pluginSessionStarts ?? []; - if (sessionStarts.length === 0) return undefined; - const registry = this.agent.skills?.registry; - if (registry === undefined) return undefined; - const blocks: string[] = []; - for (const sessionStart of sessionStarts) { - const skill = registry.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); - if (skill === undefined) { - this.agent.log.warn('plugin sessionStart skill not found', { - pluginId: sessionStart.pluginId, - skillName: sessionStart.skillName, - }); - continue; - } - blocks.push(renderSessionStartBlock(sessionStart, skill, registry.renderSkillPrompt(skill, ''))); - } - if (blocks.length === 0) return undefined; - return blocks.join('\n'); + return renderPluginSessionStartReminder({ + sessionStarts: this.agent.pluginSessionStarts, + registry: this.agent.skills?.registry, + log: this.agent.log, + }); } } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 61b19e569..2c2258857 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -76,6 +76,13 @@ export interface ResumeSessionPayload { export interface ReloadSessionPayload { readonly sessionId: string; + /** + * When true, append a fresh `` system reminder to the + * main agent after the session is reloaded, reflecting the currently enabled + * plugins. Used by the explicit `/reload` command so the model sees plugin + * changes without starting a new session. Defaults to false. + */ + readonly forcePluginSessionStartReminder?: boolean; } export interface ForkSessionPayload { diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 7088fba23..7f3d4f805 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -355,7 +355,11 @@ export class KimiCore implements PromisableMethods { async resumeSessionWithOverrides( input: ResumeSessionPayload, - overrides: { kaos?: Kaos; persistenceKaos?: Kaos }, + overrides: { + kaos?: Kaos; + persistenceKaos?: Kaos; + forcePluginSessionStartReminder?: boolean; + }, ): Promise { const summary = await this.sessionStore.get(input.sessionId); const parentKaosForRead = overrides.kaos ?? (await this.getKaos()); @@ -430,6 +434,11 @@ export class KimiCore implements PromisableMethods { throw error; } this.sessions.set(summary.id, session); + if (overrides.forcePluginSessionStartReminder === true) { + // Append before constructing the result so the returned ResumeSessionResult + // (and any SDK caller's resumeState) reflects the refreshed plugin context. + await session.appendPluginSessionStartReminder(); + } return resumeSessionResult(summary, session, warning); } @@ -452,7 +461,10 @@ export class KimiCore implements PromisableMethods { await active.closeForReload(); this.sessions.delete(summary.id); } - return this.resumeSession({ sessionId: summary.id }); + return this.resumeSessionWithOverrides( + { sessionId: summary.id }, + { forcePluginSessionStartReminder: input.forcePluginSessionStartReminder }, + ); } async forkSession(input: ForkSessionPayload): Promise { diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index bc6642151..922f57990 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -10,6 +10,7 @@ import type { KimiConfig, SDKSessionRPC } from '#/rpc'; import { proxyWithExtraPayload } from '#/rpc/types'; import { Agent, type AgentOptions, type AgentType } from '../agent'; +import { renderPluginSessionStartReminder } from '../agent/injection/plugin-session-start'; import { HookEngine, type HookDef } from './hooks'; import type { PermissionManagerOptions, PermissionRule } from '../agent/permission'; import { @@ -544,6 +545,56 @@ export class Session { } } + /** + * Appends a fresh `` system reminder to the main agent + * using the currently enabled plugins, then flushes records so the reminder is + * persisted and visible on the wire. Used by the explicit `/reload` flow after + * the session has been re-resumed with reloaded plugin state. + * + * When no plugin session start is currently resolvable but an earlier + * When no plugin session start is currently resolvable but the context may still + * carry stale plugin guidance — either an earlier `` + * reminder, or a compaction summary that may have folded one in — appends a + * neutralizing reminder instead, so the model does not keep following stale + * plugin instructions and the turn-loop injector does not dedup against them. + */ + async appendPluginSessionStartReminder(): Promise { + await this.skillsReady; + const mainAgent = this.requireMainAgent(); + const reminder = renderPluginSessionStartReminder({ + sessionStarts: mainAgent.pluginSessionStarts, + registry: mainAgent.skills?.registry, + log: mainAgent.log, + }); + if (reminder !== undefined) { + mainAgent.context.appendSystemReminder( + `${reminder}\n\nThis supersedes any earlier plugin_session_start reminder in this session.`, + { kind: 'injection', variant: 'plugin_session_start' }, + ); + } else if (this.shouldNeutralizePluginSessionStart(mainAgent)) { + mainAgent.context.appendSystemReminder( + 'There are currently no active plugin session starts. This supersedes any earlier plugin_session_start reminder in this session.', + { kind: 'injection', variant: 'plugin_session_start' }, + ); + } else { + return; + } + await mainAgent.records.flush(); + } + + private shouldNeutralizePluginSessionStart(mainAgent: Agent): boolean { + return mainAgent.context.history.some((message) => { + const kind = message.origin?.kind; + if (kind === 'injection') { + return message.origin?.variant === 'plugin_session_start'; + } + // A compaction summary replaces earlier messages (including any plugin + // session-start reminder) with a single summary that may still carry stale + // plugin guidance, so the origin-only check above is not sufficient. + return kind === 'compaction_summary'; + }); + } + get hasActiveTurn(): boolean { for (const agent of this.readyAgents()) { if (agent.turn.hasActiveTurn) return true; diff --git a/packages/agent-core/test/agent/injection/plugin-session-start.test.ts b/packages/agent-core/test/agent/injection/plugin-session-start.test.ts index 0fe14ef36..8a8993c6b 100644 --- a/packages/agent-core/test/agent/injection/plugin-session-start.test.ts +++ b/packages/agent-core/test/agent/injection/plugin-session-start.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import type { Agent } from '../../../src/agent'; import type { PromptOrigin } from '../../../src/agent/context'; -import { PluginSessionStartInjector } from '../../../src/agent/injection/plugin-session-start'; +import { + PluginSessionStartInjector, + renderPluginSessionStartReminder, +} from '../../../src/agent/injection/plugin-session-start'; import type { EnabledPluginSessionStart } from '../../../src/plugin/types'; import type { SkillDefinition } from '../../../src/skill/types'; @@ -210,3 +213,59 @@ describe('PluginSessionStartInjector', () => { expect(text).not.toContain('project body'); }); }); + +describe('renderPluginSessionStartReminder', () => { + function registryFor(skills: readonly SkillDefinition[]) { + const byPluginAndName = new Map( + skills.flatMap((s) => + s.plugin === undefined ? [] : [[`${s.plugin.id}\0${s.name.toLowerCase()}`, s] as const], + ), + ); + return { + getPluginSkill: (pluginId: string, name: string) => + byPluginAndName.get(`${pluginId}\0${name.toLowerCase()}`), + renderSkillPrompt: (s: SkillDefinition) => s.content, + }; + } + + it('renders a block per resolvable sessionStart', () => { + const text = renderPluginSessionStartReminder({ + sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], + registry: registryFor([ + skill('using-superpowers', 'plugin body', { id: 'superpowers' }), + ]), + }); + expect(text).toContain( + '', + ); + expect(text).toContain('plugin body'); + }); + + it('returns undefined when there are no sessionStarts', () => { + expect( + renderPluginSessionStartReminder({ sessionStarts: [], registry: registryFor([]) }), + ).toBeUndefined(); + }); + + it('returns undefined when the registry is unavailable', () => { + expect( + renderPluginSessionStartReminder({ + sessionStarts: [{ pluginId: 'demo', skillName: 'x' }], + registry: undefined, + }), + ).toBeUndefined(); + }); + + it('returns undefined and warns when the skill cannot be resolved', () => { + const warnings: Array<{ message: string; payload?: unknown }> = []; + const text = renderPluginSessionStartReminder({ + sessionStarts: [{ pluginId: 'demo', skillName: 'missing' }], + registry: registryFor([]), + log: { warn: (message, payload) => warnings.push({ message, payload }) }, + }); + expect(text).toBeUndefined(); + expect(warnings).toContainEqual( + expect.objectContaining({ message: 'plugin sessionStart skill not found' }), + ); + }); +}); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index d9853289b..7d5821181 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -921,8 +921,268 @@ base_url = "https://search.example.test/v1" }); expect(core.sessions.get(created.id)).toBe(active); }); + + it('appends a fresh plugin_session_start reminder on forced reload', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const pluginRoot = join(tmp, 'plugin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeSessionStartPlugin(pluginRoot, 'OLD BODY'); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + await core.installPlugin({ source: pluginRoot }); + const created = await rpc.createSession({ + id: 'ses_runtime_reload_reminder', + workDir, + model: 'default-mock', + }); + + // Before any forced reload the model has not been told about the plugin yet + // (no turn has run, so the turn-loop injector has not fired). + expect(pluginSessionStartReminders(core, created.id)).toHaveLength(0); + + // Update the skill content on disk so the reload must pick up the new body. + // Preserve the SKILL.md frontmatter — the parser requires it to register the skill. + await writeFile( + managedSkillPath(homeDir), + `---\nname: greeter\ndescription: A greeter skill\n---\nNEW BODY\n`, + ); + + const reloaded = await rpc.reloadSession({ + sessionId: created.id, + forcePluginSessionStartReminder: true, + }); + + const reminders = pluginSessionStartReminders(core, created.id); + expect(reminders).toHaveLength(1); + expect(reminders[0]).toContain(''); + expect(reminders[0]).toContain('NEW BODY'); + expect(reminders[0]).not.toContain('OLD BODY'); + expect(reminders[0]).toContain('supersedes any earlier plugin_session_start'); + + // The returned ResumeSessionResult must already include the fresh reminder + // (otherwise SDK callers reading getResumeState() see stale plugin context). + const resultReminders = remindersFromHistory( + reloaded.agents['main']?.context.history ?? [], + ); + expect(resultReminders).toHaveLength(1); + expect(resultReminders[0]).toContain('NEW BODY'); + }); + + it('neutralizes a stale plugin_session_start reminder when the plugin is removed', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const pluginRoot = join(tmp, 'plugin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeSessionStartPlugin(pluginRoot, 'BODY'); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + await core.installPlugin({ source: pluginRoot }); + const created = await rpc.createSession({ + id: 'ses_runtime_reload_neutralize', + workDir, + model: 'default-mock', + }); + + // First forced reload appends an active reminder, establishing a prior + // plugin_session_start in history. + await rpc.reloadSession({ + sessionId: created.id, + forcePluginSessionStartReminder: true, + }); + expect(pluginSessionStartReminders(core, created.id)).toHaveLength(1); + + // Removing the plugin means no sessionStart is resolvable on the next reload; + // the stale reminder must be neutralized rather than left in place. + await core.removePlugin({ id: 'demo' }); + await rpc.reloadSession({ + sessionId: created.id, + forcePluginSessionStartReminder: true, + }); + + const reminders = pluginSessionStartReminders(core, created.id); + expect(reminders).toHaveLength(2); + expect(reminders.at(-1)).toContain('no active plugin session starts'); + expect(reminders.at(-1)).toContain('supersedes any earlier plugin_session_start'); + }); + + it('does not append a plugin_session_start reminder on reload without the force flag', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const pluginRoot = join(tmp, 'plugin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeSessionStartPlugin(pluginRoot, 'BODY'); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + await core.installPlugin({ source: pluginRoot }); + const created = await rpc.createSession({ + id: 'ses_runtime_reload_no_force', + workDir, + model: 'default-mock', + }); + + await rpc.reloadSession({ sessionId: created.id }); + + expect(pluginSessionStartReminders(core, created.id)).toHaveLength(0); + }); + + it('appends nothing on forced reload when no plugin declares a sessionStart', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const pluginRoot = join(tmp, 'plugin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await mkdir(pluginRoot, { recursive: true }); + await writeFile( + join(pluginRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'demo', version: '1.0.0' }), + ); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + await core.installPlugin({ source: pluginRoot }); + const created = await rpc.createSession({ + id: 'ses_runtime_reload_no_sessionstart', + workDir, + model: 'default-mock', + }); + + await rpc.reloadSession({ + sessionId: created.id, + forcePluginSessionStartReminder: true, + }); + + expect(pluginSessionStartReminders(core, created.id)).toHaveLength(0); + }); + + it('neutralizes stale plugin guidance after compaction when no sessionStart is active', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_reload_compacted', + workDir, + model: 'default-mock', + }); + const session = core.sessions.get(created.id); + const main = session?.getReadyAgent('main'); + + // Simulate a compaction that folded earlier messages (and any plugin guidance) + // into a single summary, leaving no discrete plugin_session_start behind. + main?.context.appendMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'summary of earlier conversation with plugin guidance' }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }); + + await session?.appendPluginSessionStartReminder(); + + const reminders = pluginSessionStartReminders(core, created.id); + expect(reminders).toHaveLength(1); + expect(reminders[0]).toContain('no active plugin session starts'); + }); }); +async function writeSessionStartPlugin(root: string, skillBody: string): Promise { + await mkdir(join(root, 'skills', 'greeter'), { recursive: true }); + await writeFile( + join(root, 'kimi.plugin.json'), + JSON.stringify({ + name: 'demo', + version: '1.0.0', + skills: ['./skills'], + sessionStart: { skill: 'greeter' }, + }), + ); + await writeFile( + join(root, 'skills', 'greeter', 'SKILL.md'), + `---\nname: greeter\ndescription: A greeter skill\n---\n${skillBody}\n`, + ); +} + +function managedSkillPath(homeDir: string): string { + return join(homeDir, 'plugins', 'managed', 'demo', 'skills', 'greeter', 'SKILL.md'); +} + +function pluginSessionStartReminders(core: KimiCore, sessionId: string): string[] { + const agent = core.sessions.get(sessionId)?.getReadyAgent('main'); + if (agent === undefined) return []; + return remindersFromHistory(agent.context.history); +} + +function remindersFromHistory( + history: ReadonlyArray<{ + role: string; + origin?: { kind: string; variant?: string }; + content: ReadonlyArray<{ type: string; text?: string }>; + }>, +): string[] { + return history + .filter( + (message) => + message.role === 'user' && + message.origin?.kind === 'injection' && + message.origin.variant === 'plugin_session_start', + ) + .map((message) => message.content.map((part) => part.text ?? '').join('')); +} + async function readMainWire(sessionDir: string): Promise[]> { const wire = await readFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), 'utf-8'); return wire diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 82288ae5d..53da798e3 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -22,6 +22,7 @@ import type { ListSessionsOptions, RenameSessionInput, ResumeSessionInput, + ReloadSessionInput, SessionSummary, TelemetryClient, TelemetryContextPatch, @@ -142,16 +143,21 @@ export class KimiHarness { return session; } - async reloadSession(input: ResumeSessionInput): Promise { + async reloadSession(input: ReloadSessionInput): Promise { const id = normalizeSessionId(input.id); const active = this.activeSessions.get(id); if (active !== undefined) { - await active.reloadSession(); + await active.reloadSession({ + forcePluginSessionStartReminder: input.forcePluginSessionStartReminder, + }); this.trackSessionEvent(active.id, 'session_reload'); return active; } - const summary = await this.rpc.reloadSession({ sessionId: id }); + const summary = await this.rpc.reloadSession({ + sessionId: id, + forcePluginSessionStartReminder: input.forcePluginSessionStartReminder, + }); const session = new Session({ id: summary.id, workDir: summary.workDir, diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 7582bec64..db8d14d11 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -66,6 +66,10 @@ export interface SessionIdRpcInput { readonly sessionId: string; } +export interface ReloadSessionRpcInput extends SessionIdRpcInput { + readonly forcePluginSessionStartReminder?: boolean; +} + export interface SetSessionModelRpcInput extends SessionIdRpcInput { readonly model: string; } @@ -150,9 +154,12 @@ export abstract class SDKRpcClientBase { return this.resumeSession(input); } - async reloadSession(input: SessionIdRpcInput): Promise { + async reloadSession(input: ReloadSessionRpcInput): Promise { const rpc = await this.getRpc(); - return rpc.reloadSession({ sessionId: input.sessionId }); + return rpc.reloadSession({ + sessionId: input.sessionId, + forcePluginSessionStartReminder: input.forcePluginSessionStartReminder, + }); } async forkSession(input: ForkSessionInput): Promise { diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index b6b2eb9ee..38c270850 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -22,6 +22,7 @@ import type { PluginInfo, PluginSummary, PromptInput, + ReloadSessionOptions, ReloadSummary, ResumedSessionState, ResumedSessionSummary, @@ -68,9 +69,12 @@ export class Session { return this.resumeState; } - async reloadSession(): Promise { + async reloadSession(options?: ReloadSessionOptions): Promise { this.ensureOpen(); - const summary = await this.rpc.reloadSession({ sessionId: this.id }); + const summary = await this.rpc.reloadSession({ + sessionId: this.id, + forcePluginSessionStartReminder: options?.forcePluginSessionStartReminder, + }); this.summary = summary; this.resumeState = resumeStateFromSummary(summary); return summary; diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index ff1f0fd95..9bc328de7 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -118,6 +118,10 @@ export interface ResumeSessionInput { readonly sessionStartedProperties?: TelemetryProperties; } +export interface ReloadSessionInput extends ResumeSessionInput { + readonly forcePluginSessionStartReminder?: boolean; +} + export interface AddAdditionalDirInput { readonly id: string; readonly path: string; @@ -166,6 +170,10 @@ export interface CompactOptions { readonly instruction?: string | undefined; } +export interface ReloadSessionOptions { + readonly forcePluginSessionStartReminder?: boolean; +} + export interface PlanInfo { readonly id: string; readonly content: string; diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index bf2fafc13..dfdb6ffc8 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -387,4 +387,23 @@ micro_compaction = false expect(session.getResumeState()?.agents['main']).toBeDefined(); await expect(session.getStatus()).resolves.toMatchObject({ model: 'kimi-for-coding' }); }); + + it('forwards forcePluginSessionStartReminder to the active session reload', async () => { + const homeDir = await makeTempDir(); + const workDir = join(homeDir, 'work'); + const configPath = join(homeDir, 'config.toml'); + await writeFile(configPath, COMPLETE_TOML, 'utf-8'); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + const session = await harness.createSession({ + id: 'session-sdk-reload-forward', + workDir, + model: 'kimi-for-coding', + }); + + const reloadSpy = vi.spyOn(session, 'reloadSession').mockResolvedValue({} as never); + + await harness.reloadSession({ id: session.id, forcePluginSessionStartReminder: true }); + + expect(reloadSpy).toHaveBeenCalledWith({ forcePluginSessionStartReminder: true }); + }); });