refactor(agent-core): move HookEngine to sessions (#165)

This commit is contained in:
_Kerman 2026-05-28 21:35:42 +08:00 committed by GitHub
parent 0a766584cb
commit 74e867a300
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 154 additions and 218 deletions

View file

@ -4,7 +4,6 @@ import type { Agent } from '../..';
import type { TelemetryPropertyValue } from '../../telemetry';
import {
BackgroundProcessManager,
type BackgroundProcessManagerOptions,
type BackgroundTaskInfo,
isBackgroundTaskTerminal,
type ReconcileResult,
@ -36,11 +35,11 @@ export class BackgroundManager extends BackgroundProcessManager {
private readonly scheduledNotificationKeys = new Set<string>();
private readonly deliveredNotificationKeys = new Set<string>();
constructor(
public readonly agent: Agent,
options: BackgroundProcessManagerOptions = {},
) {
super(options);
constructor(public readonly agent: Agent) {
super({
maxRunningTasks: agent.kimiConfig?.background?.maxRunningTasks,
sessionDir: agent.homedir,
});
this.onLifecycle((event, info) => {
switch (event) {

View file

@ -91,16 +91,6 @@ export interface CronManagerOptions {
* means "no automatic timer — caller drives `tick()` manually".
*/
readonly pollIntervalMs?: number | null;
/**
* Per-session directory used to persist cron tasks across
* `kimi resume`. When set, `addTask` / `removeTasks` mirror the
* in-memory store to `<sessionDir>/cron/<id>.json` and
* `loadFromDisk()` re-populates the store on resume. When omitted
* (subagents, tests, ephemeral sessions), the manager stays purely
* in-memory and `loadFromDisk()` is a no-op.
*/
readonly sessionDir?: string;
}
export class CronManager {
@ -158,9 +148,9 @@ export class CronManager {
resolveClockSources(process.env['KIMI_CRON_CLOCK']) ??
SYSTEM_CLOCKS;
this.persistStore =
opts.sessionDir === undefined
agent.homedir === undefined
? undefined
: createCronPersistStore(opts.sessionDir);
: createCronPersistStore(agent.homedir);
this.scheduler = createCronScheduler({
clocks: this.clocks,
@ -185,6 +175,8 @@ export class CronManager {
? null
: opts.pollIntervalMs,
});
this.start();
}
/**

View file

@ -4,7 +4,7 @@ import { join } from 'pathe';
import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors';
import { log } from '#/logging/logger';
import type { Logger } from '#/logging/types';
import type { AgentAPI, AgentEvent, SDKAgentRPC, UsageStatus } from '#/rpc';
import type { AgentAPI, AgentEvent, KimiConfig, SDKAgentRPC, UsageStatus } from '#/rpc';
import {
generate,
type ChatProvider,
@ -32,7 +32,7 @@ import { FullCompaction, type CompactionStrategy } from './compaction';
import { CronManager } from './cron';
import { ConfigState } from './config';
import { ContextMemory } from './context';
import { HookEngine } from './hooks';
import { HookEngine } from '../session/hooks';
import { InjectionManager } from './injection/manager';
import { PermissionManager, type PermissionManagerOptions } from './permission';
import { PlanMode } from './plan';
@ -60,10 +60,10 @@ export type { BuiltinTool, ToolInfo, ToolSource, UserToolRegistration } from './
export type AgentType = 'main' | 'sub' | 'independent';
export interface AgentConfig {
export interface AgentOptions {
readonly runtime: RuntimeConfig;
readonly config?: KimiConfig;
readonly homedir?: string;
readonly skills?: SkillRegistry;
readonly rpc?: SDKAgentRPC;
readonly persistence?: AgentRecordPersistence;
readonly type?: AgentType;
@ -71,16 +71,9 @@ export interface AgentConfig {
readonly compactionStrategy?: CompactionStrategy;
readonly providerManager?: ProviderManager | undefined;
readonly subagentHost?: SessionSubagentHost | undefined;
readonly skills?: SkillRegistry;
readonly mcp?: McpConnectionManager;
readonly hookEngine?: HookEngine;
readonly backgroundMaxRunningTasks?: number;
readonly backgroundSessionDir?: string;
/**
* Per-session directory used by `CronManager` to persist scheduled
* tasks across `kimi resume`. When omitted the cron stack stays
* purely in-memory (subagents, ephemeral sessions). Set in parallel
* with {@link backgroundSessionDir} from the session homedir.
*/
readonly cronSessionDir?: string;
readonly permission?: PermissionManagerOptions | undefined;
readonly log?: Logger;
@ -90,6 +83,7 @@ export interface AgentConfig {
export class Agent {
readonly runtime: RuntimeConfig;
readonly kimiConfig?: KimiConfig;
readonly homedir?: string;
readonly skills?: SkillManager;
readonly pluginSessionStarts: readonly EnabledPluginSessionStart[];
@ -114,38 +108,37 @@ export class Agent {
readonly usage: UsageRecorder;
readonly tools: ToolManager;
readonly background: BackgroundManager;
readonly cron: CronManager;
readonly cron: CronManager | null;
readonly replayBuilder: ReplayBuilder;
readonly log: Logger;
private lastLlmConfigLogSignature?: string;
constructor(config: AgentConfig) {
this.log = config.log ?? log;
this.runtime = config.runtime;
this.homedir = config.homedir;
if (config.skills !== undefined) {
this.skills = new SkillManager(this, config.skills);
constructor(options: AgentOptions) {
this.log = options.log ?? log;
this.kimiConfig = options.config;
this.runtime = options.runtime;
this.homedir = options.homedir;
if (options.skills !== undefined) {
this.skills = new SkillManager(this, options.skills);
}
this.pluginSessionStarts = config.pluginSessionStarts ?? [];
this.rawGenerate = config.generate ?? generate;
this.providerManager = config.providerManager;
this.subagentHost = config.subagentHost;
this.mcp = config.mcp;
this.hooks = config.hookEngine;
this.type = config.type ?? 'main';
this.rpc = config.rpc;
this.telemetry = config.telemetry ?? noopTelemetryClient;
this.blobStore = config.homedir
? new BlobStore({ blobsDir: join(config.homedir, 'blobs') })
this.pluginSessionStarts = options.pluginSessionStarts ?? [];
this.rawGenerate = options.generate ?? generate;
this.providerManager = options.providerManager;
this.subagentHost = options.subagentHost;
this.mcp = options.mcp;
this.hooks = options.hookEngine;
this.type = options.type ?? 'main';
this.rpc = options.rpc;
this.telemetry = options.telemetry ?? noopTelemetryClient;
this.blobStore = options.homedir
? new BlobStore({ blobsDir: join(options.homedir, 'blobs') })
: undefined;
this.records = new AgentRecords(
this,
config.persistence ??
(config.homedir
? new FileSystemAgentRecordPersistence(join(config.homedir, 'wire.jsonl'), {
options.persistence ??
(options.homedir
? new FileSystemAgentRecordPersistence(join(options.homedir, 'wire.jsonl'), {
onError: (error) => {
this.emitRecordsWriteError(error);
},
@ -153,38 +146,17 @@ export class Agent {
})
: undefined),
);
this.fullCompaction = new FullCompaction(this, config.compactionStrategy);
this.fullCompaction = new FullCompaction(this, options.compactionStrategy);
this.context = new ContextMemory(this);
this.config = new ConfigState(this);
this.turn = new TurnFlow(this);
this.injection = new InjectionManager(this);
this.permission = new PermissionManager(this, config.permission);
this.permission = new PermissionManager(this, options.permission);
this.planMode = new PlanMode(this);
this.usage = new UsageRecorder(this);
this.tools = new ToolManager(this);
this.background = new BackgroundManager(this, {
maxRunningTasks: config.backgroundMaxRunningTasks,
sessionDir: config.backgroundSessionDir,
});
this.cron = new CronManager(this, {
// Subagents stay in-memory: their default profiles don't expose
// cron tools, so attaching a sessionDir would only litter the
// subagent homedir with an empty `cron/` directory on first
// mkdir. Main / non-sub agents persist when the session supplied
// a directory.
sessionDir: this.type !== 'sub' ? config.cronSessionDir : undefined,
});
if (this.type !== 'sub') {
// Skip auto-tick for subagents: each session can spawn many
// subagents, and stacking 1s setInterval timers + SIGUSR1
// listeners per subagent serves no purpose — the default subagent
// profiles don't expose Cron tools, so the store stays empty.
// The scheduler unref()'s its setInterval so the cron timer never
// keeps the process alive on its own, and isKilled (reading
// KIMI_DISABLE_CRON) short-circuits every tick — no need to
// delay start when the killswitch is set.
this.cron.start();
}
this.background = new BackgroundManager(this);
this.cron = this.type === 'sub' ? null : new CronManager(this);
this.replayBuilder = new ReplayBuilder(this);
}
@ -282,12 +254,7 @@ export class Agent {
const result = await this.records.replay();
await this.background.loadFromDisk();
await this.background.reconcile();
// Rehydrate cron tasks scheduled in the previous CLI invocation.
// No-op when this agent was constructed without a `cronSessionDir`
// (subagents, ephemeral sessions). The scheduler's `createdAt`-based
// baseline then handles any fire times missed during downtime via
// `coalescedCount` (and the 7-day stale flag) without further wiring.
await this.cron.loadFromDisk();
await this.cron?.loadFromDisk();
this.turn.finishResume();
return result;
}

View file

@ -374,9 +374,9 @@ export class ToolManager {
new b.TaskListTool(background),
new b.TaskOutputTool(background),
new b.TaskStopTool(background),
this.agent.type !== 'sub' && new b.CronCreateTool(this.agent.cron),
this.agent.type !== 'sub' && new b.CronListTool(this.agent.cron),
this.agent.type !== 'sub' && new b.CronDeleteTool(this.agent.cron),
this.agent.cron && new b.CronCreateTool(this.agent.cron),
this.agent.cron && new b.CronListTool(this.agent.cron),
this.agent.cron && new b.CronDeleteTool(this.agent.cron),
this.agent.skills !== undefined &&
this.agent.skills.registry.listInvocableSkills().length > 0 &&
new b.SkillTool(this.agent),

View file

@ -33,7 +33,7 @@ import type { AgentEvent, TurnEndedEvent } from '../../rpc';
import type { TelemetryPropertyValue } from '../../telemetry';
import { abortable } from '../../utils/abort';
import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context';
import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../hooks';
import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks';
import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args';
import { ToolCallDeduplicator } from './tool-dedup';

View file

@ -1,4 +1,4 @@
import { HOOK_EVENT_TYPES } from '#/agent/hooks/types';
import { HOOK_EVENT_TYPES } from '../session/hooks/types';
import { parsePattern } from '#/agent/permission/matches-rule';
import { ErrorCodes, KimiError } from '#/errors';
import { z } from 'zod';

View file

@ -180,6 +180,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
const runtime = await this.resolveRuntime(config);
const session = new Session({
runtime: { ...runtime, kaos: runtime.kaos.withCwd(workDir) },
config,
id,
homedir: summary.sessionDir,
kimiHomeDir: this.homeDir,
@ -259,6 +260,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
const runtime = await this.resolveRuntime(config);
const session = new Session({
runtime: { ...runtime, kaos: runtime.kaos.withCwd(summary.workDir) },
config,
id: summary.id,
homedir: summary.sessionDir,
kimiHomeDir: this.homeDir,

View file

@ -4,11 +4,11 @@ import { join } from 'pathe';
import { ErrorCodes, KimiError } from '#/errors';
import { getRootLogger, log } from '#/logging/logger';
import type { Logger, SessionLogHandle } from '#/logging/types';
import type { SDKSessionRPC } from '#/rpc';
import type { KimiConfig, SDKSessionRPC } from '#/rpc';
import { proxyWithExtraPayload } from '#/rpc/types';
import { Agent, type AgentConfig, type AgentType } from '../agent';
import { HookEngine, type HookDef } from '../agent/hooks';
import { Agent, type AgentOptions, type AgentType } from '../agent';
import { HookEngine, type HookDef } from './hooks';
import type { PermissionManagerOptions, PermissionRule } from '../agent/permission';
import { parseBooleanEnv, resolveConfigValue, type BackgroundConfig } from '../config';
import { makeErrorPayload } from '../errors';
@ -39,8 +39,9 @@ import {
import { noopTelemetryClient, type TelemetryClient } from '../telemetry';
import { SessionSubagentHost } from './subagent-host';
export interface SessionConfig {
export interface SessionOptions {
readonly runtime: RuntimeConfig;
readonly config?: KimiConfig;
readonly id?: string | undefined;
readonly homedir: string;
readonly kimiHomeDir?: string;
@ -105,29 +106,29 @@ export class Session {
};
private writeMetadataPromise = Promise.resolve();
constructor(public readonly config: SessionConfig) {
constructor(public readonly options: SessionOptions) {
// Attach the per-session log sink up front so the constructor's
// fire-and-forget `loadSkills` / `loadMcpServers` failures (and
// anything else that races) land in the session log, not just global.
this.logHandle =
config.id === undefined
options.id === undefined
? undefined
: getRootLogger().attachSession({
sessionId: config.id,
sessionDir: config.homedir,
sessionId: options.id,
sessionDir: options.homedir,
});
this.log =
this.logHandle?.logger ??
(config.id === undefined ? log : log.createChild({ sessionId: config.id }));
this.rpc = config.rpc;
this.hookEngine = new HookEngine(config.hooks, {
cwd: config.runtime.kaos.getcwd(),
sessionId: config.id,
(options.id === undefined ? log : log.createChild({ sessionId: options.id }));
this.rpc = options.rpc;
this.hookEngine = new HookEngine(options.hooks, {
cwd: options.runtime.kaos.getcwd(),
sessionId: options.id,
});
this.telemetry = config.telemetry ?? noopTelemetryClient;
this.skills = new SkillRegistry({ sessionId: config.id });
this.telemetry = options.telemetry ?? noopTelemetryClient;
this.skills = new SkillRegistry({ sessionId: options.id });
this.mcp = new McpConnectionManager({
oauthService: new McpOAuthService({ kimiHomeDir: config.kimiHomeDir }),
oauthService: new McpOAuthService({ kimiHomeDir: options.kimiHomeDir }),
log: this.log,
});
this.mcp.onStatusChange((entry) => {
@ -181,13 +182,9 @@ export class Session {
async close(): Promise<void> {
try {
// Stop cron FIRST. `stopBackgroundTasksOnExit()` can await
// long-running background workers (especially with
// `background.keepAliveOnExit=false`); while we are waiting, a due
// cron tick would otherwise still call `turn.steer()` and start a
// fresh turn after shutdown has already begun, racing the
// metadata flush below.
await this.stopCronOnExit();
await Promise.allSettled(
Array.from(this.agents.values(), async (agent) => agent.cron?.stop()),
);
await this.stopBackgroundTasksOnExit();
await this.flushMetadata();
await this.triggerSessionEnd('exit');
@ -200,20 +197,11 @@ export class Session {
}
}
// Stop every agent's cron scheduler on close. No keepAlive notion — cron
// intervals reference the agent/session graph and must die with the session.
// `allSettled` keeps one agent's failure from blocking the rest.
private async stopCronOnExit(): Promise<void> {
await Promise.allSettled(
Array.from(this.agents.values(), (agent) => agent.cron.stop()),
);
}
private async stopBackgroundTasksOnExit(): Promise<void> {
const keepAliveOnExit = resolveConfigValue({
env: process.env,
envKey: BACKGROUND_KEEP_ALIVE_ON_EXIT_ENV,
configValue: this.config.background?.keepAliveOnExit,
configValue: this.options.background?.keepAliveOnExit,
defaultValue: true,
parseEnv: parseBooleanEnv,
});
@ -226,14 +214,14 @@ export class Session {
}
async createAgent(
config: Partial<AgentConfig>,
config: Partial<AgentOptions>,
profile?: ResolvedAgentProfile,
parentAgentId?: string | undefined,
): Promise<{ readonly id: string; readonly agent: Agent }> {
await this.skillsReady;
const type = config.type ?? 'main';
const id = type === 'main' ? 'main' : this.nextGeneratedAgentId();
const homedir = config.homedir ?? join(this.config.homedir, 'agents', id);
const homedir = config.homedir ?? join(this.options.homedir, 'agents', id);
const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId ?? null);
if (profile) {
await this.bootstrapAgentProfile(agent, profile);
@ -301,21 +289,21 @@ export class Session {
}
protected get metadataPath() {
return join(this.config.homedir, 'state.json');
return join(this.options.homedir, 'state.json');
}
writeMetadata() {
const text = JSON.stringify(this.metadata, null, 2);
const write = async () => {
await this.config.runtime.kaos.mkdir(this.config.homedir, { parents: true, existOk: true });
await this.config.runtime.kaos.writeText(this.metadataPath, text);
await this.options.runtime.kaos.mkdir(this.options.homedir, { parents: true, existOk: true });
await this.options.runtime.kaos.writeText(this.metadataPath, text);
};
this.writeMetadataPromise = this.writeMetadataPromise.then(write, write);
return this.writeMetadataPromise;
}
async readMetadata() {
const text = await this.config.runtime.kaos.readText(this.metadataPath);
const text = await this.options.runtime.kaos.readText(this.metadataPath);
this.metadata = JSON.parse(text);
return this.metadata;
}
@ -334,21 +322,21 @@ export class Session {
private async loadSkills(): Promise<void> {
const roots = await resolveSkillRoots({
paths: {
userHomeDir: this.config.skills?.userHomeDir ?? homedir(),
workDir: this.config.runtime.kaos.getcwd(),
userHomeDir: this.options.skills?.userHomeDir ?? homedir(),
workDir: this.options.runtime.kaos.getcwd(),
},
explicitDirs: this.config.skills?.explicitDirs,
extraDirs: this.config.skills?.extraDirs,
pluginSkillRoots: this.config.skills?.pluginSkillRoots,
mergeAllAvailableSkills: this.config.skills?.mergeAllAvailableSkills,
builtinDir: this.config.skills?.builtinDir,
explicitDirs: this.options.skills?.explicitDirs,
extraDirs: this.options.skills?.extraDirs,
pluginSkillRoots: this.options.skills?.pluginSkillRoots,
mergeAllAvailableSkills: this.options.skills?.mergeAllAvailableSkills,
builtinDir: this.options.skills?.builtinDir,
});
await this.skills.loadRoots(roots);
registerBuiltinSkills(this.skills);
}
private async loadMcpServers(): Promise<void> {
const servers = this.config.mcpConfig?.servers;
const servers = this.options.mcpConfig?.servers;
if (servers === undefined || Object.keys(servers).length === 0) return;
await this.mcp.connectAll(servers);
const entries = this.mcp.list().filter((entry) => entry.status !== 'disabled');
@ -399,7 +387,7 @@ export class Session {
}
private backgroundTaskTimeoutMs(): number | undefined {
const timeoutS = this.config.background?.agentTaskTimeoutS;
const timeoutS = this.options.background?.agentTaskTimeoutS;
return timeoutS === undefined ? undefined : timeoutS * 1000;
}
@ -414,28 +402,26 @@ export class Session {
id: string,
homedir: string,
type: AgentType,
config: Partial<AgentConfig> = {},
config: Partial<AgentOptions> = {},
parentAgentId: string | null = null,
): Agent {
return new Agent({
...config,
type,
runtime: this.config.runtime,
runtime: this.options.runtime,
config: this.options.config,
homedir,
skills: this.skills,
rpc: proxyWithExtraPayload(this.rpc, { agentId: id }),
providerManager: this.config.providerManager,
providerManager: this.options.providerManager,
hookEngine: config.hookEngine ?? this.hookEngine,
subagentHost:
config.subagentHost ?? new SessionSubagentHost(this, id, this.backgroundTaskTimeoutMs()),
mcp: this.mcp,
backgroundMaxRunningTasks: this.config.background?.maxRunningTasks,
backgroundSessionDir: homedir,
cronSessionDir: homedir,
permission: this.permissionOptions(parentAgentId, config.permission),
telemetry: this.telemetry,
log: this.log.createChild({ agentId: id }),
pluginSessionStarts: type === 'main' ? this.config.pluginSessionStarts : undefined,
pluginSessionStarts: type === 'main' ? this.options.pluginSessionStarts : undefined,
});
}
@ -446,7 +432,7 @@ export class Session {
if (parentAgentId === null) {
return {
...input,
initialRules: input?.initialRules ?? this.config.permissionRules,
initialRules: input?.initialRules ?? this.options.permissionRules,
};
}
return {

View file

@ -12,14 +12,14 @@ import {
} from '@moonshot-ai/kosong';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AgentConfig } from '../../src/agent';
import type { AgentOptions } from '../../src/agent';
import { DefaultCompactionStrategy, type CompactionStrategy } from '../../src/agent/compaction';
import { HookEngine, type HookEngineTriggerArgs } from '../../src/agent/hooks';
import { HookEngine, type HookEngineTriggerArgs } from '../../src/session/hooks';
import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry';
import type { TestAgentContext, TestAgentOptions } from './harness/agent';
import { testAgent } from './harness/agent';
type GenerateFn = NonNullable<AgentConfig['generate']>;
type GenerateFn = NonNullable<AgentOptions['generate']>;
const CATALOGUED_PROVIDER = {
type: 'kimi',

View file

@ -26,14 +26,14 @@ describe('Agent + Cron integration (P1.7)', () => {
});
afterEach(async () => {
await ctx.agent.cron.stop();
await ctx.agent.cron?.stop();
vi.unstubAllEnvs();
});
it('exposes agent.cron with its session store on construction', () => {
expect(ctx.agent.cron).toBeDefined();
expect(ctx.agent.cron.store).toBeDefined();
expect(ctx.agent.cron.store.list()).toEqual([]);
expect(ctx.agent.cron!.store).toBeDefined();
expect(ctx.agent.cron!.store.list()).toEqual([]);
});
it('registers CronCreate / CronList / CronDelete in the tool manager', () => {
@ -58,7 +58,7 @@ describe('Agent + Cron integration (P1.7)', () => {
// killswitch lives in `resolveExecution`, so a direct call is the
// precise unit being asserted, and it stays robust if the loop /
// dispatch surface changes around it (P1.8 onwards).
const tool = new CronCreateTool(ctx.agent.cron);
const tool = new CronCreateTool(ctx.agent.cron!);
const args: CronCreateInput = {
cron: '*/5 * * * *',
prompt: 'x',
@ -76,6 +76,6 @@ describe('Agent + Cron integration (P1.7)', () => {
);
// And no task slipped into the store.
expect(ctx.agent.cron.store.list()).toEqual([]);
expect(ctx.agent.cron!.store.list()).toEqual([]);
});
});

View file

@ -50,7 +50,7 @@ describe('Cron — session E2E (P1.9)', () => {
// `agent.cron.stop()`, but doing it here as well keeps the test
// self-contained against future harness changes and ensures the
// SIGUSR1 handler (if any) is unbound before the next test.
await ctx.agent.cron.stop();
await ctx.agent.cron?.stop();
vi.unstubAllEnvs();
});
@ -60,7 +60,7 @@ describe('Cron — session E2E (P1.9)', () => {
// test-only escape hatch — Agent.cron is `readonly` precisely
// because production code must never overwrite it; tests are the
// only legitimate exception.
await ctx.agent.cron.stop();
await ctx.agent.cron!.stop();
const harness = createClocks(LOCAL_ANCHOR_MS);
(ctx.agent as unknown as { cron: CronManager }).cron = new CronManager(
ctx.agent,
@ -71,7 +71,7 @@ describe('Cron — session E2E (P1.9)', () => {
pollIntervalMs: null,
},
);
ctx.agent.cron.start();
ctx.agent.cron!.start();
// Spy on agent.turn.steer. We wrap rather than replace so the real
// steer logic still runs (and the `turn.steer` record is written /
@ -96,7 +96,7 @@ describe('Cron — session E2E (P1.9)', () => {
// bypass `emitScheduled` telemetry and skip the byte-length /
// expression checks; that would not be the production code path
// this commit is meant to smoke.
const createTool = new CronCreateTool(ctx.agent.cron);
const createTool = new CronCreateTool(ctx.agent.cron!);
const execution = createTool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'cron-fired prompt',
@ -113,13 +113,13 @@ describe('Cron — session E2E (P1.9)', () => {
signal: new AbortController().signal,
});
expect(createResult.isError ?? false).toBe(false);
expect(ctx.agent.cron.store.list().length).toBe(1);
expect(ctx.agent.cron!.store.list().length).toBe(1);
// Advance 15 minutes — exactly three ideal */5 fires across the gap
// (12:05, 12:10, 12:15). See the file header for the calibration
// derivation.
harness.advance(15 * 60_000);
ctx.agent.cron.tick();
ctx.agent.cron!.tick();
// ── Steer was called exactly once ─────────────────────────────────
expect(steerCalls.length).toBe(1);
@ -149,9 +149,9 @@ describe('Cron — session E2E (P1.9)', () => {
// Optional second case from the P1.9 plan: prove the three-tool
// surface composes correctly end-to-end on the real manager. No
// clock manipulation needed — list/delete are time-invariant.
const createTool = new CronCreateTool(ctx.agent.cron);
const listTool = new CronListTool(ctx.agent.cron);
const deleteTool = new CronDeleteTool(ctx.agent.cron);
const createTool = new CronCreateTool(ctx.agent.cron!);
const listTool = new CronListTool(ctx.agent.cron!);
const deleteTool = new CronDeleteTool(ctx.agent.cron!);
const ctxArgs = {
turnId: 'p19-tools',
toolCallId: 'p19-tools-call',

View file

@ -38,6 +38,8 @@ export interface AgentStubOptions {
* encodes "input was buffered into the steer queue" (turn in flight).
*/
readonly steerReturns?: number | null;
/** Optional homedir so CronManager persistence can derive its path. */
readonly homedir?: string;
}
export interface AgentStub {
@ -70,7 +72,7 @@ export function createAgentStub(opts: AgentStubOptions = {}): AgentStub {
telemetryCalls.push({ event, props });
},
};
const agent = { turn, telemetry } as unknown as Agent;
const agent = { turn, telemetry, homedir: opts.homedir } as unknown as Agent;
return {
agent,
steerCalls,

View file

@ -185,9 +185,9 @@ describe('CronManager — P1.8 manual tick + SIGUSR1', () => {
if (process.platform === 'win32') return;
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
const before = process.listenerCount('SIGUSR1');
manager.start();
// Constructor auto-starts, which binds SIGUSR1 under KIMI_CRON_MANUAL_TICK=1.
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
await manager.stop();
expect(process.listenerCount('SIGUSR1')).toBe(before);
@ -197,10 +197,11 @@ describe('CronManager — P1.8 manual tick + SIGUSR1', () => {
if (process.platform === 'win32') return;
const stub = createAgentStub();
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
const before = process.listenerCount('SIGUSR1');
// Constructor already calls start() once; an explicit second
// call must not stack a handler.
const manager = new CronManager(stub.agent, { pollIntervalMs: null });
try {
manager.start();
manager.start();
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
} finally {

View file

@ -50,12 +50,11 @@ async function readDiskIds(): Promise<readonly string[]> {
describe('CronManager — persistence and resume', () => {
it('addTask writes a JSON record to <sessionDir>/cron/<id>.json', async () => {
const { agent } = createAgentStub();
const { agent } = createAgentStub({ homedir: sessionDir });
const harness = createClocks();
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
sessionDir,
});
const task = manager.addTask({
@ -82,12 +81,11 @@ describe('CronManager — persistence and resume', () => {
});
it('removeTasks deletes the JSON record', async () => {
const { agent } = createAgentStub();
const { agent } = createAgentStub({ homedir: sessionDir });
const harness = createClocks();
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,
sessionDir,
});
const task = manager.addTask({ cron: '*/5 * * * *', prompt: 'a' });
@ -103,12 +101,11 @@ describe('CronManager — persistence and resume', () => {
it('loadFromDisk re-adopts tasks with original id and createdAt', async () => {
// First "session": schedule two recurring tasks.
const stubA = createAgentStub();
const stubA = createAgentStub({ homedir: sessionDir });
const clockA = createClocks();
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
const t1 = managerA.addTask({ cron: '*/5 * * * *', prompt: 'a' });
const t2 = managerA.addTask({
@ -120,12 +117,11 @@ describe('CronManager — persistence and resume', () => {
await managerA.stop();
// Second "session": fresh manager, same sessionDir.
const stubB = createAgentStub();
const stubB = createAgentStub({ homedir: sessionDir });
const clockB = createClocks(clockA.now() + 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
expect(managerB.store.list()).toEqual([]);
await managerB.loadFromDisk();
@ -146,12 +142,11 @@ describe('CronManager — persistence and resume', () => {
it('recurring task missed during downtime fires once with coalescedCount > 1', async () => {
// Session A: create a `*/5 * * * *` task.
const stubA = createAgentStub();
const stubA = createAgentStub({ homedir: sessionDir });
const clockA = createClocks();
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
managerA.addTask({ cron: '*/5 * * * *', prompt: 'check' });
await managerA.flushPersist();
@ -160,12 +155,11 @@ describe('CronManager — persistence and resume', () => {
// Session B: 23 minutes later (≈ 4 ideal fires missed: t+5, +10,
// +15, +20). With createdAt as the baseline the scheduler must
// collapse them into one fire.
const stubB = createAgentStub();
const stubB = createAgentStub({ homedir: sessionDir });
const clockB = createClocks(clockA.now() + 23 * 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
await managerB.loadFromDisk();
managerB.tick();
@ -183,12 +177,11 @@ describe('CronManager — persistence and resume', () => {
it('one-shot scheduled in the past fires once on resume and the file is removed', async () => {
// Session A: schedule a one-shot 5 minutes ahead of clockA's now,
// then quit before it fires.
const stubA = createAgentStub();
const stubA = createAgentStub({ homedir: sessionDir });
const clockA = createClocks(WALL_ANCHOR);
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
// Compute a cron expression that lands 5 minutes in the future
// using the harness's anchor (Nov 14 2023 22:13:20 UTC). The next
@ -204,12 +197,11 @@ describe('CronManager — persistence and resume', () => {
// Session B: 10 minutes after the anchor — the one-shot's ideal
// fire (anchor + 100s ≈ 22:15:00) is in the past.
const stubB = createAgentStub();
const stubB = createAgentStub({ homedir: sessionDir });
const clockB = createClocks(clockA.now() + 10 * 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
await managerB.loadFromDisk();
managerB.tick();
@ -237,12 +229,11 @@ describe('CronManager — persistence and resume', () => {
// ideal fire, tick once. The scheduler's onAdvanceCursor callback
// must stamp `lastFiredAt` on the persisted record so session B
// doesn't re-coalesce that already-delivered occurrence.
const stubA = createAgentStub();
const stubA = createAgentStub({ homedir: sessionDir });
const clockA = createClocks(WALL_ANCHOR);
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
const task = managerA.addTask({ cron: '*/5 * * * *', prompt: 'check' });
await managerA.flushPersist();
@ -271,12 +262,11 @@ describe('CronManager — persistence and resume', () => {
// the one that was already delivered in session A). With the
// persistence, session A's fire is skipped on resume so the
// count is strictly lower.
const stubB = createAgentStub();
const stubB = createAgentStub({ homedir: sessionDir });
const clockB = createClocks(WALL_ANCHOR + 23 * 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
await managerB.loadFromDisk();
managerB.tick();
@ -297,12 +287,11 @@ describe('CronManager — persistence and resume', () => {
// (clock skew or a bench env mistake) the scheduler must not skip
// legitimately-due fires. The sanity gate ignores the bogus value
// and falls back to `createdAt`, matching pre-persistence behaviour.
const stubA = createAgentStub();
const stubA = createAgentStub({ homedir: sessionDir });
const clockA = createClocks();
const managerA = new CronManager(stubA.agent, {
clocks: clockA.clocks,
pollIntervalMs: null,
sessionDir,
});
const task = managerA.addTask({ cron: '*/5 * * * *', prompt: 'check' });
await managerA.flushPersist();
@ -320,12 +309,11 @@ describe('CronManager — persistence and resume', () => {
// Session B: 23 minutes later. Even though lastFiredAt is in the
// future, the scheduler must still fire (sanity gate ignores it).
const stubB = createAgentStub();
const stubB = createAgentStub({ homedir: sessionDir });
const clockB = createClocks(clockA.now() + 23 * 60_000);
const managerB = new CronManager(stubB.agent, {
clocks: clockB.clocks,
pollIntervalMs: null,
sessionDir,
});
await managerB.loadFromDisk();
managerB.tick();
@ -341,7 +329,7 @@ describe('CronManager — persistence and resume', () => {
it('no sessionDir = pure in-memory: no FS side effects, loadFromDisk is a no-op', async () => {
const { agent } = createAgentStub();
const harness = createClocks();
// Construct without sessionDir.
// Construct without homedir.
const manager = new CronManager(agent, {
clocks: harness.clocks,
pollIntervalMs: null,

View file

@ -5,9 +5,9 @@
* never serve any purpose default subagent profiles don't expose the
* Cron tools to the LLM. This test pins both halves of the fix:
*
* 1. `agent.cron` is still constructed (consumers reach into
* `agent.cron.store` and the field is `readonly` on the type).
* 2. `cron.start()` is skipped for `type: 'sub'` so the SIGUSR1
* 1. `agent.cron` is `null` for `type: 'sub'` so no timers or
* listeners leak for ephemeral agents.
* 2. `cron.start()` is never called for subagents, so the SIGUSR1
* listener count stays put.
* 3. The three Cron tools (`CronCreate` / `CronList` / `CronDelete`)
* are NOT registered in the subagent's tool manager.
@ -39,9 +39,8 @@ describe('Agent + Cron — subagent suppression', () => {
const before = process.listenerCount('SIGUSR1');
const ctx = testAgent({ type: 'sub' });
// Manager is still wired so `agent.cron.store` access stays safe.
expect(ctx.agent.cron).toBeDefined();
expect(ctx.agent.cron.store).toBeDefined();
// Subagents do not get a CronManager instance at all.
expect(ctx.agent.cron).toBeNull();
// start() was not called — no SIGUSR1 binding accrued.
expect(process.listenerCount('SIGUSR1')).toBe(before);

View file

@ -8,7 +8,7 @@ import { expect, onTestFinished, vi } from 'vitest';
import {
Agent,
type AgentConfig,
type AgentOptions,
type AgentRecord,
type AgentRecordPersistence,
} from '../../../src/agent';
@ -65,7 +65,7 @@ type RpcLogEntry = RpcSnapshotEntry & {
};
type PromiseAgentAPI = PromisifyMethods<AgentAPI>;
type GenerateFn = NonNullable<AgentConfig['generate']>;
type GenerateFn = NonNullable<AgentOptions['generate']>;
type TestToolResult = ExecutableToolResult & {
readonly content?: unknown;
@ -93,14 +93,14 @@ export interface TestAgentOptions {
readonly runtime?: RuntimeConfig | undefined;
readonly compactionStrategy?: CompactionStrategy | undefined;
readonly generate?: GenerateFn | undefined;
readonly hookEngine?: AgentConfig['hookEngine'];
readonly type?: AgentConfig['type'];
readonly permission?: AgentConfig['permission'];
readonly hookEngine?: AgentOptions['hookEngine'];
readonly type?: AgentOptions['type'];
readonly permission?: AgentOptions['permission'];
readonly providerManager?: ProviderManager;
readonly initialConfig?: KimiConfig;
readonly providerManagerOverrides?: Omit<ConstructorParameters<typeof ProviderManager>[0], 'config'>;
readonly sessionId?: string;
readonly subagentHost?: AgentConfig['subagentHost'];
readonly subagentHost?: AgentOptions['subagentHost'];
readonly onEvent?: ((event: AgentRecord) => AgentRecord | undefined) | undefined;
readonly persistence?: AgentRecordPersistence | undefined;
readonly telemetry?: TelemetryClient | undefined;
@ -198,7 +198,7 @@ export class AgentTestContext {
// we register cleanup transparently without forcing every test to
// remember an afterEach.
onTestFinished(async () => {
await this.agent.cron.stop();
await this.agent.cron?.stop();
});
}

View file

@ -6,7 +6,7 @@ import {
type StreamedMessagePart,
} from '@moonshot-ai/kosong';
import type { AgentConfig } from '../../../src/agent';
import type { AgentOptions } from '../../../src/agent';
import { estimateTokensForMessages } from '../../../src/utils/tokens';
import {
generateInputSnapshot,
@ -15,7 +15,7 @@ import {
type GenerateCall,
} from './snapshots';
type GenerateFn = NonNullable<AgentConfig['generate']>;
type GenerateFn = NonNullable<AgentOptions['generate']>;
interface ScriptedResponse {
readonly parts: readonly StreamedMessagePart[];

View file

@ -1,7 +1,7 @@
import type { ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import { HookEngine } from '../../src/agent/hooks';
import { HookEngine } from '../../src/session/hooks';
import type { SessionSubagentHost } from '../../src/session/subagent-host';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createCommandKaos, testAgent } from './harness/agent';

View file

@ -15,8 +15,8 @@ import {
} from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import { HookEngine } from '../../src/agent/hooks';
import type { AgentConfig } from '../../src/agent';
import { HookEngine } from '../../src/session/hooks';
import type { AgentOptions } from '../../src/agent';
import type { Logger, LogPayload } from '../../src/logging';
import {
estimateTokens,
@ -28,7 +28,7 @@ import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createCommandKaos, testAgent, type TestAgentOptions } from './harness/agent';
import { executeTool } from '../tools/fixtures/execute-tool';
type GenerateFn = NonNullable<AgentConfig['generate']>;
type GenerateFn = NonNullable<AgentOptions['generate']>;
interface CapturedLogEntry {
readonly level: 'error' | 'warn' | 'info' | 'debug';

View file

@ -73,9 +73,9 @@ custom_headers = { "X-Test" = "1" }
storage: 'file',
key: 'oauth/custom-kimi-code',
});
expect(session?.config.runtime.webSearcher).toBeDefined();
expect(session?.options.runtime.webSearcher).toBeDefined();
await session!.config.runtime.webSearcher!.search('kimi');
await session!.options.runtime.webSearcher!.search('kimi');
expect(getAccessToken).toHaveBeenCalledWith();
const init = fetchImpl.mock.calls[0]?.[1] as RequestInit;

View file

@ -3,7 +3,7 @@ import type { ContentPart } from '@moonshot-ai/kosong';
// Dynamic-import contract: locks the public shape of the future HookEngine
// without forcing TS module resolution to find a file that doesn't exist yet.
const ENGINE_MODULE = '../../src/agent/hooks/engine' as string;
const ENGINE_MODULE = '../../src/session/hooks/engine' as string;
type HookDef = {
event: string;

View file

@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest';
// Dynamic-import contract: locks the Agent <-> HookEngine integration shape
// (engine ctor, trigger surface, summary, wire callbacks, event helpers,
// config round-trip) before the implementation lands.
const ENGINE_MODULE = '../../src/agent/hooks/engine' as string;
const ENGINE_MODULE = '../../src/session/hooks/engine' as string;
const CONFIG_MODULE = '../../src/config' as string;
type HookDef = {

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
const RUNNER_MODULE = '../../src/agent/hooks/runner' as string;
const RUNNER_MODULE = '../../src/session/hooks/runner' as string;
interface HookResult {
action: 'allow' | 'block';

View file

@ -488,7 +488,7 @@ describe('McpConnectionManager', () => {
transport: 'stdio',
command: process.execPath,
args: [crashAfterConnectFixture],
env: { KIMI_TEST_MCP_EXIT_AFTER_MS: '50', KIMI_TEST_MCP_STDERR: 'fatal: out of memory' },
env: { KIMI_TEST_MCP_EXIT_AFTER_MS: '500', KIMI_TEST_MCP_STDERR: 'fatal: out of memory' },
startupTimeoutMs: 4_000,
},
});

View file

@ -36,7 +36,7 @@ describe('Session.close stops cron', () => {
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
});
const main = await session.createMain();
const stopSpy = vi.spyOn(main.cron, 'stop');
const stopSpy = vi.spyOn(main.cron!, 'stop');
await session.close();