Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reload-plugin-session-start.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}

Expand Down
4 changes: 3 additions & 1 deletion apps/kimi-code/test/tui/commands/reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
64 changes: 46 additions & 18 deletions packages/agent-core/src/agent/injection/plugin-session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
});
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/agent-core/src/rpc/core-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 14 additions & 2 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,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());
Expand Down Expand Up @@ -430,6 +434,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);
}

Expand All @@ -452,7 +461,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> {
Expand Down
51 changes: 51 additions & 0 deletions packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -544,6 +545,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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' }),
);
});
});
Loading
Loading