mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(reload): re-inject plugin session-start reminder after /reload (#1086)
* fix(reload): re-inject plugin session-start reminder after /reload Reload reloaded plugins and resumed the session, but the model kept seeing the stale plugin session-start reminder from before the reload, so plugin skill changes only took effect in a fresh session. Append a fresh plugin_session_start reminder to the main agent after reload, gated on a new forcePluginSessionStartReminder flag that only the explicit /reload command sets, so config and experiment toggles that reuse the reload RPC do not spam the transcript. * fix(reload): keep reload result fresh and neutralize stale plugin reminder Append the plugin session-start reminder before constructing ResumeSessionResult so SDK callers reading getResumeState() see the refreshed plugin context instead of a pre-reload snapshot. When a plugin with a prior plugin_session_start reminder is disabled or removed, append a neutralizing reminder so the model does not keep following stale plugin instructions. * fix(reload): neutralize stale plugin reminder after compaction A full compaction folds the discrete plugin_session_start reminder into a compaction_summary, so the origin-only scan no longer detects it. Also treat a compaction_summary in history as a signal to neutralize, so disabling or removing a plugin after compaction still emits a superseding 'no active plugin session starts' reminder. * fix(reload): thread plugin reminder option through KimiHarness.reloadSession KimiHarness.reloadSession is a public SDK entry point; forward forcePluginSessionStartReminder to both the active-session and RPC reload paths so SDK callers using the harness can opt into the refreshed plugin reminder too.
This commit is contained in:
parent
46808e1cff
commit
fe667d7c2e
14 changed files with 498 additions and 30 deletions
5
.changeset/reload-plugin-session-start.md
Normal file
5
.changeset/reload-plugin-session-start.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -16,7 +16,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void>
|
|||
const session = host.session;
|
||||
|
||||
if (session !== undefined) {
|
||||
await session.reloadSession();
|
||||
await session.reloadSession({ forcePluginSessionStartReminder: true });
|
||||
await host.reloadCurrentSessionView(session, 'Session reloaded.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 `<plugin_session_start>` 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,13 @@ export interface ResumeSessionPayload {
|
|||
|
||||
export interface ReloadSessionPayload {
|
||||
readonly sessionId: string;
|
||||
/**
|
||||
* When true, append a fresh `<plugin_session_start>` 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 {
|
||||
|
|
|
|||
|
|
@ -357,7 +357,11 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
|
||||
async resumeSessionWithOverrides(
|
||||
input: ResumeSessionPayload,
|
||||
overrides: { kaos?: Kaos; persistenceKaos?: Kaos },
|
||||
overrides: {
|
||||
kaos?: Kaos;
|
||||
persistenceKaos?: Kaos;
|
||||
forcePluginSessionStartReminder?: boolean;
|
||||
},
|
||||
): Promise<ResumeSessionResult> {
|
||||
const summary = await this.sessionStore.get(input.sessionId);
|
||||
const parentKaosForRead = overrides.kaos ?? (await this.getKaos());
|
||||
|
|
@ -432,6 +436,11 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
@ -454,7 +463,10 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
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<ResumeSessionResult> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -545,6 +546,56 @@ export class Session {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a fresh `<plugin_session_start>` 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 `<plugin_session_start>`
|
||||
* 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<void> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'<plugin_session_start plugin="superpowers" skill="using-superpowers">',
|
||||
);
|
||||
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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<CoreAPI, SDKAPI>();
|
||||
const core = new KimiCore(coreRpc, { homeDir });
|
||||
const rpc = await sdkRpc({
|
||||
emitEvent: vi.fn(),
|
||||
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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('<plugin_session_start plugin="demo" skill="greeter">');
|
||||
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<CoreAPI, SDKAPI>();
|
||||
const core = new KimiCore(coreRpc, { homeDir });
|
||||
const rpc = await sdkRpc({
|
||||
emitEvent: vi.fn(),
|
||||
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<CoreAPI, SDKAPI>();
|
||||
const core = new KimiCore(coreRpc, { homeDir });
|
||||
const rpc = await sdkRpc({
|
||||
emitEvent: vi.fn(),
|
||||
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<CoreAPI, SDKAPI>();
|
||||
const core = new KimiCore(coreRpc, { homeDir });
|
||||
const rpc = await sdkRpc({
|
||||
emitEvent: vi.fn(),
|
||||
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<CoreAPI, SDKAPI>();
|
||||
const core = new KimiCore(coreRpc, { homeDir });
|
||||
const rpc = await sdkRpc({
|
||||
emitEvent: vi.fn(),
|
||||
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ 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<void> {
|
||||
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<readonly Record<string, unknown>[]> {
|
||||
const wire = await readFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), 'utf-8');
|
||||
return wire
|
||||
|
|
|
|||
|
|
@ -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<Session> {
|
||||
async reloadSession(input: ReloadSessionInput): Promise<Session> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<ResumedSessionSummary> {
|
||||
async reloadSession(input: ReloadSessionRpcInput): Promise<ResumedSessionSummary> {
|
||||
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<SessionSummary> {
|
||||
|
|
|
|||
|
|
@ -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<ResumedSessionSummary> {
|
||||
async reloadSession(options?: ReloadSessionOptions): Promise<ResumedSessionSummary> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue