Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2

This commit is contained in:
_Kerman 2026-07-03 15:09:22 +08:00
commit 8ba75bbc02
179 changed files with 2690 additions and 6158 deletions

View file

@ -12,8 +12,6 @@
"dev:cli:marketplace": "KIMI_CODE_DEV_MARKETPLACE_URL=https://code.kimi.com/kimi-code/plugins/marketplace.json pnpm -C apps/kimi-code run dev",
"dev:web": "pnpm -C apps/kimi-web run dev",
"dev:server": "pnpm -C packages/server-v2 run dev",
"dev:core-example": "pnpm -C packages/agent-core-v2 run example",
"dev:core-example:list": "pnpm -C packages/agent-core-v2 exec vitest list --config vitest.examples.config.ts",
"build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace",
"vis": "pnpm -C apps/vis run dev",
"dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev",

View file

@ -4,9 +4,9 @@
## Examples
`examples/` holds runnable **domain-slice scenarios**: each `examples/<name>.example.ts` is a vitest test that exercises one subset of domains end-to-end, so a single file teaches a single capability. Each example builds its **own** container (the `createServices` flat harness for App-scope slices, or `bootstrap` + child scopes for tree-spanning slices), runs its slice's services for real, and stubs the collaborators outside the slice — so examples never need the full engine, and different examples stub different subsets. Tree-spanning examples redirect `KIMI_CODE_HOME` to a single `.vitest-results/kimi-code-{timestamp}/` per run (set once in `examples/_globalSetup.ts` and shared across every file in the invocation) and seed a file-backed `IAtomicDocumentStorage`, so persisted state is written to disk for inspection.
> The runnable examples have moved to the standalone `kimi-code-mini-bench` package at `../kimi-code-mini-bench`. They are wired to `agent-core-v2` through a pnpm `link:` dependency and run as a separate Vitest project.
Run one from the repo root with `pnpm dev:core-example <name>` (a filename filter; omit `<name>` to run them all). Examples use their own vitest project (`agent-core-v2-examples`, via `vitest.examples.config.ts`), so they are separate from the real `test/` suite and their console output is shown per scenario. Add a new example by adding an `examples/<name>.example.ts` that imports only its slice's domains.
Domain-slice scenarios that used to live in `examples/<name>.example.ts` are now maintained there. Each `*.example.ts` exercises one subset of domains end-to-end, builds its own container, runs its slice's services for real, and stubs collaborators outside the slice. See `../kimi-code-mini-bench/README.md` for how to run them.
## Comment conventions

View file

@ -57,7 +57,8 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "<b>approval</b>\n<size:9><i>Session</i></size>\n IApprovalService" as approval #D5F5E3
rectangle "<b>question</b>\n<size:9><i>Session</i></size>\n IQuestionService" as question #D5F5E3
rectangle "<b>process</b>\n<size:9><i>Session</i></size>\n IProcessRunner\n IProcess" as process #D5F5E3
rectangle "<b>terminal</b>\n<size:9><i>Session</i></size>\n ITerminalService\n ITerminalBackend" as terminal #D5F5E3
rectangle "<b>terminal</b>\n<size:9><i>App</i></size>\n IHostTerminalService" as terminal_app #D6EAF8
rectangle "<b>sessionTerminal</b>\n<size:9><i>Session</i></size>\n ISessionTerminalService" as terminal_session #D5F5E3
rectangle "<b>modelProvider</b>\n<size:9><i>Session</i></size>\n IModelProvider (seed)" as modelProvider #D5F5E3
}
@ -141,8 +142,9 @@ agentFs --> workspaceContext #34495E
agentFs --> execContext #34495E
agentFs --> process #34495E
process --> execContext #34495E
terminal --> workspaceContext #34495E
terminal --> session_context #34495E
terminal_session --> terminal_app #34495E
terminal_session --> workspaceContext #34495E
terminal_session --> session_context #34495E
approval --> interaction #34495E
question --> interaction #34495E
modelProvider --> config #34495E

View file

@ -1,117 +0,0 @@
# `agent-core-v2` DI × Scope examples
Runnable examples for the `agent-core-v2` engine. Each `*.example.ts` wires one
**vertical functional slice** and teaches one DI × Scope concept. Read in the
order below, they form a learning path from the container itself up to the
edge-exposure layer — together they touch every registered service in the
package.
## Run
```bash
# every example (separate vitest project with its own config + globalSetup)
pnpm --filter @moonshot-ai/agent-core-v2 example
# one example
pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/file-tools.example.ts
```
Examples run under `vitest.examples.config.ts` (project `agent-core-v2-examples`),
which sets up a shared `KIMI_CODE_HOME` via `_globalSetup.ts`. Each file gets an
isolated module registry, so examples that clear and re-populate the scoped
registry do not leak into those that rely on import-time registrations.
## Three styles, all legitimate
- **`_harness` composition root (preferred for real slices)** —
`createSliceHost({ homeDir })` boots the **real** composition root (every
domain barrel + `bootstrap` + the seeded `IExecContext` / `ISessionContext` /
`IAgentScopeContext` values) and returns `{ app, session, agent }`. You resolve
the subject by interface and spy on real collaborators, so the example does
not hard-code a stub list and does not rot when a service gains a dependency
(`agentLifecycle`, `goals-plans-todos`, `async-tasks`, `usage-replay`,
`context`, `turn-loop`, `shell-web-tools`, `model-provider`, `extensions`,
`edge-gateway-rpc`, `config`, `session`, `oauth`, `scope`, `session-skill`).
- **`bootstrap` + real services + `console.log`** — boots the production
composition root and shows real behaviour against real files under
`KIMI_CODE_HOME`. Best for slices where the on-disk result is the point
(`persistence`, `wire-record`, `observability`).
- **`createScopedTestHost` + explicit re-registration + stubs** — builds a
minimal scope tree, registers only the slice's services, and stubs the
collaborators outside it (`stubPair`). Best for isolating one wiring concept
with no I/O (`di-container`, `file-tools`, `interaction`, `feature-flags`,
`events`, `host`, `tool-framework`, `permission`, `compaction`).
All three styles resolve the subject under test **by interface** through the
scope tree — never `new`.
## Learning path
```text
L0 di-container · scope
└─ L1 observability · config · feature-flags · persistence · events · host
└─ L2 wire-record · session · sessionIndex · agentLifecycle
· tool-framework · context · turn-loop
└─ L3 file-tools · shell-web · permission · goals · async
· model · compaction · extensions · oauth · interaction
· edge · usage · replay
```
## Roadmap
Status: ✅ exists · ⬜ planned.
### L0 — the framework
| file | status | scope | concept |
|---|---|---|---|
| `di-container` | ✅ | A/S/Ag | toy mechanics: `createDecorator`, `registerScopedService`, three `LifecycleScope` tiers, child→parent injection, eager vs delayed, disposal order |
| `scope` | ✅ | A/S | real services: App singletons vs per-Session instances (`ILogService` shared, `ISessionMetadata` per session) |
### L1 — foundational services
| file | status | scope | concept |
|---|---|---|---|
| `observability` | ✅ | A | `log` + `telemetry`, child logger and context-scoped telemetry |
| `config` | ✅ | A | every `registerSection` owner populating one shared `IConfigService` |
| `feature-flags` | ✅ | A | `flag` real, `config` stubbed; env → config → default resolution |
| `persistence` | ✅ | A | Store → Storage → backend; atomic doc / append-log / blob against real `~/.kimi-code` files |
| `events` | ✅ | A/Ag | soft coupling via `publish`/`subscribe`/`emit`/`on` edges |
| `host` | ✅ | A/S | host abstraction, the kaos `IExecContext` boundary |
### L2 — business foundations (patterns)
| file | status | scope | concept |
|---|---|---|---|
| `wire-record` | ✅ | Ag | append-log primitive; `append` + `restore` replay chain |
| `session` | ✅ | A/S | `sessionLifecycle` + `sessionMetadata`; session as a durable, tracked entity |
| `sessionIndex` | ✅ | A | business-specific Store building a query read-model |
| `session-skill` | ✅ | A/S | session skill catalog: load skills from the current `workDir` and inspect each skill's `source` provenance |
| `agentLifecycle` | ✅ | S | Agent-scope creation, parent/child |
| `tool-framework` | ✅ | Ag | registry pattern, runtime state |
| `context` | ✅ | Ag | event-sourced context, projection |
| `turn-loop` | ✅ | Ag | turn lifecycle, hooks, step loop |
### L3 — complete features (slices)
| file | status | scope | concept |
|---|---|---|---|
| `file-tools` | ✅ | A/S/Ag | the smallest real 3-tier slice: Agent service injecting Session + App ancestors + an Agent peer; marker-interface service registered `Eager` |
| `shell-web-tools` | ✅ | Ag | tool implementations (bash / web / ask) |
| `permission` | ✅ | Ag | chain-of-responsibility, policy registry |
| `goals-plans-todos` | ✅ | Ag | append-log CRUD domains |
| `async-tasks` | ✅ | Ag | long-running tasks, child scopes (background / cron / swarm) |
| `model-provider` | ✅ | A/S/Ag | provider abstraction, the kosong boundary |
| `compaction` | ✅ | Ag | context-management strategy |
| `extensions` | ✅ | A/S/Ag | plugin / mcp / skill extension points |
| `oauth` | ✅ | A | device-code login + managed `/models` refresh, config-driven |
| `interaction` | ✅ | S | `interaction` kernel + `approval` / `question` facades through the Session scope |
| `edge-gateway-rpc` | ✅ | A/Ag | `resource:action`, WS events, edge exposure |
| `usage-replay` | ✅ | Ag | usage metering, replay, system reminder, external hooks |
## Coverage
The existing examples plus the planned ones cover the ~134 registered services in
`agent-core-v2`. The 29 `unresolved` tokens in the dep-graph are external
boundaries (kaos / kosong / storage / vscode DI) and appear as `stubPair(...)`
seeds, not as real implementations.

View file

@ -1,27 +0,0 @@
/**
* Vitest global setup for the agent-core-v2 examples.
*
* Picks a single `KIMI_CODE_HOME` for the whole run (one
* `.vitest-results/kimi-code-{timestamp}/` directory) and publishes it through
* the environment so every example file in the invocation writes into the same
* directory. The previous value is restored in the teardown.
*/
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
export default function setup(): () => void {
const previous = process.env['KIMI_CODE_HOME'];
const ts = new Date().toISOString().replaceAll(/[-:.Z]/g, '');
const homeDir = join(import.meta.dirname, '..', '.vitest-results', `kimi-code-${ts}`);
mkdirSync(homeDir, { recursive: true });
process.env['KIMI_CODE_HOME'] = homeDir;
return () => {
if (previous === undefined) {
delete process.env['KIMI_CODE_HOME'];
} else {
process.env['KIMI_CODE_HOME'] = previous;
}
};
}

View file

@ -1,164 +0,0 @@
/**
* Shared harness for the `agent-core-v2` examples.
*
* Boots the **real** composition root so examples resolve services through the
* same wiring production uses, and only the genuine external boundaries (the
* seeded `IExecContext` value, plus anything a specific example wants to
* control) are supplied as seeds. This keeps examples from rotting when a
* service gains a constructor dependency: the dependency is already registered
* by its domain barrel, so the example does not hard-code a stub list.
*
* How it works:
* 1. `import '#/index'` loads every domain barrel as a side effect, which
* populates the scoped registry with all real `registerScopedService`
* descriptors.
* 2. `bootstrap(...)` builds the real App scope (storage roles, bootstrap
* snapshot, skill store) and picks up every App-scope descriptor.
* 3. `createChild(Session, …)` / `createChild(Agent, …)` pick up the Session
* and Agent descriptors. The seeded *values* (`IExecContext`,
* `ISessionContext`, `IAgentScopeContext`) which are not constructed
* services and so absent from the registry are provided here, mirroring
* what `sessionLifecycle` / `agentLifecycle` seed when they open scopes.
* 4. Per-example `sessionSeeds` / `agentSeeds` override any registration, so
* an example can substitute a capturing fake for the one collaborator it
* wants to assert on (for example `IAgentRecordService`).
*
* Examples using this harness must NOT call `_clearScopedRegistryForTests()`:
* the registry populated by step 1 is what makes resolution work.
*/
import '#/index';
import { LifecycleScope, type Scope, type ScopeSeed } from '#/_base/di/scope';
import {
bootstrap,
IBootstrapService,
type BootstrapInput,
type IBootstrapService as IBootstrapServiceType,
} from '#/app/bootstrap';
import {
ILogOptions,
resolveLoggingConfig,
} from '#/app/log/logConfig';
import {
IAgentScopeContext,
makeAgentScopeContext,
} from '#/agent/scopeContext';
import { createExecContext, execContextSeed } from '#/os/interface/execContext';
import {
makeSessionContext,
sessionContextSeed,
} from '#/session/sessionContext';
export interface SliceHost {
readonly app: Scope;
/** The default Session scope created by the harness (`sessionId`, default `s1`). */
readonly session: Scope;
/** The default Agent scope under `session` (`agentId`, default `main`). */
readonly agent: Scope;
/** Create an additional seeded Session scope under the App root (for
* multi-session examples). Shares the App scope and `KIMI_CODE_HOME`. */
newSession(id: string, overrides?: { cwd?: string; seeds?: ScopeSeed }): Scope;
/** Create an additional seeded Agent scope under the default Session. */
newAgent(id: string, overrides?: { seeds?: ScopeSeed }): Scope;
dispose(): void;
}
export interface SliceHostOptions {
/** Root directory for the real file-backed services (storage, config, logs). */
readonly homeDir: string;
/** Working directory seeded into `IExecContext`. Defaults to `homeDir`. */
readonly cwd?: string;
/** Extra App-scope seeds (rarely needed; the composition root is complete). */
readonly appSeeds?: ScopeSeed;
/** Extra Session-scope seeds (overrides for the slice under test). */
readonly sessionSeeds?: ScopeSeed;
/** Extra Agent-scope seeds (overrides for the slice under test). */
readonly agentSeeds?: ScopeSeed;
/** Session / Agent ids. */
readonly sessionId?: string;
readonly agentId?: string;
/** Workspace id used to derive the agent persistence scope. */
readonly workspaceId?: string;
}
function sessionSeeds(
boot: IBootstrapServiceType,
workspaceId: string,
sessionId: string,
cwd: string,
extra: ScopeSeed,
): ScopeSeed {
return [
...execContextSeed(createExecContext(cwd)),
...sessionContextSeed(
makeSessionContext({
sessionId,
workspaceId,
sessionDir: boot.sessionDir(workspaceId, sessionId),
sessionScope: boot.sessionScope(workspaceId, sessionId),
}),
),
...extra,
];
}
function agentSeeds(
boot: IBootstrapServiceType,
workspaceId: string,
sessionId: string,
agentId: string,
extra: ScopeSeed,
): ScopeSeed {
return [
[
IAgentScopeContext,
makeAgentScopeContext({
agentId,
agentScope: boot.agentScope(workspaceId, sessionId, agentId),
}),
],
...extra,
];
}
export function createSliceHost(options: SliceHostOptions): SliceHost {
const input: BootstrapInput = { homeDir: options.homeDir };
// `ILogOptions` is an App-scope seeded value (built from env + homeDir); the
// real startup seeds it before any log writer is constructed.
const logSeed: ScopeSeed = [
[ILogOptions, resolveLoggingConfig({ homeDir: options.homeDir, env: process.env })],
];
const { app } = bootstrap(input, [...logSeed, ...(options.appSeeds ?? [])]);
const sessionId = options.sessionId ?? 's1';
const agentId = options.agentId ?? 'main';
const workspaceId = options.workspaceId ?? 'ws_example';
const cwd = options.cwd ?? options.homeDir;
const boot = app.accessor.get(IBootstrapService);
const session = app.createChild(LifecycleScope.Session, sessionId, {
extra: sessionSeeds(boot, workspaceId, sessionId, cwd, options.sessionSeeds ?? []),
});
const agent = session.createChild(LifecycleScope.Agent, agentId, {
extra: agentSeeds(boot, workspaceId, sessionId, agentId, options.agentSeeds ?? []),
});
return {
app,
session,
agent,
newSession(id, overrides) {
return app.createChild(LifecycleScope.Session, id, {
extra: sessionSeeds(boot, workspaceId, id, overrides?.cwd ?? cwd, overrides?.seeds ?? []),
});
},
newAgent(id, overrides) {
return session.createChild(LifecycleScope.Agent, id, {
extra: agentSeeds(boot, workspaceId, sessionId, id, overrides?.seeds ?? []),
});
},
dispose: () => app.dispose(),
};
}

View file

@ -1,114 +0,0 @@
/**
* Scenario: the **agentLifecycle** slice creating Agent scopes under a
* Session and the parent/child agent relationship the session tracks.
*
* Concept taught: a Session owns a set of Agents. `IAgentLifecycleService`
* (Session scope) is the factory every `create(...)` builds a new child
* **Agent** scope beneath the session, seeds its identity
* (`IAgentScopeContext.agentId`) plus per-agent services (wire record, blob
* store, MCP), and registers it in the session's agent set. The session then
* tracks its agents through `list` / `getHandle` and broadcasts `onDidCreate` /
* `onDidDispose` as the set changes. Because each Agent scope is a *child* of
* the Session scope, an agent resolves its own Agent-scope seeds and also
* inherits Session/App ancestors upward through the scope tree.
*
* Wiring: the real composition root (`_harness`) provides every collaborator
* (`ISessionMetadata`, `IAgentMcpService` and its peers, ), so the slice runs
* for real with no hand-rolled stub list. We spy on `ISessionMetadata` only to
* observe the `registerAgent` call.
*
* Prerequisites: example 01 (container & scope tree), example 13 (file-tools slice).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/agentLifecycle.example.ts
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import {
IAgentLifecycleService,
} from '#/session/agentLifecycle';
import { ISessionMetadata } from '#/session/sessionMetadata';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { createSliceHost, type SliceHost } from './_harness';
describe('agentLifecycle slice (Agent scopes under a Session)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
async function setUp() {
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
// The host environment probes the OS asynchronously; the real composition
// root awaits this before opening a Session scope, so Agent-scope services
// (which read `osKind`/`pathClass` at construction) see a ready snapshot.
await host.app.accessor.get(IHostEnvironment).ready;
return host.session.accessor.get(IAgentLifecycleService);
}
it('creates an agent under the session and tracks it in list/getHandle', async () => {
const lifecycle = await setUp();
const agent = await lifecycle.create({ agentId: 'main' });
expect(agent.id).toBe('main');
expect(lifecycle.getHandle('main')).toBe(agent);
expect(lifecycle.list().map((h) => h.id)).toEqual(['main']);
});
it('tracks multiple agents and assigns distinct ids', async () => {
const lifecycle = await setUp();
const a = await lifecycle.create({});
const b = await lifecycle.create({});
expect(a.id).not.toBe(b.id);
expect(lifecycle.list().map((h) => h.id)).toEqual(expect.arrayContaining([a.id, b.id]));
});
it('persists each created agent into the session metadata registry', async () => {
const lifecycle = await setUp();
const metadata = host.session.accessor.get(ISessionMetadata);
const registerAgent = vi.spyOn(metadata, 'registerAgent').mockResolvedValue();
await lifecycle.create({ agentId: 'child', forkedFrom: 'main', swarmItem: 'swarm-1' });
expect(registerAgent).toHaveBeenCalledWith(
'child',
expect.objectContaining({ forkedFrom: 'main', swarmItem: 'swarm-1' }),
);
});
it('fires onDidCreate on create and onDidDispose on remove', async () => {
const lifecycle = await setUp();
const created: string[] = [];
const disposed: string[] = [];
const subCreate = lifecycle.onDidCreate((h) => created.push(h.id));
const subDispose = lifecycle.onDidDispose((id) => disposed.push(id));
const agent = await lifecycle.create({});
expect(created).toEqual([agent.id]);
await lifecycle.remove(agent.id);
expect(disposed).toEqual([agent.id]);
expect(lifecycle.getHandle(agent.id)).toBeUndefined();
subCreate.dispose();
subDispose.dispose();
});
it('builds each agent as a child scope that inherits Session ancestors', async () => {
const lifecycle = await setUp();
const agent = await lifecycle.create({ agentId: 'main' });
// Own Agent-scope seed: the identity the lifecycle stamped on creation.
expect(agent.accessor.get(IAgentScopeContext).agentId).toBe('main');
// Upward resolution to the Session parent: a Session-scope service the agent
// never registered itself is still visible through the scope tree.
expect(agent.accessor.get(ISessionWorkspaceContext).workDir).toBe(process.env['KIMI_CODE_HOME']);
});
});

View file

@ -1,111 +0,0 @@
/**
* Scenario: the **async-tasks** slice long-running work owned by an Agent-scope service.
*
* Concept taught: background tasks, cron tasks, and swarm (multi-agent) runs
* all share one shape an *asynchronous task whose state and output are owned
* by an Agent-scope service*, decoupled from whoever triggered it. The caller
* fires and forgets; the service retains the task, drives its lifecycle, and
* records the outcome.
*
* - `IAgentBackgroundService` owns running/restored background tasks and a
* bounded output ring.
* - `IAgentCronService` owns the scheduled cron task set and its fire loop.
* - `IAgentSwarmService` owns swarm-mode state for multi-agent runs and
* auto-exits when the turn ends.
*
* All three are bound at Agent scope, but background and cron each inject ~9
* collaborators. We demonstrate the shared shape with `IAgentSwarmService`
* because it is the lightest of the three. Its auto-exit is driven by the real
* `IAgentTurnService` `onEnded` hook the same path the agent loop uses.
*
* Wiring: the real composition root (`_harness`) provides every collaborator;
* we spy on the real `IAgentRecordService` only to observe the task records.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/async-tasks.example.ts
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IAgentRecordService } from '#/agent/record';
import { IAgentSwarmService } from '#/agent/swarm';
import {
IAgentTurnService,
type Turn,
type TurnResult,
} from '#/agent/turn';
import { createSliceHost, type SliceHost } from './_harness';
function fakeTurn(id = 1): Turn {
return {
id,
abortController: new AbortController(),
ready: Promise.resolve(),
result: Promise.resolve<TurnResult>({ reason: 'completed' }),
} as Turn;
}
describe('async-tasks slice (Agent-scope swarm task)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
function setUp() {
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
const records = host.agent.accessor.get(IAgentRecordService);
const appended: Array<{ type: string }> = [];
vi.spyOn(records, 'append').mockImplementation((r) => {
appended.push(r as { type: string });
});
const swarm = host.agent.accessor.get(IAgentSwarmService);
const turn = host.agent.accessor.get(IAgentTurnService);
const types = () => appended.map((r) => r.type).filter((t) => t.startsWith('swarm_mode'));
return { swarm, turn, types };
}
it('owns the swarm task state and records enter/exit', () => {
const { swarm, types } = setUp();
expect(swarm.isActive).toBe(false);
swarm.enter('manual');
expect(swarm.isActive).toBe(true);
swarm.exit();
expect(swarm.isActive).toBe(false);
expect(types()).toEqual(['swarm_mode.enter', 'swarm_mode.exit']);
});
it('treats a duplicate enter as a no-op (guards task state)', () => {
const { swarm, types } = setUp();
swarm.enter('manual');
swarm.enter('task');
expect(swarm.isActive).toBe(true);
expect(types()).toEqual(['swarm_mode.enter']);
});
it('auto-exits a task-triggered swarm run when the turn ends', async () => {
const { swarm, turn, types } = setUp();
swarm.enter('task');
expect(swarm.isActive).toBe(true);
await turn.hooks.onEnded.run({ turn: fakeTurn(), result: { reason: 'completed' } });
expect(swarm.isActive).toBe(false);
expect(types()).toEqual(['swarm_mode.enter', 'swarm_mode.exit']);
});
it('keeps a manual swarm run active across turn end (rule flips with trigger)', async () => {
const { swarm, turn, types } = setUp();
swarm.enter('manual');
await turn.hooks.onEnded.run({ turn: fakeTurn(), result: { reason: 'completed' } });
expect(swarm.isActive).toBe(true);
expect(types()).toEqual(['swarm_mode.enter']);
});
});

View file

@ -1,240 +0,0 @@
/**
* Scenario: the **compaction** slice the context-size signal that chooses
* between micro and full compaction.
*
* Concept taught: context management is driven by a single *reading* the
* Agent-scope `IAgentContextSizeService` reports how large the conversation has
* grown (`getStatus().contextTokensWithPending`). Two distinct Agent-scope
* strategies consume that same reading and fire at different thresholds:
*
* - **micro compaction** (`IAgentMicroCompactionService`) cheap; clears the
* bodies of old tool results. It triggers when the reading reaches
* `minContextUsageRatio` (0.5) of the model window.
* - **full compaction** (`IAgentFullCompactionService`) expensive; asks the
* LLM to summarize the prefix. It triggers when the reading reaches the
* model window's `triggerRatio` (0.85), via
* `DefaultCompactionStrategy.shouldCompact`.
*
* We deliberately do NOT wire the two compaction services end-to-end here:
* each injects roughly 811 heavy collaborators (context memory, wire record,
* profile, loop, turn, LLM requester, ). Instead we demonstrate the smallest
* true thing: the real `AgentContextSizeService` is resolved through the scope
* tree with only its two genuine collaborators stubbed. We assert its real
* behavior a measurement updates the reading, splicing messages into context
* memory raises the pending estimate through the real `onSpliced` hook, and a
* change emits the live `agent.status.updated` signal and then show that
* this real reading flips a faithful micro/full decision as it crosses the
* 0.5 and 0.85 thresholds.
*
* Real: `AgentContextSizeService`. Stubbed: `IAgentContextMemoryService`
* (in-memory fake carrying a real `onSpliced` hook) and `IAgentRecordService`
* (append / signal / define doubles). No App- or Session-scope seeds are
* required, because the size service injects only those two Agent-scope peers.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/compaction.example.ts
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import {
AgentContextSizeService,
type ContextSizeStatus,
IAgentContextSizeService,
} from '#/agent/contextSize';
import {
type ContextMessage,
IAgentContextMemoryService,
} from '#/agent/contextMemory';
import { IAgentRecordService } from '#/agent/record';
import { createHooks } from '#/hooks';
/**
* In-memory `IAgentContextMemoryService` with a real `onSpliced` hook. The real
* `AgentContextSizeService` registers a handler on this hook in its
* constructor, so splicing here drives the size service exactly as the real
* context memory would.
*/
function fakeContextMemory(): IAgentContextMemoryService {
const messages: ContextMessage[] = [];
const hooks = createHooks<{
onSpliced: {
start: number;
deleteCount: number;
messages: ContextMessage[];
tokens?: number;
};
}>(['onSpliced']);
return {
_serviceBrand: undefined,
hooks,
get: () => [...messages],
splice: (start, deleteCount, inserted, tokens) => {
const added = [...inserted];
messages.splice(start, deleteCount, ...added);
void hooks.onSpliced.run({
start,
deleteCount,
messages: added,
tokens,
});
},
};
}
/** `IAgentRecordService` double — exposes the `signal` spy so tests can assert the live size signal. */
function fakeRecordService() {
const signal = vi.fn();
const service = {
_serviceBrand: undefined,
append: vi.fn(),
signal,
define: () => ({ dispose: () => {} }),
restoring: null,
} as unknown as IAgentRecordService;
return { service, signal };
}
type CompactionDecision = 'none' | 'micro' | 'full';
/**
* Faithful mirror of the two real thresholds, both consuming the same real
* reading (`status.contextTokensWithPending`):
* - micro compaction fires at `minContextUsageRatio` (0.5) of the window
* (see `AgentMicroCompactionService.contextSizeRatio` / `detect`);
* - full compaction fires at the model window `triggerRatio` (0.85)
* (see `DefaultCompactionStrategy.shouldCompact`).
*/
function decideCompaction(
status: ContextSizeStatus,
maxContextTokens: number,
): CompactionDecision {
if (maxContextTokens <= 0) return 'none';
const ratio = status.contextTokensWithPending / maxContextTokens;
if (ratio >= 0.85) return 'full';
if (ratio >= 0.5) return 'micro';
return 'none';
}
describe('compaction slice (context-size signal → micro vs full decision)', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
// Register the one real Agent-scope service of the slice. Its two
// collaborators are supplied as stubPair seeds on the Agent scope below.
registerScopedService(
LifecycleScope.Agent,
IAgentContextSizeService,
AgentContextSizeService,
);
});
it('reports a zero reading, then reflects a real measurement', () => {
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
stubPair(IAgentContextMemoryService, fakeContextMemory()),
stubPair(IAgentRecordService, fakeRecordService().service),
]);
const size = agent.accessor.get(IAgentContextSizeService);
expect(size.getStatus()).toEqual({
contextTokens: 0,
contextTokensWithPending: 0,
});
size.measured(0, 42_000);
expect(size.getStatus()).toEqual({
contextTokens: 42_000,
contextTokensWithPending: 42_000,
});
host.dispose();
});
it('emits agent.status.updated when the measured size changes', () => {
const record = fakeRecordService();
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
stubPair(IAgentContextMemoryService, fakeContextMemory()),
stubPair(IAgentRecordService, record.service),
]);
const size = agent.accessor.get(IAgentContextSizeService);
size.measured(0, 80_000);
// The live "context-size signal" the rest of the agent reacts to.
expect(record.signal).toHaveBeenCalledWith({
type: 'agent.status.updated',
contextTokens: 80_000,
});
host.dispose();
});
it('tracks pending tokens as messages are spliced into context memory', () => {
const context = fakeContextMemory();
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
stubPair(IAgentContextMemoryService, context),
stubPair(IAgentRecordService, fakeRecordService().service),
]);
const size = agent.accessor.get(IAgentContextSizeService);
// Wake the delayed proxy so its constructor runs and registers the
// `onSpliced` handler before we splice.
size.getStatus();
const messages: ContextMessage[] = [
{ role: 'user', content: [{ type: 'text', text: 'hello world' }] },
{
role: 'assistant',
content: [{ type: 'text', text: 'hi there, how can I help?' }],
toolCalls: [],
},
];
context.splice(0, 0, messages);
// No measurement yet, so the whole estimate is "pending".
expect(size.getStatus().contextTokens).toBe(0);
expect(size.getStatus().contextTokensWithPending).toBe(
estimateTokensForMessages(messages),
);
host.dispose();
});
it('drives a micro vs full compaction decision from the size reading', () => {
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
stubPair(IAgentContextMemoryService, fakeContextMemory()),
stubPair(IAgentRecordService, fakeRecordService().service),
]);
const size = agent.accessor.get(IAgentContextSizeService);
const window = 200_000; // model max_context_tokens
size.measured(0, 40_000); // 0.20 of the window
expect(decideCompaction(size.getStatus(), window)).toBe('none');
size.measured(0, 120_000); // 0.60 of the window → micro (>= 0.5)
expect(decideCompaction(size.getStatus(), window)).toBe('micro');
size.measured(0, 180_000); // 0.90 of the window → full (>= 0.85)
expect(decideCompaction(size.getStatus(), window)).toBe('full');
host.dispose();
});
});

View file

@ -1,201 +0,0 @@
/**
* Scenario: the **config** slice every Service that registers a config
* section, shown against one shared, file-backed `IConfigService`.
*
* `config` holds no schema of its own; each domain that consumes a config owns
* its section and registers it from its Service constructor. This example
* resolves **every** current section owner so its `registerSection` runs, then
* reads the single `IConfigRegistry` / `IConfigService` they all populated:
*
* App-scope owners:
* - `IModelService` `models` (+ the `KIMI_MODEL_*` overlay)
* - `IProviderService` `providers`
* - `IFlagService` `experimental`
*
* Agent-scope owners:
* - `IAgentBackgroundService` `background`
* - `IAgentCronService` `cron`
* - `IAgentPermissionRulesService` `permission`
* - `IAgentProfileService` `thinking`, `defaultThinking`
* - `IAgentLoopService` `loopControl`
* - `IAgentExternalHooksService` `hooks`
*
* Wiring: the real composition root (`_harness`) provides every collaborator,
* so each owner is resolved for real no hand-rolled stub list. The only
* override is `IAgentCronService`, seeded with `{ isSubagent: true }` so its
* runtime scheduler does not start (only its `cron` section registration is
* relevant here).
*
* Two scenarios are shown:
* 1. **register + inspect** every owner registers its section into the one
* registry; `inspect` reports each section's default layer.
* 2. **write + round-trip** a schema-valid value for every *persistable*
* section is written through `IConfigService.set`; each is validated,
* env-stripped, and persisted, then `reload()` parses the file back.
*
* All Services come from `src/`; nothing here defines a new Service.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { type ServiceIdentifier } from '#/_base/di/instantiation';
import { AgentCronService, IAgentCronService } from '#/agent/cron';
import {
type ConfigInspectValue,
IConfigRegistry,
IConfigService,
} from '#/app/config/config';
import { IFlagService } from '#/app/flag';
import { IModelService } from '#/app/model';
import { IProviderService } from '#/app/provider';
import { IAgentBackgroundService } from '#/agent/background';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentPermissionRulesService } from '#/agent/permissionRules';
import { IAgentProfileService } from '#/agent/profile';
import { createSliceHost, type SliceHost } from './_harness';
/**
* One schema-valid sample value per **persistable** section, written through
* `IConfigService.set` so each owner's write path round-trips to `config.toml`.
* `cron` is intentionally absent: it is operational / env-only, so it is never
* persisted to `config.toml` by design.
*/
const SECTION_VALUES: Record<string, unknown> = {
models: {
'kimi-k2': { provider: 'moonshot', model: 'kimi-k2-0905-preview', maxContextSize: 262_144 },
},
providers: {
moonshot: { type: 'kimi', apiKey: 'YOUR_API_KEY' },
},
experimental: { demo_feature: true },
background: { maxRunningTasks: 4, keepAliveOnExit: true },
permission: {
rules: [{ decision: 'allow', scope: 'user', pattern: 'bash(git status)' }],
},
thinking: { mode: 'auto', effort: 'medium' },
defaultThinking: true,
loopControl: { maxStepsPerTurn: 50, maxRetriesPerStep: 3 },
hooks: [{ event: 'PreToolUse', matcher: 'bash', command: 'echo demo' }],
};
/** Domains every current section owner registers, in registration order. */
const EXPECTED_SECTIONS = [
'models',
'providers',
'experimental',
'background',
'cron',
'permission',
'thinking',
'defaultThinking',
'loopControl',
'hooks',
] as const;
describe('config slice (every section owner against one shared registry)', () => {
let host: SliceHost;
let configPath: string;
function setUp() {
const homeDir = process.env['KIMI_CODE_HOME'];
if (homeDir === undefined) {
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
}
configPath = join(homeDir, 'config.toml');
host = createSliceHost({
homeDir,
// Seed cron as a subagent so its scheduler/tool registration stays idle.
agentSeeds: [
[
IAgentCronService as unknown as ServiceIdentifier<unknown>,
new SyncDescriptor(AgentCronService, [{ isSubagent: true }], true),
],
],
});
}
/** Resolve every section owner so its constructor registers its section. */
function resolveOwners(): void {
host.app.accessor.get(IModelService).list();
host.app.accessor.get(IProviderService).list();
host.app.accessor.get(IFlagService).snapshot();
host.agent.accessor.get(IAgentBackgroundService);
host.agent.accessor.get(IAgentPermissionRulesService);
host.agent.accessor.get(IAgentProfileService);
host.agent.accessor.get(IAgentExternalHooksService);
host.agent.accessor.get(IAgentLoopService);
host.agent.accessor.get(IAgentCronService);
}
afterEach(() => host?.dispose());
test('every section owner registers its section into the shared registry', async () => {
setUp();
const registry = host.app.accessor.get(IConfigRegistry);
const config = host.app.accessor.get(IConfigService);
await config.ready;
resolveOwners();
const registered = registry
.listSections()
.map((s) => s.domain)
.toSorted();
console.log('registered sections:', registered);
// Every known owner registers its section. The real composition root may
// register additional sections as the system grows, so assert inclusion
// rather than an exact list (which would rot on the next new section).
expect(registered).toEqual(expect.arrayContaining([...EXPECTED_SECTIONS]));
console.log('\ninspect (default layer) per section:');
for (const domain of EXPECTED_SECTIONS) {
console.log(` ${domain}:`, summarizeInspect(config.inspect(domain)));
}
});
test('writes every persistable section through config and round-trips the file', async () => {
setUp();
const config = host.app.accessor.get(IConfigService);
await config.ready;
resolveOwners();
let changes = 0;
const sub = config.onDidChangeConfiguration(() => changes++);
for (const [domain, value] of Object.entries(SECTION_VALUES)) {
await config.set(domain, value);
}
sub.dispose();
const onDisk = readFileSync(configPath, 'utf8').trim();
console.log('config.toml after writing every section:');
for (const line of onDisk.split('\n')) {
console.log(' ', line);
}
console.log(
`\n${Object.keys(SECTION_VALUES).length} sections written; onDidChangeConfiguration fired ${changes} times.`,
);
await config.reload();
console.log('\ninspect after reload (round-trip) per section:');
for (const domain of Object.keys(SECTION_VALUES)) {
console.log(` ${domain}:`, config.inspect(domain).value);
}
});
});
function summarizeInspect(inspect: ConfigInspectValue<unknown>): Record<string, unknown> {
return {
hasDefaultValue: inspect.defaultValue !== undefined,
hasUserValue: inspect.userValue !== undefined,
hasMemoryValue: inspect.memoryValue !== undefined,
keys: inspect.value !== null && typeof inspect.value === 'object' ? Object.keys(inspect.value) : [],
};
}

View file

@ -1,158 +0,0 @@
/**
* Scenario: the **context** slice event-sourced conversation memory.
*
* Concept taught: `IAgentContextMemoryService` is *not* a private array of
* messages. It is an event-sourced projection over the append-log
* (`IAgentRecordService`, backed by `IAgentWireRecordService`). Every mutation
* goes through `splice(start, deleteCount, messages)`, which (1) stamps each
* message with a stable local id, (2) appends a durable `context.splice` record
* to the append-log, and (3) applies the same splice to its in-memory history.
* Because the durable record is the source of truth, the history can be
* rebuilt by replaying the records the `get()` view is a projection, not the
* state itself.
*
* Wiring: the real composition root (`_harness`) provides every collaborator,
* including the real `IAgentContextMemoryService`, `IAgentRecordService`, and
* `IAgentWireRecordService`. We do not stub context memory. We spy on the real
* `IAgentRecordService.append` only to capture the `context.splice` records so
* we can show the projection is reproducible from the append-log alone.
*
* Prerequisites: example 01 (container & scope tree),
* example `goals-plans-todos` (append-log CRUD + spying on the record service).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/context.example.ts
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
type ContextMessage,
IAgentContextMemoryService,
} from '#/agent/contextMemory';
import {
type AgentRecord,
IAgentRecordService,
} from '#/agent/record';
import {
IAgentWireRecordService,
type PersistedWireRecord,
} from '#/agent/wireRecord';
import { createSliceHost, type SliceHost } from './_harness';
function userMessage(text: string): ContextMessage {
return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] };
}
function assistantMessage(text: string): ContextMessage {
return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] };
}
describe('context slice (event-sourced conversation memory)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
function setUp() {
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
// Resolve the real append-log first and instrument it; the context memory
// service is constructed lazily and shares the same singletons.
const records = host.agent.accessor.get(IAgentRecordService);
const wireRecords = host.agent.accessor.get(IAgentWireRecordService);
const appended: AgentRecord[] = [];
const originalAppend = records.append.bind(records);
vi.spyOn(records, 'append').mockImplementation((record) => {
appended.push(record as AgentRecord);
return originalAppend(record);
});
const context = host.agent.accessor.get(IAgentContextMemoryService);
const spliceRecords = () =>
appended.filter((r): r is AgentRecord<'context.splice'> => r.type === 'context.splice');
return { context, records, wireRecords, appended, spliceRecords };
}
it('splices a user message into context and records a context.splice on the append-log', () => {
const { context, wireRecords, spliceRecords } = setUp();
context.splice(0, 0, [userMessage('hello')]);
// The projection reflects the splice: the message is readable back, stamped
// with a stable local id assigned on entry.
const history = context.get();
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({ role: 'user', content: [{ type: 'text', text: 'hello' }] });
expect(history[0]?.id).toMatch(/^msg_/);
// The mutation is durable: one context.splice record landed on the
// append-log (the record facade and its wire-record backing agree).
expect(spliceRecords()).toHaveLength(1);
expect(spliceRecords()[0]).toMatchObject({ start: 0, deleteCount: 0 });
expect(wireRecords.getRecords().some((r) => r.type === 'context.splice')).toBe(true);
});
it('preserves message order across a user/assistant turn', () => {
const { context } = setUp();
context.splice(0, 0, [userMessage('hi'), assistantMessage('hello, how can I help?')]);
const history = context.get();
expect(history.map((m) => m.role)).toEqual(['user', 'assistant']);
expect(history.map((m) => m.content[0])).toEqual([
{ type: 'text', text: 'hi' },
{ type: 'text', text: 'hello, how can I help?' },
]);
});
it('replaces a message in place when splicing with a deleteCount', () => {
const { context, spliceRecords } = setUp();
context.splice(0, 0, [userMessage('first'), userMessage('second')]);
expect(context.get().map((m) => m.content[0])).toEqual([
{ type: 'text', text: 'first' },
{ type: 'text', text: 'second' },
]);
// Replace the message at index 1 with a new one.
context.splice(1, 1, [assistantMessage('replacement')]);
const history = context.get();
expect(history).toHaveLength(2);
expect(history.map((m) => m.role)).toEqual(['user', 'assistant']);
expect(history.map((m) => m.content[0])).toEqual([
{ type: 'text', text: 'first' },
{ type: 'text', text: 'replacement' },
]);
// Both splices were recorded; the second carries the deletion.
const records = spliceRecords();
expect(records).toHaveLength(2);
expect(records[1]).toMatchObject({ start: 1, deleteCount: 1 });
});
it('rebuilds the same history in a fresh agent by replaying the append-log records', async () => {
const { context, wireRecords } = setUp();
context.splice(0, 0, [userMessage('remember this')]);
context.splice(1, 0, [assistantMessage('noted')]);
const original = context.get();
expect(original).toHaveLength(2);
// The append-log's durable records are the source of truth. Capture them in
// the wire-record format the restore path expects.
const persisted = wireRecords.getRecords();
// A brand-new agent scope has an empty context. Resolve its context memory
// first so its constructor registers the context.splice resumer, then replay
// the persisted records — no private array is shared between the two agents.
const freshAgent = host.newAgent('fresh');
const freshContext = freshAgent.accessor.get(IAgentContextMemoryService);
const freshWireRecords = freshAgent.accessor.get(IAgentWireRecordService);
expect(freshContext.get()).toHaveLength(0);
await freshWireRecords.restore(persisted);
// The projection is reproducible from the append-log alone: the fresh
// agent reconstructs the same messages, including their stable ids.
expect(freshContext.get()).toEqual(original);
});
});

View file

@ -1,159 +0,0 @@
/**
* Example 01 the DI container and the `App → Session → Agent` scope tree.
*
* Concept taught: a service declares an identity (`createDecorator`), its
* dependencies (`@IToken`), and a lifetime (`registerScopedService`); the
* container decides construction, singleton-per-scope, ordering, and disposal.
*
* It also shows `InstantiationType`: a `Delayed` service hands back a proxy
* that is only constructed when a method is first called, while an `Eager`
* service is constructed immediately on `accessor.get(...)`.
*
* Scope tiers: App (process-wide) Session (one session) Agent (one agent).
* Short-lived may inject long-lived; never the reverse. Disposal is
* deterministic: child scopes die before parents.
*
* Prerequisites: none (this is the entry point). Uses only in-file fixtures.
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/di-container.example.ts
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { type IDisposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { createDecorator } from '#/_base/di/instantiation';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
interface IGreeter {
greet(): string;
}
interface IConsumer {
label(): string;
}
const IGreeter = createDecorator<IGreeter>('ex01-greeter');
const IConsumer = createDecorator<IConsumer>('ex01-consumer');
/** A Session-scope service that depends on an App-scope `IGreeter`. */
class Consumer implements IConsumer {
constructor(@IGreeter private readonly greeter: IGreeter) {}
label(): string {
return `consumed:${this.greeter.greet()}`;
}
}
/** Disposable fixtures used only by the disposal-order test. */
const disposalLog: string[] = [];
class AppThing implements IDisposable {
dispose(): void {
disposalLog.push('app');
}
}
class SessionThing implements IDisposable {
dispose(): void {
disposalLog.push('session');
}
}
class AgentThing implements IDisposable {
dispose(): void {
disposalLog.push('agent');
}
}
const IAppThing = createDecorator<AppThing>('ex01-app-thing');
const ISessionThing = createDecorator<SessionThing>('ex01-session-thing');
const IAgentThing = createDecorator<AgentThing>('ex01-agent-thing');
describe('example 01 — di container & scope tree', () => {
beforeEach(() => {
disposalLog.length = 0;
_clearScopedRegistryForTests();
registerScopedService(LifecycleScope.Session, IConsumer, Consumer);
// Eager so `accessor.get(...)` returns the real instance immediately rather
// than a delayed proxy — the disposal-order test needs the instances to
// actually be constructed so the scope has something to dispose.
registerScopedService(
LifecycleScope.App,
IAppThing,
AppThing,
InstantiationType.Eager,
);
registerScopedService(
LifecycleScope.Session,
ISessionThing,
SessionThing,
InstantiationType.Eager,
);
registerScopedService(
LifecycleScope.Agent,
IAgentThing,
AgentThing,
InstantiationType.Eager,
);
});
it('injects an App-scope ancestor into a Session-scope child', () => {
const host = createScopedTestHost([
stubPair<IGreeter>(IGreeter, { greet: () => 'hello-from-app' }),
]);
const session = host.child(LifecycleScope.Session, 's1');
const consumer = session.accessor.get(IConsumer);
expect(consumer.label()).toBe('consumed:hello-from-app');
host.dispose();
});
it('isolates stubs between sibling Session scopes', () => {
const host = createScopedTestHost();
const s1 = host.child(LifecycleScope.Session, 's1', [
stubPair<IGreeter>(IGreeter, { greet: () => 'one' }),
]);
const s2 = host.child(LifecycleScope.Session, 's2', [
stubPair<IGreeter>(IGreeter, { greet: () => 'two' }),
]);
expect(s1.accessor.get(IConsumer).label()).toBe('consumed:one');
expect(s2.accessor.get(IConsumer).label()).toBe('consumed:two');
host.dispose();
});
it('builds an Agent scope under a Session and resolves upward', () => {
const host = createScopedTestHost([
stubPair<IGreeter>(IGreeter, { greet: () => 'from-app' }),
]);
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
// The Agent scope has no IGreeter seed, so resolution walks up to App.
expect(agent.accessor.get(IGreeter).greet()).toBe('from-app');
// IConsumer is registered at Session scope; the Agent scope finds it on the ancestor.
expect(agent.accessor.get(IConsumer).label()).toBe('consumed:from-app');
host.dispose();
});
it('disposes child scopes before parent scopes', () => {
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
// Force construction of each scoped instance.
host.app.accessor.get(IAppThing);
session.accessor.get(ISessionThing);
agent.accessor.get(IAgentThing);
host.dispose();
expect(disposalLog).toEqual(['agent', 'session', 'app']);
});
});

View file

@ -1,182 +0,0 @@
/**
* Scenario: the **edge-gateway-rpc** slice the edge-exposure layer where the
* agent's `resource:action` RPC surface meets the REST/WS transport edge.
*
* Concept taught: the agent is not reached directly. `IAgentRPCService`
* (Agent scope) is the typed `resource:action` RPC surface one method per
* action (`prompt`, `registerTool`, `getTools`, ) that edge transports call
* into. The `gateway` domain (App scope) is the transport edge itself:
* `IRestGateway` drives request/response actions, while the WS side owns the
* streaming connections. The WS fan-out is backed by a process-wide event
* sink, `IEventService` (App scope) a minimal type-tagged pub/sub bus that
* the edge package subscribes to and republishes over sockets. So the data
* path is: transport (gateway) RPC action (agent) domain fact event
* sink (`IEventService`) WS connections.
*
* We keep this example read-only and deterministic: no sockets are opened and
* no servers listen. We only resolve the real services and exercise safe,
* synchronous-ish methods `registerTool` / `getTools` on the RPC, a
* session-status probe on the REST gateway, and a `publish` / `subscribe`
* round-trip on the event sink.
*
* Wiring: the real composition root (`_harness`) provides every collaborator
* (the tool registry, the session lifecycle the gateway resolves through, the
* record log, ) so the slice runs for real with no hand-rolled stub list. We
* spy on `IAgentRecordService.append` only to observe the
* `tools.register_user_tool` record the RPC writes when an action is
* registered.
*
* Note on the WS gateway: the App-scope `IWSGateway` binding in this package
* still carries an Agent-scope `IAgentRecordService` dependency that the real
* composition root does not satisfy at App scope, so it is intentionally not
* instantiated here (WS sequencing / journaling / replay is completed in the
* edge `server` package on top of `IEventService` + `IAgentRecordService`).
* We therefore exercise the WS *backing* the `IEventService` event sink
* directly, which is the part this package owns and wires for real.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/edge-gateway-rpc.example.ts
*/
import { randomUUID } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IAgentRecordService } from '#/agent/record';
import { IAgentRPCService } from '#/agent/rpc';
import { type DomainEvent, IEventService } from '#/app/event';
import { IRestGateway } from '#/app/gateway';
import { createSliceHost, type SliceHost } from './_harness';
describe('edge-gateway-rpc slice (resource:action RPC over the gateway/event edge)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
function newHomeDir(): string {
const root = process.env['KIMI_CODE_HOME'];
if (root === undefined) {
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
}
// Per-test isolated home so this example never writes into the shared
// run-wide home (which other examples share) — keeps the slice hermetic.
const dir = join(root, randomUUID());
mkdirSync(dir, { recursive: true });
return dir;
}
function setUp() {
host = createSliceHost({ homeDir: newHomeDir() });
return {
rpc: host.agent.accessor.get(IAgentRPCService),
rest: host.app.accessor.get(IRestGateway),
events: host.app.accessor.get(IEventService),
};
}
it('resolves the edge services and exposes the resource:action transport surface', async () => {
const { rpc, rest, events } = setUp();
// The agent RPC surface exposes typed `resource:action` methods the edge
// calls into — tool actions here are the clearest example.
expect(typeof rpc.registerTool).toBe('function');
expect(typeof rpc.getTools).toBe('function');
expect(typeof rpc.prompt).toBe('function');
// The App-scope REST gateway is the request/response transport edge.
expect(typeof rest.prompt).toBe('function');
expect(typeof rest.getStatus).toBe('function');
// The WS fan-out is backed by the App-scope event sink.
expect(typeof events.publish).toBe('function');
expect(typeof events.subscribe).toBe('function');
expect(typeof events.onDidPublish).toBe('function');
// The REST gateway resolves sessions through the session lifecycle; an
// unknown session reports status `false` rather than throwing. This is a
// safe, read-only probe — no socket, no session created.
expect(await rest.getStatus('does-not-exist')).toBe(false);
});
it('registers an action (tool) through the RPC and lists it back', async () => {
host = createSliceHost({ homeDir: newHomeDir() });
// Instrument the real record service first; the RPC's user-tool registrar
// is constructed against the same singleton and writes a
// `tools.register_user_tool` record when an action is registered.
const records = host.agent.accessor.get(IAgentRecordService);
const appended: Array<{ type: string; name?: string }> = [];
vi.spyOn(records, 'append').mockImplementation((r) => {
appended.push(r as { type: string; name?: string });
});
const rpc = host.agent.accessor.get(IAgentRPCService);
const before = await rpc.getTools({});
expect(before.some((tool) => tool.name === 'echo-example')).toBe(false);
rpc.registerTool({
name: 'echo-example',
description: 'Echoes its input — example edge action.',
parameters: { type: 'object', properties: { text: { type: 'string' } } },
});
// The action is now part of the agent's RPC-listed tool set, sourced as a
// user-registered tool and auto-activated by the profile.
const after = await rpc.getTools({});
const registered = after.find((tool) => tool.name === 'echo-example');
expect(registered).toMatchObject({
name: 'echo-example',
description: 'Echoes its input — example edge action.',
source: 'user',
active: true,
});
// Registering the action is itself a recorded domain fact.
expect(appended).toContainEqual(
expect.objectContaining({ type: 'tools.register_user_tool', name: 'echo-example' }),
);
// Unregistering removes the action from the listed set (and records the
// unregister fact), leaving the agent the way we found it.
rpc.unregisterTool({ name: 'echo-example' });
const final = await rpc.getTools({});
expect(final.some((tool) => tool.name === 'echo-example')).toBe(false);
expect(appended).toContainEqual(
expect.objectContaining({ type: 'tools.unregister_user_tool', name: 'echo-example' }),
);
});
it('streams a domain event over the event sink that backs the WS fan-out', () => {
const { events } = setUp();
const viaSubscribe: DomainEvent[] = [];
const viaOnDidPublish: DomainEvent[] = [];
const subA = events.subscribe((event) => viaSubscribe.push(event));
const subB = events.onDidPublish((event) => viaOnDidPublish.push(event));
const domainEvent: DomainEvent = {
type: 'session.edgeExample',
payload: { sessionId: 's1', kind: 'demo' },
};
events.publish(domainEvent);
// Both subscription paths on the real bus receive the published fact —
// this is the event the edge package would republish over the WS
// connections tracked by the gateway.
expect(viaSubscribe).toEqual([domainEvent]);
expect(viaOnDidPublish).toEqual([domainEvent]);
subA.dispose();
subB.dispose();
// After disposal the sink no longer delivers to the removed handlers.
events.publish({ type: 'session.edgeExample.afterDispose', payload: null });
expect(viaSubscribe).toHaveLength(1);
expect(viaOnDidPublish).toHaveLength(1);
});
});

View file

@ -1,110 +0,0 @@
/**
* Scenario: the **event bus** slice soft coupling through `IEventService`.
*
* Concept taught: not every dependency is a constructor injection. When a
* domain wants to broadcast a fact to an *unknown* set of consumers, it
* publishes a typed `DomainEvent` to the App-scope `IEventService` instead of
* importing and calling each consumer. The dep-graph records these as `publish`
* / `subscribe` / `emit` / `on` edges softer than `ctor` edges because the
* publisher holds no reference to its consumers.
*
* Real publishers in the graph include `ISessionLifecycleService`,
* `IModelCatalogService`, and `IOAuthService`; here we use a tiny in-file publisher to isolate the
* wiring without pulling in those domains.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/events.example.ts
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { type IDisposable } from '#/_base/di';
import { createDecorator } from '#/_base/di/instantiation';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost } from '#/_base/di/test';
import {
type DomainEvent,
EventService,
IEventService,
} from '#/app/event';
interface IPublisher {
announce(kind: string, detail: string): void;
}
/** An Agent-scope publisher that broadcasts through the App-scope bus. */
class Publisher implements IPublisher {
constructor(@IEventService private readonly events: IEventService) {}
announce(kind: string, detail: string): void {
this.events.publish({ type: kind, payload: { detail } });
}
}
const IPublisher = createDecorator<IPublisher>('ex-events-publisher');
describe('events slice (soft coupling via IEventService)', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(LifecycleScope.App, IEventService, EventService, undefined, 'event');
registerScopedService(LifecycleScope.Agent, IPublisher, Publisher);
});
it('delivers a published DomainEvent to a subscriber', () => {
const host = createScopedTestHost();
const bus = host.app.accessor.get(IEventService);
const received: DomainEvent[] = [];
const sub = bus.subscribe((e) => received.push(e));
bus.publish({ type: 'session.archived', payload: { sessionId: 's1' } });
expect(received).toEqual([
{ type: 'session.archived', payload: { sessionId: 's1' } },
]);
sub.dispose();
host.dispose();
});
it('decouples an Agent-scope publisher from its consumers', () => {
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
// The consumer subscribes through the same App-scope bus the publisher
// resolves upward to — neither side imports the other.
const received: DomainEvent[] = [];
host.app.accessor.get(IEventService).subscribe((e) => received.push(e));
agent.accessor.get(IPublisher).announce('turn.completed', 'turn-42');
expect(received).toEqual([
{ type: 'turn.completed', payload: { detail: 'turn-42' } },
]);
host.dispose();
});
it('stops delivering after the subscription is disposed', () => {
const host = createScopedTestHost();
const bus = host.app.accessor.get(IEventService);
const received: DomainEvent[] = [];
const sub: IDisposable = bus.subscribe((e) => received.push(e));
bus.publish({ type: 'first', payload: null });
sub.dispose();
bus.publish({ type: 'second', payload: null });
expect(received.map((e) => e.type)).toEqual(['first']);
host.dispose();
});
});

View file

@ -1,174 +0,0 @@
/**
* Scenario: the **extensions** slice the plugin, MCP, and skill-catalog
* surfaces through which the agent is extended without touching its core.
*
* Concept taught: three scoped services form the extension plane, each owning
* a different lifetime and contribution channel.
*
* - `IPluginService` (App) discovers installed plugins and exposes their
* *consumption plane*: skill roots, MCP servers, hooks, and session-start
* reminders that other domains fold in. With no plugins installed, every
* collection is empty but the surface still resolves and reports shape.
* - `IAgentMcpService` (Agent) manages the per-agent MCP server connections:
* it lists configured servers, surfaces their status, and lets callers
* subscribe to `onStatusChange`. With no connection manager seeded, it
* resolves as a quiet shell no servers, no network, an already-settled
* initial load.
* - `IGlobalSkillCatalog` (App) merges the code-defined builtin skills with
* user / brand skills discovered from the home directories, loading once
* and sharing the result with every Session catalog. Each `SkillDefinition`
* carries a `source` tag so the catalog reports provenance, not just names.
*
* Wiring: the real composition root (`_harness`) provides every collaborator
* (the file-backed plugin store, the agent's MCP service shell, the filesystem
* `ISkillCatalogStore`, ) so each surface resolves for real with no
* hand-rolled stub list. The isolated `KIMI_CODE_HOME` has no plugins
* installed and no MCP servers configured, so every surface reports empty
* contents and nothing connects over the network or loads a real plugin.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/extensions.example.ts
*/
import { afterEach, describe, expect, test } from 'vitest';
import { IAgentMcpService } from '#/agent/mcp';
import { IGlobalSkillCatalog } from '#/app/globalSkillCatalog';
import { IPluginService } from '#/app/plugin';
import { createSliceHost, type SliceHost } from './_harness';
describe('extensions slice (plugins × MCP × skill catalog)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
function setUp(): SliceHost {
if (process.env['KIMI_CODE_HOME'] === undefined) {
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
}
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME'] });
return host;
}
test('plugin service resolves and reports no contributed skills/MCP/hooks when no plugins are installed', async () => {
const h = setUp();
const plugins = h.app.accessor.get(IPluginService);
const summaries = await plugins.listPlugins();
const mcpServers = await plugins.enabledMcpServers();
const skillRoots = await plugins.pluginSkillRoots();
const hooks = await plugins.enabledHooks();
const sessionStarts = await plugins.enabledSessionStarts();
console.log('plugins:', {
installed: summaries.length,
mcpServers: Object.keys(mcpServers).length,
skillRoots: skillRoots.length,
hooks: hooks.length,
sessionStarts: sessionStarts.length,
});
// The consumption plane is present but empty: no plugins are installed.
expect(summaries).toEqual([]);
expect(mcpServers).toEqual({});
expect(skillRoots).toEqual([]);
expect(hooks).toEqual([]);
expect(sessionStarts).toEqual([]);
// `onDidReload` is an Event — subscribing yields a disposable and, with no
// reload triggered, the listener never fires.
let reloads = 0;
const sub = plugins.onDidReload(() => {
reloads++;
});
expect(typeof sub.dispose).toBe('function');
sub.dispose();
expect(reloads).toBe(0);
});
test('agent MCP service resolves with no servers and exposes a status subscription', async () => {
const h = setUp();
const mcp = h.agent.accessor.get(IAgentMcpService);
const entries = mcp.list();
console.log('agent MCP servers:', entries.length);
// No connection manager is seeded, so no servers are configured or connected.
expect(Array.isArray(entries)).toBe(true);
expect(entries).toEqual([]);
expect(mcp.resolved('does-not-exist')).toBeUndefined();
expect(mcp.getRemoteServerUrl('does-not-exist')).toBeUndefined();
// The initial load is already settled — nothing ever connected.
await expect(mcp.waitForInitialLoad()).resolves.toBeUndefined();
expect(mcp.initialLoadDurationMs()).toBe(0);
expect(mcp.oauthService).toBeUndefined();
// `onStatusChange` yields a disposable; with no servers the listener is
// never invoked.
let statusChanges = 0;
const sub = mcp.onStatusChange(() => {
statusChanges++;
});
expect(typeof sub.dispose).toBe('function');
sub.dispose();
expect(statusChanges).toBe(0);
});
test('global skill catalog loads builtin skills with provenance', async () => {
const h = setUp();
const globalCatalog = h.app.accessor.get(IGlobalSkillCatalog);
await globalCatalog.load();
const skills = globalCatalog.catalog.listSkills();
const builtins = skills.filter((skill) => skill.source === 'builtin');
console.log('skills:', { total: skills.length, builtin: builtins.length });
// The code-defined builtins are always present after load.
expect(skills.length).toBeGreaterThan(0);
expect(builtins.length).toBeGreaterThan(0);
for (const skill of skills) {
expect(['builtin', 'user', 'extra', 'project']).toContain(skill.source);
expect(skill.name.length).toBeGreaterThan(0);
}
// `getSkill` round-trips a builtin by name and preserves its provenance.
const first = builtins[0];
expect(first).toBeDefined();
if (first === undefined) return;
const inspected = globalCatalog.catalog.getSkill(first.name);
expect(inspected).toBeDefined();
if (inspected === undefined) return;
expect(inspected.name).toBe(first.name);
expect(inspected.source).toBe('builtin');
});
test('global skill catalog derives its model listing from invocable skills', async () => {
const h = setUp();
const globalCatalog = h.app.accessor.get(IGlobalSkillCatalog);
await globalCatalog.load();
const all = globalCatalog.catalog.listSkills();
const invocable = globalCatalog.catalog.listInvocableSkills();
const listing = globalCatalog.catalog.getModelSkillListing();
console.log('catalog:', {
total: all.length,
invocable: invocable.length,
listingChars: listing.length,
});
// Invocable skills are a filtered subset of the full catalog.
expect(invocable.length).toBeLessThanOrEqual(all.length);
const allNames = all.map((skill) => skill.name);
for (const skill of invocable) {
expect(allNames).toContain(skill.name);
}
// The model-facing listing is derived from the catalog (never hand-rolled).
expect(typeof listing).toBe('string');
});
});

View file

@ -1,77 +0,0 @@
/**
* Scenario: the **feature-flags** slice `flag` for real, `config` stubbed.
*
* Demonstrates running a slice's real services while stubbing the
* collaborators outside it. `IFlagService` and `IFlagRegistry` are real, so
* flag resolution (env config default) and `setConfigOverrides` behave
* exactly as in production; the `config` registry/service and `bootstrap` env
* lookup are stubbed, because the scenario does not need a real config file or
* process environment. A flag is contributed inline so the slice is
* self-contained.
*/
import { afterEach, beforeEach, describe, test } from 'vitest';
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigRegistry, IConfigService } from '#/app/config/config';
import { FlagService } from '#/app/flag/flagService';
import { type ExperimentalFlagConfig, IFlagService } from '#/app/flag/flag';
import { IFlagRegistry, registerFlagDefinition } from '#/app/flag/flagRegistry';
import { FlagRegistryService } from '#/app/flag/flagRegistryService';
registerFlagDefinition({
id: 'demo_flag',
title: 'Demo flag',
description: 'An example-only experimental flag.',
env: 'KIMI_CODE_EXPERIMENTAL_DEMO_FLAG',
default: false,
surface: 'core',
});
describe('feature-flags slice (flag, with config stubbed)', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let configValue: ExperimentalFlagConfig;
beforeEach(() => {
disposables = new DisposableStore();
configValue = {};
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.definePartialInstance(IConfigRegistry, { registerSection: () => {} });
reg.definePartialInstance(IConfigService, {
ready: Promise.resolve(),
get: () => configValue,
onDidChangeConfiguration: () => toDisposable(() => {}),
});
reg.definePartialInstance(IBootstrapService, { getEnv: () => undefined });
reg.define(IFlagRegistry, FlagRegistryService);
reg.define(IFlagService, FlagService);
},
});
});
afterEach(() => {
disposables.dispose();
});
test('resolves a flag from its default, then from a config override', () => {
const flags = ix.get(IFlagService);
const initial = flags.explain('demo_flag');
console.log('initial:', {
enabled: initial?.enabled,
source: initial?.source,
default: initial?.defaultEnabled,
});
configValue = { demo_flag: true };
flags.setConfigOverrides(configValue);
const overridden = flags.explain('demo_flag');
console.log('after setConfigOverrides({ demo_flag: true }):', {
enabled: overridden?.enabled,
source: overridden?.source,
});
});
});

View file

@ -1,116 +0,0 @@
/**
* Example 13 the `fileTools` slice across all three scope tiers.
*
* Concept taught: a real feature is a *vertical slice*. Each built-in tool is
* a DI class (constructor injects its dependencies with `@IX`) that
* self-registers via `registerTool(ReadTool)` at module load. The Agent-scope
* `IAgentToolRegistryService` consumes every module-level contribution when it
* is constructed and stores the resulting tool instances in the per-agent
* runtime table. The tool ctors themselves inject Session-scope peers
* (`ISessionAgentFileSystem`, `ISessionFsService`,
* `ISessionWorkspaceContext`) and App-scope peers (`IHostEnvironment`,
* `ITelemetryService`) the same "short-lived injects long-lived" rule made
* concrete.
*
* We stub the leaf dependencies with minimal fakes instead of constructing
* their real implementations, so the example needs no kaos and stays focused
* on the wiring.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/file-tools.example.ts
*/
import { describe, expect, it, vi } from 'vitest';
import { LifecycleScope } from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
// Side-effect import: each tool file calls `registerTool(SomeTool)` at module
// load; the barrel re-exports them so this one import is enough to add
// Read/Write/Edit/Grep/Glob to the contribution list.
import '#/agent/fileTools';
import {
IAgentBuiltinToolsRegistrar,
IAgentToolRegistryService,
} from '#/agent/toolRegistry';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry';
import {
ISessionAgentFileSystem,
ISessionFsService,
} from '#/session/agentFs';
import { ISessionProcessRunner } from '#/os/interface/process';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
// Minimal leaf fakes. The real tool constructors only read these surfaces
// during construction.
const fakeEnv: IHostEnvironment = {
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: '/home',
ready: Promise.resolve(),
};
const fakeFs = { cwd: '/workspace' } as unknown as ISessionAgentFileSystem;
const fakeFsService = {} as unknown as ISessionFsService;
const fakeRunner = {
_serviceBrand: undefined,
exec: vi.fn(),
} as unknown as ISessionProcessRunner;
const fakeWorkspace = {
workDir: '/workspace',
additionalDirs: [],
} as unknown as ISessionWorkspaceContext;
describe('example 13 — file-tools slice (App + Session + Agent)', () => {
it('registers the five built-in file tools through the scope tree', () => {
const host = createScopedTestHost([
stubPair(IHostEnvironment, fakeEnv),
stubPair(ITelemetryService, noopTelemetryService),
]);
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(ISessionAgentFileSystem, fakeFs),
stubPair(ISessionFsService, fakeFsService),
stubPair(ISessionProcessRunner, fakeRunner),
stubPair(ISessionWorkspaceContext, fakeWorkspace),
]);
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
// Force-instantiate the Eager builtin-tools registrar: its constructor
// consumes every registered tool contribution and builds each tool
// instance against this Agent scope.
agent.accessor.get(IAgentBuiltinToolsRegistrar);
const tools = agent.accessor.get(IAgentToolRegistryService).list();
const names = tools.map((t) => t.name);
expect(names).toEqual(expect.arrayContaining(['Edit', 'Glob', 'Grep', 'Read', 'Write']));
host.dispose();
});
it('resolves the same Agent-scope registry on repeated access (singleton per scope)', () => {
const host = createScopedTestHost([
stubPair(IHostEnvironment, fakeEnv),
stubPair(ITelemetryService, noopTelemetryService),
]);
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(ISessionAgentFileSystem, fakeFs),
stubPair(ISessionFsService, fakeFsService),
stubPair(ISessionProcessRunner, fakeRunner),
stubPair(ISessionWorkspaceContext, fakeWorkspace),
]);
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
const a = agent.accessor.get(IAgentToolRegistryService);
const b = agent.accessor.get(IAgentToolRegistryService);
expect(a).toBe(b);
host.dispose();
});
});

View file

@ -1,101 +0,0 @@
/**
* Scenario: the **plan** slice an entity-like domain backed by the append-log
* record layer.
*
* Concept taught: `goal`, `plan`, and `todoList` are entity-like domains whose
* durable state is carried by records on the append-log (`IAgentRecordService`),
* not by private fields alone. Each lifecycle change is persisted with
* `record.append({ type: '...' })`, and the same record both broadcasts the
* change live and rebuilds the entity on resume through its `resume` facet.
*
* We demonstrate the pattern on `plan` because it is the lightest domain that
* actually emits to the record log: `enter` appends a `plan_mode.enter` record,
* `status` reads the entity, and `exit` appends a `plan_mode.exit` record.
* `goal` follows the same append-log pattern (`goal.create` / `goal.update` /
* `goal.clear`); `todoList` stores its items in the tool store rather than the
* record log, so it is not re-wired here.
*
* Wiring: the real composition root (`_harness`) provides every collaborator,
* including the real `IAgentRecordService`. We spy on the record service's
* `append` / `define` to observe the records and to capture the `resume` facet,
* so the slice runs end-to-end for real with no hand-rolled stub list.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/goals-plans-todos.example.ts
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IAgentPlanService } from '#/agent/plan';
import { IAgentRecordService } from '#/agent/record';
import { createSliceHost, type SliceHost } from './_harness';
describe('goals-plans-todos slice (append-log CRUD via plan)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
function setUp() {
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
// Resolve the real record service first and instrument it; the plan service
// is constructed lazily and will pick up the same singleton.
const records = host.agent.accessor.get(IAgentRecordService);
const appended: Array<{ type: string; id?: string }> = [];
const facets = new Map<string, { resume?: (r: { type: string; id?: string }) => unknown }>();
vi.spyOn(records, 'append').mockImplementation((r) => {
appended.push(r as { type: string; id?: string });
});
vi.spyOn(records, 'define').mockImplementation((type, facet) => {
facets.set(type as string, facet as { resume?: (r: { type: string; id?: string }) => unknown });
return { dispose: () => facets.delete(type as string) };
});
const plan = host.agent.accessor.get(IAgentPlanService);
return { plan, appended, facets };
}
it('entering plan mode activates the plan and appends a plan_mode.enter record', async () => {
const { plan, appended } = setUp();
await plan.enter('ship-v2');
expect(appended.map((r) => r.type)).toContain('plan_mode.enter');
const status = await plan.status();
expect(status?.id).toBe('ship-v2');
});
it('exiting plan mode appends a plan_mode.exit record and deactivates the plan', async () => {
const { plan, appended } = setUp();
await plan.enter('ship-v2');
plan.exit('ship-v2');
expect(appended.map((r) => r.type)).toEqual(['plan_mode.enter', 'plan_mode.exit']);
expect(await plan.status()).toBeNull();
});
it('cancelling plan mode appends a plan_mode.cancel record', async () => {
const { plan, appended } = setUp();
await plan.enter('scratch');
plan.cancel('scratch');
expect(appended.map((r) => r.type)).toEqual(['plan_mode.enter', 'plan_mode.cancel']);
expect(await plan.status()).toBeNull();
});
it('replays records through their resume facets to rebuild plan state', async () => {
const { plan, facets } = setUp();
// Wake the lazy service so its constructor registers the resume facets.
expect(await plan.status()).toBeNull();
expect(facets.has('plan_mode.enter')).toBe(true);
await facets.get('plan_mode.enter')!.resume!({ type: 'plan_mode.enter', id: 'restored' });
expect((await plan.status())?.id).toBe('restored');
await facets.get('plan_mode.exit')!.resume!({ type: 'plan_mode.exit' });
expect(await plan.status()).toBeNull();
});
});

View file

@ -1,121 +0,0 @@
/**
* Scenario: the **os** slice `IHostEnvironment` + the `IExecContext` seed.
*
* The os dimension is organised as:
*
* os/
* interface/ contracts only: IHostEnvironment, IExecContext,
* ISessionAgentFileSystem, IHostFileSystem,
* ISessionProcessRunner, ISessionTerminalService,
* ISessionTerminalBackend, IHostFolderBrowser
* backends/
* node-local/ HostEnvironmentService, SessionAgentFileSystem,
* HostFileSystem, SessionProcessRunner, etc.
*
* Concept taught: not every dependency is *constructed* by the container. Some
* enter the scope tree as plain **values** seeded through `stubPair(...)` /
* `ScopeSeed`:
*
* - `IHostEnvironment` (App scope) an immutable snapshot of the host OS,
* shell, path style, and home directory. One per process.
* - `IExecContext` (Session scope) the session's `cwd` + env overlays. It is
* a value, not a service: it has no `registerScopedService` entry, which is
* why the dep-graph lists it as an "unresolved" token even though Session
* and Agent services inject it. `sessionLifecycle` seeds it when a session
* is created; `withCwd` / `withEnv` derive new contexts immutably.
*
* `SessionWorkspaceContextService` consumes `IExecContext` and resolves every
* path relative to the seeded `cwd` so the same service behaves differently
* in two sibling Sessions purely because each was seeded a different context.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/host.example.ts
*/
import { beforeEach, describe, expect, it } from 'vitest';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
// ── os/interface ──────────────────────────────────────────────────────
// Import contracts from the canonical os/interface paths.
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import {
createExecContext,
IExecContext,
} from '#/os/interface/execContext';
// Workspace context stays in session/ — it's a business-level facade.
import {
ISessionWorkspaceContext,
SessionWorkspaceContextService,
} from '#/session/workspaceContext';
const fakeEnv: IHostEnvironment = {
_serviceBrand: undefined,
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
pathClass: 'posix',
homeDir: '/home/test',
ready: Promise.resolve(),
};
describe('host slice (IHostEnvironment + IExecContext seed)', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Session,
ISessionWorkspaceContext,
SessionWorkspaceContextService,
);
});
it('resolves paths against the seeded IExecContext.cwd', () => {
const host = createScopedTestHost([stubPair(IHostEnvironment, fakeEnv)]);
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(IExecContext, createExecContext('/workspace')),
]);
const ws = session.accessor.get(ISessionWorkspaceContext);
expect(ws.workDir).toBe('/workspace');
expect(ws.resolve('src/index.ts')).toBe('/workspace/src/index.ts');
expect(ws.isWithin('/workspace/src/index.ts')).toBe(true);
expect(ws.isWithin('/elsewhere/file.ts')).toBe(false);
host.dispose();
});
it('isolates IExecContext between sibling Session scopes', () => {
const host = createScopedTestHost([stubPair(IHostEnvironment, fakeEnv)]);
const s1 = host.child(LifecycleScope.Session, 's1', [
stubPair(IExecContext, createExecContext('/repo-a')),
]);
const s2 = host.child(LifecycleScope.Session, 's2', [
stubPair(IExecContext, createExecContext('/repo-b')),
]);
expect(s1.accessor.get(ISessionWorkspaceContext).workDir).toBe('/repo-a');
expect(s2.accessor.get(ISessionWorkspaceContext).workDir).toBe('/repo-b');
host.dispose();
});
it('derives a new context with withCwd without mutating the original', () => {
const base = createExecContext('/workspace', [{ PATH: '/usr/bin' }]);
const derived = base.withCwd('/workspace/sub');
expect(derived.cwd).toBe('/workspace/sub');
expect(derived.envLayers).toEqual([{ PATH: '/usr/bin' }]);
// Original is untouched — IExecContext is immutable.
expect(base.cwd).toBe('/workspace');
});
});

View file

@ -1,157 +0,0 @@
/**
* Scenario: the **interaction** kernel and its `approval` / `question` facades,
* resolved through the **Session scope** they belong to.
*
* All three Services are registered at `LifecycleScope.Session`, so this
* example resolves them from a real Session scope (`createScopedTestHost`
* `host.child(LifecycleScope.Session, …)`), the same layer production uses. The
* scoped registry is cleared and re-populated explicitly in `beforeEach` rather
* than relying on import-order side effects.
*
* `ISessionInteractionService` is the only Service that owns state a pending set
* plus a recently-resolved ledger and it is domain-agnostic.
* `ISessionApprovalService` and `ISessionQuestionService` are zero-state typed facades over
* it: they tag each request with `kind: 'approval'` / `kind: 'question'`,
* rename the resolve verb (`decide` / `answer` `respond`), and cast the
* stored payload back to the typed request on `listPending`.
*
* Two calling styles are demonstrated:
*
* - **Blocking** (`request`): the caller `await`s a Promise that parks until a
* response arrives. Used by in-turn code (a tool gating on a user decision).
* - **Non-blocking** (`enqueue` + `onDidResolve`): the caller parks the request
* and returns its `id` immediately; the outcome is delivered through the
* `onDidResolve` stream. Used by edge callers that stream the result rather
* than awaiting a Promise (e.g. over WebSocket).
*
* The final scenario proves Session-scope isolation: two sessions hold
* independent brokers, so a request parked in session A is invisible to, and
* not resolvable from, session B.
*/
import type { ToolInputDisplay } from '@moonshot-ai/protocol';
import { afterEach, beforeEach, describe, test } from 'vitest';
import { InstantiationType } from '#/_base/di/extensions';
import { DisposableStore } from '#/_base/di/lifecycle';
import {
_clearScopedRegistryForTests,
LifecycleScope,
registerScopedService,
type Scope,
} from '#/_base/di/scope';
import { createScopedTestHost, type ScopedTestHost } from '#/_base/di/test';
import { type ApprovalRequest, SessionApprovalService, ISessionApprovalService } from '#/session/approval';
import { ISessionInteractionService, SessionInteractionService } from '#/session/interaction';
import { type QuestionRequest, ISessionQuestionService, SessionQuestionService } from '#/session/question';
const display: ToolInputDisplay = { kind: 'command', command: 'rm -rf /tmp/demo' };
function approval(id: string): ApprovalRequest {
return { id, toolName: 'bash', action: 'run', display };
}
function question(id: string): QuestionRequest {
return {
id,
questions: [
{
question: 'What is your name?',
options: [{ label: 'kimi' }, { label: 'other' }],
},
],
};
}
describe('interaction kernel + approval/question facades (Session scope)', () => {
let disposables: DisposableStore;
let host: ScopedTestHost;
let session: Scope;
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(LifecycleScope.Session, ISessionInteractionService, SessionInteractionService, InstantiationType.Delayed, 'interaction');
registerScopedService(LifecycleScope.Session, ISessionApprovalService, SessionApprovalService, InstantiationType.Delayed, 'approval');
registerScopedService(LifecycleScope.Session, ISessionQuestionService, SessionQuestionService, InstantiationType.Delayed, 'question');
disposables = new DisposableStore();
host = createScopedTestHost();
session = host.child(LifecycleScope.Session, 'session-a');
});
afterEach(() => {
disposables.dispose();
host.dispose();
});
test('blocking: approval.request parks until decide resolves the Promise', async () => {
const approvals = session.accessor.get(ISessionApprovalService);
// The caller (e.g. a tool) awaits the decision. Nothing resolves yet.
const decision = approvals.request(approval('bash-1'));
console.log('1) after request, pending approvals:', approvals.listPending().map((r) => r.id));
// The edge (HTTP/WS `approvals:decide`) supplies the user's decision.
approvals.decide('bash-1', { decision: 'approved' });
console.log('2) resolved decision:', await decision);
console.log('3) after decide, pending approvals:', approvals.listPending());
});
test('non-blocking: question.enqueue returns immediately; the answer streams over onDidResolve', () => {
const interaction = session.accessor.get(ISessionInteractionService);
const questions = session.accessor.get(ISessionQuestionService);
// Edge callers observe outcomes through the stream instead of awaiting.
const resolved: { id: string; response: unknown }[] = [];
disposables.add(interaction.onDidResolve((r) => resolved.push(r)));
// enqueue parks the request and returns its id without blocking.
const parked = questions.enqueue(question('q-name'));
console.log('1) enqueued question (id known up front):', parked);
console.log('2) pending questions:', questions.listPending());
// The answer arrives later (HTTP/WS `questions:answer`) and fans out.
questions.answer('q-name', { answers: { q_0: 'kimi' } });
console.log('3) onDidResolve stream delivered:', resolved);
console.log('4) after answer, pending questions:', questions.listPending());
});
test('one kernel backs both facades; onDidChangePending announces every mutation', () => {
const interaction = session.accessor.get(ISessionInteractionService);
const approvals = session.accessor.get(ISessionApprovalService);
const questions = session.accessor.get(ISessionQuestionService);
let changes = 0;
disposables.add(interaction.onDidChangePending(() => changes++));
void approvals.request(approval('bash-1')); // change #1 (park approval)
questions.enqueue(question('q-name')); // change #2 (park question)
// The kernel sees every pending interaction, regardless of which facade parked it.
console.log('1) kernel listPending (all kinds):', interaction.listPending().map((i) => i.kind));
console.log('2) kernel listPending("approval"):', interaction.listPending('approval').map((i) => i.id));
console.log('3) kernel listPending("question"):', interaction.listPending('question').map((i) => i.id));
approvals.decide('bash-1', { decision: 'rejected' }); // change #3 (resolve approval)
questions.answer('q-name', { answers: { q_0: 'kimi' } }); // change #4 (resolve question)
console.log('4) onDidChangePending fired', changes, 'times (park x2 + resolve x2)');
});
test('Session scope isolates brokers: a request parked in A is invisible to B', async () => {
const sessionB = host.child(LifecycleScope.Session, 'session-b');
const approvalsA = session.accessor.get(ISessionApprovalService);
const approvalsB = sessionB.accessor.get(ISessionApprovalService);
console.log('1) distinct broker instances per session:', approvalsA !== approvalsB);
const decisionA = approvalsA.request(approval('bash-1'));
console.log('2) A pending after park:', approvalsA.listPending().map((r) => r.id));
console.log('3) B pending (isolated):', approvalsB.listPending().map((r) => r.id));
// Deciding from B is a no-op — the id is parked in A's kernel, not B's.
approvalsB.decide('bash-1', { decision: 'approved' });
console.log('4) A still pending after B.decide (no-op):', approvalsA.listPending().map((r) => r.id));
approvalsA.decide('bash-1', { decision: 'approved' });
console.log('5) A resolved by its own broker:', await decisionA);
});
});

View file

@ -1,127 +0,0 @@
/**
* Scenario: the **model provider** slice inspecting the configured LLM
* providers and model aliases.
*
* Concept taught: provider / model configuration is split across two App-scope
* registries backed by the `providers` and `models` config sections:
*
* - `IProviderService` (App) holds the configured providers (type, baseUrl,
* credentials) and emits `onDidChangeProviders` when the set changes.
* - `IModelService` (App) holds model aliases (provider + model id + context
* limits) and emits `onDidChangeModels`.
*
* Both are read/write registries over `IConfigService`: a `set` validates and
* persists to `config.toml`, and the change event fires so downstream domains
* react without threading model lists around.
*
* Wiring: the real composition root (`_harness`) provides every collaborator.
* Each test gets its own isolated `config.toml` (a fresh `homeDir`) so writes
* in one test cannot leak into the next test's default view.
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/model-provider.example.ts
*/
import { randomUUID } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { IConfigService } from '#/app/config';
import { IModelService } from '#/app/model';
import { IProviderService } from '#/app/provider';
import { createSliceHost, type SliceHost } from './_harness';
describe('model-provider slice (provider + model registries)', () => {
let caseDir: string;
let host: SliceHost;
beforeEach(() => {
const resolved = process.env['KIMI_CODE_HOME'];
if (resolved === undefined) {
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
}
// Give every test its own config.toml so provider / model writes in one test
// cannot leak into the next test's "empty" default view.
caseDir = join(resolved, randomUUID());
mkdirSync(caseDir, { recursive: true });
});
afterEach(() => host?.dispose());
test('IProviderService lists configured providers and reflects set/get/delete', async () => {
host = createSliceHost({ homeDir: caseDir });
const config = host.app.accessor.get(IConfigService);
const providers = host.app.accessor.get(IProviderService);
await config.ready;
// Default shape: a record with no entry under our (unused) key. We do not
// assert exact emptiness — env bindings may synthesize a reserved provider.
expect(typeof providers.list()).toBe('object');
expect(providers.get('demo-openai')).toBeUndefined();
const added: string[] = [];
const removed: string[] = [];
const sub = providers.onDidChangeProviders((e) => {
added.push(...e.added);
removed.push(...e.removed);
});
await providers.set('demo-openai', {
type: 'openai',
baseUrl: 'https://example.com/v1',
apiKey: 'YOUR_API_KEY',
});
expect(providers.get('demo-openai')).toMatchObject({
type: 'openai',
baseUrl: 'https://example.com/v1',
});
expect(providers.list()['demo-openai']).toBeDefined();
expect(added).toContain('demo-openai');
await providers.delete('demo-openai');
sub.dispose();
expect(providers.get('demo-openai')).toBeUndefined();
expect(removed).toContain('demo-openai');
});
test('IModelService lists configured model aliases and reflects set/get/delete', async () => {
host = createSliceHost({ homeDir: caseDir });
const config = host.app.accessor.get(IConfigService);
const models = host.app.accessor.get(IModelService);
await config.ready;
expect(typeof models.list()).toBe('object');
expect(models.get('demo-model')).toBeUndefined();
const added: string[] = [];
const removed: string[] = [];
const sub = models.onDidChangeModels((e) => {
added.push(...e.added);
removed.push(...e.removed);
});
await models.set('demo-model', {
provider: 'demo-openai',
model: 'gpt-demo',
maxContextSize: 8192,
});
expect(models.get('demo-model')).toMatchObject({
provider: 'demo-openai',
model: 'gpt-demo',
});
expect(models.list()['demo-model']).toBeDefined();
expect(added).toContain('demo-model');
await models.delete('demo-model');
sub.dispose();
expect(models.get('demo-model')).toBeUndefined();
expect(removed).toContain('demo-model');
});
});

View file

@ -1,373 +0,0 @@
/**
* Scenario: the **Provider / Platform / Protocol / Model** slice, driven from
* a real `~/.kimi-code/config.toml` and its credentials, and exercised through
* the new `IModelResolver` `Model` god-object path introduced in the
* "Model god-object and protocol domains" change.
*
* Goals of this example:
* 1. **Sandbox the real config.** At runtime, copy `~/.kimi-code/config.toml`
* and `~/.kimi-code/credentials/` into the per-run `KIMI_CODE_HOME` the
* example harness provisions (`.vitest-results/kimi-code-{ts}/`). The real
* home is never read or written directly even an OAuth token refresh
* lands in the sandbox copy.
* 2. **List everything.** Enumerate every `[providers.*]`, `[platforms.*]`,
* supported `Protocol`, and `[models.*]` entry, then resolve each Model id
* through `IModelResolver` and report whether it produces a runnable
* `Model` (protocol, base URL, auth mode) a concrete compatibility
* matrix for the new god-object resolver.
* 3. **Ping every Model.** Send a "ping" expect a streamed response
* against **every** Model that resolved (bounded concurrency, per-request
* timeout), and report which ones actually answer an end-to-end reachability
* check for the whole configured catalogue, not just the default model.
*
* All Services come from `src/`; nothing here defines a new Service.
*/
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import '#/index';
import { type Scope, type ScopeSeed } from '#/_base/di/scope';
import { bootstrap } from '#/app/bootstrap';
import { IConfigService } from '#/app/config';
import { createUserMessage, isContentPart, type TokenUsage } from '#/app/llmProtocol';
import { IModelResolver, IModelService, type Model, type ModelConfig } from '#/app/model';
import { IPlatformService } from '#/app/platform';
import { IProviderService, type ProviderConfig } from '#/app/provider';
import { IProtocolAdapterRegistry } from '#/app/protocol';
import { ILogOptions, resolveLoggingConfig } from '#/app/log/logConfig';
const PER_REQUEST_TIMEOUT_MS = 30_000;
const PING_CONCURRENCY = 4;
const ALL_MODELS_TEST_TIMEOUT_MS = 300_000;
interface ModelReport {
readonly id: string;
readonly name?: string;
readonly protocol?: string;
readonly baseUrl?: string;
readonly authMode: string;
readonly resolved: boolean;
readonly error?: string;
}
describe('model / provider / platform / protocol slice (resolved from a sandboxed ~/.kimi-code)', () => {
let app: Scope | undefined;
let sandboxHome = '';
let configCopied = false;
let credentialsCopied = 0;
let reports: readonly ModelReport[] = [];
let useRealHome = false;
beforeAll(() => {
useRealHome = process.env['KIMI_CODE_EXAMPLE_USE_REAL_HOME'] === '1';
sandboxHome = useRealHome ? join(homedir(), '.kimi-code') : resolveSandboxHome();
if (useRealHome) {
// Run directly against the real home so OAuth token refresh can read AND
// write back the real credentials. Read-only for config — this example
// never calls IConfigService.set/replace.
configCopied = true;
credentialsCopied = 0;
} else {
const mirror = mirrorRealKimiHome(sandboxHome);
configCopied = mirror.configCopied;
credentialsCopied = mirror.credentialsCopied;
}
const logSeed: ScopeSeed = [
[ILogOptions, resolveLoggingConfig({ homeDir: sandboxHome, env: process.env })],
];
app = bootstrap({ homeDir: sandboxHome }, logSeed).app;
});
afterAll(() => app?.dispose());
test('lists every Provider / Platform / Protocol and resolves every Model', async () => {
const host = requireApp(app);
const config = host.accessor.get(IConfigService);
await config.ready;
const providers = host.accessor.get(IProviderService);
const platforms = host.accessor.get(IPlatformService);
const models = host.accessor.get(IModelService);
const resolver = host.accessor.get(IModelResolver);
const protocols = host.accessor.get(IProtocolAdapterRegistry);
// Touch each registry so its config section is registered before we read.
const providerMap = providers.list();
const platformMap = platforms.list();
const modelMap = models.list();
const supportedProtocols = protocols.supportedProtocols();
console.log(`\nhome: ${sandboxHome}${useRealHome ? ' (REAL ~/.kimi-code)' : ' (sandbox copy)'}`);
console.log(`config.toml copied: ${useRealHome ? 'n/a (using real)' : configCopied}`);
console.log(`credentials copied: ${useRealHome ? 'n/a (using real)' : credentialsCopied}`);
console.log(`\nsupported protocols: ${supportedProtocols.join(', ') || '(none)'}`);
console.log(`\n[providers.*] (${Object.keys(providerMap).length}):`);
for (const [id, p] of Object.entries(providerMap)) {
console.log(` - ${id}: type=${p.type ?? '-'} baseUrl=${p.baseUrl ?? '-'} auth=${providerAuthMode(p)} platform=${p.platformId ?? '-'}`);
}
console.log(`\n[platforms.*] (${Object.keys(platformMap).length}):`);
if (Object.keys(platformMap).length === 0) console.log(' (none configured)');
for (const [id, pl] of Object.entries(platformMap)) {
const auth = pl.auth?.apiKey !== undefined ? 'apiKey' : pl.auth?.oauth !== undefined ? 'oauth' : pl.auth?.env !== undefined ? 'env' : '-';
console.log(` - ${id}: auth=${auth} displayName=${pl.displayName ?? '-'}`);
}
reports = Object.entries(modelMap).map(([id, m]) => resolveOne(id, m, providerMap, resolver));
console.log(`\n[models.*] (${reports.length}) — resolve compatibility:`);
for (const r of reports) {
const head = r.resolved ? 'OK ' : 'FAIL';
const detail = r.resolved
? `protocol=${r.protocol} baseUrl=${r.baseUrl} auth=${r.authMode} name=${r.name}`
: `auth=${r.authMode} error=${r.error}`;
console.log(` [${head}] ${r.id}${detail}`);
}
// The example is meaningful even on a machine without the real config: it
// simply reports an empty registry instead of failing.
if (!configCopied) {
console.log('\n(no ~/.kimi-code/config.toml found — reporting an empty registry)');
return;
}
expect(Object.keys(providerMap).length).toBeGreaterThan(0);
expect(reports.length).toBeGreaterThan(0);
// Every configured Model must at least resolve into a god-object; a
// resolution failure here is a real compatibility regression.
const failures = reports.filter((r) => !r.resolved);
expect(
failures,
`models that failed to resolve: ${failures.map((f) => `${f.id}(${f.error})`).join(', ')}`,
).toEqual([]);
});
test('sends a ping → pong request through EVERY resolvable Model', async () => {
const host = requireApp(app);
if (!configCopied || reports.length === 0) {
console.log('skipped: no ~/.kimi-code/config.toml or no models configured');
return;
}
const resolver = host.accessor.get(IModelResolver);
const candidates = reports.filter((r) => r.resolved);
if (candidates.length === 0) {
console.log('skipped: no resolvable models');
return;
}
console.log(
`\npinging ${candidates.length} resolvable models ` +
`(concurrency=${PING_CONCURRENCY}, per-request timeout=${PER_REQUEST_TIMEOUT_MS}ms):`,
);
const outcomes = await mapPool(candidates, PING_CONCURRENCY, async (report) => {
const model = resolver.resolve(report.id);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PER_REQUEST_TIMEOUT_MS);
try {
const result = await collectResponse(model, controller.signal);
return {
report,
ok: true as const,
text: result.text,
finishReason: result.finishReason,
};
} catch (error) {
return {
report,
ok: false as const,
error: error instanceof Error ? error.message : String(error),
};
} finally {
clearTimeout(timer);
}
});
for (const o of outcomes) {
if (o.ok) {
console.log(
` [OK ] ${o.report.id}${JSON.stringify(truncate(o.text, 40))} ` +
`(finish=${o.finishReason ?? '-'})`,
);
} else {
console.log(` [FAIL] ${o.report.id}${truncate(o.error, 140)}`);
}
}
const passed = outcomes.filter((o) => o.ok).length;
console.log(`\nping-pong summary: ${passed}/${outcomes.length} models responded.`);
const failed = outcomes.filter((o) => !o.ok);
expect(
failed,
`models that failed to respond: ${failed.map((f) => `${f.report.id}(${f.error})`).join(', ')}`,
).toEqual([]);
}, ALL_MODELS_TEST_TIMEOUT_MS);
});
function resolveOne(
id: string,
model: ModelConfig,
providers: Readonly<Record<string, ProviderConfig>>,
resolver: IModelResolver,
): ModelReport {
const authMode = modelAuthMode(model, providers);
try {
const resolved = resolver.resolve(id);
return {
id,
name: resolved.name,
protocol: resolved.protocol,
baseUrl: resolved.baseUrl,
authMode,
resolved: true,
};
} catch (error) {
return {
id,
name: model.name ?? model.model,
authMode,
resolved: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** Mirror the resolver's auth-precedence to label where each Model's
* credential comes from, without ever reading the secret itself. */
function modelAuthMode(
model: ModelConfig,
providers: Readonly<Record<string, ProviderConfig>>,
): string {
if (model.apiKey !== undefined && model.apiKey.length > 0) return 'model.apiKey';
if (model.oauth !== undefined) return 'model.oauth';
const providerId = model.providerId ?? model.provider;
const provider = providerId === undefined ? undefined : providers[providerId];
const platformId = provider?.platformId;
if (platformId !== undefined && platformId !== '__unknown__') {
return `platform(${platformId})`;
}
if (provider?.apiKey !== undefined && provider.apiKey.length > 0) return 'provider.apiKey';
if (provider?.oauth !== undefined) return 'provider.oauth';
return 'none';
}
function providerAuthMode(provider: ProviderConfig): string {
if (provider.apiKey !== undefined && provider.apiKey.length > 0) return 'apiKey';
if (provider.oauth !== undefined) return 'oauth';
if (provider.platformId !== undefined) return `platform(${provider.platformId})`;
if (provider.env !== undefined) return 'env';
return 'none';
}
async function collectResponse(
model: Model,
signal: AbortSignal,
): Promise<{ text: string; finishReason?: string; usage?: TokenUsage }> {
let text = '';
let think = '';
let finishReason: string | undefined;
let usage: TokenUsage | undefined;
const stream = model.request(
{
systemPrompt:
'You are a connectivity check. The user will say "ping". Reply with the single word: pong',
tools: [],
messages: [createUserMessage('ping')],
},
signal,
);
for await (const event of stream) {
if (event.type === 'part') {
const part = event.part;
if (isContentPart(part) && part.type === 'text') text += part.text;
else if (isContentPart(part) && part.type === 'think') think += part.think;
} else if (event.type === 'usage') {
usage = event.usage;
} else if (event.type === 'finish') {
finishReason = event.rawFinishReason ?? event.providerFinishReason;
}
}
// Thinking models may put the answer in `think`; surface whichever carried
// content so the report shows what came back.
return { text: text.trim().length > 0 ? text : think, finishReason, usage };
}
/** Run `fn` over `items` with at most `size` in flight, preserving order. */
async function mapPool<T, R>(
items: readonly T[],
size: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
const worker = async (): Promise<void> => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i] as T);
}
};
await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
return results;
}
function truncate(s: string, max: number): string {
const oneLine = s.replaceAll(/\s+/g, ' ').trim();
return oneLine.length > max ? `${oneLine.slice(0, max)}` : oneLine;
}
function resolveSandboxHome(): string {
const fromEnv = process.env['KIMI_CODE_HOME'];
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
// Fallback for running this file outside the example harness: mirror into a
// fresh temp dir so the real home is still never touched.
const dir = join(homedir(), '.kimi-code-example-sandbox');
mkdirSync(dir, { recursive: true });
process.env['KIMI_CODE_HOME'] = dir;
return dir;
}
/** Copy `~/.kimi-code/config.toml` and `~/.kimi-code/credentials/*` into the
* sandbox home. Never reads credential contents only copies bytes. */
function mirrorRealKimiHome(sandboxHome: string): {
configCopied: boolean;
credentialsCopied: number;
} {
const realHome = join(homedir(), '.kimi-code');
let configCopied = false;
const srcConfig = join(realHome, 'config.toml');
if (existsSync(srcConfig)) {
copyFileSync(srcConfig, join(sandboxHome, 'config.toml'));
configCopied = true;
}
let credentialsCopied = 0;
const srcCreds = join(realHome, 'credentials');
if (existsSync(srcCreds) && statSync(srcCreds).isDirectory()) {
const dstCreds = join(sandboxHome, 'credentials');
mkdirSync(dstCreds, { recursive: true });
for (const entry of readdirSync(srcCreds)) {
const src = join(srcCreds, entry);
if (statSync(src).isFile()) {
copyFileSync(src, join(dstCreds, entry));
credentialsCopied++;
}
}
}
return { configCopied, credentialsCopied };
}
function requireApp(app: Scope | undefined): Scope {
if (app === undefined) throw new Error('App scope was not initialized in beforeAll');
return app;
}

