mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
80d65c7a85
commit
bcc9bc095f
29 changed files with 220 additions and 768 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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');
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue