refactor(agent-core-v2): drop ISessionAgentFileSystem, use os layer

- remove the ISessionAgentFileSystem service and its node implementation
- extend IHostFileSystem with readText decode options and stat mtimeMs/ino
- route SessionFsService, profile context, media, and plan through IHostFileSystem
- drop the unused agentFs:* session REST endpoints from server-v2/server-e2e
This commit is contained in:
haozhe.yang 2026-07-03 21:08:00 +08:00
parent 80d65c7a85
commit bcc9bc095f
29 changed files with 220 additions and 768 deletions

View file

@ -18,13 +18,13 @@ import type { Model } from '#/app/model';
import { toDisposable, type IDisposable } from '#/_base/di';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
import type { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { ReadMediaFileTool, type VideoUploader } from '#/agent/media/tools/read-media';
export interface RegisterMediaToolsDeps {
readonly fs: ISessionAgentFileSystem;
readonly fs: IHostFileSystem;
readonly env: IHostEnvironment;
readonly workspace: WorkspaceConfig;
readonly capabilities: ModelCapability;

View file

@ -27,7 +27,7 @@ import type {
} from '#/app/llmProtocol';
import { z } from 'zod';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
@ -136,7 +136,7 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
readonly description: string;
readonly parameters: Record<string, unknown> = toInputJsonSchema(ReadMediaFileInputSchema);
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly fs: IHostFileSystem,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
private readonly capabilities: ModelCapability,

View file

@ -14,7 +14,7 @@ import {
import { generateHeroSlug } from "#/_base/utils/hero-slug";
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentTelemetryContextService, ITelemetryService } from '#/app/telemetry';
import { IAgentRecordService } from '#/agent/record';
@ -56,7 +56,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentRecordService private readonly record: IAgentRecordService,
@ISessionAgentFileSystem private readonly agentFs: ISessionAgentFileSystem,
@IHostFileSystem private readonly agentFs: IHostFileSystem,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@ -344,7 +344,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {
}
private async ensurePlanDirectory(path: string): Promise<void> {
await this.agentFs.mkdir(dirname(path));
await this.agentFs.mkdir(dirname(path), { recursive: true });
}
private currentCwd(): string {

View file

@ -5,7 +5,7 @@
* then project-level files from the project root down to the cwd) and assembles
* the {@link SystemPromptContext} bag consumed by `IAgentProfileService.useProfile`.
*
* Runs on top of `ISessionAgentFileSystem` (for `readText` / `stat` / `readdir`)
* Runs on top of the os `IHostFileSystem` (for `readText` / `stat` / `readdir`)
* plus the host's `homeDir` supplied together as a small `ProfileContextDeps`
* bag threaded through the helpers.
*
@ -18,7 +18,7 @@
import { dirname, join, normalize } from 'pathe';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import type { SystemPromptContext } from './profile';
@ -38,7 +38,7 @@ export const LIST_DIR_CHILD_WIDTH = 10;
* the filesystem primitive plus the host home directory, not on `IKaos`.
*/
interface ProfileContextDeps {
readonly fs: ISessionAgentFileSystem;
readonly fs: IHostFileSystem;
readonly homeDir: string;
}
@ -253,7 +253,7 @@ function dedupeDirs(dirs: readonly string[]): string[] {
// ---------------------------------------------------------------------------
// listDirectory — compact 2-level directory tree for LLM context.
// Port of v1 `packages/agent-core/src/tools/support/list-directory.ts`, driven
// through the v2 `ISessionAgentFileSystem` (`readdir` + `stat`).
// through the os `IHostFileSystem` (`readdir` + `stat`).
// ---------------------------------------------------------------------------
interface ListDirectoryOptions {
@ -272,16 +272,9 @@ async function collectEntries(
): Promise<{ entries: Entry[]; total: number; readable: boolean }> {
const all: Entry[] = [];
try {
const names = await deps.fs.readdir(dirPath);
for (const name of names) {
let isDir = false;
try {
const st = await deps.fs.stat(join(dirPath, name));
isDir = st.isDirectory;
} catch {
// Unreadable entries keep isDir=false; still list the name.
}
all.push({ name, isDir });
const dirents = await deps.fs.readdir(dirPath);
for (const d of dirents) {
all.push({ name: d.name, isDir: d.isDirectory });
}
} catch {
return { entries: [], total: 0, readable: false };

View file

@ -28,7 +28,7 @@ import { IConfigService } from '#/app/config';
import { resolveThinkingEffort } from './thinking';
import type { LoopControl } from '#/agent/loop/configSection';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IExecContext } from '#/session/execContext';
import { isMcpToolName } from '#/agent/tool';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
@ -81,7 +81,7 @@ export class AgentProfileService implements IAgentProfileService {
@IConfigService private readonly config: IConfigService,
@IModelResolver private readonly modelFactory: IModelResolver,
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@IHostFileSystem private readonly fs: IHostFileSystem,
@IExecContext private readonly execCtx: IExecContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,

View file

@ -36,8 +36,16 @@ function* splitLinesKeepingTerminator(text: string): Generator<string> {
export class HostFileSystem implements IHostFileSystem {
declare readonly _serviceBrand: undefined;
async readText(path: string): Promise<string> {
return readFile(path, 'utf8');
async readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): Promise<string> {
if (options === undefined) {
return readFile(path, 'utf8');
}
const encoding = options.encoding ?? 'utf-8';
const errors = options.errors ?? 'strict';
return decodeTextWithErrors(await readFile(path), encoding, errors);
}
async writeText(path: string, data: string): Promise<void> {
@ -142,7 +150,13 @@ export class HostFileSystem implements IHostFileSystem {
async stat(path: string): Promise<HostFileStat> {
const s = await stat(path);
return { isFile: s.isFile(), isDirectory: s.isDirectory(), size: s.size };
return {
isFile: s.isFile(),
isDirectory: s.isDirectory(),
size: s.size,
mtimeMs: s.mtimeMs,
ino: s.ino,
};
}
async readdir(path: string): Promise<readonly HostDirEntry[]> {

View file

@ -14,6 +14,10 @@ export interface HostFileStat {
readonly isFile: boolean;
readonly isDirectory: boolean;
readonly size: number;
/** Last-modified time in epoch milliseconds, when the backend exposes it. */
readonly mtimeMs?: number;
/** Inode number, when the backend exposes it (`0` on backends without inodes). */
readonly ino?: number;
}
export interface HostDirEntry {
@ -25,7 +29,10 @@ export interface HostDirEntry {
export interface IHostFileSystem {
readonly _serviceBrand: undefined;
readText(path: string): Promise<string>;
readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): Promise<string>;
writeText(path: string, data: string): Promise<void>;
/**
* Read bytes from `path`. When `n` is given, reads at most the first `n`

View file

@ -1,433 +0,0 @@
/**
* `agentFs` domain (L2) `ISessionAgentFileSystem` implementation.
*
* Focused file-IO surface implemented directly on Node's `fs/promises`.
* Relative paths are resolved against `IExecContext.cwd`; `glob` uses the
* vendored `_globWalk` traversal (with (dev, ino) cycle detection tailored
* around Windows FAT/exFAT inode-less filesystems). No `IKaos` dependency
* `withCwd` derives a fresh instance around `IExecContext.withCwd(cwd)`.
* Bound at Session scope.
*/
import { mkdir, open, readdir, readFile, stat, writeFile, appendFile } from 'node:fs/promises';
import { isAbsolute, join, normalize } from 'pathe';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import {
decodeTextWithErrors,
globPatternToRegex,
type TextDecodeErrors,
} from '#/_base/execEnv';
import { ErrorCodes, KimiError } from '#/errors';
import { IExecContext } from '#/session/execContext';
import { type AgentFileStat, ISessionAgentFileSystem } from './fileSystem';
const READ_CHUNK_SIZE = 64 * 1024;
/**
* Build the `(dev, ino)` cycle-detection key used by `_globWalk`'s
* visited set. Returns `null` when `ino` is 0, which Node returns on
* filesystems that don't carry inodes (Windows FAT/exFAT, some SMB/NFS
* mounts). A null key signals "no reliable identity for this dir" so
* the caller skips visited tracking for that descent cycle safety
* is weakened on those filesystems, but normal walking works instead
* of every directory colliding on the shared key `"<dev>:0"`.
*/
function cycleKey(s: { dev: number; ino: number }): string | null {
if (s.ino === 0) return null;
return `${String(s.dev)}:${String(s.ino)}`;
}
function isUtf8Encoding(encoding: BufferEncoding): boolean {
return encoding === 'utf-8' || encoding === 'utf8';
}
function* splitLinesKeepingTerminator(text: string): Generator<string> {
if (text.length === 0) return;
let start = 0;
for (let i = 0; i < text.length; i += 1) {
if (text.codePointAt(i) === 0x0a) {
yield text.slice(start, i + 1);
start = i + 1;
}
}
if (start < text.length) {
yield text.slice(start);
}
}
export class SessionAgentFileSystem implements ISessionAgentFileSystem {
declare readonly _serviceBrand: undefined;
constructor(@IExecContext private readonly ctx: IExecContext) {}
get cwd(): string {
return this.ctx.cwd;
}
private _resolvePath(path: string): string {
if (isAbsolute(path)) return normalize(path);
return join(this.ctx.cwd, path);
}
async readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): Promise<string> {
const resolved = this._resolvePath(path);
const encoding = options?.encoding ?? 'utf-8';
const errors = options?.errors ?? 'strict';
const data = await readFile(resolved);
return decodeTextWithErrors(data, encoding, errors);
}
async writeText(
path: string,
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<void> {
const resolved = this._resolvePath(path);
const encoding = options?.encoding ?? 'utf-8';
const mode = options?.mode ?? 'w';
if (mode === 'a') {
await appendFile(resolved, data, encoding);
} else {
await writeFile(resolved, data, encoding);
}
}
async readBytes(path: string, n?: number): Promise<Uint8Array> {
const resolved = this._resolvePath(path);
if (n === undefined) {
return Buffer.from(await readFile(resolved));
}
const fh = await open(resolved, 'r');
try {
const buf = Buffer.alloc(n);
const { bytesRead } = await fh.read(buf, 0, n, 0);
return buf.subarray(0, bytesRead);
} finally {
await fh.close();
}
}
async *readLines(
path: string,
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): AsyncGenerator<string> {
const resolved = this._resolvePath(path);
const encoding = options?.encoding ?? 'utf-8';
const errors = options?.errors ?? 'strict';
if (!isUtf8Encoding(encoding)) {
const content = decodeTextWithErrors(await readFile(resolved), encoding, errors);
yield* splitLinesKeepingTerminator(content);
return;
}
yield* this._readUtf8Lines(resolved, errors);
}
private async *_readUtf8Lines(
resolved: string,
errors: TextDecodeErrors,
): AsyncGenerator<string> {
const fh = await open(resolved, 'r');
try {
const buf = Buffer.alloc(READ_CHUNK_SIZE);
let pending: Buffer[] = [];
let pendingOffset = 0;
let fileOffset = 0;
while (true) {
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
if (bytesRead === 0) break;
const chunk = buf.subarray(0, bytesRead);
let lineStart = 0;
for (let i = 0; i < chunk.length; i += 1) {
const byte = chunk[i];
if (byte !== 0x0a) continue;
const piece = chunk.subarray(lineStart, i + 1);
const lineOffset = pending.length === 0 ? fileOffset + lineStart : pendingOffset;
const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]);
yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0);
pending = [];
lineStart = i + 1;
}
if (lineStart < chunk.length) {
const tail = Buffer.from(chunk.subarray(lineStart));
if (pending.length === 0) pendingOffset = fileOffset + lineStart;
pending.push(tail);
}
fileOffset += bytesRead;
}
if (pending.length > 0) {
const line = Buffer.concat(pending);
yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0);
}
} finally {
await fh.close();
}
}
async writeBytes(path: string, data: Uint8Array): Promise<void> {
const resolved = this._resolvePath(path);
await writeFile(resolved, data);
}
async stat(path: string): Promise<AgentFileStat> {
const resolved = this._resolvePath(path);
// The public interface has no `followSymlinks` toggle; always follow
// symlinks (matching the previous `IKaos.backend.stat` default).
const s = await stat(resolved);
return {
isFile: s.isFile(),
isDirectory: s.isDirectory(),
size: s.size,
mtimeMs: s.mtimeMs,
ino: s.ino,
};
}
async readdir(path: string): Promise<readonly string[]> {
const resolved = this._resolvePath(path);
return await readdir(resolved);
}
async glob(pattern: string): Promise<readonly string[]> {
const resolved = this._resolvePath('.');
const caseSensitive = true;
const patternParts = pattern.split('/');
// Seed `visited` with basePath's own inode so that a symlink inside
// basePath that points back at basePath is caught on its first
// encounter (not on the second level — the "+1 depth" off-by-one
// that would otherwise leak if the caller globs directly from the
// loop root). `stat` failure here is tolerated: `_globWalk` will
// hit the same error via readdir and return empty.
const initVisited = new Set<string>();
try {
const rootStat = await stat(resolved);
const rootKey = cycleKey(rootStat);
if (rootKey !== null) initVisited.add(rootKey);
} catch {
// base does not exist / not accessible — walker handles via its own catch
}
const out: string[] = [];
for await (const match of this._globWalk(resolved, patternParts, caseSensitive, initVisited)) {
out.push(match);
}
return out;
}
// `visited` holds the `(stDev, stIno)` keys of directories on the
// current descent path. Before recursing into a subdirectory, we
// check its key against `visited`; if present we skip it (cycle
// detected) and otherwise recurse with a fresh Set containing the
// additional key. The per-recurse copy gives the check path-local
// semantics: two legitimate symlinks to the same target in separate
// branches both traverse, which is more permissive than Python stdlib
// while still cycle-safe.
// Same-directory self-recursion (e.g. `**` matching zero dirs with
// pattern tail) passes `visited` unchanged — no descent, no cycle
// risk.
//
// Windows note: Node's `fs.Stats.ino` returns `0` on filesystems
// that don't support inodes (FAT/exFAT, some SMB/NFS mounts). If we
// keyed on `ino=0`, every directory on such a drive would share the
// key `"<dev>:0"` and the first would "visit" all others. The
// module-level `cycleKey` helper returns `null` in that case, which
// causes the call sites to skip visited tracking for that descent
// — cycle safety is lost on those filesystems, but normal walking
// works.
private async *_globWalk(
basePath: string,
patternParts: string[],
caseSensitive: boolean,
visited: Set<string>,
): AsyncGenerator<string> {
if (patternParts.length === 0) {
return;
}
const [currentPattern, ...remainingParts] = patternParts;
if (currentPattern === '**') {
// `**` matches zero or more directory components.
//
// There are exactly two cases to handle:
// (a) `**` matches zero directories → continue at basePath with the
// remaining pattern parts (or yield basePath itself when `**`
// is the final segment).
// (b) `**` matches one or more directories → recurse into each
// subdirectory, keeping `**` (i.e. the full patternParts) at
// the front. The "zero directories" case is then re-evaluated
// at the subdirectory level by that recursive call.
//
// We must NOT additionally recurse with `remainingParts` on
// subdirectories — that would double-count every match at depth ≥ 1
// because case (a) inside the child recursion already yields those
// results.
if (remainingParts.length > 0) {
yield* this._globWalk(basePath, remainingParts, caseSensitive, visited);
} else {
// Pattern ends with `**`: yield basePath itself (zero-dir match).
yield basePath;
}
let entries: string[];
try {
entries = await readdir(basePath);
} catch {
return;
}
for (const entry of entries) {
// Use join to avoid "//entry" when basePath is a filesystem root.
const fullPath = join(basePath, entry);
let entryStat;
try {
entryStat = await stat(fullPath);
} catch {
continue;
}
if (entryStat.isDirectory()) {
const key = cycleKey(entryStat);
if (key !== null && visited.has(key)) continue;
yield* this._globWalk(
fullPath,
patternParts,
caseSensitive,
key !== null ? new Set([...visited, key]) : visited,
);
} else if (remainingParts.length === 0) {
// Pattern ends with `**`: non-directory entries match too
// (since `**` matches "anything").
yield fullPath;
}
}
} else {
const regex = globPatternToRegex(currentPattern ?? '', caseSensitive);
let entries: string[];
try {
entries = await readdir(basePath);
} catch {
return;
}
for (const entry of entries) {
if (!regex.test(entry)) {
continue;
}
// Use join to avoid "//entry" when basePath is a filesystem root.
const fullPath = join(basePath, entry);
if (remainingParts.length === 0) {
yield fullPath;
} else {
let entryStat;
try {
entryStat = await stat(fullPath);
} catch {
continue;
}
if (entryStat.isDirectory()) {
const key = cycleKey(entryStat);
if (key !== null && visited.has(key)) continue;
yield* this._globWalk(
fullPath,
remainingParts,
caseSensitive,
key !== null ? new Set([...visited, key]) : visited,
);
}
}
}
}
}
async mkdir(
path: string,
options?: { readonly parents?: boolean; readonly existOk?: boolean },
): Promise<void> {
const resolved = this._resolvePath(path);
const parents = options?.parents ?? true;
const existOk = options?.existOk ?? true;
if (parents) {
// `fs.mkdir(..., { recursive: true })` silently succeeds when the
// target already exists — it does NOT raise EEXIST. To honor the
// `existOk: false` semantics, we must probe for existence ourselves
// before delegating to the recursive mkdir.
if (!existOk) {
try {
const s = await stat(resolved);
if (s.isDirectory()) {
throw new KimiError(
ErrorCodes.FS_ALREADY_EXISTS,
`${resolved} already exists`,
);
}
// Path exists but is not a directory — let `mkdir` surface the
// appropriate error (EEXIST/ENOTDIR) below.
} catch (error: unknown) {
if (error instanceof KimiError) throw error;
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') throw error;
// ENOENT: target doesn't exist yet — proceed to mkdir.
}
}
await mkdir(resolved, { recursive: true });
return;
}
// Non-recursive: fs.mkdir naturally throws EEXIST on collision.
try {
await mkdir(resolved);
} catch (error: unknown) {
if (
existOk &&
error instanceof Error &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'EEXIST'
) {
// `existOk` only applies when the conflicting path is itself a
// directory. If a regular file (or other non-directory) already
// occupies the path, silently returning would be a lie — the
// requested directory still does not exist. Surface the conflict
// explicitly so callers cannot mistake "file collision" for
// "directory already present".
const s = await stat(resolved);
if (!s.isDirectory()) {
throw new KimiError(
ErrorCodes.FS_ALREADY_EXISTS,
`${resolved} already exists but is not a directory`,
);
}
return;
}
throw error;
}
}
withCwd(cwd: string): ISessionAgentFileSystem {
// DI bypass: `withCwd` returns a fresh immutable value on top of the
// derived `IExecContext`, mirroring the pre-refactor pattern
// (`new SessionAgentFileSystem(this.kaos.withCwd(cwd))`).
return new SessionAgentFileSystem(this.ctx.withCwd(cwd));
}
}
registerScopedService(
LifecycleScope.Session,
ISessionAgentFileSystem,
SessionAgentFileSystem,
InstantiationType.Delayed,
'agentFs',
);

View file

@ -1,54 +0,0 @@
/**
* `agentFs` domain (L2) the Agent's filesystem.
*
* Defines the `ISessionAgentFileSystem` that business code injects to read and
* write files inside the Agent's execution environment. Session-scoped; the
* implementation resolves relative paths against `IExecContext.cwd` and talks
* to Node's `fs/promises` directly.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { TextDecodeErrors } from '#/_base/execEnv';
export interface AgentFileStat {
readonly isFile: boolean;
readonly isDirectory: boolean;
readonly size: number;
/** Last-modified time in epoch milliseconds, when the backend exposes it. */
readonly mtimeMs?: number;
/** Inode number, when the backend exposes it (`0` on backends without inodes). */
readonly ino?: number;
}
export interface ISessionAgentFileSystem {
readonly _serviceBrand: undefined;
readonly cwd: string;
readText(
path: string,
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): Promise<string>;
writeText(
path: string,
data: string,
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
): Promise<void>;
readBytes(path: string, n?: number): Promise<Uint8Array>;
readLines(
path: string,
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): AsyncGenerator<string>;
writeBytes(path: string, data: Uint8Array): Promise<void>;
stat(path: string): Promise<AgentFileStat>;
readdir(path: string): Promise<readonly string[]>;
glob(pattern: string): Promise<readonly string[]>;
mkdir(
path: string,
options?: { readonly parents?: boolean; readonly existOk?: boolean },
): Promise<void>;
withCwd(cwd: string): ISessionAgentFileSystem;
}
export const ISessionAgentFileSystem: ServiceIdentifier<ISessionAgentFileSystem> =
createDecorator<ISessionAgentFileSystem>('sessionAgentFileSystem');

View file

@ -2,11 +2,11 @@
* `agentFs` domain (L2) wire-shaped filesystem operations.
*
* Defines the `ISessionFsService` that backs the fs REST surface: content search,
* content grep, and git status/diff. It is the higher-level counterpart to
* `ISessionAgentFileSystem` (the thin IO primitive): it orchestrates the IO primitive
* plus `ISessionProcessRunner` (for `rg` / `git` / `gh`) and returns protocol-shaped
* responses. Session-scoped the scope itself is the session, so no
* `sessionId` is threaded through.
* content grep, and git status/diff. It orchestrates the os `IHostFileSystem`
* (file IO, resolved against the workspace root) plus `ISessionProcessRunner`
* (for `rg` / `git` / `gh`) and returns protocol-shaped responses.
* Session-scoped the scope itself is the session, so no `sessionId` is
* threaded through.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';

View file

@ -2,8 +2,8 @@
* `agentFs` domain (L2) `ISessionFsService` implementation.
*
* Backs the fs REST surface (search / grep / git status / git diff) by
* orchestrating `ISessionAgentFileSystem` (file IO), `ISessionProcessRunner` (`rg`),
* and `IGitService` (git status / diff). Bound at Session scope the workspace
* orchestrating the os `IHostFileSystem` (file IO, resolved against the
* workspace root), `ISessionProcessRunner` (`rg`), and `IGitService` (git
* root and execution environment come from the scope, so no `sessionId` is
* threaded through. Git operations are delegated to the App-scoped
* `IGitService`; this service only confines paths and computes repo-relative
@ -13,7 +13,7 @@
* follow symlinks, matching the rest of v2 (`_base/tools/policies/path-access.ts`).
*/
import { basename, extname, isAbsolute, relative, sep } from 'node:path';
import { basename, extname, isAbsolute, join, relative, sep } from 'node:path';
import {
ErrorCode,
@ -49,10 +49,10 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, KimiError } from '#/errors';
import { IGitService } from '#/app/git';
import { ITelemetryService } from '#/app/telemetry';
import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem';
import { ISessionProcessRunner } from '#/session/process';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { type AgentFileStat, ISessionAgentFileSystem } from './fileSystem';
import { type FsDownloadResolved, type FsPathResolved, ISessionFsService } from './fs';
import { runCommand } from './fsProcess';
import { ensureRgPath, type RgProbe, type RgResolution } from './rgLocator';
@ -94,19 +94,24 @@ export class SessionFsService implements ISessionFsService {
constructor(
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@IHostFileSystem private readonly hostFs: IHostFileSystem,
@ISessionProcessRunner private readonly runner: ISessionProcessRunner,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IGitService private readonly git: IGitService,
) {}
/** Resolve a workspace-relative path (or `.`) back to an absolute path for `IHostFileSystem`. */
private absOf(rel: string): string {
return rel === '' || rel === '.' ? this.workspace.workDir : join(this.workspace.workDir, rel);
}
async list(req: FsListRequest): Promise<FsListResponse> {
const abs = this.resolveWithin(req.path);
const rel = this.toRel(abs);
let topStat: AgentFileStat;
let topStat: HostFileStat;
try {
topStat = await this.fs.stat(rel);
topStat = await this.hostFs.stat(abs);
} catch (err) {
throw mapFsError(err, req.path);
}
@ -133,14 +138,14 @@ export class SessionFsService implements ISessionFsService {
interface Child {
readonly name: string;
readonly relPath: string;
readonly stat: AgentFileStat;
readonly stat: HostFileStat;
}
while (queue.length > 0) {
const entry = queue.shift()!;
let names: readonly string[];
try {
names = await this.fs.readdir(entry.relPath === '' ? '.' : entry.relPath);
names = (await this.hostFs.readdir(this.absOf(entry.relPath))).map((e) => e.name);
} catch (err) {
if (entry.relPath === (rel === '.' ? '' : rel)) {
throw mapFsError(err, req.path);
@ -156,7 +161,7 @@ export class SessionFsService implements ISessionFsService {
continue;
}
if (req.exclude_globs && matchesAnyGlob(childRel, req.exclude_globs)) continue;
const st = await this.fs.stat(childRel).catch(() => undefined);
const st = await this.hostFs.stat(this.absOf(childRel)).catch(() => undefined);
if (st === undefined) continue;
visible.push({ name, relPath: childRel, stat: st });
}
@ -196,9 +201,9 @@ export class SessionFsService implements ISessionFsService {
const abs = this.resolveWithin(req.path);
const rel = this.toRel(abs);
let st: AgentFileStat;
let st: HostFileStat;
try {
st = await this.fs.stat(rel);
st = await this.hostFs.stat(abs);
} catch (err) {
throw mapFsError(err, req.path);
}
@ -217,7 +222,7 @@ export class SessionFsService implements ISessionFsService {
const sampleSize = Math.min(FS_BINARY_SAMPLE_BYTES, st.size);
const sample =
sampleSize === 0 ? new Uint8Array() : await this.fs.readBytes(rel, sampleSize);
sampleSize === 0 ? new Uint8Array() : await this.hostFs.readBytes(abs, sampleSize);
const isBinary = detectBinary(sample);
if (isBinary && req.encoding === 'utf-8') {
@ -231,7 +236,7 @@ export class SessionFsService implements ISessionFsService {
if (effectiveLength <= 0) {
bytes = new Uint8Array();
} else {
const window = await this.fs.readBytes(rel, req.offset + effectiveLength);
const window = await this.hostFs.readBytes(abs, req.offset + effectiveLength);
bytes = window.subarray(req.offset, req.offset + effectiveLength);
}
@ -295,9 +300,9 @@ export class SessionFsService implements ISessionFsService {
async stat(req: FsStatRequest): Promise<FsStatResponse> {
const abs = this.resolveWithin(req.path);
const rel = this.toRel(abs);
let st: AgentFileStat;
let st: HostFileStat;
try {
st = await this.fs.stat(rel);
st = await this.hostFs.stat(abs);
} catch (err) {
throw mapFsError(err, req.path);
}
@ -315,7 +320,7 @@ export class SessionFsService implements ISessionFsService {
await Promise.all(
resolved.map(async ({ raw, rel, abs }) => {
try {
const st = await this.fs.stat(rel);
const st = await this.hostFs.stat(abs);
const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs);
entries[raw] = buildFsEntry(rel, name, st, false);
} catch {
@ -330,7 +335,7 @@ export class SessionFsService implements ISessionFsService {
const abs = this.resolveWithin(req.path);
const rel = this.toRel(abs);
try {
await this.fs.mkdir(rel, { parents: req.recursive, existOk: req.recursive });
await this.hostFs.mkdir(abs, { recursive: req.recursive });
} catch (err) {
const code = errnoCode(err);
if (code === 'EEXIST') {
@ -345,16 +350,16 @@ export class SessionFsService implements ISessionFsService {
}
throw err;
}
const st = await this.fs.stat(rel);
const st = await this.hostFs.stat(abs);
return buildFsEntry(rel, basename(abs), st, false);
}
async resolvePath(relPath: string): Promise<FsPathResolved> {
const abs = this.resolveWithin(relPath);
const rel = this.toRel(abs);
let st: AgentFileStat;
let st: HostFileStat;
try {
st = await this.fs.stat(rel);
st = await this.hostFs.stat(abs);
} catch (err) {
throw mapFsError(err, relPath);
}
@ -364,9 +369,9 @@ export class SessionFsService implements ISessionFsService {
async resolveDownload(relPath: string): Promise<FsDownloadResolved> {
const abs = this.resolveWithin(relPath);
const rel = this.toRel(abs);
let st: AgentFileStat;
let st: HostFileStat;
try {
st = await this.fs.stat(rel);
st = await this.hostFs.stat(abs);
} catch (err) {
throw mapFsError(err, relPath);
}
@ -377,7 +382,7 @@ export class SessionFsService implements ISessionFsService {
}
const sampleSize = Math.min(FS_BINARY_SAMPLE_BYTES, st.size);
const sample =
sampleSize === 0 ? new Uint8Array() : await this.fs.readBytes(rel, sampleSize);
sampleSize === 0 ? new Uint8Array() : await this.hostFs.readBytes(abs, sampleSize);
const isBinary = detectBinary(sample);
return {
absolute: abs,
@ -530,7 +535,7 @@ export class SessionFsService implements ISessionFsService {
filesScanned += 1;
let content: string;
try {
content = await this.fs.readText(rel);
content = await this.hostFs.readText(this.absOf(rel));
} catch {
continue;
}
@ -579,14 +584,14 @@ export class SessionFsService implements ISessionFsService {
if (depth > WALK_MAX_DEPTH) return;
let names: readonly string[];
try {
names = await this.fs.readdir(rootRel === '' ? '.' : rootRel);
names = (await this.hostFs.readdir(this.absOf(rootRel))).map((e) => e.name);
} catch {
return;
}
for (const name of names) {
if (name === '.git') continue;
const childRel = rootRel === '' ? name : `${rootRel}/${name}`;
const st = await this.fs.stat(childRel).catch(() => undefined);
const st = await this.hostFs.stat(this.absOf(childRel)).catch(() => undefined);
if (st === undefined) continue;
const isDir = st.isDirectory;
if (matcher) {
@ -608,7 +613,7 @@ export class SessionFsService implements ISessionFsService {
const ig = ignore();
ig.add('.git/');
try {
const contents = await this.fs.readText('.gitignore');
const contents = await this.hostFs.readText(join(this.workspace.workDir, '.gitignore'));
ig.add(contents);
} catch {
// No .gitignore — keep the `.git/` default only.
@ -778,11 +783,11 @@ function isHidden(name: string): boolean {
}
function sortChildren(
children: { name: string; stat: AgentFileStat }[],
children: { name: string; stat: HostFileStat }[],
sort: FsListRequest['sort'],
): void {
const cmp = {
type_first: (a: { name: string; stat: AgentFileStat }, b: { name: string; stat: AgentFileStat }) => {
type_first: (a: { name: string; stat: HostFileStat }, b: { name: string; stat: HostFileStat }) => {
const ad = a.stat.isDirectory ? 0 : 1;
const bd = b.stat.isDirectory ? 0 : 1;
if (ad !== bd) return ad - bd;
@ -797,7 +802,7 @@ function sortChildren(
children.sort(cmp);
}
function buildEtag(st: AgentFileStat): string {
function buildEtag(st: HostFileStat): string {
const mtime = Math.floor(st.mtimeMs ?? 0);
const ino = st.ino ?? 0;
return [mtime.toString(36), st.size.toString(36), ino.toString(36)].join('-');
@ -806,7 +811,7 @@ function buildEtag(st: AgentFileStat): string {
function buildFsEntry(
relPath: string,
name: string,
st: AgentFileStat,
st: HostFileStat,
withMime: boolean,
): FsEntry {
const kind: FsEntry['kind'] = st.isDirectory ? 'directory' : 'file';

View file

@ -1,10 +1,8 @@
/**
* `agentFs` domain barrel re-exports the agent-filesystem contract
* and its node-local backend, plus the session-level facade files.
* `agentFs` domain barrel re-exports the session fs facade (`ISessionFsService`)
* and its helpers.
*/
export * from './fileSystem';
export * from './agentFsService';
export * from './errors';
export * from './fs';
export * from './fsService';

View file

@ -16,7 +16,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/session/agentLifecycle';
import { IBootstrapService } from '#/app/bootstrap';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IExecContext } from '#/session/execContext';
import { IAgentProfileService, prepareSystemPromptContext } from '#/agent/profile';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
@ -31,7 +31,7 @@ export class SessionWarningService implements ISessionWarningService {
constructor(
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@IHostFileSystem private readonly fs: IHostFileSystem,
@IExecContext private readonly ctx: IExecContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,

View file

@ -1,85 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import {
LifecycleScope,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { SessionAgentFileSystem, ISessionAgentFileSystem } from '#/session/agentFs';
import { IExecContext, createExecContext } from '#/session/execContext';
describe('SessionAgentFileSystem (backed by IExecContext)', () => {
let dir: string;
beforeEach(async () => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Session,
ISessionAgentFileSystem,
SessionAgentFileSystem,
InstantiationType.Delayed,
'agentFs',
);
dir = await mkdtemp(join(tmpdir(), 'agentfs-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
async function makeFs(): Promise<ISessionAgentFileSystem> {
const host = createScopedTestHost();
const session = host.child(
LifecycleScope.Session,
's',
[stubPair(IExecContext, createExecContext(dir))],
);
return session.accessor.get(ISessionAgentFileSystem);
}
it('writes and reads text relative to cwd', async () => {
const fs = await makeFs();
await fs.writeText('a.txt', 'hello');
expect(await fs.readText('a.txt')).toBe('hello');
});
it('stat reports file kind and byte size', async () => {
const fs = await makeFs();
await fs.writeText('b.txt', 'abc');
const st = await fs.stat('b.txt');
expect(st.isFile).toBe(true);
expect(st.isDirectory).toBe(false);
expect(st.size).toBe(3);
});
it('stat reports directories', async () => {
const fs = await makeFs();
await fs.mkdir('sub');
const st = await fs.stat('sub');
expect(st.isDirectory).toBe(true);
expect(st.isFile).toBe(false);
});
it('readdir returns entry names', async () => {
const fs = await makeFs();
await fs.writeText('x.txt', '');
await fs.mkdir('sub');
const names = [...(await fs.readdir('.'))].sort();
expect(names).toEqual(['sub', 'x.txt']);
});
it('withCwd derives a sub-view rooted at the new cwd', async () => {
const fs = await makeFs();
await fs.mkdir('sub');
await fs.writeText('sub/c.txt', 'deep');
const sub = fs.withCwd(join(dir, 'sub'));
expect(sub.cwd).toBe(join(dir, 'sub'));
expect(await sub.readText('c.txt')).toBe('deep');
});
});

View file

@ -1,4 +1,4 @@
import { isAbsolute, relative, resolve } from 'node:path';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { Readable, Writable } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -12,7 +12,7 @@ import {
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IGitService } from '#/app/git';
import { ErrorCodes, KimiError } from '#/errors';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { type HostDirEntry, IHostFileSystem } from '#/os/interface/hostFileSystem';
import { ISessionFsService } from '#/session/agentFs/fs';
import { SessionFsService } from '#/session/agentFs/fsService';
import { ISessionProcessRunner, type IProcess } from '#/session/process';
@ -38,16 +38,20 @@ function stubWorkspace(): ISessionWorkspaceContext {
};
}
function fakeFs(files: Record<string, string>): ISessionAgentFileSystem {
const fileMap = new Map(Object.entries(files));
const dirSet = new Set<string>(['']);
for (const p of fileMap.keys()) {
const parts = p.split('/');
function fakeFs(files: Record<string, string>): IHostFileSystem {
// Keys are stored as absolute paths; fsService now resolves workspace-relative
// paths to absolute (`join(WORK_DIR, rel)`) before calling into `IHostFileSystem`.
const fileMap = new Map<string, string>();
const dirSet = new Set<string>([WORK_DIR]);
for (const [rel, content] of Object.entries(files)) {
const abs = join(WORK_DIR, rel);
fileMap.set(abs, content);
const parts = rel.split('/');
for (let i = 1; i < parts.length; i++) {
dirSet.add(parts.slice(0, i).join('/'));
dirSet.add(join(WORK_DIR, parts.slice(0, i).join('/')));
}
}
const isDir = (p: string): boolean => p === '' || p === '.' || dirSet.has(p);
const isDir = (p: string): boolean => p === WORK_DIR || dirSet.has(p);
const enoent = (p: string): NodeJS.ErrnoException => {
const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
@ -55,7 +59,6 @@ function fakeFs(files: Record<string, string>): ISessionAgentFileSystem {
};
return {
_serviceBrand: undefined,
cwd: WORK_DIR,
readText: async (p) => {
const c = fileMap.get(p);
if (c === undefined) throw enoent(p);
@ -72,6 +75,7 @@ function fakeFs(files: Record<string, string>): ISessionAgentFileSystem {
// not needed by the fs surface under test
},
writeBytes: async () => {},
createExclusive: async () => false,
stat: async (p) => {
if (fileMap.has(p)) {
return {
@ -88,41 +92,62 @@ function fakeFs(files: Record<string, string>): ISessionAgentFileSystem {
throw enoent(p);
},
readdir: async (p) => {
const prefix = p === '.' || p === '' ? '' : `${p}/`;
const children = new Set<string>();
const addChild = (key: string): void => {
if (key === '' || key === p) return;
if (!key.startsWith(prefix)) return;
if (!isDir(p)) throw enoent(p);
const prefix = `${p}/`;
const children = new Map<string, HostDirEntry>();
const addDir = (name: string): void => {
if (!children.has(name)) {
children.set(name, { name, isFile: false, isDirectory: true });
}
};
const addFile = (name: string): void => {
if (!children.has(name)) {
children.set(name, { name, isFile: true, isDirectory: false });
}
};
const visit = (key: string, isFile: boolean): void => {
if (key === p || !key.startsWith(prefix)) return;
const rest = key.slice(prefix.length);
const first = rest.split('/')[0];
if (first !== undefined && first.length > 0) children.add(first);
if (first === undefined || first.length === 0) return;
if (rest.includes('/')) addDir(first);
else if (isFile) addFile(first);
else addDir(first);
};
for (const f of fileMap.keys()) addChild(f);
for (const d of dirSet) addChild(d);
return [...children];
for (const d of dirSet) visit(d, false);
for (const f of fileMap.keys()) visit(f, true);
return [...children.values()];
},
glob: async () => [],
mkdir: async (p, options) => {
const existOk = options?.existOk ?? true;
if ((dirSet.has(p) || fileMap.has(p)) && !existOk) {
const recursive = options?.recursive ?? false;
const exists = isDir(p) || fileMap.has(p);
if (recursive) {
// Add every ancestor up to (but not including) WORK_DIR, mirroring
// `fs.mkdir(..., { recursive: true })` which never throws EEXIST.
let current = p;
while (current !== WORK_DIR && current.length > WORK_DIR.length) {
dirSet.add(current);
const next = current.slice(0, current.lastIndexOf('/'));
if (next === current || next === '') break;
current = next;
}
dirSet.add(p);
return;
}
if (exists) {
const err = new Error(`EEXIST: ${p}`) as NodeJS.ErrnoException;
err.code = 'EEXIST';
throw err;
}
const parents = options?.parents ?? true;
if (!parents) {
const parent = p.split('/').slice(0, -1).join('/');
if (parent !== '' && !isDir(parent)) {
const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
const parent = p.slice(0, p.lastIndexOf('/'));
if (parent !== '' && parent !== WORK_DIR && !isDir(parent)) {
const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
dirSet.add(p);
},
withCwd: () => {
throw new Error('not implemented');
},
remove: async () => {},
};
}
@ -215,7 +240,7 @@ function makeSession(
host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(ISessionWorkspaceContext, stubWorkspace()),
stubPair(ISessionAgentFileSystem, fakeFs(files)),
stubPair(IHostFileSystem, fakeFs(files)),
stubPair(ISessionProcessRunner, fakeRunner(handler)),
stubPair(ITelemetryService, telemetryStub(events)),
stubPair(IGitService, git),

View file

@ -93,7 +93,7 @@ import {
IAgentPermissionGate,
IAgentPermissionModeService,
IAgentPermissionRulesService,
ISessionAgentFileSystem,
IHostFileSystem,
ISessionContext,
ISessionProcessRunner,
IAgentScopeContext,
@ -149,7 +149,7 @@ import { ISessionSwarmService } from '#/session/swarm';
import type { PathAccessOperation } from '#/session/workspaceContext';
import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events';
import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import { createScriptedGenerate } from './scripted-generate';
import {
DEFAULT_TEST_SYSTEM_PROMPT,
@ -397,7 +397,7 @@ type ExecContextOverride = {
*/
export interface ExecEnvOverride {
readonly execContext?: ExecContextOverride;
readonly agentFs?: ISessionAgentFileSystem | Partial<ISessionAgentFileSystem>;
readonly hostFs?: IHostFileSystem | Partial<IHostFileSystem>;
readonly processRunner?: ISessionProcessRunner | Partial<ISessionProcessRunner>;
}
@ -414,8 +414,8 @@ export function execEnvServices(override: ExecEnvOverride = {}): TestAgentServic
createExecContext(override.execContext.cwd ?? '/workspace', override.execContext.envLayers),
);
}
if (override.agentFs !== undefined) {
reg.defineInstance(ISessionAgentFileSystem, resolveAgentFsOverride(override.agentFs));
if (override.hostFs !== undefined) {
reg.defineInstance(IHostFileSystem, resolveHostFsOverride(override.hostFs));
}
if (override.processRunner !== undefined) {
reg.defineInstance(
@ -430,30 +430,28 @@ export function execEnvServices(override: ExecEnvOverride = {}): TestAgentServic
});
}
function resolveAgentFsOverride(
input: ISessionAgentFileSystem | Partial<ISessionAgentFileSystem>,
): ISessionAgentFileSystem {
function resolveHostFsOverride(input: IHostFileSystem | Partial<IHostFileSystem>): IHostFileSystem {
// A full impl (class instance or `Proxy` over one) exposes every core
// method. A partial override typically covers only a few of them. If every
// core method is a function, pass the input through unchanged; otherwise
// treat it as a partial override and spread it over the fake defaults.
if (isFullAgentFs(input)) return input as ISessionAgentFileSystem;
return createFakeAgentFs(input as Partial<ISessionAgentFileSystem>);
if (isFullHostFs(input)) return input as IHostFileSystem;
return createFakeHostFs(input as Partial<IHostFileSystem>);
}
function isFullAgentFs(input: unknown): boolean {
function isFullHostFs(input: unknown): boolean {
if (typeof input !== 'object' || input === null) return false;
const keys: readonly (keyof ISessionAgentFileSystem)[] = [
const keys: readonly (keyof IHostFileSystem)[] = [
'readText',
'writeText',
'readBytes',
'readLines',
'writeBytes',
'readLines',
'createExclusive',
'stat',
'readdir',
'glob',
'mkdir',
'withCwd',
'remove',
];
return keys.every((k) => typeof (input as Record<string, unknown>)[k] === 'function');
}
@ -990,9 +988,9 @@ export class AgentTestContext {
reg.defineInstance(ISessionApprovalService, this.createApprovalService());
reg.defineInstance(ISessionQuestionService, this.createQuestionService());
reg.defineInstance(IExecContext, createExecContext(this.cwd));
// Note: `ISessionAgentFileSystem` and `ISessionProcessRunner` are
// auto-registered by their service files and backed by `IExecContext`.
// Tests that need a fake override them via `execEnvServices`.
// Note: the os `IHostFileSystem` (App scope) and `ISessionProcessRunner`
// are auto-registered by their service files. Tests that need a fake
// filesystem override it via `execEnvServices({ hostFs })`.
reg.defineDescriptor(
ISessionWorkspaceContext,
new SyncDescriptor(SessionWorkspaceContextService),

View file

@ -1,14 +1,14 @@
/**
* ReadMediaFileTool tests for the v2 output/capability contract.
*
* Self-contained: builds minimal fake `ISessionAgentFileSystem` and `IKaos` inline
* so the tool can be exercised without the missing composition root.
* Self-contained: builds a minimal fake `IHostFileSystem` inline so the tool can
* be exercised without the missing composition root.
*/
import type { ContentPart, ModelCapability } from '#/app/llmProtocol/kosong';
import { describe, expect, it, vi } from 'vitest';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
import {
ReadMediaFileInputSchema,
@ -65,10 +65,9 @@ interface FakeFile {
readonly size?: number;
}
function createTestFs(files: Record<string, FakeFile>): ISessionAgentFileSystem {
function createTestFs(files: Record<string, FakeFile>): IHostFileSystem {
const lookup = (path: string): FakeFile | undefined => files[path];
return {
cwd: '/workspace',
readBytes: vi.fn(async (path: string, _n?: number) => lookup(path)?.data ?? Buffer.alloc(0)),
stat: vi.fn(async (path: string) => {
const file = lookup(path);
@ -78,7 +77,7 @@ function createTestFs(files: Record<string, FakeFile>): ISessionAgentFileSystem
size: file?.size ?? file?.data.length ?? 0,
};
}),
} as unknown as ISessionAgentFileSystem;
} as unknown as IHostFileSystem;
}
function createTestEnv(): IHostEnvironment {

View file

@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFakeAgentFs } from '../tools/fixtures/fake-exec';
import { createFakeHostFs } from '../tools/fixtures/fake-exec';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IAgentPlanService } from '#/agent/plan';
@ -62,7 +62,7 @@ describe('PlanModeService dynamic injection content', () => {
beforeEach(() => {
readText = async () => '';
ctx = createTestAgent(execEnvServices({
agentFs: createFakeAgentFs({
hostFs: createFakeHostFs({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: (path: string) => readText(path),
writeText: vi.fn(async () => undefined),
@ -144,7 +144,7 @@ describe('PlanModeService dynamic injection cadence', () => {
beforeEach(() => {
ctx = createTestAgent(execEnvServices({
agentFs: createFakeAgentFs({
hostFs: createFakeHostFs({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: async () => '',
writeText: vi.fn(async () => undefined),

View file

@ -12,7 +12,7 @@ import type { ITelemetryService } from '#/app/telemetry';
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import { executeTool } from '../tools/fixtures/execute-tool';
import { createFakeAgentFs } from '../tools/fixtures/fake-exec';
import { createFakeHostFs } from '../tools/fixtures/fake-exec';
import {
createTestAgent,
execEnvServices,
@ -218,7 +218,7 @@ describe('AgentPlanService EnterPlanMode telemetry', () => {
records.splice(0);
ctx = createTestAgent(
execEnvServices({
agentFs: createFakeAgentFs({
hostFs: createFakeHostFs({
mkdir: vi.fn().mockResolvedValue(undefined),
}),
}),

View file

@ -10,9 +10,9 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentPlanService, type PlanData } from '#/agent/plan';
import { IAgentPermissionRulesService } from '#/agent/permissionRules';
import { IAgentProfileService } from '#/agent/profile';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import type { ISessionProcessRunner } from '#/session/process';
import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import {
createCommandRunner,
createTestAgent,
@ -21,7 +21,7 @@ import {
} from '../harness';
interface PlanFakes {
readonly fs: ISessionAgentFileSystem;
readonly fs: IHostFileSystem;
readonly runner: ISessionProcessRunner;
}
@ -30,8 +30,8 @@ interface PlanFakes {
* readText no-op, runner throws). Individual tests override the specific
* methods they need.
*/
function createPlanFakes(overrides: Partial<ISessionAgentFileSystem> = {}): PlanFakes {
const fs = createFakeAgentFs({
function createPlanFakes(overrides: Partial<IHostFileSystem> = {}): PlanFakes {
const fs = createFakeHostFs({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: vi.fn().mockResolvedValue(''),
...overrides,
@ -49,7 +49,7 @@ function createPlanCommandFakes(stdout: string): PlanFakes {
function createPlanFileFakes(
files = new Map<string, string>(),
overrides: Partial<ISessionAgentFileSystem> = {},
overrides: Partial<IHostFileSystem> = {},
): {
readonly files: Map<string, string>;
readonly readText: ReturnType<typeof vi.fn>;
@ -91,7 +91,7 @@ describe('Plan service', () => {
tempDirs = [];
ctx = createTestAgent(
execEnvServices({
agentFs: delegatingFs(),
hostFs: delegatingFs(),
processRunner: delegatingRunner(),
}),
);
@ -115,13 +115,13 @@ describe('Plan service', () => {
* A fs whose methods delegate to whichever `activeFakes.fs` is set at call
* time. Lets a test swap fakes mid-flight by reassigning `activeFakes`.
*/
function delegatingFs(): ISessionAgentFileSystem {
function delegatingFs(): IHostFileSystem {
return new Proxy(createPlanFakes().fs, {
get(_target, prop, receiver) {
const value = Reflect.get(activeFakes.fs, prop, receiver);
return typeof value === 'function' ? value.bind(activeFakes.fs) : value;
},
}) as ISessionAgentFileSystem;
}) as IHostFileSystem;
}
function delegatingRunner(): ISessionProcessRunner {
@ -184,7 +184,7 @@ describe('Plan service', () => {
const status = await expectActivePlan();
expect(status.path.startsWith(`${join(cwd, 'plan')}/`)).toBe(true);
expect(status.path.endsWith('.md')).toBe(true);
expect(mkdir).toHaveBeenCalledWith(join(cwd, 'plan'));
expect(mkdir).toHaveBeenCalledWith(join(cwd, 'plan'), { recursive: true });
expect(writeText).not.toHaveBeenCalled();
expect(ctx.allEvents.some((event) => event.event === 'turn.started')).toBe(false);
expect(ctx.llmCalls).toHaveLength(0);

View file

@ -4,7 +4,7 @@ import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SessionAgentFileSystem } from '#/session/agentFs/agentFsService';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import { createExecContext } from '#/session/execContext';
import { IAgentProfileService, type ResolvedAgentProfile } from '#/agent/profile';
@ -38,12 +38,12 @@ describe('AgentProfileService.applyProfile', () => {
// (empty temp dir) so a developer's real ~/.kimi-code / ~/.agents files
// never leak into the assertions.
const execCtx = createExecContext(workDir);
const fs = new SessionAgentFileSystem(execCtx);
const fs = new HostFileSystem();
ctx = createTestAgent(
execEnvServices({
hostEnvironment: { homeDir },
execContext: execCtx,
agentFs: fs,
hostFs: fs,
}),
);
return { ctx, profile: ctx.get(IAgentProfileService) };

View file

@ -4,21 +4,20 @@ import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SessionAgentFileSystem } from '#/session/agentFs/agentFsService';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import { createExecContext } from '#/session/execContext';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { loadAgentsMd, prepareSystemPromptContext } from '#/agent/profile';
/**
* Build a `SessionAgentFileSystem` rooted at `workDir` the v2 profile
* context loaders take `{ fs, homeDir }` and read every AGENTS.md through the
* fs's `readText` / `readdir` / `stat`.
* Build an os-backed `IHostFileSystem`. The v2 profile context loaders take
* `{ fs, homeDir }` and read every AGENTS.md through the fs's `readText` /
* `readdir` / `stat` using absolute paths, so no cwd rooting is needed.
*/
function createFs(workDir: string): ISessionAgentFileSystem {
return new SessionAgentFileSystem(createExecContext(workDir));
function createFs(): IHostFileSystem {
return new HostFileSystem();
}
let fs: ISessionAgentFileSystem;
let fs: IHostFileSystem;
let homeDir: string;
let workDir: string;
let extraDirs: string[];
@ -27,7 +26,7 @@ beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'kimi-agents-home-'));
workDir = await mkdtemp(join(tmpdir(), 'kimi-agents-work-'));
extraDirs = [];
fs = createFs(workDir);
fs = createFs();
});
afterEach(async () => {

View file

@ -14,7 +14,8 @@ import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/
import { IAgentLifecycleService } from '#/session/agentLifecycle';
import { IBootstrapService } from '#/app/bootstrap';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { SessionAgentFileSystem, ISessionAgentFileSystem } from '#/session/agentFs';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { createExecContext, IExecContext } from '#/session/execContext';
import { IAgentProfileService } from '#/agent/profile';
import { ISessionWarningService, SessionWarningService } from '#/session/session';
@ -68,10 +69,10 @@ function build(args: {
stubPair(IHostEnvironment, hostEnvironment(args.homeDir)),
]);
const ctx = createExecContext(args.workDir);
const fs: ISessionAgentFileSystem = new SessionAgentFileSystem(ctx);
const fs: IHostFileSystem = new HostFileSystem();
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(IExecContext, ctx),
stubPair(ISessionAgentFileSystem, fs),
stubPair(IHostFileSystem, fs),
stubPair(ISessionWorkspaceContext, workspaceStub(args.additionalDirs ?? [])),
stubPair(
IAgentLifecycleService,

View file

@ -1,4 +1,4 @@
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import type { ISessionProcessRunner } from '#/session/process';
function notImplemented(name: string): never {
@ -15,23 +15,19 @@ export function createFakeProcessRunner(
};
}
export function createFakeAgentFs(
overrides: Partial<ISessionAgentFileSystem> = {},
): ISessionAgentFileSystem {
const cwd = overrides.cwd ?? '/workspace';
const fs: ISessionAgentFileSystem = {
export function createFakeHostFs(overrides: Partial<IHostFileSystem> = {}): IHostFileSystem {
const fs: IHostFileSystem = {
_serviceBrand: undefined,
cwd,
readText: () => notImplemented('FakeAgentFs.readText'),
writeText: () => notImplemented('FakeAgentFs.writeText'),
readBytes: () => notImplemented('FakeAgentFs.readBytes'),
readLines: () => notImplemented('FakeAgentFs.readLines'),
writeBytes: () => notImplemented('FakeAgentFs.writeBytes'),
stat: () => notImplemented('FakeAgentFs.stat'),
readdir: () => notImplemented('FakeAgentFs.readdir'),
glob: () => notImplemented('FakeAgentFs.glob'),
mkdir: () => notImplemented('FakeAgentFs.mkdir'),
withCwd: (nextCwd) => createFakeAgentFs({ ...overrides, cwd: nextCwd }),
readText: () => notImplemented('FakeHostFs.readText'),
writeText: () => notImplemented('FakeHostFs.writeText'),
readBytes: () => notImplemented('FakeHostFs.readBytes'),
writeBytes: () => notImplemented('FakeHostFs.writeBytes'),
readLines: () => notImplemented('FakeHostFs.readLines'),
createExclusive: () => notImplemented('FakeHostFs.createExclusive'),
stat: () => notImplemented('FakeHostFs.stat'),
readdir: () => notImplemented('FakeHostFs.readdir'),
mkdir: () => notImplemented('FakeHostFs.mkdir'),
remove: () => notImplemented('FakeHostFs.remove'),
};
return { ...fs, ...overrides };
}

View file

@ -17,7 +17,7 @@ import {
import { describe, expect, it, vi } from 'vitest';
import { abortError, abortable } from '#/_base/utils/abort';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import type { ContextMessage } from '#/agent/contextMemory';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { IOAuthService } from '#/app/auth';
@ -39,7 +39,7 @@ import type {
SessionSwarmTask as QueuedSubagentTask,
} from '#/session/swarm';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import {
configServices,
appServices,
@ -1747,7 +1747,7 @@ describe('Agent turn flow', () => {
throw new APIStatusError(401, 'Unauthorized', 'req-upload-401');
}),
} as unknown as ChatProvider;
const ctx = testAgent(oauthOptions.services, execEnvServices({ agentFs: createVideoAgentFs() }), {
const ctx = testAgent(oauthOptions.services, execEnvServices({ hostFs: createVideoHostFs() }), {
initialConfig: oauthOptions.initialConfig,
autoConfigure: false,
});
@ -1767,7 +1767,7 @@ describe('Agent turn flow', () => {
return uploadVideo.call(provider, input, { auth });
});
const registration = registerMediaTools(ctx.get(IAgentToolRegistryService), {
fs: ctx.get(ISessionAgentFileSystem),
fs: ctx.get(IHostFileSystem),
env: ctx.get(IHostEnvironment),
workspace: { workspaceDir: '/workspace', additionalDirs: [] },
capabilities: mediaCapabilities(),
@ -2090,8 +2090,8 @@ function createExecRunner(output: string): {
return { runner: createFakeProcessRunner({ exec }), exec };
}
function createVideoAgentFs(): ISessionAgentFileSystem {
return createFakeAgentFs({
function createVideoHostFs(): IHostFileSystem {
return createFakeHostFs({
stat: vi.fn(async () => ({
isFile: true,
isDirectory: false,

View file

@ -18,7 +18,7 @@ import {
createAgentTaskPersistence,
type TaskServiceTestManager,
} from '../task/stubs';
import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import {
DEFAULT_TEST_SYSTEM_PROMPT,
InMemoryWireRecordPersistence,
@ -66,7 +66,7 @@ describe('Agent resume', () => {
const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute on resume'));
const ctx = testAgent(
execEnvServices({
agentFs: createFakeAgentFs({ readText: vi.fn().mockResolvedValue('') }),
hostFs: createFakeHostFs({ readText: vi.fn().mockResolvedValue('') }),
processRunner: createFakeProcessRunner({ exec: execWithEnv }),
}),
{ autoConfigure: false, persistence },

View file

@ -70,7 +70,6 @@ export const SESSION = {
addAdditionalDir: RW,
removeAdditionalDir: RW,
},
agentFs: { readText: RO, writeText: RW, stat: RO, readdir: RO, mkdir: RW },
fs: { search: RO, grep: RO, gitStatus: RO, diff: RO },
} as const satisfies Manifest;

View file

@ -4,7 +4,7 @@
* The `session` resource (read/update/setTitle/setArchived/status/isIdle/
* archive) is flattened onto the {@link SessionScope} handle itself, since it
* is the primary thing you do with a session; every other resource
* (`approvals`, `questions`, `interactions`, `workspace`, `agentFs`, `fs`) is
* (`approvals`, `questions`, `interactions`, `workspace`, `fs`) is
* a sub-namespace. `agent(agentId)` enters the agent scope, and
* `service<T>(resource)` is the escape hatch for actions not in the manifest.
*/
@ -30,7 +30,6 @@ export type ApprovalsResource = ResourceShape<SessionManifest['approvals']>;
export type QuestionsResource = ResourceShape<SessionManifest['questions']>;
export type InteractionsResource = ResourceShape<SessionManifest['interactions']>;
export type SessionWorkspaceResource = ResourceShape<SessionManifest['workspace']>;
export type AgentFsResource = ResourceShape<SessionManifest['agentFs']>;
export type SessionFsResource = ResourceShape<SessionManifest['fs']>;
/** Session scope handle — obtained via `client.session(sessionId)`. */
@ -39,7 +38,6 @@ export class SessionScope {
readonly questions: QuestionsResource;
readonly interactions: InteractionsResource;
readonly workspace: SessionWorkspaceResource;
readonly agentFs: AgentFsResource;
readonly fs: SessionFsResource;
private readonly sessionResource: SessionResource;
@ -60,7 +58,6 @@ export class SessionScope {
this.questions = makeResource(rpc, 'session', params, 'questions', SESSION.questions);
this.interactions = makeResource(rpc, 'session', params, 'interactions', SESSION.interactions);
this.workspace = makeResource(rpc, 'session', params, 'workspace', SESSION.workspace);
this.agentFs = makeResource(rpc, 'session', params, 'agentFs', SESSION.agentFs);
this.fs = makeResource(rpc, 'session', params, 'fs', SESSION.fs);
}

View file

@ -22,7 +22,6 @@
*/
import {
ISessionAgentFileSystem,
IAgentRPCService,
ISessionApprovalService,
IAuthSummaryService,
@ -156,12 +155,6 @@ export const actionMap: Record<ScopeKind, Record<string, ActionTarget>> = {
'workspace:addAdditionalDir': { service: ISessionWorkspaceContext, method: 'addAdditionalDir' },
'workspace:removeAdditionalDir': { service: ISessionWorkspaceContext, method: 'removeAdditionalDir' },
'agentFs:readText': { service: ISessionAgentFileSystem, method: 'readText', readonly: true },
'agentFs:writeText': { service: ISessionAgentFileSystem, method: 'writeText' },
'agentFs:stat': { service: ISessionAgentFileSystem, method: 'stat', readonly: true },
'agentFs:readdir': { service: ISessionAgentFileSystem, method: 'readdir', readonly: true },
'agentFs:mkdir': { service: ISessionAgentFileSystem, method: 'mkdir' },
'fs:search': { service: ISessionFsService, method: 'search', readonly: true },
'fs:grep': { service: ISessionFsService, method: 'grep', readonly: true },
'fs:gitStatus': { service: ISessionFsService, method: 'gitStatus', readonly: true },