View file

@ -1,237 +0,0 @@
/**
* Scenario: the **auth modelCatalog** slice a device-code OAuth login
* followed by a managed `/models` refresh, with both steps observed through
* `config.onDidChangeConfiguration`.
*
* This example exists to make one design point concrete: **the caller never
* hand-rolls a `/models` request.** The flow is split into two internal,
* config-driven steps, and the caller reacts to config changes instead of
* plumbing model lists around:
*
* 1. **Login writes a credential, not models.** `IOAuthService.startLogin`
* drives the device-code flow; on success `OAuthService` only provisions
* the provider credential (the OAuth ref) into the `providers` config
* section. That write fires `config.onDidChangeConfiguration('providers')`, which the
* `provider` domain forwards as `providerService.onDidChangeProviders`. `auth` does
* not know about `modelCatalog` dependency direction stays one-way
* (`modelCatalog` `auth`, never the reverse).
* 2. **Refresh pulls `/models` internally and merges it into config.**
* `IOAuthService.refreshOAuthProviderModels` resolves the OAuth
* token through `IOAuthService`, fetches the managed model list, and
* writes the result into the `models` / `providers` / `defaultModel`
* sections through `IConfigService` each firing `onDidChangeConfiguration`. The caller
* *triggers* the refresh explicitly (it is not auto-chained inside login),
* then observes the new aliases arrive through config.
*
* Everything runs against the real App-scope Services **and** the real OAuth
* clients `KimiOAuthToolkit` (device-code protocol + token persistence) and
* `fetchManagedKimiCodeModels` (the `/models` request) are not stubbed. The
* only thing faked is the wire itself: `globalThis.fetch` is replaced with a
* tiny URL/method router that answers the OAuth device-code endpoints and the
* `/models` endpoint. No server listens on any port; the clients construct real
* requests and read real `Response` objects, so the request / response shapes
* (headers, snake_case wire, status-code branches) are exercised for real.
*
* All Services come from `src/`; nothing here defines a new Service.
*/
import { randomUUID } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { IOAuthService } from '#/app/auth';
import { IConfigService } from '#/app/config';
import { IModelService } from '#/app/model';
import { IProviderService } from '#/app/provider';
import { createSliceHost, type SliceHost } from './_harness';
const STUB_ACCESS_TOKEN = 'stub-access-token';
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
/**
* Replace `globalThis.fetch` with a router that answers exactly the three
* requests this slice issues: device authorization, device-code token polling,
* and the managed `/models` listing. Anything else throws so an unexpected call
* is loud instead of silently hitting the network.
*/
function installFetchMock(): void {
const router = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const method = (
init?.method ?? (input instanceof Request ? input.method : 'GET')
).toUpperCase();
const path = new URL(url).pathname;
if (method === 'POST' && path.endsWith('/api/oauth/device_authorization')) {
return jsonResponse(200, {
user_code: 'STUB-USER-CODE',
device_code: 'stub-device-code',
verification_uri: 'https://example.com/device',
verification_uri_complete: 'https://example.com/device?code=STUB-USER-CODE',
expires_in: 900,
interval: 0,
});
}
if (method === 'POST' && path.endsWith('/api/oauth/token')) {
return jsonResponse(200, {
access_token: STUB_ACCESS_TOKEN,
refresh_token: 'stub-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
scope: '',
});
}
if (method === 'GET' && path.endsWith('/models')) {
return jsonResponse(200, {
data: [
{
id: 'k2-thinking',
context_length: 262_144,
supports_reasoning: true,
supports_image_in: false,
supports_video_in: false,
supports_thinking_type: 'both',
display_name: 'K2 Thinking',
},
{
id: 'k2',
context_length: 131_072,
supports_reasoning: false,
supports_image_in: false,
supports_video_in: false,
},
],
});
}
throw new Error(`unexpected fetch: ${method} ${url}`);
};
vi.stubGlobal('fetch', router);
}
async function waitUntil(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (!predicate()) throw new Error('waitUntil timed out');
}
describe('oauth → modelCatalog slice (request-layer fetch mock, real clients)', () => {
let homeDir: string;
let caseDir: string;
let host: SliceHost | undefined;
beforeEach(() => {
const resolved = process.env['KIMI_CODE_HOME'];
if (resolved === undefined) {
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
}
homeDir = resolved;
// The real `KimiOAuthToolkit` persists tokens to `{homeDir}/credentials`;
// give each test its own home so a token saved by one test cannot make the
// next test look "already authenticated" and skip the device-code flow.
caseDir = join(homeDir, randomUUID());
mkdirSync(caseDir, { recursive: true });
installFetchMock();
});
afterEach(() => {
host?.dispose();
host = undefined;
vi.unstubAllGlobals();
});
test('device-code login provisions the provider credential through config.onDidChangeConfiguration', async () => {
host = createSliceHost({ homeDir: caseDir });
const app = host.app;
const config = app.accessor.get(IConfigService);
const oauth = app.accessor.get(IOAuthService);
const providers = app.accessor.get(IProviderService);
await config.ready;
providers.list();
const changed: string[] = [];
const sub = config.onDidChangeConfiguration((e) => changed.push(e.domain));
const start = await oauth.startLogin();
console.log('device code issued:', start.user_code, '→', start.verification_uri);
expect(start.status).toBe('pending');
// The credential lands asynchronously: handleSuccess provisions the
// provider after the device-code promise resolves. Wait for the OAuth ref
// to appear in config rather than for the flow status, so the config write
// is guaranteed to have committed.
await waitUntil(() => providers.get(KIMI_CODE_PROVIDER_NAME)?.oauth !== undefined);
sub.dispose();
const provider = providers.get(KIMI_CODE_PROVIDER_NAME);
console.log('provisioned provider:', JSON.stringify(provider));
console.log('config domains changed by login:', changed);
expect(provider?.oauth).toBeDefined();
expect(changed).toContain('providers');
expect(await oauth.status()).toEqual({ loggedIn: true, provider: KIMI_CODE_PROVIDER_NAME });
});
test('refreshOAuthProviderModels fetches /models internally and lands aliases through config.onDidChangeConfiguration', async () => {
host = createSliceHost({ homeDir: caseDir });
const app = host.app;
const config = app.accessor.get(IConfigService);
const oauth = app.accessor.get(IOAuthService);
const providers = app.accessor.get(IProviderService);
const models = app.accessor.get(IModelService);
await config.ready;
providers.list();
// Login first so the provider holds an OAuth ref; the refresh resolves the
// token from that ref. The caller triggers the refresh explicitly — login
// does not auto-fetch models.
await oauth.startLogin();
await waitUntil(() => providers.get(KIMI_CODE_PROVIDER_NAME)?.oauth !== undefined);
const changed: string[] = [];
const sub = config.onDidChangeConfiguration((e) => changed.push(e.domain));
const result = await oauth.refreshOAuthProviderModels();
sub.dispose();
const aliases = models.list();
console.log('refresh result:', JSON.stringify(result));
console.log('config domains changed by refresh:', changed);
console.log('model aliases after refresh:', Object.keys(aliases));
console.log('defaultModel:', config.get('defaultModel'));
expect(result.failed).toEqual([]);
expect(result.unchanged).toEqual([]);
expect(result.changed).toHaveLength(1);
expect(result.changed[0]).toMatchObject({ provider_id: KIMI_CODE_PROVIDER_NAME, added: 2 });
// `applyManagedKimiCodeConfig` keys aliases as `kimi-code/<model id>`.
expect(aliases['kimi-code/k2-thinking']).toMatchObject({
provider: KIMI_CODE_PROVIDER_NAME,
model: 'k2-thinking',
displayName: 'K2 Thinking',
});
expect(aliases['kimi-code/k2']).toBeDefined();
// Models arrived through config, not through a caller-threaded return value.
expect(changed).toContain('models');
expect(changed).toContain('defaultModel');
expect(config.get('defaultModel')).toBe('kimi-code/k2-thinking');
});
});

View file

@ -1,54 +0,0 @@
/**
* Scenario: the **observability** slice `log` + `telemetry`.
*
* Builds a flat container that runs both services for real (neither has
* cross-domain collaborators, so nothing is stubbed). `ILogService` writes
* through the App console writer; `ITelemetryService` fans events out to a
* console appender while merging bound context. The two compose: a child
* logger and a context-scoped telemetry both carry their bound fields into
* the output.
*/
import { afterEach, beforeEach, describe, test } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { ILogService, ILogWriterService } from '#/app/log/log';
import { ConsoleLogWriterService, LogService } from '#/app/log/logService';
import { ConsoleAppender } from '#/app/telemetry/consoleAppender';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { TelemetryService } from '#/app/telemetry/telemetryService';
describe('observability slice (log + telemetry)', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.define(ILogWriterService, ConsoleLogWriterService);
reg.define(ILogService, LogService);
reg.define(ITelemetryService, TelemetryService);
},
});
});
afterEach(() => {
disposables.dispose();
});
test('emits structured logs and telemetry events with bound context', async () => {
const log = ix.get(ILogService);
log.setLevel('debug');
log.debug('log: debug entry', { feature: 'observability' });
log.info('log: info entry');
log.child({ requestId: 'req-1' }).info('log: child entry with bound requestId');
const telemetry = ix.get(ITelemetryService);
telemetry.setAppender(new ConsoleAppender());
telemetry.setContext({ app: 'example' });
telemetry.track('session_started', { sessionId: 's1' });
telemetry.withContext({ agentId: 'a1' }).track('turn_completed', { turns: 3 });
await telemetry.shutdown();
});
});

View file

@ -1,296 +0,0 @@
/**
* Scenario: the **permission** slice `IAgentPermissionGate` composing
* policy, mode, and rules into an allow/deny decision.
*
* Concept taught: the permission gate is a **chain-of-responsibility**. When a
* tool is about to run, `IAgentPermissionGate.authorize(context)` delegates the
* decision to `IAgentPermissionPolicyService`, which walks an ordered list of
* policies and returns the first verdict. The verdict is driven by two other
* Agent-scope services the gate also depends on:
*
* - `IAgentPermissionModeService` the top-level posture (`manual` / `yolo`
* / `auto`). `yolo` approves almost everything; `manual` lets the rule set
* decide.
* - `IAgentPermissionRulesService` the user/session rules. A `deny` rule
* always fires, regardless of mode; an `allow` rule approves a match.
*
* The full built-in policy chain (`AgentPermissionPolicyService`) constructs
* ~18 policies that reach into git, plan, swarm, workspace, and other domains
* far more wiring than a teaching example needs. So this example registers the
* **real** gate, mode, and rules services, but seeds a tiny in-file
* `IAgentPermissionPolicyService` that mimics the chain by reading the *real*
* mode + rules services. That keeps the decision honest: changing a real rule
* on the real rules service flips the real gate's verdict.
*
* Prerequisites: example 01 (container & scope tree), example 03 (host seeds).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/permission.example.ts
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
import {
AgentPermissionGate,
IAgentPermissionGate,
} from '#/agent/permissionGate';
import {
AgentPermissionModeService,
IAgentPermissionModeService,
} from '#/agent/permissionMode';
import {
IAgentPermissionPolicyService,
type PermissionPolicyEvaluation,
type PermissionRuleDecision,
} from '#/agent/permissionPolicy';
import {
AgentPermissionRulesService,
IAgentPermissionRulesService,
matchPermissionRule,
} from '#/agent/permissionRules';
import { IAgentRecordService } from '#/agent/record';
import {
type ResolvedToolExecutionHookContext,
} from '#/agent/tool';
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry';
import { ISessionContext } from '#/session/sessionContext';
// --- Leaf fakes for collaborators outside the slice -----------------------
// The gate/mode/rules constructors only *touch* these surfaces; everything
// else is cast away. `record.define` / `contextInjector.register` must return
// a disposable because the real services `_register(...)` them.
const fakeRecord = {
define: () => ({ dispose: () => {} }),
append: () => {},
} as unknown as IAgentRecordService;
const fakeContextInjector = {
register: () => ({ dispose: () => {} }),
} as unknown as IAgentContextInjectorService;
const fakeToolExecutor = {
hooks: {
onWillExecuteTool: { register: () => ({ dispose: () => {} }) },
onDidExecuteTool: { register: () => ({ dispose: () => {} }) },
},
} as unknown as IAgentToolExecutorService;
const fakeExternalHooks = {} as unknown as IAgentExternalHooksService;
const fakeSession = {
sessionId: 's1',
workspaceId: 'ws1',
sessionDir: '/tmp/s1',
metaScope: 'test',
} as unknown as ISessionContext;
// --- A tiny in-file policy chain ------------------------------------------
// Mirrors the precedence of the real built-ins that read mode + rules:
// 1. a matching user `deny` rule always fires (regardless of mode);
// 2. `yolo` mode approves everything not denied;
// 3. a matching user `allow` rule approves;
// 4. otherwise no decision (the gate treats `undefined` as "allow").
const USER_RULE_SCOPES = new Set(['turn-override', 'project', 'user']);
function matchingUserRule(
context: ResolvedToolExecutionHookContext,
decision: PermissionRuleDecision,
rules: IAgentPermissionRulesService,
) {
for (const rule of rules.rules) {
if (!USER_RULE_SCOPES.has(rule.scope)) continue;
if (rule.decision !== decision) continue;
if (
matchPermissionRule({
rule,
toolName: context.toolCall.name,
execution: context.execution,
}) !== undefined
) {
return rule;
}
}
return undefined;
}
class RuleBasedPermissionPolicy implements IAgentPermissionPolicyService {
declare readonly _serviceBrand: undefined;
constructor(
@IAgentPermissionModeService
private readonly modeService: IAgentPermissionModeService,
@IAgentPermissionRulesService
private readonly rulesService: IAgentPermissionRulesService,
) {}
async evaluate(
context: ResolvedToolExecutionHookContext,
): Promise<PermissionPolicyEvaluation | undefined> {
if (matchingUserRule(context, 'deny', this.rulesService) !== undefined) {
return {
policyName: 'example-user-deny',
result: {
kind: 'deny',
message: `Tool "${context.toolCall.name}" was denied by permission rule.`,
},
};
}
if (this.modeService.mode === 'yolo') {
return { policyName: 'example-yolo', result: { kind: 'approve' } };
}
if (matchingUserRule(context, 'allow', this.rulesService) !== undefined) {
return { policyName: 'example-user-allow', result: { kind: 'approve' } };
}
return undefined;
}
registerPolicy() {
return { dispose: () => {} };
}
}
// --- Helper: build the smallest valid tool-execution context --------------
function toolContext(toolName: string): ResolvedToolExecutionHookContext {
const toolCall = {
type: 'function' as const,
id: `tc-${toolName}`,
name: toolName,
arguments: null,
};
return {
turnId: '1',
signal: new AbortController().signal,
toolCall,
toolCalls: [toolCall],
args: {},
execution: {
approvalRule: toolName,
execute: async () => ({ output: '' }),
},
};
}
describe('permission slice (gate composing policy + mode + rules)', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
// The three real services of the slice. The heavy built-in policy chain is
// replaced by the tiny `RuleBasedPermissionPolicy` above, which still reads
// the real mode + rules services.
registerScopedService(
LifecycleScope.Agent,
IAgentPermissionModeService,
AgentPermissionModeService,
);
registerScopedService(
LifecycleScope.Agent,
IAgentPermissionRulesService,
AgentPermissionRulesService,
);
registerScopedService(
LifecycleScope.Agent,
IAgentPermissionPolicyService,
RuleBasedPermissionPolicy,
);
// `IAgentPermissionGate` is NOT registered here: its constructor takes a
// leading `options` value before its @IX dependencies, so it is seeded as a
// SyncDescriptor (carrying `[{}]`) on the Agent scope in `buildAgent()`.
});
function buildAgent() {
const host = createScopedTestHost([
stubPair(ITelemetryService, noopTelemetryService),
]);
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(ISessionContext, fakeSession),
]);
const agent = host.childOf(session, LifecycleScope.Agent, 'main', [
// The real gate expects a leading `PermissionGateOptions` argument
// (before its @IX dependencies), so it is provided as a SyncDescriptor
// with `[{}]` — the same shape the production composition root uses.
[IAgentPermissionGate, new SyncDescriptor(AgentPermissionGate, [{}])],
stubPair(IAgentRecordService, fakeRecord),
stubPair(IAgentContextInjectorService, fakeContextInjector),
stubPair(IAgentToolExecutorService, fakeToolExecutor),
stubPair(IAgentExternalHooksService, fakeExternalHooks),
]);
return { host, agent };
}
it('denies a tool when a matching deny rule is registered', async () => {
const { host, agent } = buildAgent();
agent.accessor
.get(IAgentPermissionRulesService)
.addRules([{ decision: 'deny', scope: 'user', pattern: 'Bash' }]);
const result = await agent.accessor
.get(IAgentPermissionGate)
.authorize(toolContext('Bash'));
expect(result?.block).toBe(true);
expect(result?.reason).toContain('Bash');
host.dispose();
});
it('flips the decision on the same agent when a rule changes', async () => {
const { host, agent } = buildAgent();
const gate = agent.accessor.get(IAgentPermissionGate);
const rules = agent.accessor.get(IAgentPermissionRulesService);
// No rules + manual mode => the chain returns no decision, so the gate
// allows the call (undefined verdict).
expect(await gate.authorize(toolContext('Bash'))).toBeUndefined();
// Adding a deny rule flips the same tool from allowed to blocked.
rules.addRules([{ decision: 'deny', scope: 'user', pattern: 'Bash' }]);
const denied = await gate.authorize(toolContext('Bash'));
expect(denied?.block).toBe(true);
host.dispose();
});
it('surfaces the composed mode + rules through gate.data()', () => {
const { host, agent } = buildAgent();
agent.accessor.get(IAgentPermissionModeService).setMode('yolo');
agent.accessor
.get(IAgentPermissionRulesService)
.addRules([{ decision: 'allow', scope: 'user', pattern: 'Read' }]);
expect(agent.accessor.get(IAgentPermissionGate).data()).toEqual({
mode: 'yolo',
rules: [{ decision: 'allow', scope: 'user', pattern: 'Read' }],
});
host.dispose();
});
it('lets yolo mode approve, but a deny rule still blocks', async () => {
const { host, agent } = buildAgent();
const gate = agent.accessor.get(IAgentPermissionGate);
agent.accessor.get(IAgentPermissionModeService).setMode('yolo');
// yolo approves a tool with no matching rule.
expect(await gate.authorize(toolContext('Read'))).toBeUndefined();
// A deny rule fires regardless of mode.
agent.accessor
.get(IAgentPermissionRulesService)
.addRules([{ decision: 'deny', scope: 'user', pattern: 'Read' }]);
expect((await gate.authorize(toolContext('Read')))?.block).toBe(true);
host.dispose();
});
});

View file

@ -1,155 +0,0 @@
/**
* Scenario: the **persistence** module how the `persistence` dimension's
* Services compose into a complete call chain (Store Storage backend),
* shown through the real files that make up `~/.kimi-code`.
*
* The persistence dimension is organised as:
*
* persistence/
* interface/ contracts only: IStorageService + role tokens,
* IAppendLogStore, IAtomicDocumentStore, IQueryStore,
* IFileStore, IAgentBlobStoreService
* backends/
* node-fs/ FileStorageService, AppendLogStore, AtomicDocumentStore,
* FileStoreService, AgentBlobStoreService
* memory/ InMemoryStorageService (test backend)
*
* Business code imports from `persistence/interface` and never sees a backend.
* The composition root (`bootstrap`) wires each role token to a backend:
*
* IStorageService FileStorageService(homeDir) [config store]
* IAppendLogStorage FileStorageService(homeDir) [wire logs]
* IAtomicDocumentStorage FileStorageService(homeDir) [JSON docs]
* IBlobStorage FileStorageService(homeDir) [blobs]
*
* A server-only profile could route any of these to Postgres / Redis / S3
* without touching business code.
*
* Instead of writing to a made-up scope, each access pattern is demonstrated
* against the actual on-disk path a real Domain Service persists to, so the
* resulting files mirror a real `~/.kimi-code` tree:
*
* - `config.toml` an **atomic document** (TOML codec), written through
* `IAtomicTomlDocumentStore` (the same Store `config` uses).
* - `sessions/<workspace>/<session>/session-meta/state.json` an **atomic
* document** (JSON codec), written through `IAtomicDocumentStore` (the same
* Store `sessionMetadata` uses).
* - `wire/<hash>.jsonl` an **append log** (JSONL framing), written through
* `IAppendLogStore` (the same Store `wireRecord` uses). `wireRecord` keys the
* log by a hash of the home dir; this example writes one record stream under
* the same `wire/` scope.
*
* All Services come from `src/`; nothing here defines a new Service.
*/
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, test } from 'vitest';
import type { Scope } from '#/_base/di/scope';
import { bootstrap } from '#/app/bootstrap/bootstrap';
// ── persistence/interface ─────────────────────────────────────────────
// Contracts only — the four role tokens (IStorageService, IAppendLogStorage,
// IAtomicDocumentStorage, IBlobStorage) share the same `IStorageService`
// interface but are registered as distinct DI tokens so the composition root
// can route each one to a different backend.
import {
IAppendLogStorage,
IAtomicDocumentStorage,
IStorageService,
} from '#/persistence/interface/storage';
// Store-layer facades — typed access patterns on top of the byte-level storage.
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import {
IAtomicDocumentStore,
IAtomicTomlDocumentStore,
} from '#/persistence/interface/atomicDocumentStore';
// ── side-effect import ────────────────────────────────────────────────
// Loading the backends barrel triggers `registerScopedService` calls that wire
// the Store implementations (AppendLogStore, AtomicDocumentStore, etc.) into
// the DI scope registry. Without this import the Stores would not resolve.
import '#/persistence/backends/node-fs';
const textDecoder = new TextDecoder();
function decode(bytes: Uint8Array | undefined): string {
return bytes === undefined ? '(undefined)' : textDecoder.decode(bytes);
}
const WIRE_KEY = 'example';
describe('persistence module (Store → Storage → backend, real ~/.kimi-code files)', () => {
let homeDir: string;
let app: Scope;
beforeEach(() => {
const resolved = process.env['KIMI_CODE_HOME'];
if (resolved === undefined) {
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
}
homeDir = resolved;
mkdirSync(homeDir, { recursive: true });
app = bootstrap({ homeDir }).app;
});
afterEach(() => {
app.dispose();
});
test('typed Store → raw Storage bytes → real file path', async () => {
// 1) Atomic document, TOML codec → config.toml
const tomlDocs = app.accessor.get(IAtomicTomlDocumentStore);
const configBytes = app.accessor.get(IStorageService);
const configValue = { theme: 'dark', telemetry: { enabled: true } };
await tomlDocs.set('', 'config.toml', configValue);
console.log('1) config.toml (atomic doc, TOML):');
console.log(' typed get :', await tomlDocs.get('', 'config.toml'));
console.log(' raw bytes :');
for (const line of decode(await configBytes.read('', 'config.toml')).trim().split('\n')) {
console.log(' ', line);
}
console.log(' path :', join(homeDir, 'config.toml'));
// 2) Atomic document, JSON codec → sessions/.../session-meta/state.json
const docs = app.accessor.get(IAtomicDocumentStore);
const docBytes = app.accessor.get(IAtomicDocumentStorage);
const metaScope = 'sessions/example/s-example/session-meta';
const meta = {
id: 's-example',
title: 'example session',
createdAt: 1_000,
updatedAt: 2_000,
archived: false,
};
await docs.set(metaScope, 'state.json', meta);
console.log('2) state.json (atomic doc, JSON):');
console.log(' typed get :', await docs.get(metaScope, 'state.json'));
console.log(' raw bytes :', decode(await docBytes.read(metaScope, 'state.json')).trim());
console.log(' path :', join(homeDir, metaScope, 'state.json'));
// 3) Append log, JSONL framing → wire/<hash>.jsonl
const logs = app.accessor.get(IAppendLogStore);
const logBytes = app.accessor.get(IAppendLogStorage);
const key = WIRE_KEY;
logs.append('wire', key, { type: 'metadata', protocol_version: '1.5' });
logs.append('wire', key, { type: 'swarm_mode.enter', trigger: 'manual' });
logs.append('wire', key, { type: 'swarm_mode.exit' });
await logs.flush();
const readBack: unknown[] = [];
for await (const record of logs.read('wire', key)) {
readBack.push(record);
}
console.log('3) wire/<hash>.jsonl (append log, JSONL):');
console.log(' typed read:', readBack);
console.log(' raw bytes :');
for (const line of decode(await logBytes.read('wire', key)).trim().split('\n')) {
console.log(' ', line);
}
console.log(' path :', join(homeDir, 'wire', `${key}.jsonl`));
});
});

View file

@ -1,53 +0,0 @@
/**
* Scenario: the **DI Scope** foundation how resolution follows the tree.
*
* Not a business slice but the model every other slice rests on. Two rules,
* shown with real services resolved through the composition root (`_harness`):
*
* - an **App-scoped** service (`ILogService`) resolves to the same instance
* whether you ask the App scope or a child Session scope resolution walks
* up the tree and finds the one App instance;
* - a **Session-scoped** service (`ISessionMetadata`) is one distinct instance
* per session, so two sessions hold independent state.
*
* Wiring: the real composition root (`_harness`) provides every service; we open
* two Session scopes to show the per-session isolation.
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/scope.example.ts
*/
import { afterEach, describe, expect, test } from 'vitest';
import { ILogService } from '#/app/log/log';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { createSliceHost, type SliceHost } from './_harness';
describe('di scope foundation (App singletons vs. per-Session instances)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
test('App services are shared; Session services are per-session', async () => {
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
const sessionA = host.session;
const sessionB = host.newSession('scope-b');
// App-scoped service: the same instance is visible from App and Session.
const logFromApp = host.app.accessor.get(ILogService);
const logFromSession = sessionA.accessor.get(ILogService);
expect(logFromApp).toBe(logFromSession);
// Session-scoped service: each session gets its own instance + state.
const metaA = sessionA.accessor.get(ISessionMetadata);
const metaB = sessionB.accessor.get(ISessionMetadata);
expect(metaA).not.toBe(metaB);
await Promise.all([metaA.ready, metaB.ready]);
await metaA.setTitle('session A');
await metaB.setTitle('session B');
const [a, b] = await Promise.all([metaA.read(), metaB.read()]);
expect(a.title).toBe('session A');
expect(b.title).toBe('session B');
});
});

View file

@ -1,98 +0,0 @@
/**
* Scenario: the **session skill catalog** loading the skills available in
* the current directory and inspecting where each one came from.
*
* Concept taught: the skill domain is split across scopes by state identity.
* `IGlobalSkillCatalog` (App) holds the process-wide set code-defined
* builtins plus user / brand skills discovered from the home directories and
* is loaded once; `ISessionSkillCatalog` (Session) merges that global set with
* the project skills discovered from the session's current `workDir`
* (`ISessionWorkspaceContext` `IExecContext.cwd`), reloading when the
* workDir changes. Every `SkillDefinition` carries a `source` tag
* (`builtin` | `user` | `extra` | `project`), so the catalog can report
* *provenance* which layer and which directory a skill came from not just
* its name.
*
* Wiring: the real composition root (`_harness`) provides every collaborator
* (the filesystem `ISkillCatalogStore`, the workspace context, ) so the
* catalog reads real `SKILL.md` files from disk. We seed an empty
* `IPluginService` so the slice stays focused on the builtin / user / project
* layers and contributes no plugin skills.
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/session-skill.example.ts
*/
import { afterEach, describe, expect, test } from 'vitest';
import { type ServiceIdentifier } from '#/_base/di/instantiation';
import { IPluginService } from '#/app/plugin/plugin';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog';
import { createSliceHost, type SliceHost } from './_harness';
/** Plugin contribution plane turned off: no plugin skill roots, no reloads. */
const noopPlugins: IPluginService = {
_serviceBrand: undefined,
pluginSkillRoots: async () => [],
onDidReload: () => ({ dispose: () => {} }),
} as unknown as IPluginService;
describe('session skill catalog (load from current dir + inspect provenance)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
function setUp() {
if (process.env['KIMI_CODE_HOME'] === undefined) {
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
}
host = createSliceHost({
homeDir: process.env['KIMI_CODE_HOME'],
cwd: process.cwd(),
sessionSeeds: [[IPluginService as ServiceIdentifier<unknown>, noopPlugins]],
});
return host.session.accessor.get(ISessionSkillCatalog);
}
test('lists every merged skill with its source and path', async () => {
const catalog = setUp();
await catalog.load();
await catalog.ready;
const skills = catalog.catalog.listSkills();
console.log('total skills =', skills.length);
const counts = new Map<string, number>();
for (const skill of skills) {
counts.set(skill.source, (counts.get(skill.source) ?? 0) + 1);
console.log(` [${skill.source}] ${skill.name}`);
}
console.log('by source =', Object.fromEntries(counts));
expect(skills.length).toBeGreaterThan(0);
for (const skill of skills) {
expect(['builtin', 'user', 'extra', 'project']).toContain(skill.source);
}
});
test('inspects a single skill by name and reports its provenance', async () => {
const catalog = setUp();
await catalog.load();
const first = catalog.catalog.listSkills()[0];
expect(first).toBeDefined();
if (first === undefined) return;
const inspected = catalog.catalog.getSkill(first.name);
expect(inspected).toBeDefined();
if (inspected === undefined) return;
console.log('inspect:', {
name: inspected.name,
source: inspected.source,
dir: inspected.dir,
});
expect(inspected.name).toBe(first.name);
expect(inspected.source).toBe(first.source);
});
});

View file

@ -1,45 +0,0 @@
/**
* Scenario: the **session** slice `sessionLifecycle` + `sessionMetadata`.
*
* Shows the session as a durable, tracked entity and how the slice's domains
* compose: `ISessionLifecycleService` (App) creates Session child scopes
* seeding each with its identity and storage and materializing its metadata
* and tracks the live set, while each session's `ISessionMetadata` (Session)
* reads and updates the persisted document through the App `storage` service.
*
* Wiring: the real composition root (`_harness`) provides every collaborator
* (storage, skill catalog, log, ) so the slice runs for real against a temp
* `KIMI_CODE_HOME`. Sessions are created through the lifecycle service itself.
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/session.example.ts
*/
import { afterEach, describe, expect, test } from 'vitest';
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { createSliceHost, type SliceHost } from './_harness';
describe('session slice (sessionLifecycle + sessionMetadata)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
test('creates, tracks, persists, and closes sessions', async () => {
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
const lifecycle = host.app.accessor.get(ISessionLifecycleService);
const first = await lifecycle.create({ sessionId: 'demo-a', workDir: process.env['KIMI_CODE_HOME']! });
await lifecycle.create({ sessionId: 'demo-b', workDir: process.env['KIMI_CODE_HOME']! });
expect(lifecycle.list().map((h) => h.id)).toEqual(expect.arrayContaining(['demo-a', 'demo-b']));
const meta = first.accessor.get(ISessionMetadata);
await meta.ready;
await meta.setTitle('first session');
expect((await meta.read()).title).toBe('first session');
await lifecycle.close('demo-b');
expect(lifecycle.list().map((h) => h.id)).not.toContain('demo-b');
});
});

View file

@ -1,124 +0,0 @@
/**
* Scenario: the **sessionIndex** module a business-specific Store composed
* from lower-level persistence Stores.
*
* Shows how a real Domain Service builds a query read-model by aggregating two
* more fundamental Stores. `FileSessionIndex` (`ISessionIndex`) enumerates the
* persisted session set with `IStorageService.list` (workspace and session
* directories) and reads each session's `state.json` with
* `IAtomicDocumentStore.get`, projecting the raw documents into
* `Page<SessionSummary>`. It is the "business-specific Store" case from the
* persistence layering rules: named after the domain because its semantics
* (enumerate / filter / page sessions) are unique, not a generic access
* pattern.
*
* The `state.json` documents are the same ones `sessionMetadata` writes during
* `sessionLifecycle.create`; here they are seeded directly through the real
* `IAtomicDocumentStore` so the scenario stays focused on the index read-model
* rather than the session write path. All Services come from `src/`; nothing
* here defines a new Service.
*/
import { mkdirSync } from 'node:fs';
import { afterEach, beforeEach, describe, test } from 'vitest';
import { relative } from 'pathe';
import type { Scope } from '#/_base/di/scope';
import { bootstrap } from '#/app/bootstrap/bootstrap';
import { IBootstrapService } from '#/app/bootstrap';
import '#/app/bootstrap';
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex';
import '#/app/sessionIndex';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import '#/persistence/backends/node-fs';
const META_SCOPE = 'session-meta';
const META_KEY = 'state.json';
interface SeedMeta {
readonly title: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
}
describe('sessionIndex module (business Store over storage Stores)', () => {
let homeDir: string;
let app: Scope;
let sessionsScope: string;
let docs: IAtomicDocumentStore;
let index: ISessionIndex;
beforeEach(async () => {
const resolved = process.env['KIMI_CODE_HOME'];
if (resolved === undefined) {
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
}
homeDir = resolved;
mkdirSync(homeDir, { recursive: true });
app = bootstrap({ homeDir }).app;
const layout = app.accessor.get(IBootstrapService);
sessionsScope = relative(layout.homeDir, layout.sessionsDir);
docs = app.accessor.get(IAtomicDocumentStore);
index = app.accessor.get(ISessionIndex);
await seed('ws-a', 's1', {
title: 'first session',
createdAt: 1000,
updatedAt: 1000,
archived: false,
});
await seed('ws-a', 's2', {
title: 'most recently active',
createdAt: 2000,
updatedAt: 3000,
archived: false,
});
await seed('ws-a', 's3', {
title: 'archived session',
createdAt: 1500,
updatedAt: 2500,
archived: true,
});
await seed('ws-b', 's4', {
title: 'other workspace',
createdAt: 500,
updatedAt: 500,
archived: false,
});
});
afterEach(() => {
app.dispose();
});
async function seed(workspaceId: string, sessionId: string, meta: SeedMeta): Promise<void> {
await docs.set(`${sessionsScope}/${workspaceId}/${sessionId}/${META_SCOPE}`, META_KEY, {
id: sessionId,
...meta,
});
}
test('enumerates, filters, pages, and counts persisted sessions', async () => {
const print = (label: string, items: readonly SessionSummary[]): void => {
console.log(label, items.map((s) => `${s.id}(${s.workspaceId}${s.archived ? ',archived' : ''})`));
};
print('1) list({}) — non-archived, newest updatedAt first:', (await index.list({})).items);
print('2) list({ workspaceId: "ws-a" }):', (await index.list({ workspaceId: 'ws-a' })).items);
print(
'3) list({ includeArchived: true }):',
(await index.list({ includeArchived: true })).items,
);
print('4) list({ limit: 2 }) — top two by updatedAt:', (await index.list({ limit: 2 })).items);
console.log('5) get("s4"):', await index.get('s4'));
console.log('6) countActive("ws-a"):', await index.countActive('ws-a'));
});
});

View file

@ -1,180 +0,0 @@
/**
* Scenario: the **shell / web / ask tools** slice built-in tool
* implementations discovered through the module-level contribution registry
* and executed through the kaos execution boundary.
*
* Concept taught: the tools an agent can run are not hard-coded into the agent.
* Each built-in tool is a DI class that self-registers via `registerTool(...)`
* at module load. When an Agent scope is created, `IAgentToolRegistryService`'s
* constructor consumes every module-level contribution instantiating each
* tool with `IInstantiationService.createInstance` and dropping the resulting
* `ExecutableTool` into the per-agent runtime table:
* - `BashTool` `Bash`
* - `FetchURLTool` `FetchURL` (and `WebSearchTool` `WebSearch`
* when the host supplies a `WebSearchProvider`
* via the `web` service options)
* - `AskUserQuestionTool` `AskUserQuestion`
* Once registered, a tool is discovered through `list()` / `resolve(name)` and
* run through the same `resolveExecution → execute(ctx)` path every tool uses.
* `Bash` executes through the kaos `ISessionProcessRunner` (a real shell
* process in the session cwd) never `node:child_process` directly.
*
* Wiring: the real composition root (`_harness`) provides every collaborator
* (the seeded `IExecContext`, the real `ISessionProcessRunner`,
* `IAgentBackgroundService`, `IHostEnvironment`, ) so the tools register and
* run for real with no hand-rolled stub list. `WebSearch` is host-injected: it
* only lands in the registry when a `WebSearchProvider` is supplied, so we
* demonstrate that path by registering a `WebSearchTool` backed by a canned
* provider (no network) through the registry's public `register` API.
*
* Prerequisites: the container & scope-tree example and the tool-framework example.
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/shell-web-tools.example.ts
*/
import { afterEach, describe, expect, it } from 'vitest';
import {
IAgentBuiltinToolsRegistrar,
IAgentToolRegistryService,
} from '#/agent/toolRegistry';
import { IAgentWebService } from '#/agent/web';
import {
WebSearchTool,
type WebSearchProvider,
type WebSearchResult,
} from '#/agent/web/tools/web-search';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { createSliceHost, type SliceHost } from './_harness';
/** Read the JSON-schema `properties` bag off a tool's `parameters`. */
function schemaProps(tool: { parameters?: Record<string, unknown> }): Record<string, unknown> {
const params = tool.parameters as { properties?: Record<string, unknown> } | undefined;
return params?.properties ?? {};
}
describe('shell-web-tools slice (built-in tools via Agent-scope registration services)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
async function setUp() {
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
// `BashTool` reads `IHostEnvironment.osKind` in its constructor; the host
// environment probes the OS asynchronously, so await its `ready` gate
// before the tool is constructed (the real composition root awaits this
// before opening a Session scope).
await host.app.accessor.get(IHostEnvironment).ready;
// Force-instantiate the Eager builtin-tools registrar: its constructor
// consumes every module-level `registerTool(...)` contribution and builds
// each tool instance against this Agent scope (the same path
// `AgentLifecycleService.create` runs in production). Bash / AskUser land
// this way; `FetchURL` is registered by `IAgentWebService` (options-based
// service), so resolve that too.
host.agent.accessor.get(IAgentBuiltinToolsRegistrar);
host.agent.accessor.get(IAgentWebService);
return host.agent.accessor.get(IAgentToolRegistryService);
}
it('registers the built-in Bash, FetchURL and AskUserQuestion tools with builtin metadata', async () => {
const registry = await setUp();
const byName = new Map(registry.list().map((t) => [t.name, t]));
expect(byName.has('Bash')).toBe(true);
expect(byName.has('FetchURL')).toBe(true);
expect(byName.has('AskUserQuestion')).toBe(true);
for (const name of ['Bash', 'FetchURL', 'AskUserQuestion']) {
const info = byName.get(name)!;
expect(info.source).toBe('builtin');
expect(info.description.length).toBeGreaterThan(0);
expect(info.parameters).toBeDefined();
}
// `WebSearch` is host-injected: the real web service registers it only when
// a `WebSearchProvider` is supplied, which the default composition root does
// not. The next test demonstrates that path explicitly.
expect(byName.has('WebSearch')).toBe(false);
});
it('resolves each built-in tool by name and exposes its input schema', async () => {
const registry = await setUp();
const bash = registry.resolve('Bash');
const fetch = registry.resolve('FetchURL');
const ask = registry.resolve('AskUserQuestion');
expect(bash).toBeDefined();
expect(fetch).toBeDefined();
expect(ask).toBeDefined();
expect(registry.resolve('DoesNotExist')).toBeUndefined();
expect(schemaProps(bash!)).toHaveProperty('command');
expect(schemaProps(fetch!)).toHaveProperty('url');
expect(schemaProps(ask!)).toHaveProperty('questions');
});
it('invokes the Bash tool through the real process runner on a harmless command', async () => {
const registry = await setUp();
const bash = registry.resolve('Bash');
expect(bash).toBeDefined();
if (bash === undefined) return;
const execution = await bash.resolveExecution({ command: 'echo hello' });
expect('execute' in execution).toBe(true);
if (!('execute' in execution)) return;
const result = await execution.execute({
turnId: 't1',
toolCallId: 'call-bash',
signal: new AbortController().signal,
});
expect(result.isError).not.toBe(true);
expect(String(result.output)).toContain('hello');
});
it('registers a host-injected WebSearch tool and invokes it without network', async () => {
const registry = await setUp();
// Not present until a provider-backed tool is registered.
expect(registry.resolve('WebSearch')).toBeUndefined();
const canned: WebSearchResult[] = [
{ title: 'Example Result', url: 'https://example.com/', snippet: 'a canned snippet' },
];
const provider: WebSearchProvider = {
async search(_query, options) {
return options?.includeContent ? canned.map((r) => ({ ...r, content: 'body' })) : canned;
},
};
// Mirror what the real web service does when a provider is supplied.
const registration = registry.register(new WebSearchTool(provider));
const search = registry.resolve('WebSearch');
expect(search).toBeDefined();
if (search === undefined) return;
const execution = await search.resolveExecution({ query: 'kimi code', limit: 5 });
expect('execute' in execution).toBe(true);
if (!('execute' in execution)) return;
const result = await execution.execute({
turnId: 't1',
toolCallId: 'call-search',
signal: new AbortController().signal,
});
expect(result.isError).not.toBe(true);
expect(String(result.output)).toContain('Example Result');
expect(String(result.output)).toContain('https://example.com/');
// Disposing the registration handle unregisters the tool again.
registration.dispose();
expect(registry.resolve('WebSearch')).toBeUndefined();
});
});

View file

@ -1,157 +0,0 @@
/**
* Scenario: the **tool framework** slice `IAgentToolRegistryService` as a
* runtime registry.
*
* Concept taught: the tools an agent can run are not hard-coded into the agent.
* They are *registered at runtime* into an Agent-scope `IAgentToolRegistryService`
* and then discovered through `list()` / `resolve(name)`. The registry is the
* single source of truth for "which tools exist in this agent", and it is
* **one-per-Agent-scope**: two sibling agents get independent registries, while
* repeated lookups inside one agent return the same instance. Registrations are
* reversible `register()` hands back an `IDisposable` that unregisters the
* tool and observable, through the `onRegistered` / `onUnregistered` hooks.
*
* `AgentToolRegistryService` is unusually self-contained for a service: its
* constructor carries **no** `@IX` dependencies, so the slice needs no
* `stubPair(...)` collaborators at all. We register a tiny in-file fake tool to
* prove registration + lookup without pulling in real tool classes.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/tool-framework.example.ts
*/
import { beforeEach, describe, expect, it } from 'vitest';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost } from '#/_base/di/test';
import {
AgentToolRegistryService,
IAgentToolRegistryService,
} from '#/agent/toolRegistry';
import type { ExecutableTool } from '#/agent/tool';
/** Minimal `ExecutableTool` — just enough metadata for the registry to store. */
function fakeTool(name: string): ExecutableTool {
return {
name,
description: `fake ${name} tool`,
parameters: { type: 'object', properties: {} },
resolveExecution: () => ({
approvalRule: 'allow',
execute: async () => ({ output: `ok:${name}` }),
}),
};
}
describe('tool-framework slice (IAgentToolRegistryService runtime registry)', () => {
beforeEach(() => {
_clearScopedRegistryForTests();
// The only real service in this slice. It has no constructor dependencies,
// so no collaborators need to be seeded on the App / Session scopes.
registerScopedService(
LifecycleScope.Agent,
IAgentToolRegistryService,
AgentToolRegistryService,
);
});
it('lists a registered tool and resolves it by name', () => {
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
const registry = agent.accessor.get(IAgentToolRegistryService);
const echo = fakeTool('Echo');
const fetch = fakeTool('Fetch');
registry.register(echo); // source defaults to 'builtin'
registry.register(fetch, { source: 'mcp' });
// list() is sorted by name and carries the registration source.
expect(registry.list()).toEqual([
{
name: 'Echo',
description: 'fake Echo tool',
parameters: { type: 'object', properties: {} },
source: 'builtin',
},
{
name: 'Fetch',
description: 'fake Fetch tool',
parameters: { type: 'object', properties: {} },
source: 'mcp',
},
]);
// resolve() returns the exact registered instance; unknown names miss.
expect(registry.resolve('Echo')).toBe(echo);
expect(registry.resolve('Missing')).toBeUndefined();
host.dispose();
});
it('is one-per-Agent-scope: isolated between siblings, singleton within one agent', () => {
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agentA = host.childOf(session, LifecycleScope.Agent, 'a');
const agentB = host.childOf(session, LifecycleScope.Agent, 'b');
const registryA = agentA.accessor.get(IAgentToolRegistryService);
registryA.register(fakeTool('Echo'));
// Same instance on repeated access inside one agent (singleton per scope).
expect(agentA.accessor.get(IAgentToolRegistryService)).toBe(registryA);
// A sibling agent gets its own independent registry.
expect(registryA.list().map((t) => t.name)).toEqual(['Echo']);
expect(agentB.accessor.get(IAgentToolRegistryService).list()).toEqual([]);
host.dispose();
});
it('unregisters a tool when the registration disposable is disposed', () => {
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
const registry = agent.accessor.get(IAgentToolRegistryService);
const registration = registry.register(fakeTool('Echo'));
expect(registry.resolve('Echo')).toBeDefined();
// Disposing the handle returned by register() removes the tool.
registration.dispose();
expect(registry.resolve('Echo')).toBeUndefined();
expect(registry.list()).toEqual([]);
host.dispose();
});
it('fires the onRegistered hook when a tool is registered', async () => {
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1');
const agent = host.childOf(session, LifecycleScope.Agent, 'main');
const registry = agent.accessor.get(IAgentToolRegistryService);
const seen: string[] = [];
registry.hooks.onRegistered.register('capture', (ctx) => {
seen.push(ctx.tool.name);
});
registry.register(fakeTool('Echo'));
// register() runs the hook fire-and-forget; flush a microtask to observe it.
await Promise.resolve();
expect(seen).toEqual(['Echo']);
host.dispose();
});
});

View file

@ -1,246 +0,0 @@
/**
* Scenario: the **turn-loop** slice one execution round of an agent, owned
* by `IAgentTurnService` and driven by `IAgentLoopService`.
*
* Concept taught: a *turn* is the unit of agent execution. `IAgentTurnService`
* owns one round at a time it mints the turn handle (`id`, `abortController`,
* `ready`, `result`), records the launch, and exposes the lifecycle hooks that
* collaborators hang behavior on:
*
* - `onLaunched` fires when a turn is launched (with the fresh handle).
* - `onEnded` fires when the round finishes (with the terminal `TurnResult`).
* - `turn.ready` resolves once the loop reaches its first `beforeStep`.
* - `turn.result` resolves with the reason the round ended.
*
* `IAgentLoopService` is the engine *inside* the turn: its `runTurn(turn)`
* drives the step loop (`beforeStep` LLM `afterStep` ) and returns the
* `TurnResult` the turn service then publishes through `onEnded`. The turn
* service registers a hook on the loop's `beforeStep` to resolve `turn.ready`,
* so the two services meet at the loop hooks rather than knowing each other's
* internals.
*
* Wiring: the real composition root (`_harness`) provides every collaborator,
* including the real `IAgentTurnService` and `IAgentRecordService`. The only
* collaborator we substitute is `IAgentLoopService`, seeded via `agentSeeds`
* with a tiny in-memory loop so we can launch a real turn and observe the full
* lifecycle without booting an LLM. We spy on the real record service's
* `append` / `signal` to observe the `turn.launch` / `turn.started` /
* `turn.ended` traffic.
*
* Prerequisites: example 01 (container & scope tree); the `async-tasks` example
* for the `onEnded` hook shape.
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/turn-loop.example.ts
*/
import { randomUUID } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { type ServiceIdentifier } from '#/_base/di/instantiation';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentRecordService } from '#/agent/record';
import {
IAgentTurnService,
type Turn,
type TurnContextOverflowContext,
type TurnEndedContext,
type TurnResult,
type TurnStepContext,
type TurnStepUsageContext,
} from '#/agent/turn';
import { OrderedHookSlot } from '#/hooks';
import { createSliceHost, type SliceHost } from './_harness';
/**
* Build an in-memory `IAgentLoopService` whose step-loop hooks are real
* `OrderedHookSlot`s. The real `AgentLoopService` registers a
* `turn-before-step-event` anchor in `beforeStep`, and `AgentTurnService`
* orders its ready-resolving hook `{ before: 'turn-before-step-event' }` so
* we register the same anchor here, otherwise the turn service's constructor
* throws on the missing ordering target.
*/
function makeLoop(runTurn: (turn: Turn) => Promise<TurnResult>): IAgentLoopService {
const beforeStep = new OrderedHookSlot<TurnStepContext>();
beforeStep.register('turn-before-step-event', async (_ctx, next) => {
await next();
});
return {
_serviceBrand: undefined,
hooks: {
beforeStep,
onStepUsage: new OrderedHookSlot<TurnStepUsageContext>(),
afterStep: new OrderedHookSlot<TurnStepContext>(),
onContextOverflow: new OrderedHookSlot<TurnContextOverflowContext>(),
},
runTurn,
};
}
/**
* A loop whose `runTurn` resolves the in-flight turn only when `finish` is
* called. `started` resolves once the turn service has actually entered
* `runTurn` (which happens after the async user-prompt hook step), so callers
* must await it before calling `finish`.
*/
function controlledLoop(): {
loop: IAgentLoopService;
started: Promise<void>;
finish: (result: TurnResult) => void;
} {
let finish!: (result: TurnResult) => void;
let resolveStarted!: () => void;
const started = new Promise<void>((resolve) => {
resolveStarted = resolve;
});
const loop = makeLoop(
() =>
new Promise<TurnResult>((resolve) => {
finish = resolve;
resolveStarted();
}),
);
return { loop, started, finish: (result) => finish(result) };
}
/** A loop that drives one `beforeStep` → `afterStep` cycle, mimicking the real step loop. */
function stepDrivingLoop(): IAgentLoopService {
let loop!: IAgentLoopService;
loop = makeLoop(async (turn) => {
await loop.hooks.beforeStep.run({ turn, continueTurn: false });
await loop.hooks.afterStep.run({ turn, continueTurn: false });
return { reason: 'completed' };
});
return loop;
}
describe('turn-loop slice (turn lifecycle + step loop)', () => {
let host: SliceHost;
afterEach(() => {
host?.dispose();
vi.restoreAllMocks();
});
function setUp(loop: IAgentLoopService) {
// Isolate the real file-backed services (record log, storage) in a per-test
// home so concurrent example files never contend on the same on-disk paths.
const caseDir = join(process.env['KIMI_CODE_HOME']!, randomUUID());
mkdirSync(caseDir, { recursive: true });
host = createSliceHost({
homeDir: caseDir,
// Substitute only the loop engine; the real turn service and record
// service run end-to-end for real.
agentSeeds: [[IAgentLoopService as ServiceIdentifier<unknown>, loop]],
});
// Resolve the real record service first and instrument it; the turn service
// is constructed lazily and will pick up the same singleton.
const records = host.agent.accessor.get(IAgentRecordService);
const appended: Array<{ type: string; turnId?: number }> = [];
const signaled: Array<{ type: string; reason?: string }> = [];
vi.spyOn(records, 'append').mockImplementation((r) => {
appended.push(r as { type: string; turnId?: number });
});
vi.spyOn(records, 'signal').mockImplementation((e) => {
signaled.push(e as { type: string; reason?: string });
});
const turn = host.agent.accessor.get(IAgentTurnService);
return { turn, appended, signaled };
}
it('launching a turn fires onLaunched, returns the handle, and records turn.launch', async () => {
const loop = makeLoop(async () => ({ reason: 'completed' }));
const { turn, appended } = setUp(loop);
const launched: Turn[] = [];
turn.hooks.onLaunched.register('observe', async (ctx, next) => {
launched.push(ctx.turn);
await next();
});
const handle = turn.launch({ kind: 'user' });
// The handle is exposed synchronously and the turn is now the active round.
expect(handle.id).toBe(0);
expect(handle.abortController).toBeInstanceOf(AbortController);
expect(turn.getActiveTurn()).toBe(handle);
const result = await handle.result;
expect(result).toEqual({ reason: 'completed' });
expect(launched).toEqual([handle]);
expect(appended).toContainEqual(expect.objectContaining({ type: 'turn.launch', turnId: 0 }));
});
it('drives the step loop hooks and resolves turn.ready on the first beforeStep', async () => {
const loop = stepDrivingLoop();
const { turn } = setUp(loop);
const steps: string[] = [];
loop.hooks.beforeStep.register('observe', async (_ctx, next) => {
steps.push('beforeStep');
await next();
});
loop.hooks.afterStep.register('observe', async (_ctx, next) => {
steps.push('afterStep');
await next();
});
const handle = turn.launch({ kind: 'user' });
await handle.result;
// The loop drove one step cycle in order.
expect(steps).toEqual(['beforeStep', 'afterStep']);
// The turn service's beforeStep hook resolves `turn.ready`.
await expect(handle.ready).resolves.toBeUndefined();
});
it('fires onEnded with the result and clears the active turn when the loop completes', async () => {
const loop = makeLoop(async () => ({ reason: 'completed' }));
const { turn, signaled } = setUp(loop);
const ended: TurnEndedContext[] = [];
turn.hooks.onEnded.register('observe', async (ctx, next) => {
ended.push(ctx);
await next();
});
const handle = turn.launch({ kind: 'user' });
await handle.result;
expect(ended).toHaveLength(1);
expect(ended[0]).toMatchObject({ turn: handle, result: { reason: 'completed' } });
// State transitions: the round is over, the slot is free, the reason is remembered.
expect(turn.getActiveTurn()).toBeUndefined();
expect(turn.lastEndedReason()).toBe('completed');
const types = signaled.map((e) => e.type);
expect(types).toContain('turn.started');
expect(types).toContain('turn.ended');
expect(signaled.find((e) => e.type === 'turn.ended')).toMatchObject({ reason: 'completed' });
});
it('rejects a second launch while a turn is active, then frees the slot when it ends', async () => {
const { loop, started, finish } = controlledLoop();
const { turn } = setUp(loop);
const first = turn.launch({ kind: 'user' });
expect(turn.getActiveTurn()).toBe(first);
expect(() => turn.launch({ kind: 'user' })).toThrow(
/Cannot launch a new turn while turn \d+ is active/,
);
// Wait until the loop's runTurn has actually been entered, then let the
// in-flight turn finish so the slot is released.
await started;
finish({ reason: 'completed' });
await first.result;
expect(turn.getActiveTurn()).toBeUndefined();
});
});

View file

@ -1,115 +0,0 @@
/**
* Scenario: the **usage-replay** slice usage metering backed by the record
* log, and rebuilding state by replaying records.
*
* Concept taught: `IAgentUsageService` records token usage per model and per
* turn. Every `record(model, usage, context)` appends a `usage.record` to the
* append-log *and* applies it to the in-memory aggregate; on resume, the
* `usage.record` `resume` facet replays each stored record to rebuild the same
* aggregate without re-appending or re-signaling. The `context.type === 'turn'`
* form additionally tracks a per-`turnId` window that resets when the turn
* changes.
*
* `IAgentRecordService` (which owns the replay read model),
* `IAgentSystemReminderService`, and `IAgentExternalHooksService` are siblings
* in this cross-cutting layer; the composition root wires them for real, but
* this slice focuses on usage because it is the smallest and self-contained.
*
* Wiring: the real composition root (`_harness`) provides every collaborator;
* we spy on the real `IAgentRecordService` to observe the appended records and
* capture the `resume` facet.
*
* Prerequisites: example 01 (container & scope tree).
*
* Run:
* pnpm --filter @moonshot-ai/agent-core-v2 example -- examples/usage-replay.example.ts
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IAgentRecordService } from '#/agent/record';
import { IAgentUsageService } from '#/agent/usage';
import type { TokenUsage } from '#/app/llmProtocol';
import { createSliceHost, type SliceHost } from './_harness';
const u = (inputOther: number, output: number): TokenUsage => ({
inputOther,
output,
inputCacheRead: 0,
inputCacheCreation: 0,
});
describe('usage-replay slice (IAgentUsageService + record fan-out)', () => {
let host: SliceHost;
afterEach(() => host?.dispose());
function setUp() {
host = createSliceHost({ homeDir: process.env['KIMI_CODE_HOME']! });
const records = host.agent.accessor.get(IAgentRecordService);
const appended: Array<{ type: string; model?: string }> = [];
const signals: Array<{ type: string }> = [];
const facets = new Map<string, { resume?: (r: { type: string }) => unknown }>();
vi.spyOn(records, 'append').mockImplementation((r) => {
appended.push(r as { type: string; model?: string });
});
vi.spyOn(records, 'signal').mockImplementation((s) => {
signals.push(s as { type: string });
});
vi.spyOn(records, 'define').mockImplementation((type, facet) => {
facets.set(type as string, facet as { resume?: (r: { type: string }) => unknown });
return { dispose: () => facets.delete(type as string) };
});
const usage = host.agent.accessor.get(IAgentUsageService);
return { usage, appended, signals, facets };
}
it('aggregates recorded usage per model and exposes a running total', () => {
const { usage } = setUp();
usage.record('gpt-a', u(10, 5));
usage.record('gpt-a', u(3, 2));
usage.record('gpt-b', u(100, 50));
const status = usage.status();
expect(status?.byModel?.['gpt-a']).toEqual(u(13, 7));
expect(status?.byModel?.['gpt-b']).toEqual(u(100, 50));
});
it('tracks the current turn and resets the window when the turnId changes', () => {
const { usage } = setUp();
usage.record('m', u(1, 1), { type: 'turn', turnId: 1 });
usage.record('m', u(2, 2), { type: 'turn', turnId: 1 });
expect(usage.status()?.currentTurn).toEqual(u(3, 3));
usage.record('m', u(9, 9), { type: 'turn', turnId: 2 });
expect(usage.status()?.currentTurn).toEqual(u(9, 9));
});
it('record() fans out to one append plus an agent.status.updated signal', () => {
const { usage, appended, signals } = setUp();
usage.record('m', u(1, 1));
expect(appended).toEqual([
{ type: 'usage.record', model: 'm', usage: u(1, 1), context: undefined },
]);
expect(signals.map((s) => s.type)).toContain('agent.status.updated');
});
it('rebuilds usage from restored records through the usage.record resume facet', () => {
const { usage, facets, appended } = setUp();
// Wake the lazy service so its constructor registers the resume facet.
usage.status();
const resume = facets.get('usage.record')?.resume;
expect(resume).toBeTypeOf('function');
resume!({ type: 'usage.record', model: 'restored', usage: u(7, 3) });
expect(usage.status()?.byModel?.['restored']).toEqual(u(7, 3));
// Replay must not re-append or re-signal.
expect(appended).toEqual([]);
});
});

View file

@ -1,95 +0,0 @@
/**
* Scenario: the **wire-record** module a durable-record + replay chain built
* on the append-log Store.
*
* Shows how a real Domain Service (`IAgentWireRecordService` / `AgentWireRecordService`)
* aggregates the `IAppendLogStore` access pattern into a complete engine call
* chain: `append` stamps and persists records (writing a `metadata` header
* first), and `restore` reads the log back and replays each record through the
* `register`-ed resumers so domain state can be rebuilt after a restart. The
* record types (`swarm_mode.enter` / `swarm_mode.exit`) come from the real
* `swarm` domain's `WireRecordMap` declaration merge.
*
* Persistence is gated on the `homedir` option: the scoped-registered
* `IAgentWireRecordService` passes no options and is therefore in-memory only, so this
* scenario constructs the real `AgentWireRecordService` with `createInstance(...,
* { homedir })` — the same way production wires a persisting wire record —
* resolving its `IAppendLogStore` dependency from the container. The resumers
* are small side callbacks (not Services). All resolved Services come from
* `src/`; nothing here defines a new Service.
*/
import { mkdirSync } from 'node:fs';
import { afterEach, beforeEach, describe, test } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IAppendLogStorage } from '#/persistence/interface/storage';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { AgentWireRecordService, type IAgentWireRecordService } from '#/agent/wireRecord';
import '#/agent/swarm/swarm';
const textDecoder = new TextDecoder();
describe('wire-record module (durable record + replay over IAppendLogStore)', () => {
let homeDir: string;
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
const resolved = process.env['KIMI_CODE_HOME'];
if (resolved === undefined) {
throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
}
homeDir = resolved;
mkdirSync(homeDir, { recursive: true });
disposables = new DisposableStore();
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(IAppendLogStorage, new FileStorageService(homeDir));
reg.define(IAppendLogStore, AppendLogStore);
},
});
});
afterEach(() => {
disposables.dispose();
});
test('appends to a JSONL log, then restores and replays through resumers', async () => {
const logBytes = ix.get(IAppendLogStorage);
// --- writer side: append records, which persist to wire/<hash>.jsonl ---
const writer: IAgentWireRecordService = ix.createInstance(AgentWireRecordService, { homedir: homeDir });
writer.append({ type: 'swarm_mode.enter', trigger: 'manual' });
writer.append({ type: 'swarm_mode.exit' });
await writer.flush();
const [logKey] = await logBytes.list('wire');
const raw = textDecoder.decode((await logBytes.read('wire', logKey)) ?? new Uint8Array());
console.log('1) persisted log key:', logKey);
console.log('2) raw JSONL (metadata header + appended records):');
for (const line of raw.trim().split('\n')) {
console.log(' ', line);
}
// --- reader side: a fresh instance on the same log replays the records ---
const replayed: string[] = [];
const reader: IAgentWireRecordService = ix.createInstance(AgentWireRecordService, { homedir: homeDir });
reader.register('swarm_mode.enter', (rec) => {
replayed.push(`enter(trigger=${rec.trigger})`);
});
reader.register('swarm_mode.exit', () => {
replayed.push('exit');
});
const result = await reader.restore();
console.log('3) restore result:', result);
console.log('4) resumers replayed (in order):', replayed);
});
});

View file

@ -49,7 +49,6 @@
"scripts": {
"build": "tsdown",
"test": "vitest run",
"example": "vitest run --config vitest.examples.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint:domain": "node scripts/check-domain-layers.mjs",
"clean": "rm -rf dist",
@ -62,14 +61,15 @@
"@anthropic-ai/sdk": "^0.95.2",
"@google/genai": "^1.49.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
"@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/kimi-telemetry": "workspace:^",
"@moonshot-ai/protocol": "workspace:^",
"@mozilla/readability": "^0.6.0",
"chokidar": "^4.0.3",
"ignore": "^5.3.2",
"js-yaml": "^4.1.1",
"linkedom": "^0.18.12",
"node-pty": "^1.1.0",
"nunjucks": "^3.2.4",
"openai": "^6.34.0",
"pathe": "^2.0.3",
@ -85,13 +85,13 @@
"devDependencies": {
"@dagrejs/dagre": "^1.1.4",
"@types/js-yaml": "^4.0.9",
"@types/yauzl": "^2.10.3",
"@types/nunjucks": "^3.2.6",
"@types/picomatch": "^4.0.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@types/retry": "0.12.0",
"@types/sinon": "^21.0.1",
"@types/yauzl": "^2.10.3",
"@vitejs/plugin-react": "^4.4.1",
"@xyflow/react": "^12.4.0",
"react": "^19.1.0",

View file

@ -82,6 +82,10 @@ const DOMAIN_LAYER = new Map([
['protocol', 1],
['hooks', 1],
['storage', 1],
// `task` is the managed-concurrent-execution primitive (run + defer).
// Depends only on `_base`; sits in L1 beside the other program-control
// layer substrates.
['task', 1],
// persistence/ and os/ — the two-level scopes. `interface` holds contracts
// (same layer as the old domains they replace); `backends` holds
// implementations that may depend on cross-domain services at various layers.
@ -94,8 +98,8 @@ const DOMAIN_LAYER = new Map([
// L2 — data & cross-cutting capabilities
['records', 2],
['wireRecord', 2],
['blobStore', 2],
['filestore', 2],
['blob', 2],
['file', 2],
['config', 2],
['agentFs', 2],
['process', 2],
@ -116,7 +120,7 @@ const DOMAIN_LAYER = new Map([
['flag', 3],
['toolExecutor', 3],
['toolRegistry', 3],
['toolStore', 3],
['toolState', 3],
['userTool', 3],
['permissionMode', 3],
['permissionPolicy', 3],
@ -158,6 +162,7 @@ const DOMAIN_LAYER = new Map([
['background', 5],
['mcp', 5],
['cron', 5],
['cronPersistence', 5],
['agentTool', 5],
['externalHooks', 5],
// `btw` forks a single side-question sub-agent via `agentLifecycle`,
@ -244,7 +249,7 @@ function domainFromRel(rel, { exemptRootFile }) {
* - `swarm>agentLifecycle`: swarm spawns/manages sub-agents.
* - `background>agentLifecycle`: background agent-tasks spawn sub-agents.
* - `cron>agentLifecycle` : cron coordinator steers the main agent.
* - `cron>sessionActivity`: cron scheduler gates on session idle.
* - `cron>sessionContext`: cron scheduler reads session identity for store filtering.
*
* Post-rebase-v2 restructuring introduced cross-domain type sharing between
* L3 (registries/capabilities) and L4 (agent behaviour). The tool contract
@ -271,7 +276,7 @@ const ALLOWED_EXCEPTIONS = new Set([
'swarm>agentLifecycle',
'background>agentLifecycle',
'cron>agentLifecycle',
'cron>sessionActivity',
'cron>sessionContext',
'wireRecord>hooks',
// L3/L4 type-sharing: tool contract + execution hook contexts now live in
// `tool`; the remaining upward import is a `loop` error/event helper.

View file

@ -76,8 +76,8 @@ const FRAMEWORK_BINDINGS: readonly { token: string; scope: ServiceScope; impl: s
* Production composition-root bindings seeded by `bootstrap()` via
* `ScopeOptions.extra`. `buildCollection` applies `extra` AFTER the static
* `registerScopedService` registry, so these take precedence at runtime: they
* override a static default where one exists (e.g. `ISkillCatalogStore`
* `FileSkillCatalogStore`) and supply the binding where the layer ships no
* override a static default where one exists (e.g. `ISkillDiscovery`
* `FileSkillDiscovery`) and supply the binding where the layer ships no
* in-package default (the Storage-layer tokens `FileStorageService`, whose
* in-memory backend is no longer auto-registered). The analyzer mirrors that
* so the graph reflects the backend that actually runs in production.
@ -91,7 +91,7 @@ const PRODUCTION_OVERRIDES: readonly { token: string; scope: ServiceScope; impl:
{ token: 'IAppendLogStorage', scope: 'App', impl: 'FileStorageService' },
{ token: 'IAtomicDocumentStorage', scope: 'App', impl: 'FileStorageService' },
{ token: 'IBlobStorage', scope: 'App', impl: 'FileStorageService' },
{ token: 'ISkillCatalogStore', scope: 'App', impl: 'FileSkillCatalogStore' },
{ token: 'ISkillDiscovery', scope: 'App', impl: 'FileSkillDiscovery' },
];
/**

View file

@ -33,6 +33,40 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Create a `taskService.run()`-compatible executor that waits for a
* subagent completion promise. Resolves with the subagent result on
* success, throws on abort or failure.
*/
export function createAgentExecutor(
handle: SubagentHandle,
abortController: AbortController,
): (signal: AbortSignal, output: (data: string) => void) => Promise<SubagentCompletion> {
return async (signal, output) => {
const requestAbort = (): void => {
abortController.abort(signal.reason);
};
if (signal.aborted) {
requestAbort();
} else {
signal.addEventListener('abort', requestAbort, { once: true });
}
try {
const outcome = await handle.completion;
output(outcome.result);
return outcome;
} catch (error: unknown) {
if (signal.aborted && (isAbortError(error) || error === signal.reason)) {
throw error;
}
throw error;
} finally {
signal.removeEventListener('abort', requestAbort);
}
};
}
export class AgentBackgroundTask implements BackgroundTask {
readonly kind = 'agent' as const;
readonly idPrefix: string = 'agent';

View file

@ -1,16 +1,19 @@
import { createDecorator } from "#/_base/di";
import type { ITaskHandle } from '#/app/task';
import type { Hooks } from '#/hooks';
import type {
BackgroundTask,
BackgroundTaskInfo,
BackgroundTaskInfoBase,
BackgroundTaskStatus,
} from './task';
export { AgentBackgroundTask } from './agent-task';
export { createAgentExecutor } from './agent-task';
export type { AgentBackgroundTaskInfo, SubagentHandle } from './agent-task';
export { ProcessBackgroundTask } from './process-task';
export type { ProcessBackgroundTaskInfo } from './process-task';
export { QuestionBackgroundTask } from './question-task';
export { ProcessBackgroundTask, createProcessExecutor, ProcessExitError } from './process-task';
export type { ProcessBackgroundTaskInfo, ProcessTaskResult } from './process-task';
export { QuestionBackgroundTask, createQuestionExecutor, QuestionTaskError } from './question-task';
export type { QuestionBackgroundTaskInfo } from './question-task';
export { BackgroundTaskPersistence } from './persist';
export type {
@ -48,6 +51,36 @@ export interface RegisterBackgroundTaskOptions {
export type ForegroundTaskReleaseReason = 'detached' | 'terminal';
/**
* Options for tracking a TaskHandle with the BackgroundService.
* Callers create the handle via `taskService.run()`, then pass it here.
*/
export interface BackgroundTrackOptions {
readonly idPrefix?: string;
readonly description: string;
/** If `true`, the task is immediately detached (background). Default: `true`. */
readonly detached?: boolean;
/** Deadline after which the handle is cancelled. */
readonly timeoutMs?: number;
/** Deadline to apply if a foreground task is detached. */
readonly detachTimeoutMs?: number;
/** Foreground caller signal (ignored for detached tasks). */
readonly signal?: AbortSignal;
/** Callback to force-stop the underlying work (e.g., SIGKILL). */
readonly forceStop?: () => Promise<void>;
/** Hook called when a foreground task is detached. */
readonly onDetach?: () => void;
/** Produce the typed `BackgroundTaskInfo` from the base fields. */
readonly toInfo: (base: BackgroundTaskInfoBase) => BackgroundTaskInfo;
}
/** Returned by `track()` so callers can race `handle.result` against detach. */
export interface IBackgroundEntry {
readonly taskId: string;
/** Resolves with `'detached'` when the RPC layer detaches this task. */
readonly onDidDetach: Promise<ForegroundTaskReleaseReason>;
}
export interface BackgroundNotificationContext {
readonly notificationType: string;
readonly title: string;
@ -59,10 +92,17 @@ export interface BackgroundNotificationContext {
export interface IAgentBackgroundService {
readonly _serviceBrand: undefined;
readonly hooks: Hooks<{
onDidNotify: BackgroundNotificationContext;
}>;
/** Track a `ITaskHandle` (from `taskService.run()`). */
track(handle: ITaskHandle, options: BackgroundTrackOptions): IBackgroundEntry;
/** @deprecated Use `taskService.run()` + `track()` instead. */
registerTask(task: BackgroundTask, options?: RegisterBackgroundTaskOptions): string;
getTask(taskId: string): BackgroundTaskInfo | undefined;
list(activeOnly?: boolean, limit?: number): readonly BackgroundTaskInfo[];
persistOutput(taskId: string): void;

View file

@ -19,6 +19,7 @@ import { Disposable } from '#/_base/di';
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
import type { BackgroundTaskOrigin } from '#/agent/contextMemory';
import { renderNotificationXml } from '#/agent/contextMemory/notification-xml';
import { ITaskService, type ITaskHandle, TERMINAL_TASK_STATES } from '#/app/task';
import {
TERMINAL_STATUSES,
type BackgroundTaskInfoBase,
@ -29,7 +30,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IConfigService } from '#/app/config';
import { IAgentPromptService } from '#/agent/prompt';
import { ISessionContext } from '#/session/sessionContext';
import { IAtomicDocumentStore, IStorageService } from '#/app/storage';
import { IAtomicDocumentStore, IFileSystemStorageService } from '#/app/storage';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
import {
@ -40,7 +41,9 @@ import {
type BackgroundTaskInfo,
type BackgroundTaskOutputSnapshot,
type BackgroundTaskStatus,
type BackgroundTrackOptions,
type ForegroundTaskReleaseReason,
type IBackgroundEntry,
type RegisterBackgroundTaskOptions,
} from './background';
import { BACKGROUND_SECTION, type BackgroundConfig } from './configSection';
@ -87,12 +90,16 @@ interface BackgroundTaskNotificationContext {
interface ManagedTask {
readonly taskId: string;
readonly task: BackgroundTask;
readonly task: BackgroundTask | undefined;
readonly handle: ITaskHandle | undefined;
readonly toInfoFn?: (base: BackgroundTaskInfoBase) => BackgroundTaskInfo;
readonly forceStopFn?: () => Promise<void>;
readonly onDetachFn?: () => void;
readonly outputChunks: string[];
outputSizeBytes: number;
retainedOutputBytes: number;
status: BackgroundTaskStatus;
options: RegisterBackgroundTaskOptions;
options: RegisterBackgroundTaskOptions & { description?: string };
readonly startedAt: number;
endedAt: number | null;
foregroundRelease?: ForegroundRelease;
@ -108,7 +115,9 @@ interface ManagedTask {
pendingOutputBytes: number;
outputPersistStarted: boolean;
timeoutHandle?: ReturnType<typeof setTimeout>;
timedOut: boolean;
readonly waiters: Array<() => void>;
handleSubscription?: { dispose(): void };
}
const MAX_OUTPUT_BYTES = 1024 * 1024;
@ -140,8 +149,9 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IConfigService private readonly config: IConfigService,
@IAtomicDocumentStore atomicDocs: IAtomicDocumentStore,
@IStorageService byteStore: IStorageService,
@IFileSystemStorageService byteStore: IFileSystemStorageService,
@ISessionContext session: ISessionContext,
@ITaskService private readonly taskService: ITaskService,
) {
super();
this.persistence = new BackgroundTaskPersistence(
@ -199,6 +209,7 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
const entry: ManagedTask = {
taskId: generateTaskId(task.idPrefix),
task,
handle: undefined,
outputChunks: [],
outputSizeBytes: 0,
retainedOutputBytes: 0,
@ -216,6 +227,7 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
outputPersistStarted: detached,
waiters: [],
terminalFired: false,
timedOut: false,
};
this.tasks.set(entry.taskId, entry);
this.ghosts.delete(entry.taskId);
@ -254,6 +266,85 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
return entry.taskId;
}
track(handle: ITaskHandle, options: BackgroundTrackOptions): IBackgroundEntry {
const detached = options.detached ?? true;
this.assertCanRegister(detached);
const taskId = generateTaskId(options.idPrefix ?? 'task');
const timeoutMs = options.timeoutMs;
const entry: ManagedTask = {
taskId,
task: undefined,
handle,
toInfoFn: options.toInfo,
forceStopFn: options.forceStop,
onDetachFn: options.onDetach,
outputChunks: [],
outputSizeBytes: 0,
retainedOutputBytes: 0,
status: 'running',
options: { detached, timeoutMs, detachTimeoutMs: options.detachTimeoutMs, signal: detached ? undefined : options.signal, description: options.description },
startedAt: Date.now(),
endedAt: null,
foregroundRelease: detached ? undefined : createForegroundRelease(),
abortController: new AbortController(),
lifecyclePromise: Promise.resolve(),
persistWriteQueue: Promise.resolve(),
outputWriteQueue: Promise.resolve(),
pendingOutput: [],
pendingOutputBytes: 0,
outputPersistStarted: detached,
waiters: [],
terminalFired: false,
timedOut: false,
};
this.tasks.set(taskId, entry);
this.ghosts.delete(taskId);
if (timeoutMs !== undefined && timeoutMs > 0) {
entry.timeoutHandle = setTimeout(() => {
entry.timedOut = true;
handle.cancel();
}, timeoutMs);
entry.timeoutHandle.unref?.();
}
const outputSub = handle.onDidOutput((chunk) => {
this.appendOutput(entry, chunk);
});
const stateSub = handle.onDidChangeState((state) => {
if (!TERMINAL_TASK_STATES.has(state)) return;
const status = entry.timedOut ? 'timed_out' as const
: state === 'cancelled' ? 'killed' as const
: state === 'failed' ? 'failed' as const
: 'completed' as const;
void this.settleTask(entry, { status, stopReason: entry.stopReason });
});
entry.handleSubscription = {
dispose() {
outputSub.dispose();
stateSub.dispose();
},
};
entry.lifecyclePromise = handle.result.then(() => {}, () => {});
this.installForegroundSignal(entry);
if (this.isDetached(entry)) {
void this.persistLive(entry);
this.recordTaskStarted(this.toInfo(entry));
}
return {
taskId,
onDidDetach: entry.foregroundRelease?.promise ?? Promise.resolve('terminal' as const),
};
}
getTask(taskId: string): BackgroundTaskInfo | undefined {
const entry = this.tasks.get(taskId);
return entry === undefined ? this.ghosts.get(taskId) : this.toInfo(entry);
@ -381,7 +472,8 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
entry.foregroundSignalCleanup = undefined;
this.applyDetachTimeout(entry);
try {
entry.task.onDetach?.();
const onDetach = entry.onDetachFn ?? entry.task?.onDetach;
onDetach?.();
} catch {
/* detach has already succeeded; hooks must not make RPC fail */
}
@ -402,8 +494,13 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
}
if (timeoutMs > 0) {
entry.timeoutHandle = setTimeout(() => {
entry.abortController.abort('Timed out');
void this.settleTask(entry, { status: 'timed_out' });
entry.timedOut = true;
if (entry.handle) {
entry.handle.cancel();
} else {
entry.abortController.abort('Timed out');
void this.settleTask(entry, { status: 'timed_out' });
}
}, timeoutMs);
entry.timeoutHandle.unref?.();
}
@ -426,7 +523,11 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
}
entry.stopReason = stopReason;
entry.abortController.abort(abortReason);
if (entry.handle) {
entry.handle.cancel();
} else {
entry.abortController.abort(abortReason);
}
let graceTimer: ReturnType<typeof setTimeout> | undefined;
const graceful = await Promise.race([
@ -450,7 +551,8 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
if (!graceful) {
try {
await entry.task.forceStop?.();
const forceStop = entry.forceStopFn ?? entry.task?.forceStop;
await forceStop?.();
} catch {
/* best effort */
}
@ -656,6 +758,8 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
settlement.stopReason ?? (settlement.status === 'killed' ? entry.stopReason : undefined);
entry.foregroundSignalCleanup?.();
entry.foregroundSignalCleanup = undefined;
entry.handleSubscription?.dispose();
entry.handleSubscription = undefined;
if (entry.timeoutHandle !== undefined) {
clearTimeout(entry.timeoutHandle);
entry.timeoutHandle = undefined;
@ -830,7 +934,7 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
private toInfo(entry: ManagedTask): BackgroundTaskInfo {
const base: BackgroundTaskInfoBase = {
taskId: entry.taskId,
description: entry.task.description,
description: entry.task?.description ?? entry.options.description ?? '',
status: entry.status,
detached: this.isDetached(entry) ? true : false,
startedAt: entry.startedAt,
@ -839,7 +943,8 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
terminalNotificationSuppressed: entry.terminalNotificationSuppressed,
timeoutMs: entry.options.timeoutMs,
};
return entry.task.toInfo(base);
if (entry.toInfoFn) return entry.toInfoFn(base);
return entry.task!.toInfo(base);
}
}

View file

@ -4,7 +4,7 @@
*
* Persists task state (`<taskId>.json`) and raw task output (`output.log`)
* through the `storage` access-pattern stores (`IAtomicDocumentStore` for
* atomic whole-document state, `IStorageService` byte primitives for ordered
* atomic whole-document state, `IFileSystemStorageService` byte primitives for ordered
* output append), addressed under the session's storage scope so the domain
* never touches the filesystem. Task ids are validated against the
* `{prefix}-{8 hex}` shape before use as path segments (path-traversal and
@ -15,7 +15,7 @@
import { join } from 'pathe';
import type { IAtomicDocumentStore, IStorageService } from '#/app/storage';
import type { IAtomicDocumentStore, IFileSystemStorageService } from '#/app/storage';
import type { BackgroundTaskInfo, BackgroundTaskStatus } from './task';
@ -43,7 +43,7 @@ export class BackgroundTaskPersistence {
private readonly sessionDir: string,
private readonly sessionScope: string,
private readonly docs: IAtomicDocumentStore,
private readonly bytes: IStorageService,
private readonly bytes: IFileSystemStorageService,
) {}
private tasksScope(): string {

View file

@ -188,6 +188,108 @@ function observeProcessStream(
});
}
export interface ProcessTaskResult {
readonly exitCode: number | null;
}
/**
* Create a `taskService.run()`-compatible executor that drives a spawned
* process to completion. Returns a resolved `ProcessTaskResult` on exit 0,
* throws on non-zero exit or abort.
*/
export function createProcessExecutor(
proc: IProcess,
onOutput?: ProcessBackgroundTaskOutputCallback,
): (signal: AbortSignal, output: (data: string) => void) => Promise<ProcessTaskResult> {
return async (signal, output) => {
const forwardOutput = (chunk: string, kind: ProcessBackgroundTaskOutputKind): void => {
if (chunk.length === 0) return;
output(chunk);
onOutput?.(kind, chunk);
};
const streamDrained = Promise.all([
observeProcessStreamRaw(proc.stdout, 'stdout', signal, forwardOutput),
observeProcessStreamRaw(proc.stderr, 'stderr', signal, forwardOutput),
]).then(() => undefined);
void streamDrained.catch(() => {});
const requestStop = (): void => {
void proc.kill('SIGTERM').catch(() => {});
};
if (signal.aborted) {
requestStop();
} else {
signal.addEventListener('abort', requestStop, { once: true });
}
try {
const exitCode = await proc.wait();
await waitForStreamDrain(streamDrained);
signal.removeEventListener('abort', requestStop);
await disposeProcess(proc);
if (signal.aborted) throw signal.reason;
if (exitCode !== 0) {
const err = new ProcessExitError(exitCode);
throw err;
}
return { exitCode };
} catch (error: unknown) {
await waitForStreamDrainSettled(streamDrained);
signal.removeEventListener('abort', requestStop);
await disposeProcess(proc);
throw error;
}
};
}
export class ProcessExitError extends Error {
constructor(readonly exitCode: number | null) {
super(`Process exited with code ${exitCode}`);
this.name = 'ProcessExitError';
}
}
function observeProcessStreamRaw(
stream: Readable,
kind: ProcessBackgroundTaskOutputKind,
signal: AbortSignal,
onChunk: (chunk: string, kind: ProcessBackgroundTaskOutputKind) => void,
): Promise<void> {
stream.setEncoding('utf8');
const onData = (chunk: string): void => {
onChunk(chunk, kind);
};
stream.on('data', onData);
return new Promise<void>((resolve, reject) => {
let ended = false;
const cleanup = (): void => {
stream.removeListener('data', onData);
stream.removeListener('end', onEnd);
stream.removeListener('close', onClose);
stream.removeListener('error', onError);
};
const done = (): void => { cleanup(); resolve(); };
const fail = (error: unknown): void => { cleanup(); reject(error); };
const onEnd = (): void => { ended = true; done(); };
const onClose = (): void => {
if (ended || signal.aborted) { done(); return; }
fail(createPrematureCloseError());
};
const onError = (error: Error): void => {
if (signal.aborted) { done(); } else { fail(error); }
};
stream.once('end', onEnd);
stream.once('close', onClose);
stream.once('error', onError);
});
}
async function disposeProcess(proc: IProcess): Promise<void> {
try { await proc.dispose(); } catch { /* best-effort */ }
}
function createPrematureCloseError(): Error {
const error = new Error('Premature close') as NodeJS.ErrnoException;
error.code = 'ERR_STREAM_PREMATURE_CLOSE';

View file

@ -16,6 +16,31 @@ export interface QuestionBackgroundTaskOptions {
readonly toolCallId?: string;
}
/**
* Create a `taskService.run()`-compatible executor that runs a question
* thunk and resolves with its result. Throws on error or abort.
*/
export function createQuestionExecutor(
run: (signal: AbortSignal) => Promise<ExecutableToolResult>,
): (signal: AbortSignal, output: (data: string) => void) => Promise<ExecutableToolResult> {
return async (signal, output) => {
const result = await run(signal);
const text = serializeToolOutput(result.output);
if (text.length > 0) output(text);
if (result.isError === true) {
throw new QuestionTaskError(errorStopReason(result) ?? 'Question failed');
}
return result;
};
}
export class QuestionTaskError extends Error {
constructor(message: string) {
super(message);
this.name = 'QuestionTaskError';
}
}
export class QuestionBackgroundTask implements BackgroundTask {
readonly kind = 'question' as const;
readonly idPrefix = 'question';

View file

@ -0,0 +1,29 @@
/**
* `blob` domain `IAgentBlobService` contract.
*
* Offloads large inline media payloads to content-addressed blob storage and
* rehydrates them on read. Bound at Agent scope.
*/
import type { ContentPart } from '#/app/llmProtocol';
import { createDecorator } from "#/_base/di";
export const BLOBREF_PROTOCOL = 'blobref:';
export const MISSING_MEDIA_PLACEHOLDER = '[media missing]';
export interface AgentBlobServiceOptions {
// Reserved for future overrides (threshold / cache size). The persistence
// root is derived from `IAgentScopeContext.scope('blobs')`.
}
export interface IAgentBlobService {
readonly _serviceBrand: undefined;
offloadParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]>;
rehydrateParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]>;
isBlobRef(url: string): boolean;
}
export const IAgentBlobService = createDecorator<IAgentBlobService>(
'agentBlobService',
);

View file

@ -0,0 +1,203 @@
/**
* `blob` domain `IAgentBlobService` implementation.
*
* Offloads large inline media payloads into content-addressed blobs and
* rehydrates them on read; persists bytes through `IBlobStore` under the
* agent's `scope('blobs')` root, matching the v1 `<agentDir>/blobs/<sha256>`
* layout. Bound at Agent scope.
*/
import { createHash } from 'node:crypto';
import type { ContentPart } from '#/app/llmProtocol';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IBlobStore } from '#/persistence/interface/blobStore';
import {
BLOBREF_PROTOCOL,
IAgentBlobService,
MISSING_MEDIA_PLACEHOLDER,
type AgentBlobServiceOptions,
} from './agentBlobService';
const DEFAULT_THRESHOLD = 4096;
const DEFAULT_MAX_CACHE_SIZE = 50 * 1024 * 1024;
const DATA_URI_HEADER_RE = /^data:([^;]+);base64,/;
export class AgentBlobServiceImpl implements IAgentBlobService {
declare readonly _serviceBrand: undefined;
private readonly storageScope: string;
private readonly cache = new Map<string, Buffer>();
private readonly cacheSizes = new Map<string, number>();
private currentCacheSize = 0;
constructor(
private readonly options: AgentBlobServiceOptions = {},
@IBlobStore private readonly blobs: IBlobStore,
@IAgentScopeContext agentCtx: IAgentScopeContext,
) {
this.storageScope = agentCtx.scope('blobs');
}
protected get threshold(): number {
return DEFAULT_THRESHOLD;
}
protected get maxCacheSize(): number {
return DEFAULT_MAX_CACHE_SIZE;
}
isBlobRef(url: string): boolean {
return url.startsWith(BLOBREF_PROTOCOL);
}
async offloadParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]> {
let changed = false;
const out: ContentPart[] = [];
for (const part of parts) {
const next = await this.offloadContentPart(part);
if (next !== part) changed = true;
out.push(next);
}
return changed ? out : parts;
}
async rehydrateParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]> {
let changed = false;
const out: ContentPart[] = [];
for (const part of parts) {
const next = await this.rehydrateContentPart(part);
if (next !== part) changed = true;
out.push(next);
}
return changed ? out : parts;
}
private async offloadContentPart(part: ContentPart): Promise<ContentPart> {
let updated: Record<string, unknown> | undefined;
for (const [key, value] of Object.entries(part)) {
const mediaObj = asMediaContainer(value);
if (mediaObj === undefined) continue;
const url = mediaObj.url;
if (typeof url !== 'string') continue;
const newUrl = await this.maybeOffloadString(url);
if (newUrl === url) continue;
if (updated === undefined) updated = { ...part };
updated[key] = { ...(value as object), url: newUrl };
}
return updated === undefined ? part : (updated as unknown as ContentPart);
}
private async rehydrateContentPart(part: ContentPart): Promise<ContentPart> {
let updated: Record<string, unknown> | undefined;
for (const [key, value] of Object.entries(part)) {
const mediaObj = asMediaContainer(value);
if (mediaObj === undefined) continue;
const url = mediaObj.url;
if (typeof url !== 'string' || !this.isBlobRef(url)) continue;
const newUrl = await this.rehydrateBlobRefUrl(url);
if (updated === undefined) updated = { ...part };
updated[key] = { ...(value as object), url: newUrl ?? MISSING_MEDIA_PLACEHOLDER };
}
return updated === undefined ? part : (updated as unknown as ContentPart);
}
private async rehydrateBlobRefUrl(url: string): Promise<string | undefined> {
const rest = url.slice(BLOBREF_PROTOCOL.length);
const semiIdx = rest.indexOf(';');
if (semiIdx === -1) return undefined;
const mimeType = rest.slice(0, semiIdx);
const hash = rest.slice(semiIdx + 1);
if (hash.length === 0) return undefined;
const payload = await this.readBlob(hash);
if (payload === undefined) return undefined;
return `data:${mimeType};base64,${payload.toString('base64')}`;
}
private async readBlob(hash: string): Promise<Buffer | undefined> {
const cached = this.cache.get(hash);
if (cached !== undefined) {
this.cache.delete(hash);
this.cache.set(hash, cached);
return cached;
}
const payload = await this.blobs.get(this.storageScope, hash).catch(() => undefined);
if (payload !== undefined) {
this.setCache(hash, Buffer.from(payload));
}
return payload !== undefined ? Buffer.from(payload) : undefined;
}
private async maybeOffloadString(value: string): Promise<string> {
if (this.isBlobRef(value)) return value;
const match = DATA_URI_HEADER_RE.exec(value);
if (match === null) return value;
const mimeType = match[1]!;
const payload = value.slice(match[0].length);
if (payload.length < this.threshold) return value;
return this.writeBlob(mimeType, payload);
}
private async writeBlob(mimeType: string, base64Payload: string): Promise<string> {
const hash = createHash('sha256').update(base64Payload, 'utf8').digest('hex');
const binary = Buffer.from(base64Payload, 'base64');
await this.blobs.put(this.storageScope, hash, binary);
this.setCache(hash, binary);
return `${BLOBREF_PROTOCOL}${mimeType};${hash}`;
}
private setCache(hash: string, payload: Buffer): void {
const size = payload.byteLength;
if (this.cache.has(hash)) {
const oldSize = this.cacheSizes.get(hash) ?? 0;
this.currentCacheSize += size - oldSize;
this.cache.delete(hash);
} else {
if (size > this.maxCacheSize) return;
while (this.currentCacheSize + size > this.maxCacheSize && this.cache.size > 0) {
this.evictLRU();
}
this.currentCacheSize += size;
}
this.cache.set(hash, payload);
this.cacheSizes.set(hash, size);
}
private evictLRU(): void {
const lru = this.cache.keys().next().value;
if (lru === undefined) return;
const size = this.cacheSizes.get(lru) ?? 0;
this.currentCacheSize -= size;
this.cache.delete(lru);
this.cacheSizes.delete(lru);
}
}
function asMediaContainer(value: unknown): { url: unknown } | undefined {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const obj = value as Record<string, unknown>;
return 'url' in obj ? (obj as { url: unknown }) : undefined;
}
registerScopedService(
LifecycleScope.Agent,
IAgentBlobService,
AgentBlobServiceImpl,
InstantiationType.Delayed,
'agentBlob',
);

View file

@ -0,0 +1,6 @@
/**
* `blob` domain barrel.
*/
export * from './agentBlobService';
export * from './agentBlobServiceImpl';

View file

@ -1,6 +0,0 @@
/**
* `blobStore` domain barrel compatibility re-export.
*/
export * from '#/persistence/interface/blobStore';
export * from '#/persistence/backends/node-fs/blobStoreService';

View file

@ -1,81 +0,0 @@
/**
* `cron` domain (L5) `IAgentCronService` contract.
*
* Owns the agent's set of scheduled cron tasks and the queries the cron tools
* and the edge layer need against them. The data record (`CronTask`) lives here
* beside the contract because every method takes or returns it. Bound at Agent
* scope.
*/
import type { ContentPart } from '#/app/llmProtocol';
import { createDecorator } from '#/_base/di';
import type { Turn } from '#/agent/turn';
/**
* Persistent representation of a cron task.
*
* - `id` 8-hex; jitter is keyed off this hash, so a stable id == stable
* jitter across schedule rewrites.
* - `cron` 5-field expression, evaluated in local time.
* - `createdAt` wall-clock epoch ms at original scheduling. NOT updated
* when the scheduler fires; recurring uses it as the baseline floor when
* no `lastFiredAt` has been recorded. Also the input to the 7-day stale
* judgment.
* - `recurring` undefined / true means "fire repeatedly until deleted or
* auto-expired"; false means "fire once then auto-delete".
* - `lastFiredAt` wall-clock epoch ms of the last ideal occurrence whose
* jittered delivery has actually completed. Persisted so a `kimi resume`
* does not replay already-delivered recurring fires. A value greater than
* the current wall clock is treated as corrupt and ignored.
*/
export interface CronTask {
readonly id: string;
readonly cron: string;
readonly prompt: string;
readonly createdAt: number;
readonly recurring?: boolean;
readonly lastFiredAt?: number;
}
/** Everything the caller supplies; `id` and `createdAt` are generated by the service. */
export type CronTaskInit = Omit<CronTask, 'id' | 'createdAt'>;
export interface CronLoadOptions {
readonly replace?: boolean;
}
export interface IAgentCronService {
readonly _serviceBrand: undefined;
readonly isEnabled: boolean;
// —— task CRUD (used by the cron tools and the edge layer) ——
addTask(init: CronTaskInit): CronTask;
removeTasks(ids: readonly string[]): readonly string[];
getTask(id: string): CronTask | undefined;
list(): readonly CronTask[];
// —— scheduling queries (used by the cron tools and monitoring) ——
/** Wall-clock epoch ms read through the configured clock source. */
now(): number;
isStale(task: CronTask): boolean;
getNextFireTime(): number | null;
getNextFireForTask(taskId: string): number | null;
// —— lifecycle (driven by the engine, resume, and the test seam) ——
loadFromDisk(options?: CronLoadOptions): Promise<void>;
start(): void;
stop(): Promise<void>;
tick(): void;
flushPersist(): Promise<void>;
handleMissed(
tasks: readonly CronTask[],
renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[],
): Turn | undefined;
// —— telemetry facade so the tools do not reach into ITelemetryService ——
emitScheduled(task: CronTask): void;
emitDeleted(taskId: string): void;
}
export const IAgentCronService = createDecorator<IAgentCronService>('agentCronService');

View file

@ -1,9 +1,8 @@
/**
* `cron` domain barrel re-exports the cron contract (`cron`) and its scoped
* service (`cronService`), plus a side-effect import of each cron tool so its
* `registerTool(...)` call runs at module load. Importing this barrel wires
* `IAgentCronService` into the scope registry and adds the three cron tools
* (`CronCreate` / `CronList` / `CronDelete`) to the tool contribution list.
* `cron` domain barrel re-exports cron utilities (expression parser, jitter,
* format, clock, config) and registers the three cron tools (`CronCreate` /
* `CronList` / `CronDelete`) via side-effect imports. The cron task record
* type lives in `app/cronPersistence`; the scheduling engine lives in `session/cron`.
*/
import './configSection';
@ -11,5 +10,8 @@ import './tools/cron-create';
import './tools/cron-delete';
import './tools/cron-list';
export * from './cron';
export * from './cronService';
export * from './cron-expr';
export * from './format';
export * from './jitter';
export * from './clock';
export { CRON_SECTION, type CronConfig, DEFAULT_CRON_CONFIG } from './configSection';

View file

@ -3,21 +3,20 @@
* at a future wall-clock time, either once (`recurring: false`) or on a
* cron cadence (`recurring: true`, the default).
*
* Tasks live in `AgentCronService` and are mirrored to
* `<sessionDir>/agents/<agentId>/cron/<id>.json` via
* `IAgentCronService.addTask`, so a
* `kimi resume` of the same session reloads them and the scheduler
* picks up where it left off (fires that fell during downtime are
* collapsed into a single delivery with `coalescedCount`). Tasks do
* Tasks live in `ISessionCronService` (Session scope) and are persisted
* through the App-scoped `ICronTaskPersistence` under the project's cron
* scope, so a `kimi resume` of the same session reloads them and the
* scheduler picks up where it left off (fires that fell during downtime
* are collapsed into a single delivery with `coalescedCount`). Tasks do
* NOT carry over into a brand-new session.
*
* The tool itself is pure validation + bookkeeping; the firing /
* coalesce / jitter / persistence logic lives in `AgentCronService`.
* coalesce / jitter / persistence logic lives in `SessionCronService`.
* This file only knows how to:
*
* 1. validate the request (killswitch, cron parse, 5-year window,
* session cap, byte-length cap);
* 2. add it to the service (which writes through to disk on success);
* 2. add it to the service (which writes through to the store);
* 3. report back the post-jitter `nextFireAt` and a human-readable
* schedule for the model's benefit;
* 4. emit `cron_scheduled` telemetry through the service (the tool
@ -31,7 +30,7 @@ import { registerTool } from '#/agent/toolRegistry';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern } from '#/_base/tools/support/rule-match';
import { IConfigService } from '#/app/config';
import { IAgentCronService } from '#/agent/cron/cron';
import { ISessionCronService } from '#/session/cron';
import {
CRON_SECTION,
DEFAULT_CRON_CONFIG,
@ -129,7 +128,7 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> {
constructor(
private readonly disabled: boolean = false,
@IAgentCronService private readonly cron: IAgentCronService,
@ISessionCronService private readonly cron: ISessionCronService,
) {}
resolveExecution(args: CronCreateInput): ToolExecution {
@ -324,7 +323,6 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> {
}
registerTool(CronCreateTool, {
when: (accessor) => accessor.get(IAgentCronService).isEnabled,
staticArgs: (accessor) => [
accessor.get(IConfigService).get<CronConfig>(CRON_SECTION)?.disabled
?? DEFAULT_CRON_CONFIG.disabled,

View file

@ -40,7 +40,7 @@ import { z } from 'zod';
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
import { registerTool } from '#/agent/toolRegistry';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { IAgentCronService } from '#/agent/cron/cron';
import { ISessionCronService } from '#/session/cron';
import CRON_DELETE_DESCRIPTION from './cron-delete.md?raw';
// ── Constants ────────────────────────────────────────────────────────
@ -72,7 +72,7 @@ export class CronDeleteTool implements BuiltinTool<CronDeleteInput> {
CronDeleteInputSchema,
);
constructor(@IAgentCronService private readonly cron: IAgentCronService) {}
constructor(@ISessionCronService private readonly cron: ISessionCronService) {}
resolveExecution(args: CronDeleteInput): ToolExecution {
// Format check up front. The store would reject the lookup anyway,
@ -117,6 +117,4 @@ export class CronDeleteTool implements BuiltinTool<CronDeleteInput> {
}
}
registerTool(CronDeleteTool, {
when: (accessor) => accessor.get(IAgentCronService).isEnabled,
});
registerTool(CronDeleteTool);

View file

@ -27,7 +27,7 @@
* decimal places. Useful context for the `stale`
* flag and for the LLM's "should I still be
* running?" judgement.
* - `stale` mirrors `IAgentCronService.isStale(task)`; see that
* - `stale` mirrors `ISessionCronService.isStale(task)`; see that
* method for the precise rules
* (`recurring && age >= 7 days`, gated by
* `KIMI_CRON_NO_STALE`).
@ -45,8 +45,8 @@ import { z } from 'zod';
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
import { registerTool } from '#/agent/toolRegistry';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { IAgentCronService } from '#/agent/cron/cron';
import type { CronTask } from '#/agent/cron/cron';
import { ISessionCronService } from '#/session/cron';
import type { CronTask } from '#/app/cronPersistence';
import {
cronToHuman,
parseCronExpression,
@ -92,7 +92,7 @@ export class CronListTool implements BuiltinTool<CronListInput> {
CronListInputSchema,
);
constructor(@IAgentCronService private readonly cron: IAgentCronService) {}
constructor(@ISessionCronService private readonly cron: ISessionCronService) {}
resolveExecution(_args: CronListInput): ToolExecution {
return {
@ -171,6 +171,4 @@ export class CronListTool implements BuiltinTool<CronListInput> {
}
}
registerTool(CronListTool, {
when: (accessor) => accessor.get(IAgentCronService).isEnabled,
});
registerTool(CronListTool);

View file

@ -24,7 +24,7 @@ import {
renderTodoList,
type TodoItem,
} from '#/agent/todoList/tools/todo-list';
import { IAgentToolStoreService } from '#/agent/toolStore';
import { IAgentToolState } from '#/agent/toolState';
import { IAgentTurnService } from '#/agent/turn';
import {
APIContextOverflowError,
@ -109,7 +109,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentToolStoreService private readonly toolStore: IAgentToolStoreService,
@IAgentToolState private readonly toolStore: IAgentToolState,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentTurnService turnService: IAgentTurnService,

View file

@ -20,7 +20,7 @@ export interface IAgentScopeContext {
* Persistence scope rooted at this agent. `scope()` returns the agent
* scope itself; `scope(subKey)` returns `${agentScope}/${subKey}` (e.g.
* `scope('cron')` `sessions/<wsId>/<sId>/agents/<aId>/cron`). Business
* code passes the returned string straight to `IStorageService` /
* code passes the returned string straight to `IFileSystemStorageService` /
* `IAtomicDocumentStore` / `IAppendLogStore`.
*/
scope(subKey?: string): string;

View file

@ -17,7 +17,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentToolStoreService } from '#/agent/toolStore';
import { IAgentToolState } from '#/agent/toolState';
import { IAgentTodoListService } from './todoList';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
@ -28,7 +28,7 @@ export class AgentTodoListService extends Disposable implements IAgentTodoListSe
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentToolStoreService private readonly toolStore: IAgentToolStoreService,
@IAgentToolState private readonly toolStore: IAgentToolState,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
@IInstantiationService private readonly instantiationService: IInstantiationService,

View file

@ -18,7 +18,7 @@ import { z } from 'zod';
import type { BuiltinTool } from '#/agent/tool';
import type { ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { IAgentToolStoreService } from '#/agent/toolStore';
import { IAgentToolState } from '#/agent/toolState';
import DESCRIPTION from './todo-list.md?raw';
import TODO_LIST_WRITE_REMINDER from './todo-list-write-reminder.md?raw';
@ -42,7 +42,7 @@ export function readTodoItems(raw: unknown): readonly TodoItem[] {
}));
}
declare module '#/agent/toolStore' {
declare module '#/agent/toolState' {
interface ToolStoreData {
todo: readonly TodoItem[];
}
@ -111,7 +111,7 @@ export class TodoListTool implements BuiltinTool<TodoListInput> {
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(TodoListInputSchema);
constructor(@IAgentToolStoreService private readonly store: IAgentToolStoreService) {}
constructor(@IAgentToolState private readonly store: IAgentToolState) {}
resolveExecution(args: TodoListInput): ToolExecution {
const description =

View file

@ -0,0 +1,6 @@
/**
* `toolState` domain barrel - re-exports the tool state service contract and implementation.
*/
export * from './toolState';
export * from './toolStateService';

View file

@ -15,7 +15,7 @@ export interface ToolStoreUpdate<K extends ToolStoreKey = ToolStoreKey> {
readonly value: ToolStoreData[K];
}
export interface IAgentToolStoreService extends ToolStore {
export interface IAgentToolState extends ToolStore {
readonly _serviceBrand: undefined;
data(): Readonly<Partial<ToolStoreData>>;
@ -24,4 +24,4 @@ export interface IAgentToolStoreService extends ToolStore {
}>;
}
export const IAgentToolStoreService = createDecorator<IAgentToolStoreService>('agentToolStoreService');
export const IAgentToolState = createDecorator<IAgentToolState>('agentToolState');

View file

@ -4,7 +4,7 @@ import {
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { OrderedHookSlot } from '#/hooks';
import { IAgentToolStoreService, type ToolStoreData, type ToolStoreKey } from './toolStore';
import { IAgentToolState, type ToolStoreData, type ToolStoreKey } from './toolState';
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
declare module '#/agent/wireRecord' {
@ -16,7 +16,7 @@ declare module '#/agent/wireRecord' {
}
}
export class AgentToolStoreService extends Disposable implements IAgentToolStoreService {
export class AgentToolStateService extends Disposable implements IAgentToolState {
declare readonly _serviceBrand: undefined;
private readonly store: Partial<ToolStoreData> = {};
@ -66,8 +66,8 @@ export class AgentToolStoreService extends Disposable implements IAgentToolStore
registerScopedService(
LifecycleScope.Agent,
IAgentToolStoreService,
AgentToolStoreService,
IAgentToolState,
AgentToolStateService,
InstantiationType.Delayed,
'toolStore',
'toolState',
);

View file

@ -1,6 +0,0 @@
/**
* `toolStore` domain barrel - re-exports the toolStore service contract and implementation.
*/
export * from './toolStore';
export * from './toolStoreService';

View file

@ -6,7 +6,7 @@ import {
Disposable,
toDisposable,
} from "#/_base/di";
import { IAgentBlobStoreService } from '#/agent/blobStore';
import { IAgentBlobService } from '#/agent/blob';
import { IBootstrapService } from '#/app/bootstrap';
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
import { IAppendLogStore } from '#/app/storage';
@ -56,7 +56,7 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco
constructor(
private readonly options: WireRecordServiceOptions = {},
@IBootstrapService bootstrap: IBootstrapService,
@IAgentBlobStoreService private readonly blobStore?: IAgentBlobStoreService,
@IAgentBlobService private readonly blobStore?: IAgentBlobService,
@IAppendLogStore private readonly log?: IAppendLogStore,
) {
super();

View file

@ -7,11 +7,9 @@
* (`homeDir`, `configPath`, ). `resolveBootstrapOptions` is the single place
* that reads `process.env` / `os.homedir()` / invocation input to resolve
* the snapshot; everything downstream reads from `IBootstrapService` instead of
* touching `process` directly. Bound at App scope. Also seeds the App storage
* roles (`IStorageService`, `IAppendLogStorage`, `IAtomicDocumentStorage`,
* `IBlobStorage`) each with its own `FileStorageService` rooted at `homeDir`
* (via per-token `SyncDescriptor`s), so the byte layer (and every Store above
* it) persists to disk while the roles stay independently routable.
* touching `process` directly. Bound at App scope. Also seeds the
* `IFileSystemStorageService` with a `FileStorageService` rooted at `homeDir`
* so the byte layer (and every Store above it) persists to disk.
*/
import { mkdirSync } from 'node:fs';
@ -23,14 +21,11 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { createAppScope, type Scope, type ScopeSeed } from '#/_base/di/scope';
import {
IAppendLogStorage,
IAtomicDocumentStorage,
IBlobStorage,
IStorageService,
IFileSystemStorageService,
} from '#/persistence/interface/storage';
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
import { FileSkillCatalogStore } from '#/app/globalSkillCatalog/fileSkillCatalogStore';
import { ISkillCatalogStore } from '#/app/globalSkillCatalog/skillCatalogStore';
import { FileSkillDiscovery } from '#/app/globalSkillCatalog/fileSkillDiscovery';
import { ISkillDiscovery } from '#/app/globalSkillCatalog/skillDiscovery';
export interface IBootstrapOptions {
readonly homeDir: string;
@ -48,7 +43,7 @@ export const IBootstrapOptions: ServiceIdentifier<IBootstrapOptions> =
/**
* Well-known top-level persistence areas. The bootstrap layer owns the mapping
* from each semantic name to concrete backend addressing; business code passes
* a scope string to `IStorageService` / `IAtomicDocumentStore` / `IAppendLogStore`
* a scope string to `IFileSystemStorageService` / `IAtomicDocumentStore` / `IAppendLogStore`
* without caring whether the byte layer talks to a filesystem, a database, or
* a blob store.
*/
@ -59,7 +54,8 @@ export type PersistenceScopeName =
| 'store'
| 'logs'
| 'cache'
| 'credentials';
| 'credentials'
| 'cron';
export interface IBootstrapService {
readonly _serviceBrand: undefined;
@ -81,7 +77,7 @@ export interface IBootstrapService {
/**
* Scope string for a well-known top-level persistence area. Business code
* passes this to `IStorageService` / `IAtomicDocumentStore` / `IAppendLogStore`
* passes this to `IFileSystemStorageService` / `IAtomicDocumentStore` / `IAppendLogStore`
* the backend layer converts it to concrete addressing.
*/
scope(name: PersistenceScopeName): string;
@ -160,21 +156,10 @@ export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = []
}
function storageSeed(options: IBootstrapOptions): ScopeSeed {
// Each storage role token resolves to its OWN `FileStorageService` instance
// rooted at `homeDir`. The four roles are intentionally independent so a
// composition profile can route any one of them (e.g. `IBlobStorage`) to a
// different backend; bundling them into a single shared instance would bake
// in the assumption that they are always the same backend. We seed a
// per-token `SyncDescriptor` (VS Code's `new SyncDescriptor(Ctor, [args])`
// pattern) so the container builds each instance via DI, while the `extra`
// seed still overrides the in-memory default robustly.
const file = (): SyncDescriptor<IStorageService> =>
const file = (): SyncDescriptor<IFileSystemStorageService> =>
new SyncDescriptor(FileStorageService, [options.homeDir], true);
return [
[IStorageService as ServiceIdentifier<unknown>, file()],
[IAppendLogStorage as ServiceIdentifier<unknown>, file()],
[IAtomicDocumentStorage as ServiceIdentifier<unknown>, file()],
[IBlobStorage as ServiceIdentifier<unknown>, file()],
[IFileSystemStorageService as ServiceIdentifier<unknown>, file()],
];
}
@ -184,8 +169,8 @@ function skillSeed(): ScopeSeed {
// in the skill domain (this `extra` seed overrides it in production).
return [
[
ISkillCatalogStore as ServiceIdentifier<unknown>,
new SyncDescriptor(FileSkillCatalogStore, [], true),
ISkillDiscovery as ServiceIdentifier<unknown>,
new SyncDescriptor(FileSkillDiscovery, [], true),
],
];
}

View file

@ -68,6 +68,7 @@ export class BootstrapService implements IBootstrapService {
logs: relative(options.homeDir, this.logsDir),
cache: relative(options.homeDir, this.cacheDir),
credentials: 'credentials',
cron: 'cron',
};
}

View file

@ -0,0 +1,21 @@
/**
* `cron` domain (L5) shared `CronTask` data record.
*
* The authoritative definition of a cron task's persistent shape. Used by
* `ICronTaskPersistence` (App scope) for project-level persistence and by
* `ISessionCronService` (Session scope) for the live scheduling engine.
* The `tags` map carries arbitrary metadata (e.g. `sessionId`) that the
* Session projection uses to filter tasks belonging to the current session.
*/
export interface CronTask {
readonly id: string;
readonly cron: string;
readonly prompt: string;
readonly createdAt: number;
readonly recurring?: boolean;
readonly lastFiredAt?: number;
readonly tags?: Readonly<Record<string, string>>;
}
export type CronTaskInit = Omit<CronTask, 'id' | 'createdAt'>;

View file

@ -0,0 +1,27 @@
/**
* `cron` domain (L5) `ICronTaskPersistence` contract.
*
* Project-level persistence for cron tasks. Persists tasks under
* `bootstrap.scope('cron')` as atomic documents keyed by
* `<workspaceId>/<taskId>.json`. Provides CRUD and query-by-workspace.
* A pure data layer scheduling, timers, and fire delivery are owned by
* `ISessionCronService` at Session scope. Bound at App scope.
*/
import { createDecorator } from '#/_base/di';
import type { CronTask } from './cronTask';
export interface CronTaskQuery {
readonly workspaceId: string;
}
export interface ICronTaskPersistence {
readonly _serviceBrand: undefined;
get(workspaceId: string, taskId: string): Promise<CronTask | undefined>;
list(query: CronTaskQuery): Promise<readonly CronTask[]>;
save(workspaceId: string, task: CronTask): Promise<void>;
delete(workspaceId: string, taskId: string): Promise<void>;
}
export const ICronTaskPersistence = createDecorator<ICronTaskPersistence>('cronTaskPersistence');

View file

@ -0,0 +1,100 @@
/**
* `cron` domain (L5) `ICronTaskPersistence` implementation.
*
* Persists cron tasks as atomic JSON documents under the `cron` persistence
* scope (`bootstrap.scope('cron')`), laid out as `<workspaceId>/<id>.json`.
* Pure CRUD no scheduling logic. Bound at App scope.
*/
import { Disposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAtomicDocumentStore } from '#/persistence/interface';
import { IBootstrapService } from '#/app/bootstrap';
import { ICronTaskPersistence, type CronTaskQuery } from './cronTaskPersistence';
import type { CronTask } from './cronTask';
export const CRON_ID_REGEX: RegExp = /^[0-9a-f]{8}$/;
const JSON_SUFFIX = '.json';
export function isValidCronTask(obj: unknown): obj is CronTask {
if (typeof obj !== 'object' || obj === null) return false;
const o = obj as Record<string, unknown>;
if (typeof o['id'] !== 'string' || !CRON_ID_REGEX.test(o['id'])) return false;
if (typeof o['cron'] !== 'string') return false;
if (typeof o['prompt'] !== 'string') return false;
if (typeof o['createdAt'] !== 'number') return false;
if (o['recurring'] !== undefined && typeof o['recurring'] !== 'boolean') return false;
if (
o['lastFiredAt'] !== undefined &&
(typeof o['lastFiredAt'] !== 'number' || !Number.isFinite(o['lastFiredAt']))
) {
return false;
}
if (o['tags'] !== undefined) {
if (typeof o['tags'] !== 'object' || o['tags'] === null) return false;
for (const v of Object.values(o['tags'] as Record<string, unknown>)) {
if (typeof v !== 'string') return false;
}
}
return true;
}
export class CronTaskPersistenceService extends Disposable implements ICronTaskPersistence {
declare readonly _serviceBrand: undefined;
private readonly cronScope: string;
constructor(
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore,
) {
super();
this.cronScope = this.bootstrap.scope('cron');
}
private workspaceScope(workspaceId: string): string {
return `${this.cronScope}/${workspaceId}`;
}
async get(workspaceId: string, taskId: string): Promise<CronTask | undefined> {
const scope = this.workspaceScope(workspaceId);
const value = await this.atomicDocs.get<CronTask>(scope, `${taskId}${JSON_SUFFIX}`);
if (value === undefined || !isValidCronTask(value)) return undefined;
return value;
}
async list(query: CronTaskQuery): Promise<readonly CronTask[]> {
const scope = this.workspaceScope(query.workspaceId);
const keys = await this.atomicDocs.list(scope);
const tasks: CronTask[] = [];
for (const key of keys) {
if (!key.endsWith(JSON_SUFFIX)) continue;
const id = key.slice(0, -JSON_SUFFIX.length);
if (!CRON_ID_REGEX.test(id)) continue;
const value = await this.atomicDocs.get<CronTask>(scope, key);
if (value === undefined || !isValidCronTask(value)) continue;
tasks.push(value);
}
return tasks;
}
async save(workspaceId: string, task: CronTask): Promise<void> {
const scope = this.workspaceScope(workspaceId);
await this.atomicDocs.set(scope, `${task.id}${JSON_SUFFIX}`, task);
}
async delete(workspaceId: string, taskId: string): Promise<void> {
const scope = this.workspaceScope(workspaceId);
await this.atomicDocs.delete(scope, `${taskId}${JSON_SUFFIX}`);
}
}
registerScopedService(
LifecycleScope.App,
ICronTaskPersistence,
CronTaskPersistenceService,
InstantiationType.Delayed,
'cron',
);

View file

@ -0,0 +1,8 @@
/**
* `cron` domain barrel re-exports the cron task data record, the
* `ICronTaskPersistence` contract, and registers the App-scoped persistence service.
*/
export * from './cronTask';
export * from './cronTaskPersistence';
export * from './cronTaskPersistenceService';

View file

@ -1,14 +1,9 @@
/**
* `persistence/interface` `IFileStore` contract and error helpers.
* `file` domain `IFileService` contract and error helpers.
*
* Process-global upload store backing the `/files` REST endpoints: persists
* uploaded bytes in the `IBlobStorage` backend and their `FileMeta` index in the
* same byte store, then hands callers a stream back on download. Bound at App
* scope.
*
* This file ships the interface, DI token, error domain, and error helpers
* only. The concrete `FileStoreService` implementation lives in
* `persistence/backends/node-fs/fileStoreService.ts`.
* uploaded bytes via `IBlobStore` and their `FileMeta` index in the same
* store, then hands callers a stream back on download. Bound at App scope.
*/
import type { Readable } from 'node:stream';
@ -36,7 +31,7 @@ export interface GetResult {
readonly stream: Readable;
}
export interface IFileStore {
export interface IFileService {
readonly _serviceBrand: undefined;
save(source: Readable, filename: string, options?: SaveOptions): Promise<FileMeta>;
@ -46,7 +41,7 @@ export interface IFileStore {
delete(fileId: string): Promise<void>;
}
export const IFileStore: ServiceIdentifier<IFileStore> = createDecorator<IFileStore>('fileStore');
export const IFileService: ServiceIdentifier<IFileService> = createDecorator<IFileService>('fileService');
// ---------------------------------------------------------------------------
// Error domain

View file

@ -1,11 +1,11 @@
/**
* `filestore` domain (L2) `IFileStore` implementation.
* `file` domain (L2) `IFileService` implementation.
*
* Streams uploads into the `IBlobStorage` backend under the `files` scope and
* keeps a JSON `FileMeta` index in the same backend under the `filestore`
* scope. Enforces the 50 MiB upload cap while collecting the stream, prunes the
* Streams uploads into the `IBlobStore` under the `files` scope and keeps a
* JSON `FileMeta` index in the same store under the `file` scope.
* Enforces the 50 MiB upload cap while collecting the stream, prunes the
* index when a referenced blob is missing, and hands downloads back as a lazy
* `Readable` over `readStream`. Bound at App scope.
* `Readable` over `getStream`. Bound at App scope.
*/
import { randomUUID } from 'node:crypto';
@ -15,18 +15,18 @@ import type { FileMeta } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IBlobStorage, type IStorageService } from '#/persistence/interface/storage';
import { IBlobStore } from '#/persistence/interface/blobStore';
import {
DEFAULT_MAX_UPLOAD_BYTES,
IFileStore,
IFileService,
fileNotFoundError,
fileTooLargeError,
type GetResult,
type SaveOptions,
} from '#/persistence/interface/fileStore';
} from './fileService';
const BLOB_SCOPE = 'files';
const INDEX_SCOPE = 'filestore';
const INDEX_SCOPE = 'file';
const INDEX_KEY = 'index.json';
const FILE_ID_REGEX = /^f_[A-Za-z0-9][A-Za-z0-9_-]*$/;
@ -58,13 +58,13 @@ function isFileMeta(value: unknown): value is FileMeta {
);
}
export class FileStoreService implements IFileStore {
export class FileServiceImpl implements IFileService {
declare readonly _serviceBrand: undefined;
private indexCache: Map<string, FileMeta> | undefined;
private indexLoadPromise: Promise<void> | undefined;
constructor(@IBlobStorage private readonly blobs: IStorageService) {}
constructor(@IBlobStore private readonly blobs: IBlobStore) {}
async save(source: Readable, filename: string, options: SaveOptions = {}): Promise<FileMeta> {
await this.ensureIndex();
@ -82,7 +82,7 @@ export class FileStoreService implements IFileStore {
}
const data = Buffer.concat(chunks);
await this.blobs.write(BLOB_SCOPE, id, data, { atomic: true });
await this.blobs.put(BLOB_SCOPE, id, data);
const now = Date.now();
const meta: FileMeta = {
@ -111,14 +111,14 @@ export class FileStoreService implements IFileStore {
throw fileNotFoundError(fileId);
}
const present = await this.blobs.list(BLOB_SCOPE, fileId);
if (!present.includes(fileId)) {
const present = await this.blobs.has(BLOB_SCOPE, fileId);
if (!present) {
this.indexCache!.delete(fileId);
await this.writeIndex();
throw fileNotFoundError(fileId);
}
return { meta, stream: Readable.from(this.blobs.readStream(BLOB_SCOPE, fileId)) };
return { meta, stream: Readable.from(this.blobs.getStream(BLOB_SCOPE, fileId)) };
}
async delete(fileId: string): Promise<void> {
@ -144,7 +144,7 @@ export class FileStoreService implements IFileStore {
}
private async loadIndex(): Promise<void> {
const raw = await this.blobs.read(INDEX_SCOPE, INDEX_KEY);
const raw = await this.blobs.get(INDEX_SCOPE, INDEX_KEY);
if (raw === undefined) {
this.indexCache = new Map();
return;
@ -169,16 +169,14 @@ export class FileStoreService implements IFileStore {
const cache = this.indexCache;
if (cache === undefined) return;
const payload: IndexFile = { version: 1, files: Array.from(cache.values()) };
await this.blobs.write(INDEX_SCOPE, INDEX_KEY, textEncoder.encode(JSON.stringify(payload)), {
atomic: true,
});
await this.blobs.put(INDEX_SCOPE, INDEX_KEY, textEncoder.encode(JSON.stringify(payload)));
}
}
registerScopedService(
LifecycleScope.App,
IFileStore,
FileStoreService,
IFileService,
FileServiceImpl,
InstantiationType.Delayed,
'filestore',
'file',
);

View file

@ -0,0 +1,6 @@
/**
* `file` domain barrel.
*/
export * from './fileService';
export * from './fileServiceImpl';

View file

@ -1,8 +0,0 @@
/**
* `filestore` domain barrel compatibility re-export.
*
* Re-exports from the new canonical locations in `persistence/`.
*/
export * from '#/persistence/interface/fileStore';
export * from '#/persistence/backends/node-fs/fileStoreService';

View file

@ -3,7 +3,7 @@
*
* Registers the code-defined builtin skills into an in-memory catalog. Builtin
* skills are constants (not discovered from storage), so they bypass the
* `ISkillCatalogStore` and are registered directly by the global catalog.
* `ISkillDiscovery` and are registered directly by the global catalog.
*/
import type { InMemorySkillCatalog } from '#/app/globalSkillCatalog/registry';

View file

@ -1,10 +1,10 @@
/**
* `globalSkillCatalog` domain (L5) filesystem `ISkillCatalogStore` backend.
* `globalSkillCatalog` domain (L5) filesystem `ISkillDiscovery` backend.
*
* Discovers skill bundles by walking skill roots on the local filesystem and
* parsing each SKILL.md through `parser`. This is the only file in the skill
* domain that imports `node:fs`; the rest of the domain depends on the
* `ISkillCatalogStore` interface and stays filesystem-agnostic. Bound at App
* `ISkillDiscovery` interface and stays filesystem-agnostic. Bound at App
* scope by the composition root (tests register the in-memory backend instead).
*/
@ -16,7 +16,7 @@ import {
UnsupportedSkillTypeError,
parseSkillText,
} from './parser';
import type { SkillDiscoveryResult, ISkillCatalogStore } from './skillCatalogStore';
import type { SkillDiscoveryResult, ISkillDiscovery } from './skillDiscovery';
import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types';
import { normalizeSkillName } from './types';
@ -31,7 +31,7 @@ const PROJECT_GENERIC_DIRS = ['.agents/skills'] as const;
// loop forever. Real skill trees are 1-3 levels deep.
const MAX_SKILL_SCAN_DEPTH = 8;
export class FileSkillCatalogStore implements ISkillCatalogStore {
export class FileSkillDiscovery implements ISkillDiscovery {
declare readonly _serviceBrand: undefined;
async discoverProject(

View file

@ -2,7 +2,7 @@
* `globalSkillCatalog` domain (L5) `IGlobalSkillCatalog` implementation.
*
* Registers the builtin skills and discovers user / brand skills through the
* `ISkillCatalogStore`, using the user home directories from `bootstrap`. The
* `ISkillDiscovery`, using the user home directories from `bootstrap`. The
* result is cached after the first `load()`. Bound at App scope.
*/
@ -13,7 +13,7 @@ import { IBootstrapService } from '#/app/bootstrap';
import { registerBuiltinSkills } from '#/app/globalSkillCatalog/builtin';
import { IGlobalSkillCatalog } from './globalSkillCatalog';
import { InMemorySkillCatalog } from './registry';
import { ISkillCatalogStore } from './skillCatalogStore';
import { ISkillDiscovery } from './skillDiscovery';
import type { SkillCatalog } from './types';
export class GlobalSkillCatalogService implements IGlobalSkillCatalog {
@ -23,7 +23,7 @@ export class GlobalSkillCatalogService implements IGlobalSkillCatalog {
private loaded = false;
constructor(
@ISkillCatalogStore private readonly store: ISkillCatalogStore,
@ISkillDiscovery private readonly store: ISkillDiscovery,
@IBootstrapService private readonly bootstrap: IBootstrapService,
) {}

View file

@ -1,5 +1,5 @@
/**
* `globalSkillCatalog` domain (L5) in-memory `ISkillCatalogStore` backend.
* `globalSkillCatalog` domain (L5) in-memory `ISkillDiscovery` backend.
*
* Returns preset skill lists for project / user discovery without any IO.
* Registered as the App-scope default so tests and scopes work without a
@ -10,11 +10,11 @@
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { SkillDiscoveryResult } from './skillCatalogStore';
import { ISkillCatalogStore } from './skillCatalogStore';
import type { SkillDiscoveryResult } from './skillDiscovery';
import { ISkillDiscovery } from './skillDiscovery';
import type { SkillDefinition } from './types';
export class InMemorySkillCatalogStore implements ISkillCatalogStore {
export class InMemorySkillDiscovery implements ISkillDiscovery {
declare readonly _serviceBrand: undefined;
private projectSkills: readonly SkillDefinition[] = [];
@ -39,8 +39,8 @@ export class InMemorySkillCatalogStore implements ISkillCatalogStore {
registerScopedService(
LifecycleScope.App,
ISkillCatalogStore,
InMemorySkillCatalogStore,
ISkillDiscovery,
InMemorySkillDiscovery,
InstantiationType.Delayed,
'skill',
);

View file

@ -2,13 +2,13 @@
* `globalSkillCatalog` domain barrel re-exports the skill catalog
* contracts, parsers, registry, and the App-scope catalog services. Importing
* this barrel registers the `IGlobalSkillCatalog` and the default in-memory
* `ISkillCatalogStore` bindings into the scope registry.
* `ISkillDiscovery` bindings into the scope registry.
*/
export * from './types';
export * from './parser';
export * from './registry';
export * from './skillCatalogStore';
export * from './inMemorySkillCatalogStore';
export * from './skillDiscovery';
export * from './inMemorySkillDiscovery';
export * from './globalSkillCatalog';
export * from './globalSkillCatalogService';

View file

@ -1,7 +1,7 @@
/**
* `globalSkillCatalog` domain (L5) catalog Store contract.
* `globalSkillCatalog` domain (L5) catalog discovery contract.
*
* `ISkillCatalogStore` is a business-specific Store that hides how skill
* `ISkillDiscovery` is a business-specific interface that hides how skill
* bundles are discovered: a backend walks a skill root, reads each SKILL.md,
* and parses it into `SkillDefinition`s. The skill domain depends on this
* interface only and never touches `node:fs` / `hostFs`; the backend is chosen
@ -19,7 +19,7 @@ export interface SkillDiscoveryResult {
readonly scannedRoots: readonly string[];
}
export interface ISkillCatalogStore {
export interface ISkillDiscovery {
readonly _serviceBrand: undefined;
discoverProject(
@ -30,4 +30,4 @@ export interface ISkillCatalogStore {
discoverUser(homeDir: string, osHomeDir: string): Promise<SkillDiscoveryResult>;
}
export const ISkillCatalogStore = createDecorator<ISkillCatalogStore>('skillCatalogStore');
export const ISkillDiscovery = createDecorator<ISkillDiscovery>('skillDiscovery');

View file

@ -24,7 +24,7 @@ import {
HostFolderPermissionError,
IHostFolderBrowser,
RECENT_ROOTS_LIMIT,
} from '#/os/interface/folderBrowser';
} from './hostFolderBrowser';
export class HostFolderBrowser implements IHostFolderBrowser {
declare readonly _serviceBrand: undefined;

View file

@ -1,6 +1,7 @@
/**
* `hostFolderBrowser` domain barrel compatibility re-export.
* `hostFolderBrowser` domain barrel re-exports the host folder picker
* contract and its node-local backend.
*/
export * from '#/os/interface/folderBrowser';
export * from '#/os/backends/node-local/folderBrowserService';
export * from './hostFolderBrowser';
export * from './hostFolderBrowserService';

View file

@ -4,7 +4,7 @@
* Reads the persisted session set through the `storage` access-pattern stores,
* rooted at the `sessionsDir` path layout fact from `bootstrap`. The directory
* tree `<sessionsDir>/<workspaceId>/<sessionId>/` is the index: workspace and
* session ids are enumerated via `IStorageService.list`, and each session's
* session ids are enumerated via `IFileSystemStorageService.list`, and each session's
* metadata document is read via `IAtomicDocumentStore` to build its summary.
*
* The session metadata document lives at `<sessionDir>/state.json`, a layout
@ -21,7 +21,7 @@
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap';
import { IAtomicDocumentStore, IStorageService, type Page } from '#/app/storage';
import { IAtomicDocumentStore, IFileSystemStorageService, type Page } from '#/app/storage';
import { ISessionIndex, type SessionListQuery, type SessionSummary } from './sessionIndex';
@ -43,7 +43,7 @@ export class FileSessionIndex implements ISessionIndex {
constructor(
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IStorageService private readonly storage: IStorageService,
@IFileSystemStorageService private readonly storage: IFileSystemStorageService,
@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore,
) {}

View file

@ -0,0 +1,8 @@
/**
* `task` domain barrel re-exports the task contract and implementation.
* Importing this barrel registers the `ITaskService` binding.
*/
export * from './interface';
import './taskService';
export { TaskService } from './taskService';

View file

@ -0,0 +1 @@
export * from './task';

View file

@ -0,0 +1,71 @@
/**
* `task` domain (L1) managed concurrent execution primitive.
*
* Two creation modes:
*
* - `run(fn)` active execution: wraps an async function with
* `AbortSignal`, output stream, state machine, and disposal.
* - `defer()` passive wait: the caller controls when the handle
* settles via `resolve` / `reject`.
*
* Consumers that need to track handles across turns (e.g. `background`)
* compose on top of these primitives; `ITaskService` itself is stateless
* beyond the set of live handles.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
import type { IDisposable } from '#/_base/di/lifecycle';
export type TaskState = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
export const TERMINAL_TASK_STATES: ReadonlySet<TaskState> = new Set([
'completed',
'failed',
'cancelled',
]);
export class TaskCancelledError extends Error {
constructor(readonly taskId: string) {
super(`Task ${taskId} was cancelled`);
this.name = 'TaskCancelledError';
}
}
export interface ITaskHandle<T = unknown> extends IDisposable {
readonly id: string;
readonly state: TaskState;
readonly result: Promise<T>;
readonly onDidChangeState: Event<TaskState>;
readonly onDidOutput: Event<string>;
cancel(): void;
}
export interface IDeferredHandle<T = unknown> extends ITaskHandle<T> {
resolve(value: T): void;
reject(reason?: unknown): void;
}
export interface ITaskService {
readonly _serviceBrand: undefined;
/**
* Create a task that actively runs `fn`. The function receives an
* `AbortSignal` (cancelled when the handle is cancelled/disposed) and
* an `output` callback for streaming data (e.g. process stdout).
*
* State: pending running completed | failed | cancelled.
*/
run<T>(fn: (signal: AbortSignal, output: (data: string) => void) => Promise<T>): ITaskHandle<T>;
/**
* Create a passive task whose settlement is controlled by the caller
* through the returned `resolve` / `reject` methods.
*
* State: pending completed | failed | cancelled.
*/
defer<T>(): IDeferredHandle<T>;
}
export const ITaskService: ServiceIdentifier<ITaskService> =
createDecorator<ITaskService>('taskService');

View file

@ -0,0 +1,187 @@
/**
* `task` domain (L1) `ITaskService` implementation.
*
* Manages task handles: each handle owns a state machine, an optional
* `AbortController` (for `run()`), and `Emitter` pairs for state changes
* and output. App-scoped one instance per process.
*/
import { Emitter, type Event } from '#/_base/event';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable, markAsDisposed, trackDisposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import {
type ITaskHandle,
type IDeferredHandle,
ITaskService,
type TaskState,
TERMINAL_TASK_STATES,
TaskCancelledError,
} from './interface/task';
function isTerminal(state: TaskState): boolean {
return TERMINAL_TASK_STATES.has(state);
}
class RunHandle<T> implements ITaskHandle<T> {
private _state: TaskState = 'pending';
private readonly _abortController = new AbortController();
private readonly _onDidChangeState = new Emitter<TaskState>();
readonly onDidChangeState: Event<TaskState> = this._onDidChangeState.event;
private readonly _onDidOutput = new Emitter<string>();
readonly onDidOutput: Event<string> = this._onDidOutput.event;
readonly result: Promise<T>;
private _disposed = false;
constructor(
readonly id: string,
fn: (signal: AbortSignal, output: (data: string) => void) => Promise<T>,
) {
trackDisposable(this);
const output = (data: string): void => {
if (!isTerminal(this._state) && !this._disposed) {
this._onDidOutput.fire(data);
}
};
this._transition('running');
this.result = fn(this._abortController.signal, output).then(
(value) => {
if (this._abortController.signal.aborted) {
this._transition('cancelled');
throw new TaskCancelledError(this.id);
}
this._transition('completed');
return value;
},
(error: unknown) => {
if (this._abortController.signal.aborted) {
this._transition('cancelled');
} else {
this._transition('failed');
}
throw error;
},
);
// Prevent unhandled rejection warnings when nobody has attached a handler yet.
void this.result.catch(() => {});
}
get state(): TaskState {
return this._state;
}
cancel(): void {
if (isTerminal(this._state)) return;
this._abortController.abort(new TaskCancelledError(this.id));
this._transition('cancelled');
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
markAsDisposed(this);
this.cancel();
this._onDidChangeState.dispose();
this._onDidOutput.dispose();
}
private _transition(to: TaskState): void {
if (isTerminal(this._state)) return;
this._state = to;
if (!this._disposed) {
this._onDidChangeState.fire(to);
}
}
}
class DeferHandle<T> implements IDeferredHandle<T> {
private _state: TaskState = 'pending';
private _resolvePromise!: (value: T) => void;
private _rejectPromise!: (reason: unknown) => void;
private readonly _onDidChangeState = new Emitter<TaskState>();
readonly onDidChangeState: Event<TaskState> = this._onDidChangeState.event;
private readonly _onDidOutput = new Emitter<string>();
readonly onDidOutput: Event<string> = this._onDidOutput.event;
readonly result: Promise<T>;
private _disposed = false;
constructor(readonly id: string) {
trackDisposable(this);
this.result = new Promise<T>((resolve, reject) => {
this._resolvePromise = resolve;
this._rejectPromise = reject;
});
void this.result.catch(() => {});
}
get state(): TaskState {
return this._state;
}
resolve(value: T): void {
if (isTerminal(this._state)) return;
this._transition('completed');
this._resolvePromise(value);
}
reject(reason?: unknown): void {
if (isTerminal(this._state)) return;
this._transition('failed');
this._rejectPromise(reason);
}
cancel(): void {
if (isTerminal(this._state)) return;
this._transition('cancelled');
this._rejectPromise(new TaskCancelledError(this.id));
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
markAsDisposed(this);
this.cancel();
this._onDidChangeState.dispose();
this._onDidOutput.dispose();
}
private _transition(to: TaskState): void {
if (isTerminal(this._state)) return;
this._state = to;
if (!this._disposed) {
this._onDidChangeState.fire(to);
}
}
}
export class TaskService extends Disposable implements ITaskService {
declare readonly _serviceBrand: undefined;
private _nextId = 0;
run<T>(fn: (signal: AbortSignal, output: (data: string) => void) => Promise<T>): ITaskHandle<T> {
return new RunHandle<T>(this._generateId(), fn);
}
defer<T>(): IDeferredHandle<T> {
return new DeferHandle<T>(this._generateId());
}
private _generateId(): string {
return `task-${this._nextId++}`;
}
}
registerScopedService(
LifecycleScope.App,
ITaskService,
TaskService,
InstantiationType.Delayed,
'task',
);

View file

@ -9,7 +9,7 @@
import { randomUUID } from 'node:crypto';
import { arch, platform, release } from 'node:os';
import type { IStorageService } from '#/app/storage';
import type { IFileSystemStorageService } from '#/app/storage';
import type { ITelemetryAppender, TelemetryContextPatch, TelemetryProperties } from './telemetry';
import {
@ -22,7 +22,7 @@ import {
} from './cloudTransport';
export interface CloudAppenderOptions {
readonly storage: IStorageService;
readonly storage: IFileSystemStorageService;
readonly deviceId: string;
readonly sessionId?: string;
readonly appName: string;

View file

@ -2,13 +2,13 @@
* `telemetry` domain (L1) `CloudTransport`, the HTTP transport behind
* `CloudAppender`. Posts enriched events to the telemetry endpoint with Bearer
* auth, retry, and a byte-store fallback for failed events, persisted through
* the `storage` byte layer (`IStorageService`) under the `telemetry` scope.
* the `storage` byte layer (`IFileSystemStorageService`) under the `telemetry` scope.
* App-scoped; independent of `@moonshot-ai/kimi-telemetry`.
*/
import { randomBytes } from 'node:crypto';
import type { IStorageService } from '#/app/storage';
import type { IFileSystemStorageService } from '#/app/storage';
export type CloudPrimitive = boolean | number | string | undefined | null;
@ -35,7 +35,7 @@ export interface CloudPayload {
}
export interface CloudTransportOptions {
readonly storage: IStorageService;
readonly storage: IFileSystemStorageService;
readonly deviceId: string;
readonly endpoint?: string;
readonly getAccessToken?: () => string | null | Promise<string | null>;
@ -61,7 +61,7 @@ const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export class CloudTransport {
private readonly storage: IStorageService;
private readonly storage: IFileSystemStorageService;
private readonly deviceId: string;
private readonly endpoint: string;
private readonly getAccessToken: (() => string | null | Promise<string | null>) | null;

View file

@ -1,7 +1,7 @@
/**
* `workspaceRegistry` domain (L1) `FileWorkspaceStore` implementation.
* `workspaceRegistry` domain (L1) `FileWorkspacePersistence` implementation.
*
* File backend of `IWorkspaceStore`. Persists the catalog as a single
* File backend of `IWorkspacePersistence`. Persists the catalog as a single
* v1-compatible `workspaces.json` document at the storage root
* (`<homeDir>/workspaces.json`, via `scope = ''`) through the
* `IAtomicDocumentStore` access-pattern Store. Bound at App scope.
@ -13,10 +13,10 @@ import { IAtomicDocumentStore } from '#/app/storage';
import type { Workspace } from './workspaceRegistry';
import {
IWorkspaceStore,
IWorkspacePersistence,
type PersistedWorkspaceEntry,
type PersistedWorkspaceFile,
} from './workspaceStore';
} from './workspacePersistence';
const WORKSPACE_REGISTRY_VERSION = 1;
// Empty scope resolves to `<homeDir>/<key>` (join skips empty segments),
@ -24,7 +24,7 @@ const WORKSPACE_REGISTRY_VERSION = 1;
const WORKSPACE_REGISTRY_SCOPE = '';
const WORKSPACE_REGISTRY_KEY = 'workspaces.json';
export class FileWorkspaceStore implements IWorkspaceStore {
export class FileWorkspacePersistence implements IWorkspacePersistence {
declare readonly _serviceBrand: undefined;
constructor(@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore) {}
@ -105,8 +105,8 @@ function parseTime(value: string, fallback: number): number {
registerScopedService(
LifecycleScope.App,
IWorkspaceStore,
FileWorkspaceStore,
IWorkspacePersistence,
FileWorkspacePersistence,
InstantiationType.Delayed,
'workspaceRegistry',
);

View file

@ -7,5 +7,5 @@
export * from './workspaceRegistry';
export * from './workspaceRegistryService';
export * from './workspaceStore';
export * from './fileWorkspaceStore';
export * from './workspacePersistence';
export * from './fileWorkspacePersistence';

View file

@ -1,5 +1,5 @@
/**
* `workspaceRegistry` domain (L1) `IWorkspaceStore` contract.
* `workspaceRegistry` domain (L1) `IWorkspacePersistence` contract.
*
* Domain-specific persistence Store for the known-workspaces catalog. It hides
* the on-disk document layout (`<homeDir>/workspaces.json`, the v1-compatible
@ -30,7 +30,7 @@ export interface PersistedWorkspaceFile {
readonly workspaces: Record<string, PersistedWorkspaceEntry>;
}
export interface IWorkspaceStore {
export interface IWorkspacePersistence {
readonly _serviceBrand: undefined;
/**
@ -46,5 +46,5 @@ export interface IWorkspaceStore {
save(workspaces: readonly Workspace[]): Promise<void>;
}
export const IWorkspaceStore: ServiceIdentifier<IWorkspaceStore> =
createDecorator<IWorkspaceStore>('workspaceStore');
export const IWorkspacePersistence: ServiceIdentifier<IWorkspacePersistence> =
createDecorator<IWorkspacePersistence>('workspacePersistence');

View file

@ -2,7 +2,7 @@
* `workspaceRegistry` domain (L1) `IWorkspaceRegistry` implementation.
*
* Process-wide catalog of known workspaces, now durable: an in-memory cache
* is loaded once from `IWorkspaceStore` (`<homeDir>/workspaces.json`, v1
* is loaded once from `IWorkspacePersistence` (`<homeDir>/workspaces.json`, v1
* compatible) and every mutation writes back through it. When the catalog is
* absent or malformed, it is rebuilt once from the legacy
* `<homeDir>/session_index.jsonl` (one workspace per distinct absolute
@ -16,10 +16,10 @@ import { basename, isAbsolute } from 'pathe';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
import { IStorageService } from '#/app/storage';
import { IFileSystemStorageService } from '#/app/storage';
import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry';
import { IWorkspaceStore } from './workspaceStore';
import { IWorkspacePersistence } from './workspacePersistence';
// Legacy v1 session index, read only for the one-shot rebuild. Empty scope
// resolves to `<homeDir>/<key>` (join skips empty segments).
@ -42,8 +42,8 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {
private opQueue: Promise<unknown> = Promise.resolve();
constructor(
@IWorkspaceStore private readonly store: IWorkspaceStore,
@IStorageService private readonly storage: IStorageService,
@IWorkspacePersistence private readonly store: IWorkspacePersistence,
@IFileSystemStorageService private readonly storage: IFileSystemStorageService,
) {}
list(): Promise<readonly Workspace[]> {

View file

@ -13,7 +13,7 @@ import { AuthErrors } from '#/app/auth/errors';
import { BackgroundErrors } from '#/agent/background/errors';
import { ChatProviderErrors } from '#/app/protocol/errors';
import { ConfigErrors } from '#/app/config/errors';
import { FileErrors } from '#/persistence/interface/fileStore';
import { FileErrors } from '#/app/file/fileService';
import { FsErrors } from '#/session/agentFs/errors';
import { FullCompactionErrors } from '#/agent/fullCompaction/errors';
import { GoalErrors } from '#/agent/goal/errors';
@ -37,7 +37,7 @@ export { AuthErrors } from '#/app/auth/errors';
export { BackgroundErrors } from '#/agent/background/errors';
export { ChatProviderErrors } from '#/app/protocol/errors';
export { ConfigErrors } from '#/app/config/errors';
export { FileErrors } from '#/persistence/interface/fileStore';
export { FileErrors } from '#/app/file/fileService';
export { FsErrors } from '#/session/agentFs/errors';
export { FullCompactionErrors } from '#/agent/fullCompaction/errors';
export { GoalErrors } from '#/agent/goal/errors';

View file

@ -12,6 +12,8 @@ export * from '#/app/telemetry';
export * from '#/app/bootstrap';
export * from '#/os/interface';
export * from '#/os/backends/node-local';
export * from '#/session/terminal';
export * from '#/app/task';
export { IEventService, type DomainEvent } from '#/app/event';
export * from '#/app/llmProtocol';
@ -42,6 +44,8 @@ export * from '#/agent/usage';
export * from '#/agent/toolDedupe';
export * from '#/agent/background';
export * from '#/app/cronPersistence';
export * from '#/session/cron';
import '#/agent/cron';
export * from '#/session/agentLifecycle';
@ -60,15 +64,19 @@ export * from '#/app/gateway';
export * from '#/session/workspaceContext';
export * from '#/app/workspaceRegistry';
export * from '#/session/execContext';
export * from '#/session/process';
export * from '#/session/agentFs';
export * from '#/app/hostFolderBrowser';
export * from '#/persistence/interface';
export * from '#/persistence/backends/node-fs';
export * from '#/persistence/backends/memory';
export * from '#/app/auth';
export * from '#/app/authLegacy';
export * from '#/app/file';
// Ported agent services. These keep the current service boundaries during the migration.
export * from '#/agent/blobStore';
export * from '#/agent/blob';
export * from '#/agent/contextMemory';
export * from '#/agent/systemReminder';
export * from '#/agent/contextProjector';
@ -103,7 +111,7 @@ export {
registerTool,
} from '#/agent/toolRegistry';
export type { ToolContribution, ToolContributionOptions } from '#/agent/toolRegistry';
export * from '#/agent/toolStore';
export * from '#/agent/toolState';
export * from '#/agent/userTool';
export * from '#/agent/wireRecord';
export * from '#/agent/fileTools';

View file

@ -0,0 +1,197 @@
/**
* `hostProcess` domain (L6) `IHostProcessService` node-local implementation.
*
* Spawns child processes with `node:child_process.spawn`, wraps them in the
* domain-facing `IHostProcess` handle, and provides cross-platform process-tree
* termination. The service itself is stateless; each `spawn()` returns an
* independent handle that owns its streams and exit promise. Bound at App scope.
*/
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { BufferedReadable } from '#/_base/execEnv';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import {
HostProcessError,
HostProcessErrorCode,
IHostProcessService,
type HostProcessOptions,
type IHostProcess,
} from '#/os/interface/hostProcess';
const isWindows: boolean = process.platform === 'win32';
function buildSpawnOptions(options: HostProcessOptions): SpawnOptions {
const detached = options.detached ?? !isWindows;
const spawnOptions: SpawnOptions = {
cwd: options.cwd,
env: buildEnv(options.env),
stdio: options.mergeStderr ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
detached,
windowsHide: options.windowsHide ?? true,
};
if (options.shell !== undefined) {
spawnOptions.shell = options.shell;
}
return spawnOptions;
}
function buildEnv(overrides: Record<string, string> | undefined): Record<string, string> | undefined {
if (overrides === undefined) {
return undefined;
}
return { ...(process.env as Record<string, string>), ...overrides };
}
function waitForSpawn(child: ChildProcess): Promise<void> {
return new Promise((resolve, reject) => {
const onSpawn = (): void => {
child.off('error', onError);
resolve();
};
const onError = (err: Error): void => {
child.off('spawn', onSpawn);
reject(err);
};
child.once('spawn', onSpawn);
child.once('error', onError);
});
}
class HostProcess implements IHostProcess {
declare readonly _serviceBrand: undefined;
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
readonly pid: number;
private readonly _child: ChildProcess;
private _exitCode: number | null = null;
private readonly _exitPromise: Promise<number>;
private _disposed = false;
constructor(child: ChildProcess, mergeStderr: boolean) {
if (child.stdin === null || child.stdout === null) {
throw new HostProcessError(
HostProcessErrorCode.SpawnFailed,
'Process must be created with stdin/stdout pipes.',
);
}
if (!mergeStderr && child.stderr === null) {
throw new HostProcessError(
HostProcessErrorCode.SpawnFailed,
'Process must be created with stderr pipe unless mergeStderr is set.',
);
}
this._child = child;
this.stdin = child.stdin;
this.stdout = new BufferedReadable(child.stdout);
this.stderr = mergeStderr
? this.stdout
: new BufferedReadable(child.stderr as Readable);
this.pid = child.pid ?? -1;
this._exitPromise = new Promise<number>((resolve, reject) => {
child.on('exit', (code: number | null) => {
this._exitCode = code ?? -1;
resolve(this._exitCode);
});
child.on('error', (error: Error) => {
reject(error);
});
});
}
get exitCode(): number | null {
return this._exitCode;
}
async wait(): Promise<number> {
return this._exitPromise;
}
async kill(signal?: NodeJS.Signals): Promise<void> {
if (this.pid <= 0) {
return;
}
if (isWindows) {
const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)];
return new Promise<void>((resolve) => {
const killer = spawn('taskkill', taskkillArgs, {
stdio: 'ignore',
windowsHide: true,
});
const done = (): void => {
resolve();
};
killer.once('error', done);
killer.once('close', done);
});
}
try {
process.kill(-this.pid, signal ?? 'SIGTERM');
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ESRCH') return;
if (err.code === 'EPERM') {
try {
this._child.kill(signal ?? 'SIGTERM');
} catch {
/* best effort */
}
return;
}
throw error;
}
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this.stdin.destroy();
this.stdout.destroy();
if (this.stderr !== this.stdout) {
this.stderr.destroy();
}
}
}
export class HostProcessService implements IHostProcessService {
declare readonly _serviceBrand: undefined;
async spawn(
command: string,
args: readonly string[] = [],
options: HostProcessOptions = {},
): Promise<IHostProcess> {
const spawnOptions = buildSpawnOptions(options);
const child = spawn(command, args as string[], spawnOptions);
try {
await waitForSpawn(child);
} catch (error) {
const err = error as NodeJS.ErrnoException;
throw new HostProcessError(
HostProcessErrorCode.SpawnFailed,
`Failed to spawn "${command}": ${err.message}`,
);
}
return new HostProcess(child, options.mergeStderr ?? false);
}
}
registerScopedService(
LifecycleScope.App,
IHostProcessService,
HostProcessService,
InstantiationType.Delayed,
'hostProcess',
);

View file

@ -0,0 +1,66 @@
/**
* `terminal` domain (L6) `IHostTerminalService` implementation.
*
* App-scoped OS terminal process factory backed by `node-pty`. It spawns and
* tracks every `TerminalProcess` so the whole process-wide PTY layer can be
* torn down on disposal. It has no session, workspace, or buffering concerns;
* those live in the Session-scoped `ISessionTerminalService`.
*
* `node-pty` is loaded lazily so merely importing this module (for example in
* tests that override the service with a fake) does not require the native
* module to be built or resolvable.
*/
import type { IPty } from 'node-pty';
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IHostTerminalService, type TerminalProcess, type TerminalSpawnOptions } from '#/os/interface/terminal';
export class HostTerminalService extends Disposable implements IHostTerminalService {
declare readonly _serviceBrand: undefined;
private readonly processes = new Set<TerminalProcess>();
async spawn(options: TerminalSpawnOptions): Promise<TerminalProcess> {
const pty = await import('node-pty');
const proc: IPty = pty.spawn(options.shell, [], {
name: 'xterm-256color',
cwd: options.cwd,
cols: options.cols,
rows: options.rows,
env: globalThis.process.env,
});
const terminalProcess: TerminalProcess = {
onData: (listener) => proc.onData(listener),
onExit: (listener) => proc.onExit((event) => listener({ exitCode: event.exitCode })),
write: (data) => proc.write(data),
resize: (cols, rows) => proc.resize(cols, rows),
kill: () => proc.kill(),
};
this.processes.add(terminalProcess);
return terminalProcess;
}
override dispose(): void {
for (const process of this.processes) {
try {
process.kill();
} catch {
// best-effort cleanup
}
}
this.processes.clear();
super.dispose();
}
}
registerScopedService(
LifecycleScope.App,
IHostTerminalService,
HostTerminalService,
InstantiationType.Delayed,
'terminal',
);

View file

@ -1,7 +1,4 @@
export * from './hostEnvironmentService';
export * from './hostFsService';
export * from './agentFsService';
export * from './processRunnerService';
export * from './terminalBackend';
export * from './terminalService';
export * from './folderBrowserService';
export * from './hostProcessService';
export * from './hostTerminalService';

View file

@ -1,161 +0,0 @@
/**
* `process` domain (L1) spawned-process primitives.
*
* Vendored from the former `@moonshot-ai/kaos` `LocalProcess`. `SpawnedProcess`
* wraps a Node `ChildProcess` into the domain-facing `IProcess` handle, and
* `buildLocalSpawnOptions` / `waitForSpawn` are the two spawn-time helpers used
* by the session process runner. Kept out of the runner file so the runner
* only orchestrates cwd/env resolution and delegates the lifetime plumbing
* here.
*/
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { BufferedReadable } from '#/_base/execEnv';
import type { IProcess } from '#/os/interface/process';
export const isWindows: boolean = process.platform === 'win32';
export function buildLocalSpawnOptions(
isWindowsHost: boolean,
cwd: string,
env: Record<string, string> | undefined,
): SpawnOptions {
return {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: !isWindowsHost,
windowsHide: true,
};
}
// Wait for a freshly spawned ChildProcess to either emit 'spawn' (success) or
// 'error' (ENOENT / EACCES / etc.). Until this resolves, callers should not
// assume the child is running — they may otherwise write to the stdin of a
// process that never existed.
export function waitForSpawn(child: ChildProcess): Promise<void> {
return new Promise((resolve, reject) => {
const onSpawn = (): void => {
child.off('error', onError);
resolve();
};
const onError = (err: Error): void => {
child.off('spawn', onSpawn);
reject(err);
};
child.once('spawn', onSpawn);
child.once('error', onError);
});
}
export class SpawnedProcess implements IProcess {
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
readonly pid: number;
private readonly _child: ChildProcess;
private _exitCode: number | null = null;
private readonly _exitPromise: Promise<number>;
private _disposed = false;
constructor(child: ChildProcess) {
if (child.stdin === null || child.stdout === null || child.stderr === null) {
throw new Error('Process must be created with stdin/stdout/stderr pipes.');
}
this._child = child;
this.stdin = child.stdin;
this.stdout = new BufferedReadable(child.stdout);
this.stderr = new BufferedReadable(child.stderr);
this.pid = child.pid ?? -1;
this._exitPromise = new Promise<number>((resolve, reject) => {
child.on('exit', (code: number | null) => {
this._exitCode = code ?? -1;
resolve(this._exitCode);
});
child.on('error', (error: Error) => {
reject(error);
});
});
}
get exitCode(): number | null {
return this._exitCode;
}
async wait(): Promise<number> {
return this._exitPromise;
}
kill(signal?: NodeJS.Signals): Promise<void> {
// Reject if the process never actually started (spawn failed).
// pid <= 0 indicates ChildProcess.pid was undefined, which happens
// when spawn() fails to find/execute the command. Calling
// process.kill(-1, ...) on POSIX would signal the entire process
// group, potentially killing unrelated processes.
if (this.pid <= 0) {
return Promise.resolve();
}
// On Windows, `ChildProcess.kill()` only signals the shell parent, leaving
// grandchildren alive, so terminate the whole process tree with
// `taskkill /T`. A graceful `taskkill /T` (no `/F`) does not actually
// terminate a console node.exe tree, and Windows has no real graceful
// signal for it — Node's own `ChildProcess.kill()` is always a forceful
// TerminateProcess on Windows — so always force-terminate the tree.
if (isWindows) {
const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)];
return new Promise<void>((resolve) => {
const killer = spawn('taskkill', taskkillArgs, {
stdio: 'ignore',
windowsHide: true,
});
const done = (): void => {
resolve();
};
killer.once('error', done);
killer.once('close', done);
});
}
// On POSIX, `detached:true` makes the child a process-group leader
// (pgid === pid). A plain `ChildProcess.kill()` still only signals the
// direct child, so a shell like `bash -c 'sleep 100 & sleep 100'` leaves
// grandchildren orphaned. `process.kill(-pid, signal)` signals the group
// (negative pid = process-group id under POSIX kill(2)).
try {
process.kill(-this.pid, signal ?? 'SIGTERM');
} catch (error) {
const err = error as NodeJS.ErrnoException;
// ESRCH = group already gone (child exited + reaped between
// `wait()` racing spawn + this call). Treat as successful kill.
if (err.code === 'ESRCH') return Promise.resolve();
// EPERM is typically a misconfiguration (e.g. non-detached
// spawn earlier in the file); fall back to direct `.kill()` so
// we at least signal the direct child instead of throwing.
if (err.code === 'EPERM') {
try {
this._child.kill(signal ?? 'SIGTERM');
} catch {
/* best effort */
}
return Promise.resolve();
}
throw error;
}
return Promise.resolve();
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this.stdin.destroy();
this.stdout.destroy();
this.stderr.destroy();
}
}

View file

@ -1,31 +0,0 @@
/**
* `terminal` domain (L6) default `ISessionTerminalBackend` stub.
*
* Placeholder backend registered so the binding graph is complete and
* `ISessionTerminalService` resolves out of the box. It cannot spawn a real PTY; a
* composition root that needs interactive terminals (for example the server
* or the desktop app, both of which already depend on `node-pty`) supplies a
* real backend through the scope registry to override this one.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { NotImplementedError } from '#/_base/errors';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { type TerminalProcess, type TerminalSpawnOptions, ISessionTerminalBackend } from '#/os/interface/terminal';
export class SessionNotImplementedTerminalBackend implements ISessionTerminalBackend {
declare readonly _serviceBrand: undefined;
spawn(_options: TerminalSpawnOptions): Promise<TerminalProcess> {
throw new NotImplementedError('terminalBackend');
}
}
registerScopedService(
LifecycleScope.Session,
ISessionTerminalBackend,
SessionNotImplementedTerminalBackend,
InstantiationType.Delayed,
'terminal',
);

View file

@ -0,0 +1,84 @@
/**
* `hostProcess` domain (L1) the OS process-spawning contract.
*
* Defines `IHostProcessService`, the App-scope primitive used by any domain that
* needs to spawn a child process on the host, plus the `IHostProcess` handle it
* returns. The contract is deliberately close to Python `subprocess.Popen` /
* `os.spawn*`: a single `spawn()` call returns a handle exposing stdin/stdout/
* stderr, the pid, the exit code, and lifecycle methods. Bound at App scope;
* backends in `os/backends/node-local` provide the Node implementation.
*/
import type { Readable, Writable } from 'node:stream';
import { KimiError, type ErrorCode } from '#/_base/errors';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface HostProcessOptions {
/** Working directory for the child. Defaults to `process.cwd()`. */
readonly cwd?: string;
/** Complete env bag for the child. When omitted the child inherits `process.env`. */
readonly env?: Record<string, string>;
/**
* If `true`, the command is run through the system shell. If a string, it is
* used as the shell path. Mirrors Python `subprocess.run(..., shell=True)`.
*/
readonly shell?: boolean | string;
/**
* Whether the child becomes a process-group leader. Default is `true` on
* POSIX and `false` on Windows so that `kill()` can signal the whole tree.
*/
readonly detached?: boolean;
/** Hide the child window on Windows. Default `true`. */
readonly windowsHide?: boolean;
/** Redirect stderr into stdout (the child still gets a merged stream). */
readonly mergeStderr?: boolean;
/** Optional timeout in milliseconds for `wait()`. */
readonly timeout?: number;
}
export interface IHostProcess {
readonly _serviceBrand: undefined;
readonly pid: number;
readonly exitCode: number | null;
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
/** Wait for the process to exit and return its exit code. */
wait(): Promise<number>;
/** Kill the process tree (not just the direct child) with the given signal. */
kill(signal?: NodeJS.Signals): Promise<void>;
/** Release stdio streams. Does not kill the process. */
dispose(): void;
}
export interface IHostProcessService {
readonly _serviceBrand: undefined;
/**
* Spawn a child process on the host. Resolves once the child has successfully
* started (or rejects with a coded error if spawn fails with ENOENT / EACCES
* / etc.).
*/
spawn(
command: string,
args?: readonly string[],
options?: HostProcessOptions,
): Promise<IHostProcess>;
}
export const IHostProcessService: ServiceIdentifier<IHostProcessService> =
createDecorator<IHostProcessService>('hostProcessService');
export const HostProcessErrorCode = {
SpawnFailed: 'process.spawn_failed' as ErrorCode,
} as const;
export class HostProcessError extends KimiError {
constructor(code: (typeof HostProcessErrorCode)[keyof typeof HostProcessErrorCode], message: string) {
super(code, message);
this.name = 'HostProcessError';
}
}

Some files were not shown because too many files have changed in this diff Show more