diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index 3e6d6791f..bea8498ad 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -31,7 +31,7 @@ package "App scope (process-wide)" #EAF3FB {
rectangle "hostFs\nApp\n IHostFileSystem" as hostFs #D6EAF8
rectangle "workspaceRegistry\nApp\n IWorkspaceRegistry" as workspaceRegistry #D6EAF8
rectangle "hostFolderBrowser\nApp\n IHostFolderBrowser" as hostFolderBrowser #D6EAF8
- rectangle "kaos\nApp\n IKaosFactory" as kaos_core #D6EAF8
+ rectangle "hostEnvironment\nApp\n IHostEnvironment" as hostEnvironment #D6EAF8
rectangle "auth\nApp\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
rectangle "provider\nApp\n IProviderService" as provider #D6EAF8
rectangle "flag\nApp\n IFlagService\n IFlagRegistry" as flag #D6EAF8
@@ -52,7 +52,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "workspaceContext\nSession\n IWorkspaceContext" as workspaceContext #D5F5E3
rectangle "sessionLog\nSession\n ISessionLogService\n ILogWriterService" as sessionLog #D5F5E3
rectangle "sessionSkillCatalog\nSession\n ISessionSkillCatalog" as sessionSkillCatalog #D5F5E3
- rectangle "kaos\nSession\n IKaos (seed)" as kaos #D5F5E3
+ rectangle "execContext\nSession\n IExecContext (seed)" as execContext #D5F5E3
rectangle "agentFs\nSession\n IAgentFileSystem\n IFsService" as agentFs #D5F5E3
rectangle "approval\nSession\n IApprovalService" as approval #D5F5E3
rectangle "question\nSession\n IQuestionService" as question #D5F5E3
@@ -113,7 +113,7 @@ gateway --> eventSink #34495E
sessionIndex --> bootstrap #34495E
sessionIndex --> storage #34495E
session_lifecycle --> bootstrap #34495E
-session_lifecycle --> kaos_core #34495E
+session_lifecycle --> hostEnvironment #34495E
hostFolderBrowser --> hostFs #34495E
auth --> provider #34495E
auth --> config #34495E
@@ -131,15 +131,15 @@ modelCatalog --> provider #34495E
modelCatalog --> model #34495E
modelCatalog --> config #34495E
modelCatalog --> auth #34495E
-workspaceContext --> kaos #34495E
+workspaceContext --> execContext #34495E
sessionLog --> session_context #34495E
sessionSkillCatalog --> globalSkillCatalog #34495E
sessionSkillCatalog --> workspaceContext #34495E
globalSkillCatalog --> bootstrap #34495E
agentFs --> workspaceContext #34495E
-agentFs --> kaos #34495E
+agentFs --> execContext #34495E
agentFs --> process #34495E
-process --> kaos #34495E
+process --> execContext #34495E
terminal --> workspaceContext #34495E
terminal --> session_context #34495E
approval --> interaction #34495E
@@ -334,11 +334,13 @@ rpc --> fileTools #34495E
rpc --> shellTools #34495E
fileTools --> toolRegistry #34495E
fileTools --> agentFs #34495E
-fileTools --> kaos #34495E
+fileTools --> hostEnvironment
+fileTools --> execContext #34495E
fileTools --> workspaceContext #34495E
shellTools --> toolRegistry #34495E
shellTools --> process #34495E
-shellTools --> kaos #34495E
+shellTools --> hostEnvironment
+shellTools --> execContext #34495E
shellTools --> background #34495E
' ---- event-driven (dashed) ----
diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json
index 7c35b5511..45fd999eb 100644
--- a/packages/agent-core-v2/package.json
+++ b/packages/agent-core-v2/package.json
@@ -61,7 +61,6 @@
"@antfu/utils": "^9.3.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
- "@moonshot-ai/kaos": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",
"@moonshot-ai/kimi-telemetry": "workspace:^",
"@moonshot-ai/kosong": "workspace:^",
diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs
index 83a1389e6..1aebb1144 100644
--- a/packages/agent-core-v2/scripts/check-domain-layers.mjs
+++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs
@@ -89,6 +89,7 @@ const DOMAIN_LAYER = new Map([
['permissionPolicy', 3],
['permissionRules', 3],
['plugin', 3],
+ ['record', 3],
['modelRuntime', 3],
['modelCatalog', 3],
// L4 — agent behaviour
@@ -239,6 +240,7 @@ const ALLOWED_EXCEPTIONS = new Set([
'permissionPolicy>externalHooks',
'permissionPolicy>profile',
'permissionRules>replayBuilder',
+ 'record>replayBuilder',
'plugin>externalHooks',
'plugin>mcp',
'profile>session',
diff --git a/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts b/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts
new file mode 100644
index 000000000..6e036f08d
--- /dev/null
+++ b/packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts
@@ -0,0 +1,64 @@
+/**
+ * `_base/execEnv` (L0) — `BufferedReadable` stream helper.
+ *
+ * A `Readable` wrapper that preserves source backpressure while still allowing
+ * consumers to read buffered output after the source has ended. Used by process
+ * spawners so `wait()`-then-read on small/medium outputs works without draining
+ * unboundedly. Vendored from `@moonshot-ai/kaos` `internal.ts`; kept as a pure
+ * helper with no DI dependencies.
+ */
+
+import { Readable } from 'node:stream';
+
+export class BufferedReadable extends Readable {
+ private readonly _source: Readable;
+ private _ended: boolean = false;
+
+ constructor(source: Readable) {
+ // Keep a modest prefetch window so wait()-then-read still works for
+ // common small/medium outputs without draining unboundedly.
+ super({ highWaterMark: 128 * 1024 });
+ this._source = source;
+ this._source.on('data', this._onData);
+ this._source.on('end', this._onEnd);
+ this._source.on('close', this._onClose);
+ this._source.on('error', this._onError);
+ }
+
+ override _read(): void {
+ if (!this._ended && !this.destroyed) {
+ this._source.resume();
+ }
+ }
+
+ override _destroy(error: Error | null, callback: (error?: Error | null) => void): void {
+ this._source.off('data', this._onData);
+ this._source.off('end', this._onEnd);
+ this._source.off('close', this._onClose);
+ this._source.off('error', this._onError);
+ this._source.destroy();
+ callback(error);
+ }
+
+ private readonly _onData = (chunk: string | Uint8Array): void => {
+ if (!this.push(chunk)) {
+ this._source.pause();
+ }
+ };
+
+ private readonly _onEnd = (): void => {
+ this._ended = true;
+ this.push(null);
+ };
+
+ private readonly _onClose = (): void => {
+ if (!this._ended) {
+ this._ended = true;
+ this.push(null);
+ }
+ };
+
+ private readonly _onError = (error: Error): void => {
+ this.destroy(error);
+ };
+}
diff --git a/packages/agent-core-v2/src/_base/execEnv/decodeText.ts b/packages/agent-core-v2/src/_base/execEnv/decodeText.ts
new file mode 100644
index 000000000..b756557d3
--- /dev/null
+++ b/packages/agent-core-v2/src/_base/execEnv/decodeText.ts
@@ -0,0 +1,187 @@
+/**
+ * `_base/execEnv` (L0) — Python-compatible text decoding with `errors` handling.
+ *
+ * Vendored from `@moonshot-ai/kaos` `internal.ts`. Kept as a pure helper with
+ * no DI dependencies. Used by session-scoped fs implementations to read text
+ * files with the same `strict`/`replace`/`ignore` semantics Python's
+ * `open(..., errors=)` provides.
+ */
+
+export type TextDecodeErrors = 'strict' | 'replace' | 'ignore';
+
+function isUtf8Continuation(byte: number): boolean {
+ return byte >= 0x80 && byte <= 0xbf;
+}
+
+function decodeUtf8Ignore(data: Buffer): string {
+ let output = '';
+ let i = 0;
+
+ while (i < data.length) {
+ const b0 = data[i];
+ if (b0 === undefined) break;
+
+ if (b0 <= 0x7f) {
+ output += String.fromCodePoint(b0);
+ i += 1;
+ continue;
+ }
+
+ if (b0 >= 0xc2 && b0 <= 0xdf) {
+ const b1 = data[i + 1];
+ if (b1 !== undefined && isUtf8Continuation(b1)) {
+ output += String.fromCodePoint(((b0 & 0x1f) << 6) | (b1 & 0x3f));
+ i += 2;
+ continue;
+ }
+ i += 1;
+ continue;
+ }
+
+ if (b0 >= 0xe0 && b0 <= 0xef) {
+ const b1 = data[i + 1];
+ const b2 = data[i + 2];
+ const validSecond =
+ b1 !== undefined &&
+ ((b0 === 0xe0 && b1 >= 0xa0 && b1 <= 0xbf) ||
+ (b0 >= 0xe1 && b0 <= 0xec && isUtf8Continuation(b1)) ||
+ (b0 === 0xed && b1 >= 0x80 && b1 <= 0x9f) ||
+ (b0 >= 0xee && b0 <= 0xef && isUtf8Continuation(b1)));
+
+ if (validSecond && b2 !== undefined && isUtf8Continuation(b2)) {
+ output += String.fromCodePoint(((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f));
+ i += 3;
+ continue;
+ }
+ i += 1;
+ continue;
+ }
+
+ if (b0 >= 0xf0 && b0 <= 0xf4) {
+ const b1 = data[i + 1];
+ const b2 = data[i + 2];
+ const b3 = data[i + 3];
+ const validSecond =
+ b1 !== undefined &&
+ ((b0 === 0xf0 && b1 >= 0x90 && b1 <= 0xbf) ||
+ (b0 >= 0xf1 && b0 <= 0xf3 && isUtf8Continuation(b1)) ||
+ (b0 === 0xf4 && b1 >= 0x80 && b1 <= 0x8f));
+
+ if (
+ validSecond &&
+ b2 !== undefined &&
+ b3 !== undefined &&
+ isUtf8Continuation(b2) &&
+ isUtf8Continuation(b3)
+ ) {
+ output += String.fromCodePoint(
+ ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f),
+ );
+ i += 4;
+ continue;
+ }
+ i += 1;
+ continue;
+ }
+
+ i += 1;
+ }
+
+ return output;
+}
+
+function decodeUtf16LeIgnore(data: Buffer): string {
+ let output = '';
+ let i = 0;
+
+ while (i + 1 < data.length) {
+ const first = data[i];
+ const second = data[i + 1];
+ if (first === undefined || second === undefined) break;
+
+ const codeUnit = first | (second << 8);
+
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
+ const lowFirst = data[i + 2];
+ const lowSecond = data[i + 3];
+ if (lowFirst !== undefined && lowSecond !== undefined) {
+ const low = lowFirst | (lowSecond << 8);
+ if (low >= 0xdc00 && low <= 0xdfff) {
+ const codePoint = 0x10000 + ((codeUnit - 0xd800) << 10) + (low - 0xdc00);
+ output += String.fromCodePoint(codePoint);
+ i += 4;
+ continue;
+ }
+ }
+ i += 2;
+ continue;
+ }
+
+ if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
+ i += 2;
+ continue;
+ }
+
+ output += String.fromCodePoint(codeUnit);
+ i += 2;
+ }
+
+ return output;
+}
+
+/**
+ * Decode a Buffer into a string with Python-compatible `errors` handling.
+ *
+ * - `'strict'` (default): throw on invalid sequences (via TextDecoder `fatal: true`)
+ * - `'replace'`: substitute each invalid sequence with U+FFFD (TextDecoder default)
+ * - `'ignore'`: drop invalid input sequences while preserving valid U+FFFD characters
+ *
+ * Falls back to `Buffer.toString(encoding)` for encodings TextDecoder does not
+ * support (e.g. `hex`, `base64`, `binary`, `latin1`) — those are lossless
+ * byte-to-character mappings so `errors` has no effect.
+ */
+export function decodeTextWithErrors(
+ data: Buffer,
+ encoding: BufferEncoding,
+ errors: TextDecodeErrors = 'strict',
+ ignoreBOM: boolean = false,
+): string {
+ // Map Node's BufferEncoding names to Web TextDecoder labels where the two
+ // diverge. Only UTF-family encodings participate in the strict/replace/
+ // ignore dance; the others are lossless and use Buffer.toString directly.
+ let webLabel: string | undefined;
+ // eslint-disable-next-line typescript-eslint/switch-exhaustiveness-check
+ switch (encoding) {
+ case 'utf-8':
+ case 'utf8':
+ webLabel = 'utf-8';
+ break;
+ case 'utf16le':
+ case 'ucs2':
+ case 'ucs-2':
+ webLabel = 'utf-16le';
+ break;
+ default:
+ webLabel = undefined;
+ }
+
+ if (webLabel === undefined) {
+ // Non-UTF encodings (hex/base64/latin1/binary/ascii) are lossless byte↔
+ // character mappings; `errors` is meaningless for them. Return raw.
+ return data.toString(encoding);
+ }
+
+ if (errors === 'strict') {
+ return new TextDecoder(webLabel, { fatal: true, ignoreBOM }).decode(data);
+ }
+
+ // 'ignore' must skip invalid input bytes/code units, not delete every
+ // replacement character in the decoded output. A file can contain a valid
+ // U+FFFD, and Python preserves it under errors="ignore".
+ if (errors === 'ignore') {
+ return webLabel === 'utf-8' ? decodeUtf8Ignore(data) : decodeUtf16LeIgnore(data);
+ }
+
+ // 'replace' → substitute each invalid sequence with U+FFFD (default).
+ return new TextDecoder(webLabel, { fatal: false, ignoreBOM }).decode(data);
+}
diff --git a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts
new file mode 100644
index 000000000..d9b236092
--- /dev/null
+++ b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts
@@ -0,0 +1,340 @@
+/**
+ * `_base/execEnv` (L0) — OS / shell probe.
+ *
+ * Detects the host operating system, architecture, kernel release, and a
+ * usable POSIX shell path. The result is a pure function of injected probes
+ * (`platform` / `arch` / `release` / `env` / `isFile` / `execFileText`) so the
+ * same suite runs identically on any host OS. `probeHostEnvironmentFromNode()`
+ * bundles the Node defaults for production callers and memoises the promise.
+ *
+ * On Windows the probe expects Git Bash (the canonical POSIX shell that ships
+ * with Git for Windows). If it cannot be located the function throws a plain
+ * `Error` with the checked paths in the message; the App-scope host-environment
+ * service catches that at first resolution. Set `KIMI_SHELL_PATH` to override.
+ *
+ * Vendored from `@moonshot-ai/kaos` `environment.ts` — kept as a pure helper
+ * with no DI dependencies.
+ */
+
+import { execFile as nodeExecFile } from 'node:child_process';
+import { constants as fsConstants } from 'node:fs';
+import { access } from 'node:fs/promises';
+import * as nodeOs from 'node:os';
+import * as nodePath from 'node:path';
+
+// `OsKind` carries 'macOS' / 'Linux' / 'Windows' for known platforms and falls
+// back to the raw `process.platform` string for unknown ones (e.g. 'freebsd').
+// Typed as `string` so the union is not inhabited-by-string.
+export type OsKind = string;
+export type ShellName = 'bash' | 'sh';
+export type PathClass = 'posix' | 'win32';
+
+export interface HostEnvironmentInfo {
+ readonly osKind: OsKind;
+ readonly osArch: string;
+ readonly osVersion: string;
+ readonly shellName: ShellName;
+ readonly shellPath: string;
+ readonly pathClass: PathClass;
+ readonly homeDir: string;
+}
+
+export interface HostEnvironmentProbeDeps {
+ // Accepts the full Node `Platform` enum plus arbitrary strings for
+ // forward-compatible OS kinds.
+ readonly platform: string;
+ readonly arch: string;
+ readonly release: string;
+ readonly homeDir: string;
+ readonly env: Record;
+ readonly isFile: (path: string) => Promise;
+ readonly execFileText: (
+ file: string,
+ args: readonly string[],
+ timeoutMs: number,
+ ) => Promise;
+}
+
+const GIT_EXEC_PATH_TIMEOUT_MS = 5_000;
+
+function resolveOsKind(platform: string): OsKind {
+ switch (platform) {
+ case 'darwin':
+ return 'macOS';
+ case 'linux':
+ return 'Linux';
+ case 'win32':
+ return 'Windows';
+ default:
+ return platform;
+ }
+}
+
+export async function probeHostEnvironment(
+ deps: HostEnvironmentProbeDeps,
+): Promise {
+ const osKind = resolveOsKind(deps.platform);
+ const osArch = deps.arch;
+ const osVersion = deps.release;
+ const pathClass: PathClass = deps.platform === 'win32' ? 'win32' : 'posix';
+
+ if (deps.platform === 'win32') {
+ const shellPath = await locateWindowsGitBash(deps);
+ return {
+ osKind,
+ osArch,
+ osVersion,
+ shellName: 'bash',
+ shellPath,
+ pathClass,
+ homeDir: deps.homeDir,
+ };
+ }
+
+ const candidates: readonly string[] = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash'];
+ let found: string | undefined;
+ for (const p of candidates) {
+ if (await deps.isFile(p)) {
+ found = p;
+ break;
+ }
+ }
+ if (found !== undefined) {
+ return {
+ osKind,
+ osArch,
+ osVersion,
+ shellName: 'bash',
+ shellPath: found,
+ pathClass,
+ homeDir: deps.homeDir,
+ };
+ }
+ return {
+ osKind,
+ osArch,
+ osVersion,
+ shellName: 'sh',
+ shellPath: '/bin/sh',
+ pathClass,
+ homeDir: deps.homeDir,
+ };
+}
+
+async function locateWindowsGitBash(deps: HostEnvironmentProbeDeps): Promise {
+ const checked: string[] = [];
+
+ const override = deps.env['KIMI_SHELL_PATH']?.trim();
+ if (override !== undefined && override.length > 0) {
+ checked.push(override);
+ if (await deps.isFile(override)) {
+ return override;
+ }
+ }
+
+ const gitExecutables = await findExecutablesOnPath(
+ 'git.exe',
+ deps.env['PATH'],
+ deps.platform,
+ deps.isFile,
+ );
+
+ for (const gitExe of gitExecutables) {
+ const inferred = gitBashCandidatesFromGitExe(gitExe);
+ if (inferred !== undefined) {
+ for (const candidate of inferred) {
+ checked.push(candidate);
+ if (await deps.isFile(candidate)) {
+ return candidate;
+ }
+ }
+ }
+
+ const gitExecPath = await readGitExecPath(deps, gitExe);
+ if (gitExecPath === undefined) {
+ continue;
+ }
+ for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) {
+ checked.push(candidate);
+ if (await deps.isFile(candidate)) {
+ return candidate;
+ }
+ }
+ }
+
+ const candidates: string[] = [
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
+ 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
+ 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe',
+ ];
+ const localAppData = deps.env['LOCALAPPDATA']?.trim();
+ if (localAppData !== undefined && localAppData.length > 0) {
+ candidates.push(`${localAppData}\\Programs\\Git\\bin\\bash.exe`);
+ candidates.push(`${localAppData}\\Programs\\Git\\usr\\bin\\bash.exe`);
+ }
+ for (const candidate of candidates) {
+ checked.push(candidate);
+ if (await deps.isFile(candidate)) {
+ return candidate;
+ }
+ }
+
+ throw new Error(
+ `Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe. Checked: ${checked.join(', ')}.`,
+ );
+}
+
+async function readGitExecPath(
+ deps: HostEnvironmentProbeDeps,
+ gitExe: string,
+): Promise {
+ if (deps.platform === 'win32' && !isAbsoluteWindowsPath(gitExe)) return undefined;
+
+ const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS);
+ if (stdout === undefined) return undefined;
+
+ for (const line of stdout.split(/\r?\n/)) {
+ const execPath = line.trim();
+ if (execPath.length > 0) {
+ return execPath;
+ }
+ }
+ return undefined;
+}
+
+// Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, with
+// bash at `\bin\bash.exe`. Portable installs sometimes put both in
+// `\bin\`. Only infer from those anchored layouts; package manager
+// shims live elsewhere and must resolve through `git --exec-path`.
+function gitBashCandidatesFromGitExe(gitExe: string): readonly string[] | undefined {
+ const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe));
+ const gitDir = nodePath.win32.dirname(normalizedGitExe);
+ const gitDirName = nodePath.win32.basename(gitDir).toLowerCase();
+ if (gitDirName !== 'cmd' && gitDirName !== 'bin') {
+ return undefined;
+ }
+ return gitBashCandidatesFromGitRoot(nodePath.win32.dirname(gitDir));
+}
+
+function gitBashCandidatesFromGitExecPath(execPath: string): readonly string[] {
+ const normalized = nodePath.win32.normalize(normalizeWindowsPath(execPath));
+ const parts = normalized.split('\\');
+ for (let i = parts.length - 1; i >= 0; i -= 1) {
+ const segment = parts[i]?.toLowerCase();
+ if (segment === 'mingw32' || segment === 'mingw64') {
+ const root = parts.slice(0, i).join('\\');
+ if (root.length > 0) {
+ return gitBashCandidatesFromGitRoot(root);
+ }
+ }
+ }
+
+ return gitBashCandidatesFromGitRoot(nodePath.win32.join(normalized, '..', '..'));
+}
+
+function gitBashCandidatesFromGitRoot(root: string): readonly string[] {
+ return [
+ nodePath.win32.normalize(nodePath.win32.join(root, 'bin', 'bash.exe')),
+ nodePath.win32.normalize(nodePath.win32.join(root, 'usr', 'bin', 'bash.exe')),
+ ];
+}
+
+function normalizeWindowsPath(path: string): string {
+ return path.replaceAll('/', '\\');
+}
+
+function isAbsoluteWindowsPath(path: string): boolean {
+ return nodePath.win32.isAbsolute(normalizeWindowsPath(path));
+}
+
+function dedupeWindowsPaths(paths: readonly string[]): readonly string[] {
+ const deduped: string[] = [];
+ const seen = new Set();
+ for (const path of paths) {
+ const key = normalizeWindowsPath(path).toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ deduped.push(path);
+ }
+ return deduped;
+}
+
+/**
+ * Production convenience — derive the deps bag from Node's ambient surface.
+ *
+ * The result is memoised: subsequent calls return the original promise.
+ * `HostEnvironmentInfo` is immutable for the lifetime of the process (it
+ * derives from `process.platform`, `process.arch`, `os.release()`, `os.homedir()`,
+ * and one-time shell-path discovery), so caching is sound. Tests that need to
+ * probe with different inputs should call {@link probeHostEnvironment} directly
+ * with an injected deps bag.
+ */
+let cachedProbe: Promise | undefined;
+
+export function probeHostEnvironmentFromNode(): Promise {
+ if (cachedProbe !== undefined) return cachedProbe;
+ const platform = process.platform;
+ const env = process.env as Record;
+ const isFile = async (path: string): Promise => {
+ try {
+ await access(path, fsConstants.F_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+ cachedProbe = probeHostEnvironment({
+ platform,
+ arch: process.arch,
+ release: nodeOs.release(),
+ homeDir: nodeOs.homedir(),
+ env,
+ isFile,
+ execFileText,
+ });
+ return cachedProbe;
+}
+
+async function findExecutablesOnPath(
+ name: string,
+ pathEnv: string | undefined,
+ platform: string,
+ isFile: (p: string) => Promise,
+): Promise {
+ if (pathEnv === undefined || pathEnv.length === 0) return [];
+ const listSep = platform === 'win32' ? ';' : ':';
+ const dirSep = platform === 'win32' ? '\\' : '/';
+ const paths: string[] = [];
+ for (const rawDir of pathEnv.split(listSep)) {
+ const dir = rawDir.trim();
+ if (dir.length === 0) continue;
+ if (platform === 'win32' && !isAbsoluteWindowsPath(dir)) continue;
+ const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`;
+ if (await isFile(candidate)) {
+ paths.push(candidate);
+ }
+ }
+ return platform === 'win32' ? dedupeWindowsPaths(paths) : paths;
+}
+
+async function execFileText(
+ file: string,
+ args: readonly string[],
+ timeoutMs: number,
+): Promise {
+ return new Promise((resolve) => {
+ nodeExecFile(
+ file,
+ [...args],
+ { encoding: 'utf8', timeout: timeoutMs, windowsHide: true },
+ (error, stdout) => {
+ if (error !== null) {
+ resolve(undefined);
+ return;
+ }
+ resolve(stdout);
+ },
+ );
+ });
+}
diff --git a/packages/agent-core-v2/src/_base/execEnv/globPattern.ts b/packages/agent-core-v2/src/_base/execEnv/globPattern.ts
new file mode 100644
index 000000000..e72bfc120
--- /dev/null
+++ b/packages/agent-core-v2/src/_base/execEnv/globPattern.ts
@@ -0,0 +1,66 @@
+/**
+ * `_base/execEnv` (L0) — glob-pattern-to-regex conversion.
+ *
+ * Vendored from `@moonshot-ai/kaos` `internal.ts`. Pure function used by the
+ * session-scoped fs implementation's `glob` traversal. Mirrors Python pathlib
+ * semantics: includes dotfiles, case-sensitive by default.
+ */
+
+/**
+ * Convert a single glob pattern segment (e.g. `"*.txt"`, `"file?.log"`) into
+ * a RegExp. `*` matches any run of non-`/` characters; `?` matches any single
+ * non-`/` character; `[abc]` matches one of a set (leading `!` negates).
+ */
+export function globPatternToRegex(pattern: string, caseSensitive: boolean): RegExp {
+ let regex = '^';
+ for (let i = 0; i < pattern.length; i++) {
+ const ch = pattern[i];
+ if (ch === undefined) break;
+ switch (ch) {
+ case '*':
+ regex += '[^/]*';
+ break;
+ case '?':
+ regex += '[^/]';
+ break;
+ case '[': {
+ const end = pattern.indexOf(']', i + 1);
+ if (end === -1) {
+ regex += '\\[';
+ } else {
+ // Glob character classes only use `!` for negation. A literal
+ // leading `^` must remain literal even though JS regex char
+ // classes treat it as negation in the first position.
+ let charClass = pattern.slice(i + 1, end);
+ // Escape backslashes inside the class so a trailing backslash
+ // does not accidentally escape the closing `]`.
+ charClass = charClass.replace(/\\/g, '\\\\');
+ if (charClass.startsWith('!')) {
+ charClass = '^' + charClass.slice(1);
+ } else if (charClass.startsWith('^')) {
+ charClass = '\\' + charClass;
+ }
+ regex += '[' + charClass + ']';
+ i = end;
+ }
+ break;
+ }
+ case '\\': {
+ if (i + 1 < pattern.length) {
+ const next = pattern.charAt(i + 1);
+ regex += next.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&');
+ // Advance past the escaped character so it is not processed
+ // again as a regex metacharacter. match literally.
+ i++;
+ } else {
+ regex += '\\\\';
+ }
+ break;
+ }
+ default:
+ regex += ch.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&');
+ }
+ }
+ regex += '$';
+ return new RegExp(regex, caseSensitive ? '' : 'i');
+}
diff --git a/packages/agent-core-v2/src/_base/execEnv/index.ts b/packages/agent-core-v2/src/_base/execEnv/index.ts
new file mode 100644
index 000000000..d4c27e7d3
--- /dev/null
+++ b/packages/agent-core-v2/src/_base/execEnv/index.ts
@@ -0,0 +1,22 @@
+/**
+ * `_base/execEnv` (L0) — pure execution-environment primitives.
+ *
+ * Vendored helpers previously imported from `@moonshot-ai/kaos`. None of them
+ * carry DI dependencies; higher layers wrap them into services:
+ * - `app/hostEnvironment` — memoises the OS/shell probe as `IHostEnvironment`
+ * - `session/agentFs` — reuses the fs helpers to implement the session fs
+ * - `session/process` — reuses `BufferedReadable` for the spawned process
+ */
+
+export { BufferedReadable } from './bufferedReadable';
+export { decodeTextWithErrors, type TextDecodeErrors } from './decodeText';
+export { globPatternToRegex } from './globPattern';
+export {
+ probeHostEnvironment,
+ probeHostEnvironmentFromNode,
+ type HostEnvironmentInfo,
+ type HostEnvironmentProbeDeps,
+ type OsKind,
+ type PathClass,
+ type ShellName,
+} from './environmentProbe';
diff --git a/packages/agent-core-v2/src/_base/tools/policies/path-access.ts b/packages/agent-core-v2/src/_base/tools/policies/path-access.ts
index e2892b81d..7264d9495 100644
--- a/packages/agent-core-v2/src/_base/tools/policies/path-access.ts
+++ b/packages/agent-core-v2/src/_base/tools/policies/path-access.ts
@@ -2,9 +2,9 @@
* Path safety guards used by Read/Write/Edit/Grep/Glob.
*
* Canonicalization is **lexical** only (no `realpath` / symlink following).
- * Mirrors `KaosPath.canonical()` and keeps the guard backend-aware:
- * callers should pass the active Kaos path class so SSH paths stay POSIX
- * even when the host Node process is running on Windows.
+ * The guard stays host-aware: callers pass the active `IHostEnvironment`
+ * path class so SSH paths stay POSIX even when the host Node process is
+ * running on Windows.
*
* Shared-prefix escapes (a path like `/workspace-evil` passing a naive
* `startswith('/workspace')` check) are blocked by requiring a path
@@ -14,7 +14,7 @@
import * as pathe from 'pathe';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { WorkspaceConfig } from '../support/workspace';
import { isSensitiveFile } from './sensitive';
@@ -178,7 +178,7 @@ export interface ResolvePathAccessOptions {
}
export interface ResolvePathAccessPathOptions {
- readonly kaos: Pick;
+ readonly env: Pick;
readonly workspace: WorkspaceConfig;
readonly operation: PathAccessOperation;
readonly policy?: WorkspaceAccessPolicy;
@@ -246,12 +246,12 @@ export function resolvePathAccessPath(
path: string,
options: ResolvePathAccessPathOptions,
): string {
- const { kaos, workspace, operation, policy, expandHome = true } = options;
+ const { env, workspace, operation, policy, expandHome = true } = options;
return resolvePathAccess(path, workspace.workspaceDir, workspace, {
operation,
policy,
- pathClass: kaos.pathClass(),
- homeDir: expandHome ? kaos.gethome() : undefined,
+ pathClass: env.pathClass,
+ homeDir: expandHome ? env.homeDir : undefined,
}).path;
}
diff --git a/packages/agent-core-v2/src/agent/fileTools/fileToolsService.ts b/packages/agent-core-v2/src/agent/fileTools/fileToolsService.ts
index fefae3fdf..26cbc9a61 100644
--- a/packages/agent-core-v2/src/agent/fileTools/fileToolsService.ts
+++ b/packages/agent-core-v2/src/agent/fileTools/fileToolsService.ts
@@ -3,7 +3,8 @@
*
* Registers the built-in file tools (Read / Write / Edit / Grep / Glob) into
* the agent `IAgentToolRegistryService` on construction, wiring each to the session
- * `ISessionAgentFileSystem` (file IO), `ISessionFsService` (workspace search/grep), `IKaos`
+ * `ISessionAgentFileSystem` (file IO), `ISessionFsService` (workspace search/grep),
+ * `ISessionProcessRunner` (rg subprocess for Glob), `IHostEnvironment`
* (path semantics) and the session workspace. Bound at Agent scope.
*/
@@ -11,7 +12,8 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { ISessionAgentFileSystem, ISessionFsService } from '#/session/agentFs';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
+import { ISessionProcessRunner } from '#/session/process';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
@@ -29,20 +31,21 @@ export class AgentFileToolsService implements IAgentFileToolsService {
constructor(
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@ISessionAgentFileSystem fs: ISessionAgentFileSystem,
- @IKaos kaos: IKaos,
+ @IHostEnvironment env: IHostEnvironment,
@ISessionWorkspaceContext workspace: ISessionWorkspaceContext,
@ISessionFsService fsService: ISessionFsService,
+ @ISessionProcessRunner runner: ISessionProcessRunner,
@ITelemetryService telemetry: ITelemetryService,
) {
const workspaceConfig: WorkspaceConfig = {
workspaceDir: workspace.workDir,
additionalDirs: workspace.additionalDirs,
};
- toolRegistry.register(new ReadTool(fs, kaos, workspaceConfig));
- toolRegistry.register(new WriteTool(fs, kaos, workspaceConfig));
- toolRegistry.register(new EditTool(fs, kaos, workspaceConfig));
- toolRegistry.register(new GrepTool(fsService, kaos, workspaceConfig));
- toolRegistry.register(new GlobTool(fs, kaos, workspaceConfig, telemetry));
+ toolRegistry.register(new ReadTool(fs, env, workspaceConfig));
+ toolRegistry.register(new WriteTool(fs, env, workspaceConfig));
+ toolRegistry.register(new EditTool(fs, env, workspaceConfig));
+ toolRegistry.register(new GrepTool(fsService, env, workspaceConfig));
+ toolRegistry.register(new GlobTool(fs, env, runner, workspaceConfig, telemetry));
}
}
diff --git a/packages/agent-core-v2/src/agent/fileTools/tools/edit.ts b/packages/agent-core-v2/src/agent/fileTools/tools/edit.ts
index bb9030abb..253b0db34 100644
--- a/packages/agent-core-v2/src/agent/fileTools/tools/edit.ts
+++ b/packages/agent-core-v2/src/agent/fileTools/tools/edit.ts
@@ -12,12 +12,12 @@
*
* Path access policy is resolved before any filesystem I/O. Edit access flows
* through the `agentFs` domain; path semantics (home expansion, path class)
- * come from the `kaos` domain.
+ * come from the `hostEnvironment` domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/edit.ts`): the
* `kaos.readText` / `kaos.writeText` calls become `fs.readText` /
- * `fs.writeText` against `ISessionAgentFileSystem`, and `kaos.pathClass()` /
- * `kaos.gethome()` come from `IKaos`.
+ * `fs.writeText` against `ISessionAgentFileSystem`, and the path-class /
+ * home-directory facts come from `IHostEnvironment`.
*/
import { z } from 'zod';
@@ -28,7 +28,7 @@ import { literalRulePattern, matchesPathRuleSubject } from '#/_base/tools/suppor
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { renderPrompt } from '#/_base/utils/render-prompt';
import { ISessionAgentFileSystem } from '#/session/agentFs';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
@@ -78,13 +78,13 @@ export class EditTool implements BuiltinTool {
constructor(
private readonly fs: ISessionAgentFileSystem,
- private readonly kaos: IKaos,
+ private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: EditInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
- kaos: this.kaos,
+ env: this.env,
workspace: this.workspace,
operation: 'write',
});
@@ -102,8 +102,8 @@ export class EditTool implements BuiltinTool {
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
- pathClass: this.kaos.pathClass(),
- homeDir: this.kaos.gethome(),
+ pathClass: this.env.pathClass,
+ homeDir: this.env.homeDir,
}),
execute: () => this.execution(args, path),
};
diff --git a/packages/agent-core-v2/src/agent/fileTools/tools/glob.ts b/packages/agent-core-v2/src/agent/fileTools/tools/glob.ts
index 3d29bda12..063cad560 100644
--- a/packages/agent-core-v2/src/agent/fileTools/tools/glob.ts
+++ b/packages/agent-core-v2/src/agent/fileTools/tools/glob.ts
@@ -3,30 +3,29 @@
*
* Finds files matching a glob pattern, returned sorted by modification time
* (most recent first). Implemented by shelling out to `rg --files` through the
- * session `IKaos` backend (`withCwd(root).backend.exec(rgPath, ...)`) — sharing
- * the ripgrep subprocess plumbing, gitignore handling, and sensitive-file
- * filtering with the Grep domain.
+ * session `ISessionProcessRunner` — sharing the ripgrep subprocess plumbing,
+ * gitignore handling, and sensitive-file filtering with the Grep domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/glob.ts`) onto
* the v2 domains:
* - Search: v1 `kaos.exec(rgPath, ...)` maps to
- * `this.kaos.withCwd(searchRoot).backend.exec(rgPath, ...)`. v2 `IKaos`
- * exposes process spawning through `backend.exec`; `withCwd` pins the
+ * `this.runner.exec([rgPath, ...], { cwd: searchRoot })`. Pinning the
* subprocess cwd to the search root so `--glob` patterns match paths
* relative to that root.
- * - Binary resolution: `ensureRgPath` (`#/agentFs/rgLocator`) probes the
- * execution environment for a working `rg` (system PATH, then the cached
+ * - Binary resolution: `ensureRgPath` (`#/session/agentFs/rgLocator`) probes
+ * the execution environment for a working `rg` (system PATH, then the cached
* bootstrap binary) so a missing `rg` surfaces an actionable message
* instead of a naked `spawn rg ENOENT`.
* - Subprocess plumbing: `runRgOnce` / `shouldRetryRipgrepEagain`
- * (`#/agentFs/runRg`) own spawn, capped draining, abort/timeout, two-phase
- * kill, and the single-threaded EAGAIN retry shared with v1's run-rg.
+ * (`#/session/agentFs/runRg`) own spawn, capped draining, abort/timeout,
+ * two-phase kill, and the single-threaded EAGAIN retry shared with v1's
+ * run-rg.
* - Directory pre-check: `fs.readdir(searchRoot)` surfaces a missing or
* non-directory root as "does not exist" / "is not a directory" instead of
* a misleading "No matches found" (or, for a file root, rg listing the
* file itself as its own match).
* - Path safety / home expansion / path class: `resolvePathAccessPath` over
- * the `kaos` domain, identical to Read/Write/Edit/Grep.
+ * the `hostEnvironment` domain, identical to Read/Write/Edit/Grep.
*
* Behaviour:
* - `.gitignore` / `.ignore` / `.rgignore` are respected by default
@@ -57,7 +56,8 @@ import {
runRgOnce,
shouldRetryRipgrepEagain,
} from '#/session/agentFs/runRg';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
+import { ISessionProcessRunner } from '#/session/process';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
@@ -147,20 +147,21 @@ export class GlobTool implements BuiltinTool {
private readonly telemetry: ITelemetryService;
constructor(
private readonly fs: ISessionAgentFileSystem,
- private readonly kaos: IKaos,
+ private readonly env: IHostEnvironment,
+ private readonly runner: ISessionProcessRunner,
private readonly workspace: WorkspaceConfig,
telemetry: ITelemetryService = noopTelemetryService,
) {
this.telemetry = telemetry;
this.description =
- this.kaos.pathClass() === 'win32' ? globDescription + WINDOWS_PATH_HINT : globDescription;
+ this.env.pathClass === 'win32' ? globDescription + WINDOWS_PATH_HINT : globDescription;
}
resolveExecution(args: GlobInput): ToolExecution {
let path: string | undefined;
if (args.path !== undefined) {
path = resolvePathAccessPath(args.path, {
- kaos: this.kaos,
+ env: this.env,
workspace: this.workspace,
operation: 'search',
policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false },
@@ -226,7 +227,7 @@ export class GlobTool implements BuiltinTool {
// telemetry — instead of a confusing `spawn rg ENOENT`.
let rgPath: string;
try {
- const resolution = await ensureRgPath(createRgProbe(this.kaos), {
+ const resolution = await ensureRgPath(createRgProbe(this.runner), {
signal,
allowCachedFallback: true,
});
@@ -250,11 +251,9 @@ export class GlobTool implements BuiltinTool {
// rg*, so with an absolute search path a pattern containing a `/` (e.g.
// `src/**/*.ts`) is matched against the absolute path and never matches.
// Running from the search root makes glob matching relative to it.
- const execKaos = this.kaos.withCwd(searchRoot);
-
let run;
try {
- run = await runRgOnce(execKaos, buildRgArgs(rgPath, args), signal);
+ run = await runRgOnce(this.runner, buildRgArgs(rgPath, args), signal, { cwd: searchRoot });
} catch (error) {
return {
isError: true,
@@ -270,7 +269,7 @@ export class GlobTool implements BuiltinTool {
// pool and usually succeeds.
if (shouldRetryRipgrepEagain(run)) {
try {
- run = await runRgOnce(execKaos, buildRgArgs(rgPath, args, true), signal);
+ run = await runRgOnce(this.runner, buildRgArgs(rgPath, args, true), signal, { cwd: searchRoot });
} catch (error) {
return {
isError: true,
@@ -340,7 +339,7 @@ export class GlobTool implements BuiltinTool {
// save tokens, but only for the primary workspace. Relative paths are
// later resolved against workspaceDir, so additionalDir matches stay
// absolute to keep follow-up Read/Edit calls on the same file.
- const pathClass = this.kaos.pathClass();
+ const pathClass = this.env.pathClass;
const shouldRelativize = isWithinDirectory(searchRoot, this.workspace.workspaceDir, pathClass);
const displayLines = limited.map((p) =>
shouldRelativize ? relativizeIfUnder(p, searchRoot, pathClass) : p,
@@ -376,16 +375,15 @@ export class GlobTool implements BuiltinTool {
}
/**
- * Adapt an `IKaos` execution environment to the locator's {@link RgProbe}. The
- * probe runs `rg --version` (or the cached binary with `--version`) through the
- * environment's backend and reports the exit code. stdout/stderr are drained
- * (flowing mode) so a chatty probe can never block the pipe; the bytes are
- * discarded.
+ * Adapt an `ISessionProcessRunner` to the locator's {@link RgProbe}. The
+ * probe runs `rg --version` (or the cached binary with `--version`) through
+ * the runner and reports the exit code. stdout/stderr are drained (flowing
+ * mode) so a chatty probe can never block the pipe; the bytes are discarded.
*/
-function createRgProbe(kaos: IKaos): RgProbe {
+function createRgProbe(runner: ISessionProcessRunner): RgProbe {
return {
exec: async (args) => {
- const proc = await kaos.backend.exec(...args);
+ const proc = await runner.exec(args);
try {
proc.stdin.end();
} catch {
diff --git a/packages/agent-core-v2/src/agent/fileTools/tools/grep.ts b/packages/agent-core-v2/src/agent/fileTools/tools/grep.ts
index bff4e8e27..bc89cd94e 100644
--- a/packages/agent-core-v2/src/agent/fileTools/tools/grep.ts
+++ b/packages/agent-core-v2/src/agent/fileTools/tools/grep.ts
@@ -29,7 +29,7 @@ import { z } from 'zod';
import { ISessionFsService } from '#/session/agentFs';
import { ErrorCodes, isKimiError } from '#/errors';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@@ -148,7 +148,7 @@ export class GrepTool implements BuiltinTool {
readonly parameters: Record = toInputJsonSchema(GrepInputSchema);
constructor(
private readonly fs: ISessionFsService,
- private readonly kaos: IKaos,
+ private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
) {}
@@ -156,7 +156,7 @@ export class GrepTool implements BuiltinTool {
let searchPath: string | undefined;
if (args.path !== undefined) {
searchPath = resolvePathAccessPath(args.path, {
- kaos: this.kaos,
+ env: this.env,
workspace: this.workspace,
operation: 'search',
policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false },
diff --git a/packages/agent-core-v2/src/agent/fileTools/tools/read.ts b/packages/agent-core-v2/src/agent/fileTools/tools/read.ts
index dccfe1898..d281ebbaa 100644
--- a/packages/agent-core-v2/src/agent/fileTools/tools/read.ts
+++ b/packages/agent-core-v2/src/agent/fileTools/tools/read.ts
@@ -14,7 +14,8 @@
*
* Path safety goes through the shared path access resolver used by
* Read/Write/Edit. Read access flows through the `agentFs` domain; path
- * semantics (home expansion, path class) come from the `kaos` domain.
+ * semantics (home expansion, path class) come from the `hostEnvironment`
+ * domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/read.ts`). The
* optional `scanTextFile` / `readLineRange` / `readTailLines` fast-paths are
@@ -24,7 +25,7 @@
import { z } from 'zod';
import { ISessionAgentFileSystem } from '#/session/agentFs';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@@ -224,13 +225,13 @@ export class ReadTool implements BuiltinTool {
readonly parameters: Record = toInputJsonSchema(ReadInputSchema);
constructor(
private readonly fs: ISessionAgentFileSystem,
- private readonly kaos: IKaos,
+ private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: ReadInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
- kaos: this.kaos,
+ env: this.env,
workspace: this.workspace,
operation: 'read',
});
@@ -242,8 +243,8 @@ export class ReadTool implements BuiltinTool {
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
- pathClass: this.kaos.pathClass(),
- homeDir: this.kaos.gethome(),
+ pathClass: this.env.pathClass,
+ homeDir: this.env.homeDir,
}),
execute: () => this.execution(args, path),
};
diff --git a/packages/agent-core-v2/src/agent/fileTools/tools/write.ts b/packages/agent-core-v2/src/agent/fileTools/tools/write.ts
index 81e04fbf7..ede805731 100644
--- a/packages/agent-core-v2/src/agent/fileTools/tools/write.ts
+++ b/packages/agent-core-v2/src/agent/fileTools/tools/write.ts
@@ -11,7 +11,7 @@
* missing file as empty) and writes the concatenation back.
*
* Write access flows through the `agentFs` domain; path semantics (home
- * expansion, path class) come from the `kaos` domain.
+ * expansion, path class) come from the `hostEnvironment` domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/write.ts`).
*/
@@ -20,7 +20,7 @@ import { dirname } from 'pathe';
import { z } from 'zod';
import type { AgentFileStat, ISessionAgentFileSystem } from '#/session/agentFs';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@@ -63,13 +63,13 @@ export class WriteTool implements BuiltinTool {
constructor(
private readonly fs: ISessionAgentFileSystem,
- private readonly kaos: IKaos,
+ private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: WriteInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
- kaos: this.kaos,
+ env: this.env,
workspace: this.workspace,
operation: 'write',
});
@@ -81,8 +81,8 @@ export class WriteTool implements BuiltinTool {
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
- pathClass: this.kaos.pathClass(),
- homeDir: this.kaos.gethome(),
+ pathClass: this.env.pathClass,
+ homeDir: this.env.homeDir,
}),
execute: () => this.execution(args, path),
};
diff --git a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts
index d4d810640..83b5c438e 100644
--- a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts
+++ b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts
@@ -18,13 +18,13 @@ import type { ChatProvider, ModelCapability } from '@moonshot-ai/kosong';
import { toDisposable, type IDisposable } from '#/_base/di';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { ReadMediaFileTool, type VideoUploader } from '#/agent/media/tools/read-media';
export interface RegisterMediaToolsDeps {
readonly fs: ISessionAgentFileSystem;
- readonly kaos: IKaos;
+ readonly env: IHostEnvironment;
readonly workspace: WorkspaceConfig;
readonly capabilities: ModelCapability;
readonly videoUploader?: VideoUploader;
@@ -48,7 +48,7 @@ export function registerMediaTools(
return toolRegistry.register(
new ReadMediaFileTool(
deps.fs,
- deps.kaos,
+ deps.env,
deps.workspace,
deps.capabilities,
deps.videoUploader,
diff --git a/packages/agent-core-v2/src/agent/media/tools/read-media.ts b/packages/agent-core-v2/src/agent/media/tools/read-media.ts
index 5fa41681a..abe8d4078 100644
--- a/packages/agent-core-v2/src/agent/media/tools/read-media.ts
+++ b/packages/agent-core-v2/src/agent/media/tools/read-media.ts
@@ -28,7 +28,7 @@ import type {
import { z } from 'zod';
import { ISessionAgentFileSystem } from '#/session/agentFs';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@@ -137,7 +137,7 @@ export class ReadMediaFileTool implements BuiltinTool {
readonly parameters: Record = toInputJsonSchema(ReadMediaFileInputSchema);
constructor(
private readonly fs: ISessionAgentFileSystem,
- private readonly kaos: IKaos,
+ private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
private readonly capabilities: ModelCapability,
private readonly videoUploader?: VideoUploader | undefined,
@@ -153,7 +153,7 @@ export class ReadMediaFileTool implements BuiltinTool {
return { isError: true, output: 'File path cannot be empty.' };
}
const path = resolvePathAccessPath(args.path, {
- kaos: this.kaos,
+ env: this.env,
workspace: this.workspace,
operation: 'read',
});
@@ -165,8 +165,8 @@ export class ReadMediaFileTool implements BuiltinTool {
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
- pathClass: this.kaos.pathClass(),
- homeDir: this.kaos.gethome(),
+ pathClass: this.env.pathClass,
+ homeDir: this.env.homeDir,
}),
execute: () => this.execution(args, path),
};
diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts
index 51cde53d7..86c102879 100644
--- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts
+++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-control-path-access-ask.ts
@@ -1,6 +1,6 @@
import type { ResolvedToolExecutionHookContext } from '#/agent/tool';
-import { IKaos } from '#/app/kaos';
-import type { IKaos as KaosService } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
+import type { IHostEnvironment as HostEnvironment } from '#/app/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext';
import type {
@@ -18,7 +18,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio
readonly name = 'git-control-path-access-ask';
constructor(
- @IKaos private readonly kaos: KaosService,
+ @IHostEnvironment private readonly env: HostEnvironment,
@ISessionWorkspaceContext private readonly workspace: WorkspaceContext,
) {}
@@ -27,7 +27,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio
): Promise {
const cwd = this.workspace.workDir;
if (cwd.length === 0) return undefined;
- const pathClass = this.kaos.pathClass();
+ const pathClass = this.env.pathClass;
const accesses = fileAccesses(context);
if (accesses.length === 0) return undefined;
diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts
index 19148403b..bd9d5220e 100644
--- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts
+++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts
@@ -1,7 +1,7 @@
import type { ResolvedToolExecutionHookContext } from '#/agent/tool';
import { isWithinWorkspace } from '#/_base/tools/policies/path-access';
-import { IKaos } from '#/app/kaos';
-import type { IKaos as KaosService } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
+import type { IHostEnvironment as HostEnvironment } from '#/app/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext';
import type {
@@ -17,7 +17,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli
readonly name = 'git-cwd-write-approve';
constructor(
- @IKaos private readonly kaos: KaosService,
+ @IHostEnvironment private readonly env: HostEnvironment,
@ISessionWorkspaceContext private readonly workspace: WorkspaceContext,
) {}
@@ -26,7 +26,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli
): Promise {
const toolName = context.toolCall.name;
if (toolName !== 'Write' && toolName !== 'Edit') return undefined;
- if (this.kaos.pathClass() !== 'posix') return undefined;
+ if (this.env.pathClass !== 'posix') return undefined;
const cwd = this.workspace.workDir;
if (cwd.length === 0) return undefined;
diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts
index abc7740d5..c35f0ed11 100644
--- a/packages/agent-core-v2/src/agent/profile/context.ts
+++ b/packages/agent-core-v2/src/agent/profile/context.ts
@@ -5,6 +5,10 @@
* 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`)
+ * plus the host's `homeDir` — supplied together as a small `ProfileContextDeps`
+ * bag threaded through the helpers.
+ *
* Port of v1 `packages/agent-core/src/profile/context.ts`. The combined
* AGENTS.md content is injected in full; when it exceeds the soft
* {@link AGENTS_MD_RECOMMENDED_MAX_BYTES} budget a visible `agentsMdWarning`
@@ -12,9 +16,9 @@
* truncating.
*/
-import { basename, dirname, join } from 'pathe';
+import { dirname, join, normalize } from 'pathe';
-import type { IKaos } from '#/app/kaos';
+import type { ISessionAgentFileSystem } from '#/session/agentFs';
import type { SystemPromptContext } from './profile';
@@ -26,13 +30,18 @@ import type { SystemPromptContext } from './profile';
// can trim oversized instruction files.
export const AGENTS_MD_RECOMMENDED_MAX_BYTES = 32 * 1024;
-const S_IFMT = 0o170000;
-const S_IFREG = 0o100000;
-const S_IFDIR = 0o040000;
-
export const LIST_DIR_ROOT_WIDTH = 30;
export const LIST_DIR_CHILD_WIDTH = 10;
+/**
+ * Small dep bag threaded through the context helpers so they only depend on
+ * the filesystem primitive plus the host home directory, not on `IKaos`.
+ */
+interface ProfileContextDeps {
+ readonly fs: ISessionAgentFileSystem;
+ readonly homeDir: string;
+}
+
export interface PreparedSystemPromptContext extends SystemPromptContext {
readonly cwdListing?: string;
readonly agentsMd?: string;
@@ -46,15 +55,16 @@ export interface PrepareSystemPromptContextOptions {
}
export async function prepareSystemPromptContext(
- kaos: IKaos,
+ deps: ProfileContextDeps,
+ workDir: string,
brandHome?: string,
options?: PrepareSystemPromptContextOptions,
): Promise {
const additionalDirs = dedupeDirs(options?.additionalDirs ?? []);
const [cwdListing, agentsMdResult, additionalDirsInfo] = await Promise.all([
- listDirectory(kaos, undefined, { collapseHiddenDirs: true }),
- loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]),
- loadAdditionalDirsInfo(kaos, additionalDirs),
+ listDirectory(deps, workDir, { collapseHiddenDirs: true }),
+ loadAgentsMdForRoots(deps, brandHome, [workDir]),
+ loadAdditionalDirsInfo(deps, additionalDirs),
]);
return {
cwdListing,
@@ -64,8 +74,12 @@ export async function prepareSystemPromptContext(
};
}
-export async function loadAgentsMd(kaos: IKaos, brandHome?: string): Promise {
- const result = await loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]);
+export async function loadAgentsMd(
+ deps: ProfileContextDeps,
+ workDir: string,
+ brandHome?: string,
+): Promise {
+ const result = await loadAgentsMdForRoots(deps, brandHome, [workDir]);
return result.content;
}
@@ -75,7 +89,7 @@ interface LoadedAgentsMd {
}
async function loadAgentsMdForRoots(
- kaos: IKaos,
+ deps: ProfileContextDeps,
brandHome: string | undefined,
workDirs: readonly string[],
): Promise {
@@ -83,9 +97,9 @@ async function loadAgentsMdForRoots(
const seen = new Set();
const collect = async (path: string): Promise => {
- const file = await readAgentFile(kaos, path);
+ const file = await readAgentFile(deps, path);
if (file === undefined) return false;
- const key = kaos.normpath(file.path);
+ const key = normalize(file.path);
if (seen.has(key)) return false;
seen.add(key);
discovered.push(file);
@@ -95,7 +109,7 @@ async function loadAgentsMdForRoots(
// User-level files come first so any project-level AGENTS.md overrides them.
// The brand dir follows KIMI_CODE_HOME (default ~/.kimi-code); the generic
// .agents dir stays under the real OS home so it can be shared across tools.
- const realHome = kaos.gethome();
+ const realHome = deps.homeDir;
const brandDir = brandHome ?? join(realHome, '.kimi-code');
await collect(join(brandDir, 'AGENTS.md'));
@@ -109,10 +123,9 @@ async function loadAgentsMdForRoots(
}
for (const workDir of workDirs) {
- const rootKaos = kaos.withCwd(workDir);
- const rootWorkDir = rootKaos.getcwd();
- const projectRoot = await findProjectRoot(rootKaos, rootWorkDir);
- const dirs = dirsRootToLeaf(rootKaos, rootWorkDir, projectRoot);
+ const rootWorkDir = normalize(workDir);
+ const projectRoot = await findProjectRoot(deps, rootWorkDir);
+ const dirs = dirsRootToLeaf(rootWorkDir, projectRoot);
for (const dir of dirs) {
await collect(join(dir, '.kimi-code', 'AGENTS.md'));
@@ -133,31 +146,34 @@ async function loadAgentsMdForRoots(
return { content, warning };
}
-async function loadAdditionalDirsInfo(kaos: IKaos, additionalDirs: readonly string[]): Promise {
+async function loadAdditionalDirsInfo(
+ deps: ProfileContextDeps,
+ additionalDirs: readonly string[],
+): Promise {
const sections = await Promise.all(
additionalDirs.map(async (dir) => {
- const listing = await listDirectory(kaos.withCwd(dir));
+ const listing = await listDirectory(deps, dir);
return `### ${dir}\n${listing}`;
}),
);
return sections.join('\n\n');
}
-async function findProjectRoot(kaos: IKaos, workDir: string): Promise {
- const initial = kaos.normpath(workDir);
+async function findProjectRoot(deps: ProfileContextDeps, workDir: string): Promise {
+ const initial = normalize(workDir);
let current = initial;
while (true) {
- if (await pathExists(kaos, join(current, '.git'))) return current;
+ if (await pathExists(deps, join(current, '.git'))) return current;
const parent = dirname(current);
if (parent === current) return initial;
current = parent;
}
}
-function dirsRootToLeaf(kaos: IKaos, workDir: string, projectRoot: string): string[] {
+function dirsRootToLeaf(workDir: string, projectRoot: string): string[] {
const dirs: string[] = [];
- let current = kaos.normpath(workDir);
+ let current = normalize(workDir);
while (true) {
dirs.push(current);
@@ -175,26 +191,29 @@ interface AgentFile {
readonly content: string;
}
-async function readAgentFile(kaos: IKaos, path: string): Promise {
- if (!(await isFile(kaos, path))) return undefined;
- const content = (await kaos.backend.readText(path, { errors: 'ignore' })).trim();
+async function readAgentFile(
+ deps: ProfileContextDeps,
+ path: string,
+): Promise {
+ if (!(await isFile(deps, path))) return undefined;
+ const content = (await deps.fs.readText(path, { errors: 'ignore' })).trim();
if (content.length === 0) return undefined;
return { path, content };
}
-async function pathExists(kaos: IKaos, path: string): Promise {
+async function pathExists(deps: ProfileContextDeps, path: string): Promise {
try {
- await kaos.backend.stat(path);
+ await deps.fs.stat(path);
return true;
} catch {
return false;
}
}
-async function isFile(kaos: IKaos, path: string): Promise {
+async function isFile(deps: ProfileContextDeps, path: string): Promise {
try {
- const stat = await kaos.backend.stat(path);
- return (stat.stMode & S_IFMT) === S_IFREG;
+ const stat = await deps.fs.stat(path);
+ return stat.isFile;
} catch {
return false;
}
@@ -234,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 `IKaos` backend (`iterdir` + `stat`).
+// through the v2 `ISessionAgentFileSystem` (`readdir` + `stat`).
// ---------------------------------------------------------------------------
interface ListDirectoryOptions {
@@ -247,18 +266,18 @@ interface Entry {
}
async function collectEntries(
- kaos: IKaos,
+ deps: ProfileContextDeps,
dirPath: string,
maxWidth: number,
): Promise<{ entries: Entry[]; total: number; readable: boolean }> {
const all: Entry[] = [];
try {
- for await (const fullPath of kaos.backend.iterdir(dirPath)) {
- const name = basename(fullPath);
+ const names = await deps.fs.readdir(dirPath);
+ for (const name of names) {
let isDir = false;
try {
- const st = await kaos.backend.stat(fullPath);
- isDir = (st.stMode & S_IFMT) === S_IFDIR;
+ const st = await deps.fs.stat(join(dirPath, name));
+ isDir = st.isDirectory;
} catch {
// Unreadable entries keep isDir=false; still list the name.
}
@@ -279,12 +298,12 @@ function shouldCollapseDirectory(entry: Entry, options: ListDirectoryOptions): b
}
async function listDirectory(
- kaos: IKaos,
- workDir: string = kaos.getcwd(),
+ deps: ProfileContextDeps,
+ workDir: string,
options: ListDirectoryOptions = {},
): Promise {
const lines: string[] = [];
- const { entries, total, readable } = await collectEntries(kaos, workDir, LIST_DIR_ROOT_WIDTH);
+ const { entries, total, readable } = await collectEntries(deps, workDir, LIST_DIR_ROOT_WIDTH);
if (!readable) return '[not readable]';
const remaining = total - entries.length;
@@ -300,7 +319,7 @@ async function listDirectory(
if (shouldCollapseDirectory(entry, options)) continue;
const childPrefix = isLast ? ' ' : '│ ';
const childDir = join(workDir, name);
- const child = await collectEntries(kaos, childDir, LIST_DIR_CHILD_WIDTH);
+ const child = await collectEntries(deps, childDir, LIST_DIR_CHILD_WIDTH);
if (!child.readable) {
lines.push(`${childPrefix}└── [not readable]`);
continue;
diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts
index fd2a8d30d..bf2f31d96 100644
--- a/packages/agent-core-v2/src/agent/profile/profileService.ts
+++ b/packages/agent-core-v2/src/agent/profile/profileService.ts
@@ -26,7 +26,9 @@ import { IConfigRegistry, IConfigService } from '#/app/config';
import { resolveThinkingEffort } from './thinking';
import { applyKimiModelOverrides, IChatProviderFactory, type KimiModelOverrides } from '#/app/chatProvider';
import type { LoopControl } from '#/agent/loop/configSection';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
+import { ISessionAgentFileSystem } from '#/session/agentFs';
+import { IExecContext } from '#/session/execContext';
import { isMcpToolName } from '#/agent/tool';
import { ISessionModelResolver, type ResolvedModel } from '#/session/modelRuntime';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
@@ -86,7 +88,9 @@ export class AgentProfileService implements IAgentProfileService {
@IConfigService private readonly config: IConfigService,
@ISessionModelResolver private readonly modelResolver: ISessionModelResolver,
@IChatProviderFactory private readonly chatProviders: IChatProviderFactory,
- @IKaos private readonly kaos: IKaos,
+ @IHostEnvironment private readonly env: IHostEnvironment,
+ @ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
+ @IExecContext private readonly execCtx: IExecContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
) {
@@ -168,9 +172,14 @@ export class AgentProfileService implements IAgentProfileService {
}
async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise {
- const context = await prepareSystemPromptContext(this.kaos, this.bootstrap.homeDir, {
- additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs,
- });
+ const context = await prepareSystemPromptContext(
+ { fs: this.fs, homeDir: this.env.homeDir },
+ this.execCtx.cwd,
+ this.bootstrap.homeDir,
+ {
+ additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs,
+ },
+ );
this.useProfile(profile, context);
const { agentsMdWarning } = context;
this.agentsMdWarning = agentsMdWarning;
diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts
index 66573f492..5caae2ae2 100644
--- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts
+++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts
@@ -25,14 +25,8 @@ import { IHostEnvironment } from '#/app/hostEnvironment';
import { IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
import { IAgentToolService } from '#/agent/agentTool';
-import {
- DenyAllPermissionPolicyService,
- IAgentPermissionPolicyService,
-} from '#/agent/permissionPolicy';
-import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentSwarmService } from '#/agent/swarm';
import { ITelemetryService } from '#/app/telemetry';
-import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { ToolUpdate } from '#/agent/tool';
import { IAgentTurnService } from '#/agent/turn';
@@ -74,23 +68,6 @@ import {
const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60;
-const TOOL_CALL_DISABLED_MESSAGE =
- 'Tool calls are disabled for side questions. Answer with text only.';
-const SIDE_QUESTION_SYSTEM_REMINDER = `
-This is a side-channel conversation with the user. You should answer user questions directly based on what you already know.
-
-IMPORTANT:
-- You are a separate, lightweight instance.
-- The main agent continues independently; do not reference being interrupted.
-- Do not call any tools. All tool calls are disabled and will be rejected.
- Even though tool definitions are visible in this request, they exist only
- for technical reasons (prompt cache). You must not use them.
-- Respond only with text based on what you already know from the conversation
- and this side-channel conversation.
-- Follow-up turns may happen in this side-channel conversation.
-- If you do not know the answer, say so directly.
-`;
-
export class AgentRPCService implements IAgentRPCService {
declare readonly _serviceBrand: undefined;
private readonly shellCommandControllers = new Map();
@@ -115,7 +92,6 @@ export class AgentRPCService implements IAgentRPCService {
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentSkillService private readonly skills: IAgentSkillService,
- @IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService,
@IAgentToolService private readonly agentTool: IAgentToolService,
@IAgentUsageService private readonly usage: IAgentUsageService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@@ -373,20 +349,6 @@ export class AgentRPCService implements IAgentRPCService {
await this.metadata.update(patch satisfies SessionMetaPatch);
}
- async startBtw(_payload: EmptyPayload): Promise {
- const child = await this.lifecycle.fork('main');
- child.accessor
- .get(IAgentSystemReminderService)
- ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), {
- kind: 'system_trigger',
- name: 'btw',
- });
- child.accessor
- .get(IAgentPermissionPolicyService)
- ?.registerPolicy(new DenyAllPermissionPolicyService(TOOL_CALL_DISABLED_MESSAGE));
- return child.id;
- }
-
createGoal(payload: CreateGoalPayload) {
return this.goal.createGoal(payload);
}
diff --git a/packages/agent-core-v2/src/agent/shellTools/shellToolsService.ts b/packages/agent-core-v2/src/agent/shellTools/shellToolsService.ts
index 4b942d7b9..01a396497 100644
--- a/packages/agent-core-v2/src/agent/shellTools/shellToolsService.ts
+++ b/packages/agent-core-v2/src/agent/shellTools/shellToolsService.ts
@@ -3,14 +3,15 @@
*
* Registers the built-in Bash tool into the agent `IAgentToolRegistryService` on
* construction, wiring it to the session `ISessionProcessRunner` (process spawn),
- * `IKaos` (cwd + OS/shell probe) and `IAgentBackgroundService` (background-task
- * lifecycle). Bound at Agent scope.
+ * `IHostEnvironment` (OS / shell probe), `IExecContext` (session cwd) and
+ * `IAgentBackgroundService` (background-task lifecycle). Bound at Agent scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBackgroundService } from '#/agent/background';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
+import { IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
@@ -24,11 +25,12 @@ export class AgentShellToolsService implements IAgentShellToolsService {
constructor(
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@ISessionProcessRunner runner: ISessionProcessRunner,
- @IKaos kaos: IKaos,
+ @IHostEnvironment env: IHostEnvironment,
+ @IExecContext ctx: IExecContext,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
) {
- toolRegistry.register(new BashTool(runner, kaos, background, {
+ toolRegistry.register(new BashTool(runner, env, ctx, background, {
allowBackground: () =>
profile.isToolActive('TaskOutput') && profile.isToolActive('TaskStop'),
}));
diff --git a/packages/agent-core-v2/src/agent/shellTools/tools/bash.ts b/packages/agent-core-v2/src/agent/shellTools/tools/bash.ts
index 73c7cec29..35457ba51 100644
--- a/packages/agent-core-v2/src/agent/shellTools/tools/bash.ts
+++ b/packages/agent-core-v2/src/agent/shellTools/tools/bash.ts
@@ -7,7 +7,8 @@
*
* Dependencies injected via constructor:
* - `runner` — `ISessionProcessRunner`, spawns the shell process
- * - `kaos` — `IKaos`, the execution environment (cwd / osEnv / shellPath)
+ * - `env` — `IHostEnvironment`, host OS / shell probe (osKind / shellName / shellPath)
+ * - `ctx` — `IExecContext`, session cwd used to render the shell prompt
* - `background` — `IAgentBackgroundService`, owns foreground/background task
* lifecycle (timeouts, detach, user interrupt)
*
@@ -33,7 +34,8 @@ import { z } from 'zod';
import { ProcessBackgroundTask } from '#/agent/background';
import type { IAgentBackgroundService } from '#/agent/background';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
+import type { IExecContext } from '#/session/execContext';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import type { BuiltinTool, ExecutableToolResult, ToolExecution, ToolUpdate } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
@@ -166,15 +168,16 @@ export class BashTool implements BuiltinTool {
constructor(
private readonly runner: ISessionProcessRunner,
- private readonly kaos: IKaos,
+ private readonly env: IHostEnvironment,
+ private readonly ctx: IExecContext,
private readonly background: IAgentBackgroundService,
options?: {
allowBackground?: () => boolean;
},
) {
- this.isWindowsBash = this.kaos.osEnv.osKind === 'Windows';
+ this.isWindowsBash = this.env.osKind === 'Windows';
this.allowBackground = options?.allowBackground ?? (() => true);
- this.renderedDescription = renderBashDescription(this.kaos.osEnv.shellName);
+ this.renderedDescription = renderBashDescription(this.env.shellName);
}
get description(): string {
@@ -192,7 +195,7 @@ export class BashTool implements BuiltinTool {
display: {
kind: 'command',
command: args.command,
- cwd: args.cwd ?? this.kaos.cwd,
+ cwd: args.cwd ?? this.ctx.cwd,
description: args.description,
language: 'bash',
},
@@ -206,7 +209,7 @@ export class BashTool implements BuiltinTool {
private spawn(effectiveCwd: string, command: string): Promise {
const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd;
const shellArgs = [
- this.kaos.osEnv.shellPath,
+ this.env.shellPath,
'-c',
`cd ${shellQuote(shellCwd)} && ${command}`,
];
@@ -218,7 +221,7 @@ export class BashTool implements BuiltinTool {
// to be inherited; honour an explicit ambient value when the user has
// set one.
GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0',
- SHELL: this.kaos.osEnv.shellPath,
+ SHELL: this.env.shellPath,
};
// v2's ISessionProcessRunner.exec overlays this env on process.env, so we pass
@@ -239,7 +242,7 @@ export class BashTool implements BuiltinTool {
const startsInBackground = args.run_in_background === true;
const foregroundTimeoutMs = normalizeTimeoutMs(args.timeout, false);
const command = this.isWindowsBash ? rewriteWindowsNullRedirect(args.command) : args.command;
- const effectiveCwd = args.cwd ?? this.kaos.cwd;
+ const effectiveCwd = args.cwd ?? this.ctx.cwd;
const description = startsInBackground ? args.description!.trim() : foregroundDescription(args);
const timeoutMs = startsInBackground
? args.disable_timeout
diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts
index 22e9caad6..451c73a40 100644
--- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts
+++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts
@@ -3,9 +3,9 @@
*
* Defines the `IBootstrapService`, the snapshot of the world the process runs
* in, resolved once at startup and frozen for the process: observed host facts
- * (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`, `detect`) and the app path
- * layout (`homeDir`, `configPath`, …). `resolveBootstrapOptions` is the single
- * place that reads `process.env` / `os.homedir()` / invocation input to resolve
+ * (`platform`, `arch`, `cwd`, `osHomeDir`, `getEnv`) and the app path layout
+ * (`homeDir`, `configPath`, …). `resolveBootstrapOptions` is the single place
+ * that reads `process.env` / `os.homedir()` / invocation input to resolve
* the snapshot; everything downstream reads from `IBootstrapService` instead of
* touching `process` directly. Bound at App scope. Also seeds the App storage
* roles (`IStorageService`, `IAppendLogStorage`, `IAtomicDocumentStorage`,
@@ -19,8 +19,6 @@ import { homedir } from 'node:os';
import { join } from 'pathe';
-import type { Environment } from '#/app/kaos';
-
import { SyncDescriptor } from '#/_base/di/descriptors';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { createAppScope, type Scope, type ScopeSeed } from '#/_base/di/scope';
@@ -64,7 +62,6 @@ export interface IBootstrapService {
readonly logsDir: string;
getEnv(name: string): string | undefined;
- detect(): Promise;
}
export const IBootstrapService: ServiceIdentifier =
diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts
index 54bfa647b..16cab0617 100644
--- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts
+++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts
@@ -2,14 +2,11 @@
* `bootstrap` domain (L1) — `IBootstrapService` implementation.
*
* Holds the resolved startup snapshot from the seeded `IBootstrapOptions` and
- * exposes the host facts and app path layout; `detect()` probes the host through
- * `kaos` on demand. Bound at App scope.
+ * exposes the host facts and app path layout. Bound at App scope.
*/
import { join } from 'pathe';
-import { type Environment, detectEnvironmentFromNode } from '#/app/kaos';
-
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
@@ -30,7 +27,6 @@ export class BootstrapService implements IBootstrapService {
readonly cacheDir: string;
readonly logsDir: string;
private readonly env: NodeJS.ProcessEnv;
- private detected?: Promise;
constructor(@IBootstrapOptions options: IBootstrapOptions) {
this.platform = options.platform;
@@ -50,11 +46,6 @@ export class BootstrapService implements IBootstrapService {
getEnv(name: string): string | undefined {
return this.env[name];
}
-
- detect(): Promise {
- this.detected ??= detectEnvironmentFromNode();
- return this.detected;
- }
}
registerScopedService(LifecycleScope.App, IBootstrapService, BootstrapService, InstantiationType.Eager, 'bootstrap');
diff --git a/packages/agent-core-v2/src/app/hostEnvironment/hostEnvironment.ts b/packages/agent-core-v2/src/app/hostEnvironment/hostEnvironment.ts
new file mode 100644
index 000000000..6ca005940
--- /dev/null
+++ b/packages/agent-core-v2/src/app/hostEnvironment/hostEnvironment.ts
@@ -0,0 +1,61 @@
+/**
+ * `hostEnvironment` domain (L1) — the OS / shell / path-style facts of the
+ * host the Agent runs on.
+ *
+ * Defines `IHostEnvironment`, an immutable snapshot of the host OS
+ * (`osKind`/`osArch`/`osVersion`), the POSIX shell to spawn commands with
+ * (`shellName`/`shellPath`), the target path style (`pathClass`), and the
+ * user's home directory (`homeDir`). The snapshot is a pure function of the
+ * host and never changes during a process's lifetime; the service memoises
+ * the probe.
+ *
+ * Async initialization: probing (`ready`) discovers the shell path — on
+ * Windows this may run `git.exe --exec-path`. The composition root
+ * (`session-lifecycle`) `await`s `ready` before creating any Session scope, so
+ * every Session/Agent-scope consumer reads the sync fields safely.
+ *
+ * App-scoped — one shared instance for the whole process.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+import type {
+ HostEnvironmentInfo,
+ OsKind,
+ PathClass,
+ ShellName,
+} from '#/_base/execEnv';
+
+export type { HostEnvironmentInfo, OsKind, PathClass, ShellName };
+
+export interface IHostEnvironment {
+ readonly _serviceBrand: undefined;
+
+ /** Family of the host OS (`macOS` / `Linux` / `Windows`, or the raw
+ * `process.platform` string for unknown platforms). */
+ readonly osKind: OsKind;
+ /** Host architecture (`process.arch`). */
+ readonly osArch: string;
+ /** Host kernel release (`os.release()`). */
+ readonly osVersion: string;
+ /** Name of the POSIX shell discovered on this host. */
+ readonly shellName: ShellName;
+ /** Absolute path to the POSIX shell (`/bin/bash`, `/bin/sh`, or a Git Bash
+ * installation on Windows). */
+ readonly shellPath: string;
+ /** Path style used by this host — `win32` on Windows, `posix` elsewhere. */
+ readonly pathClass: PathClass;
+ /** Absolute path of the current user's home directory (`os.homedir()`). */
+ readonly homeDir: string;
+
+ /**
+ * Resolves once the probe has completed. Every field above is populated by
+ * the time this promise settles. The composition root awaits this before
+ * creating a Session scope so all Session/Agent consumers can read the
+ * fields synchronously.
+ */
+ readonly ready: Promise;
+}
+
+export const IHostEnvironment: ServiceIdentifier =
+ createDecorator('hostEnvironment');
diff --git a/packages/agent-core-v2/src/app/hostEnvironment/hostEnvironmentService.ts b/packages/agent-core-v2/src/app/hostEnvironment/hostEnvironmentService.ts
new file mode 100644
index 000000000..88369a3a8
--- /dev/null
+++ b/packages/agent-core-v2/src/app/hostEnvironment/hostEnvironmentService.ts
@@ -0,0 +1,78 @@
+/**
+ * `hostEnvironment` domain (L1) — `IHostEnvironment` implementation.
+ *
+ * Kicks off the OS / shell probe (`probeHostEnvironmentFromNode`) at
+ * construction time; the sync fields become populated once `ready` resolves.
+ * Reads before `ready` throws with a clear message so misuse fails loudly
+ * instead of returning stale zeros. Bound at App scope.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { probeHostEnvironmentFromNode } from '#/_base/execEnv';
+
+import {
+ type HostEnvironmentInfo,
+ IHostEnvironment,
+ type OsKind,
+ type PathClass,
+ type ShellName,
+} from './hostEnvironment';
+
+export class HostEnvironmentService implements IHostEnvironment {
+ declare readonly _serviceBrand: undefined;
+
+ private _info?: HostEnvironmentInfo;
+ readonly ready: Promise;
+
+ constructor() {
+ this.ready = probeHostEnvironmentFromNode().then((info) => {
+ this._info = info;
+ });
+ }
+
+ private require(field: keyof HostEnvironmentInfo): never | HostEnvironmentInfo[typeof field] {
+ if (this._info === undefined) {
+ throw new Error(
+ `IHostEnvironment.${field} accessed before ready — await IHostEnvironment.ready first (composition root should do so before creating a Session scope).`,
+ );
+ }
+ return this._info[field];
+ }
+
+ get osKind(): OsKind {
+ return this.require('osKind') as OsKind;
+ }
+
+ get osArch(): string {
+ return this.require('osArch') as string;
+ }
+
+ get osVersion(): string {
+ return this.require('osVersion') as string;
+ }
+
+ get shellName(): ShellName {
+ return this.require('shellName') as ShellName;
+ }
+
+ get shellPath(): string {
+ return this.require('shellPath') as string;
+ }
+
+ get pathClass(): PathClass {
+ return this.require('pathClass') as PathClass;
+ }
+
+ get homeDir(): string {
+ return this.require('homeDir') as string;
+ }
+}
+
+registerScopedService(
+ LifecycleScope.App,
+ IHostEnvironment,
+ HostEnvironmentService,
+ InstantiationType.Delayed,
+ 'hostEnvironment',
+);
diff --git a/packages/agent-core-v2/src/app/hostEnvironment/index.ts b/packages/agent-core-v2/src/app/hostEnvironment/index.ts
new file mode 100644
index 000000000..05b13d078
--- /dev/null
+++ b/packages/agent-core-v2/src/app/hostEnvironment/index.ts
@@ -0,0 +1,9 @@
+/**
+ * `hostEnvironment` domain barrel — re-exports the host-environment contract
+ * (`hostEnvironment`) and its scoped service (`hostEnvironmentService`).
+ * Importing this barrel registers the `IHostEnvironment` binding into the
+ * scope registry.
+ */
+
+export * from './hostEnvironment';
+export * from './hostEnvironmentService';
diff --git a/packages/agent-core-v2/src/app/kaos/index.ts b/packages/agent-core-v2/src/app/kaos/index.ts
deleted file mode 100644
index c8653e8eb..000000000
--- a/packages/agent-core-v2/src/app/kaos/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * `kaos` domain barrel — re-exports the execution-environment contracts
- * (`kaos`) and the `IKaosFactory` binding (`kaosFactoryService`). Importing
- * this barrel registers the `IKaosFactory` binding into the scope registry.
- */
-
-export * from './kaos';
-export * from './kaosFactoryService';
diff --git a/packages/agent-core-v2/src/app/kaos/kaos.ts b/packages/agent-core-v2/src/app/kaos/kaos.ts
deleted file mode 100644
index d81aa6d83..000000000
--- a/packages/agent-core-v2/src/app/kaos/kaos.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * `kaos` domain (L1) — execution-environment contracts.
- *
- * Defines `IKaos`, the Agent's execution environment (cwd, env layers, the
- * OS/shell probe, and the backend handle the fs/process domains delegate to),
- * plus `IKaosFactory`, the App factory that builds an `IKaos` for a session
- * (local today; ssh/container behind the same factory later).
- *
- * Temporary: this domain wraps the `@moonshot-ai/kaos` package and re-exports
- * a few of its data types so business code imports them from `#/kaos` instead
- * of the package. `IKaos` is seeded into each Session scope by the composition
- * root; `IKaosFactory` is bound at App scope.
- */
-
-import type { Environment, Kaos } from '@moonshot-ai/kaos';
-
-import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
-
-export type { Environment, KaosProcess, StatResult } from '@moonshot-ai/kaos';
-export { detectEnvironmentFromNode } from '@moonshot-ai/kaos';
-
-export type PathClass = 'posix' | 'win32';
-
-export interface IKaos {
- readonly _serviceBrand: undefined;
-
- /** Human-readable backend name (e.g. `"local"`, `"ssh:host"`). */
- readonly name: string;
- /** Current working directory of this execution environment. */
- readonly cwd: string;
- /** OS / shell probe of the execution environment. */
- readonly osEnv: Environment;
- /**
- * The backend fs/process domains delegate to. Temporary — owned by this
- * environment; business code should reach for `ISessionAgentFileSystem` /
- * `ISessionProcessRunner` instead of touching this directly.
- */
- readonly backend: Kaos;
-
- pathClass(): PathClass;
- normpath(path: string): string;
- gethome(): string;
- getcwd(): string;
-
- /** Derive a new environment rooted at `cwd` (shares backend + osEnv). */
- withCwd(cwd: string): IKaos;
- /** Derive a new environment that overlays `env` onto spawned processes. */
- withEnv(env: Record): IKaos;
-}
-
-export const IKaos: ServiceIdentifier = createDecorator('kaos');
-
-export interface IKaosFactory {
- readonly _serviceBrand: undefined;
-
- /** Build a local execution environment rooted at `cwd`. */
- createLocal(cwd: string): Promise;
-}
-
-export const IKaosFactory: ServiceIdentifier =
- createDecorator('kaosFactory');
diff --git a/packages/agent-core-v2/src/app/kaos/kaosFactoryService.ts b/packages/agent-core-v2/src/app/kaos/kaosFactoryService.ts
deleted file mode 100644
index e0d70cb16..000000000
--- a/packages/agent-core-v2/src/app/kaos/kaosFactoryService.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * `kaos` domain (L1) — `IKaosFactory` implementation.
- *
- * Builds an `IKaos` for a session. Today only local (`LocalKaos`); ssh/container
- * are added behind the same factory later. Bound at App scope.
- */
-
-import { LocalKaos } from '@moonshot-ai/kaos';
-
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-
-import { type IKaos, IKaosFactory } from './kaos';
-import { KaosService } from './kaosService';
-
-export class KaosFactory implements IKaosFactory {
- declare readonly _serviceBrand: undefined;
-
- async createLocal(cwd: string): Promise {
- const base = await LocalKaos.create();
- return new KaosService(base.withCwd(cwd));
- }
-}
-
-registerScopedService(
- LifecycleScope.App,
- IKaosFactory,
- KaosFactory,
- InstantiationType.Delayed,
- 'kaos',
-);
diff --git a/packages/agent-core-v2/src/app/kaos/kaosService.ts b/packages/agent-core-v2/src/app/kaos/kaosService.ts
deleted file mode 100644
index 48e862b89..000000000
--- a/packages/agent-core-v2/src/app/kaos/kaosService.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * `kaos` domain (L1) — `IKaos` implementation.
- *
- * Thin wrapper around a `@moonshot-ai/kaos` `Kaos` backend, exposing cwd, the
- * OS/shell probe, path primitives, and context derivation (`withCwd`/`withEnv`).
- * Not registered directly — built by `IKaosFactory` and seeded into a Session
- * scope by the composition root.
- */
-
-import type { Kaos } from '@moonshot-ai/kaos';
-
-import type { Environment, IKaos, PathClass } from './kaos';
-
-export class KaosService implements IKaos {
- declare readonly _serviceBrand: undefined;
-
- constructor(readonly backend: Kaos) {}
-
- get name(): string {
- return this.backend.name;
- }
-
- get cwd(): string {
- return this.backend.getcwd();
- }
-
- get osEnv(): Environment {
- return this.backend.osEnv;
- }
-
- pathClass(): PathClass {
- return this.backend.pathClass();
- }
-
- normpath(path: string): string {
- return this.backend.normpath(path);
- }
-
- gethome(): string {
- return this.backend.gethome();
- }
-
- getcwd(): string {
- return this.backend.getcwd();
- }
-
- withCwd(cwd: string): IKaos {
- return new KaosService(this.backend.withCwd(cwd));
- }
-
- withEnv(env: Record): IKaos {
- return new KaosService(this.backend.withEnv(env));
- }
-}
diff --git a/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts
index edbf90bab..fb502b36c 100644
--- a/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts
+++ b/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts
@@ -26,7 +26,8 @@ import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IBootstrapService } from '#/app/bootstrap';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { ErrorCodes, KimiError } from '#/errors';
-import { IKaos, IKaosFactory } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
+import { createExecContext, execContextSeed } from '#/session/execContext';
import { ISessionActivity } from '#/session/session-activity';
import { ISessionIndex } from '#/app/session-index';
import { IAtomicDocumentStore, IAppendLogStore } from '#/app/storage';
@@ -71,7 +72,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
constructor(
@IInstantiationService private readonly instantiation: IInstantiationService,
@IBootstrapService private readonly bootstrap: IBootstrapService,
- @IKaosFactory private readonly kaosFactory: IKaosFactory,
+ @IHostEnvironment private readonly hostEnv: IHostEnvironment,
@ISessionIndex private readonly index: ISessionIndex,
@IAppendLogStore private readonly appendLogStore: IAppendLogStore,
@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore,
@@ -94,7 +95,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
sessionDir,
metaScope,
};
- const kaos = await this.kaosFactory.createLocal(opts.workDir);
+ // Wait for the host-environment probe to complete before creating any
+ // Session scope — Session/Agent-scope services (bash, permission policies,
+ // path-access) read `IHostEnvironment.osKind` / `pathClass` / `homeDir`
+ // synchronously in their constructors, so the probe must have landed by
+ // the time the first Session-scoped service is resolved.
+ await this.hostEnv.ready;
+ const execCtx = createExecContext(opts.workDir);
const handle = createScopedChildHandle(
this.instantiation,
LifecycleScope.Session,
@@ -102,7 +109,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
{
extra: [
...sessionContextSeed(ctx),
- [IKaos, kaos] as const,
+ ...execContextSeed(execCtx),
],
},
);
diff --git a/packages/agent-core-v2/src/session/agentFs/agentFs.ts b/packages/agent-core-v2/src/session/agentFs/agentFs.ts
index 8cc357323..4f2cb11cc 100644
--- a/packages/agent-core-v2/src/session/agentFs/agentFs.ts
+++ b/packages/agent-core-v2/src/session/agentFs/agentFs.ts
@@ -1,12 +1,14 @@
/**
* `agentFs` domain (L1) — the Agent's filesystem.
*
- * Defines the `ISessionAgentFileSystem` that business code injects to read and write
- * files inside the Agent's execution environment. Session-scoped and backed by
- * the session `IKaos`; business code depends on `ISessionAgentFileSystem` only.
+ * 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;
@@ -23,12 +25,19 @@ export interface ISessionAgentFileSystem {
readonly cwd: string;
- readText(path: string): Promise;
- writeText(path: string, data: string): Promise;
+ readText(
+ path: string,
+ options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
+ ): Promise;
+ writeText(
+ path: string,
+ data: string,
+ options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
+ ): Promise;
readBytes(path: string, n?: number): Promise;
readLines(
path: string,
- options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
+ options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): AsyncGenerator;
writeBytes(path: string, data: Uint8Array): Promise;
stat(path: string): Promise;
diff --git a/packages/agent-core-v2/src/session/agentFs/agentFsService.ts b/packages/agent-core-v2/src/session/agentFs/agentFsService.ts
index 455a019ae..f0f927d20 100644
--- a/packages/agent-core-v2/src/session/agentFs/agentFsService.ts
+++ b/packages/agent-core-v2/src/session/agentFs/agentFsService.ts
@@ -1,103 +1,426 @@
/**
* `agentFs` domain (L1) — `ISessionAgentFileSystem` implementation.
*
- * Focused file-IO surface over the session execution environment (`IKaos.backend`).
- * Relative-path resolution (in the target path style) and symlink-safe glob are
- * handled by the kaos backend; this service exposes a kaos-free, filesystem-shaped
- * interface to business code and derives sub-views via `withCwd`. Bound at Session
- * scope.
+ * 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 { IKaos, type StatResult } from '#/app/kaos';
+import {
+ decodeTextWithErrors,
+ globPatternToRegex,
+ type TextDecodeErrors,
+} from '#/_base/execEnv';
+import { ErrorCodes, KimiError } from '#/errors';
+import { IExecContext } from '#/session/execContext';
import { type AgentFileStat, ISessionAgentFileSystem } from './agentFs';
-const S_IFMT = 0o170000;
-const S_IFREG = 0o100000;
-const S_IFDIR = 0o040000;
+const READ_CHUNK_SIZE = 64 * 1024;
-function statKind(s: StatResult): Pick {
- const kind = s.stMode & S_IFMT;
- return { isFile: kind === S_IFREG, isDirectory: kind === S_IFDIR };
+/**
+ * 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 `":0"`.
+ */
+function cycleKey(s: { dev: number; ino: number }): string | null {
+ if (s.ino === 0) return null;
+ return `${String(s.dev)}:${String(s.ino)}`;
}
-function basename(p: string): string {
- const parts = p.split(/[\\/]/);
- return parts[parts.length - 1] ?? p;
+function isUtf8Encoding(encoding: BufferEncoding): boolean {
+ return encoding === 'utf-8' || encoding === 'utf8';
+}
+
+function* splitLinesKeepingTerminator(text: string): Generator {
+ 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(@IKaos private readonly kaos: IKaos) {}
+ constructor(@IExecContext private readonly ctx: IExecContext) {}
get cwd(): string {
- return this.kaos.cwd;
+ return this.ctx.cwd;
}
- readText(path: string): Promise {
- return this.kaos.backend.readText(path);
+ private _resolvePath(path: string): string {
+ if (isAbsolute(path)) return normalize(path);
+ return join(this.ctx.cwd, path);
}
- writeText(path: string, data: string): Promise {
- return this.kaos.backend.writeText(path, data).then(() => undefined);
- }
-
- readBytes(path: string, n?: number): Promise {
- return this.kaos.backend.readBytes(path, n);
- }
-
- readLines(
+ async readText(
path: string,
- options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
- ): AsyncGenerator {
- return this.kaos.backend.readLines(path, options);
+ options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
+ ): Promise {
+ 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);
}
- writeBytes(path: string, data: Uint8Array): Promise {
- return this.kaos.backend.writeBytes(path, Buffer.from(data)).then(() => undefined);
+ async writeText(
+ path: string,
+ data: string,
+ options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
+ ): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const resolved = this._resolvePath(path);
+ await writeFile(resolved, data);
}
async stat(path: string): Promise {
- const s = await this.kaos.backend.stat(path);
+ 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 {
- ...statKind(s),
- size: s.stSize,
- mtimeMs: Math.floor(s.stMtime * 1000),
- ino: s.stIno,
+ isFile: s.isFile(),
+ isDirectory: s.isDirectory(),
+ size: s.size,
+ mtimeMs: s.mtimeMs,
+ ino: s.ino,
};
}
async readdir(path: string): Promise {
- const names: string[] = [];
- for await (const entry of this.kaos.backend.iterdir(path)) {
- names.push(basename(entry));
- }
- return names;
+ const resolved = this._resolvePath(path);
+ return await readdir(resolved);
}
async glob(pattern: string): Promise {
+ 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();
+ 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.kaos.backend.glob(this.kaos.cwd, pattern)) {
+ for await (const match of this._globWalk(resolved, patternParts, caseSensitive, initVisited)) {
out.push(match);
}
return out;
}
- mkdir(
+ // `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 `":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,
+ ): AsyncGenerator {
+ 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 {
- return this.kaos.backend.mkdir(path, {
- parents: options?.parents ?? true,
- existOk: options?.existOk ?? true,
- });
+ 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 {
- return new SessionAgentFileSystem(this.kaos.withCwd(cwd));
+ // 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));
}
}
diff --git a/packages/agent-core-v2/src/session/agentFs/rgLocator.ts b/packages/agent-core-v2/src/session/agentFs/rgLocator.ts
index 6702ec03a..a68b9c952 100644
--- a/packages/agent-core-v2/src/session/agentFs/rgLocator.ts
+++ b/packages/agent-core-v2/src/session/agentFs/rgLocator.ts
@@ -5,8 +5,8 @@
* mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful degradation)
* but is driven through a caller-supplied {@link RgProbe} so it works against
* whatever execution environment the caller has — Glob probes through the
- * cwd-bound `IKaos.backend`, Grep through the `ISessionProcessRunner`. Both run
- * `rg --version` and treat exit code 0 as "available".
+ * session `ISessionProcessRunner`, Grep through the shared runner as well.
+ * Both run `rg --version` and treat exit code 0 as "available".
*
* Lookup order (first hit wins):
* 1. System `rg` on the execution-environment PATH (`rg --version`).
@@ -34,8 +34,8 @@ export interface RgResolution {
/**
* Minimal probe surface the locator runs against. Lets the same locator run
- * over Glob's `IKaos.backend.exec` and Grep's `ISessionProcessRunner` without
- * depending on either directly.
+ * over Glob's and Grep's `ISessionProcessRunner` without depending on either
+ * directly.
*/
export interface RgProbe {
/** Run `argv` and resolve with the process exit code. */
diff --git a/packages/agent-core-v2/src/session/agentFs/runRg.ts b/packages/agent-core-v2/src/session/agentFs/runRg.ts
index 42f889491..7eddda1f6 100644
--- a/packages/agent-core-v2/src/session/agentFs/runRg.ts
+++ b/packages/agent-core-v2/src/session/agentFs/runRg.ts
@@ -1,14 +1,14 @@
/**
* `agentFs` domain — shared ripgrep subprocess plumbing.
*
- * Single place that knows how Glob spawns `rg` through the session execution
- * environment (`IKaos.backend.exec`): timeout / abort handling, capped stdout /
- * stderr draining, two-phase kill with process disposal, and the EAGAIN retry
+ * Single place that knows how Glob spawns `rg` through the session
+ * `ISessionProcessRunner`: timeout / abort handling, capped stdout / stderr
+ * draining, two-phase kill with process disposal, and the EAGAIN retry
* predicate. Mode-specific argument building and output parsing stay in the
* tools themselves.
*
* Ported from v1 (`packages/agent-core/src/tools/support/run-rg.ts`) onto the
- * v2 `IKaos` execution environment. Grep keeps its own `runCommand` path in
+ * v2 `ISessionProcessRunner`. Grep keeps its own `runCommand` path in
* `fsService` (it streams JSON and has a pure-node fallback); this helper is
* shared in the sense that the previously inline Glob plumbing now lives in one
* reusable module under the same `agentFs` domain as Grep's search code.
@@ -16,7 +16,7 @@
import type { Readable } from 'node:stream';
-import type { KaosProcess, IKaos } from '#/app/kaos';
+import type { IProcess, ISessionProcessRunner } from '#/session/process';
export const DEFAULT_TIMEOUT_MS = 20_000;
export const SIGTERM_GRACE_MS = 5_000;
@@ -33,7 +33,7 @@ export interface RunRgResult {
export type RunRgOutcome = RunRgResult | { readonly kind: 'aborted' };
-async function disposeProcess(proc: KaosProcess): Promise {
+async function disposeProcess(proc: IProcess): Promise {
try {
await proc.dispose();
} catch {
@@ -42,23 +42,24 @@ async function disposeProcess(proc: KaosProcess): Promise {
}
/**
- * Spawn `rgArgs` through the (already cwd-bound) execution environment and
- * drain its stdout/stderr with a byte cap. Handles abort (via `signal`) and a
- * hard timeout with a two-phase kill (SIGTERM, then SIGKILL after a grace
- * period) and process disposal. Returns `{ kind: 'aborted' }` when the run is
+ * Spawn `rgArgs` through the session `ISessionProcessRunner` and drain its
+ * stdout/stderr with a byte cap. Handles abort (via `signal`) and a hard
+ * timeout with a two-phase kill (SIGTERM, then SIGKILL after a grace period)
+ * and process disposal. Returns `{ kind: 'aborted' }` when the run is
* cancelled so the caller can surface a stable "aborted" message. Spawn
* failures (e.g. ENOENT) are thrown to the caller.
*/
export async function runRgOnce(
- execKaos: IKaos,
+ runner: ISessionProcessRunner,
rgArgs: readonly string[],
signal: AbortSignal,
+ options?: { readonly cwd?: string },
): Promise {
if (signal.aborted) {
return { kind: 'aborted' };
}
- const proc: KaosProcess = await execKaos.backend.exec(...rgArgs);
+ const proc: IProcess = await runner.exec(rgArgs, { cwd: options?.cwd });
try {
proc.stdin.end();
diff --git a/packages/agent-core-v2/src/session/execContext/execContext.ts b/packages/agent-core-v2/src/session/execContext/execContext.ts
new file mode 100644
index 000000000..88b648fb0
--- /dev/null
+++ b/packages/agent-core-v2/src/session/execContext/execContext.ts
@@ -0,0 +1,71 @@
+/**
+ * `execContext` domain (L1) — the Session's execution context.
+ *
+ * Defines `IExecContext`, an immutable snapshot of the working directory the
+ * session runs in (`cwd`) and the env layers that are overlaid onto every
+ * spawned process (`envLayers`). The context is seeded into the Session scope
+ * by `session-lifecycle` when the session is created and never mutates in
+ * place — `withCwd` / `withEnv` return derived contexts.
+ *
+ * Consumed by:
+ * - `session/agentFs` — the fs implementation resolves relative paths
+ * against `cwd`
+ * - `session/process` — the process runner uses `cwd` and merges the env
+ * layers onto every spawn
+ * - business code that renders a "current cwd" (tool descriptions,
+ * permission policies, profile context)
+ *
+ * Pure facts — no store, no IO. Session-scoped.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+import type { ScopeSeed } from '#/_base/di/scope';
+
+export interface IExecContext {
+ readonly _serviceBrand: undefined;
+
+ /** Absolute path to the session's working directory. */
+ readonly cwd: string;
+
+ /** Ordered list of env overlays applied on top of `process.env` when
+ * spawning a process. Later layers win. */
+ readonly envLayers: readonly Record[];
+
+ /** Return a new `IExecContext` rooted at `cwd`, keeping the same env
+ * layers. Does not mutate this context. */
+ withCwd(cwd: string): IExecContext;
+
+ /** Return a new `IExecContext` with `env` appended to `envLayers`. Does
+ * not mutate this context. */
+ withEnv(env: Record): IExecContext;
+}
+
+export const IExecContext: ServiceIdentifier =
+ createDecorator('execContext');
+
+/**
+ * Construct a plain immutable `IExecContext` value. Used by `session-lifecycle`
+ * when creating a fresh Session scope, and by `withCwd`/`withEnv` derivations
+ * inside session-scoped services.
+ */
+export function createExecContext(
+ cwd: string,
+ envLayers: readonly Record[] = [],
+): IExecContext {
+ const ctx: IExecContext = {
+ _serviceBrand: undefined,
+ cwd,
+ envLayers,
+ withCwd: (nextCwd: string) => createExecContext(nextCwd, envLayers),
+ withEnv: (env: Record) => createExecContext(cwd, [...envLayers, env]),
+ };
+ return ctx;
+}
+
+/**
+ * Build the DI seed pair used by `session-lifecycle` to inject an
+ * `IExecContext` into a new Session scope.
+ */
+export function execContextSeed(ctx: IExecContext): ScopeSeed {
+ return [[IExecContext as ServiceIdentifier, ctx]];
+}
diff --git a/packages/agent-core-v2/src/session/execContext/index.ts b/packages/agent-core-v2/src/session/execContext/index.ts
new file mode 100644
index 000000000..c2dfa3ccc
--- /dev/null
+++ b/packages/agent-core-v2/src/session/execContext/index.ts
@@ -0,0 +1,7 @@
+/**
+ * `execContext` domain barrel — re-exports the `IExecContext` contract and its
+ * seed helper (`execContext`). No scope registration: `IExecContext` is a
+ * seeded value, not a constructed service.
+ */
+
+export * from './execContext';
diff --git a/packages/agent-core-v2/src/session/process/process.ts b/packages/agent-core-v2/src/session/process/process.ts
index ecfa149fa..0b47a26e6 100644
--- a/packages/agent-core-v2/src/session/process/process.ts
+++ b/packages/agent-core-v2/src/session/process/process.ts
@@ -3,7 +3,7 @@
*
* Defines the `ISessionProcessRunner` that business code injects to spawn processes
* inside the Agent's execution environment, plus the `IProcess` handle it
- * returns. Session-scoped and backed by the session `IKaos`; business code
+ * returns. Session-scoped and backed by the session's `IExecContext`; business code
* depends on `ISessionProcessRunner` only.
*/
diff --git a/packages/agent-core-v2/src/session/process/processRunnerService.ts b/packages/agent-core-v2/src/session/process/processRunnerService.ts
index aeacf048c..fd9ca7c6d 100644
--- a/packages/agent-core-v2/src/session/process/processRunnerService.ts
+++ b/packages/agent-core-v2/src/session/process/processRunnerService.ts
@@ -1,29 +1,71 @@
/**
* `process` domain (L1) — `ISessionProcessRunner` implementation.
*
- * Spawns processes through the session execution environment (`IKaos.backend`),
- * defaulting cwd/env to the execution environment and honoring per-call
- * overrides via `withCwd` / `execWithEnv`. Bound at Session scope.
+ * Spawns processes with Node `child_process.spawn`, resolving cwd + env from
+ * the session's `IExecContext` (no more `IKaos` backend). Per-call overrides
+ * (`options.cwd`, `options.env`) win over the seeded context; env layers are
+ * overlaid onto `process.env` in registration order, then the caller-supplied
+ * env goes on top. When neither `envLayers` nor `options.env` is set we pass
+ * `undefined` so the child inherits `process.env` verbatim. Lifetime plumbing
+ * (`SpawnedProcess`, `buildLocalSpawnOptions`, `waitForSpawn`) lives in the
+ * sibling `spawnedProcess.ts`. Bound at Session scope.
*/
+import { spawn } from 'node:child_process';
+
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { IKaos } from '#/app/kaos';
+import { IExecContext } from '#/session/execContext';
import { type IProcess, ISessionProcessRunner, type ProcessExecOptions } from './process';
+import {
+ buildLocalSpawnOptions,
+ isWindows,
+ SpawnedProcess,
+ waitForSpawn,
+} from './spawnedProcess';
export class SessionProcessRunner implements ISessionProcessRunner {
declare readonly _serviceBrand: undefined;
- constructor(@IKaos private readonly kaos: IKaos) {}
+ constructor(@IExecContext private readonly ctx: IExecContext) {}
- exec(args: readonly string[], options?: ProcessExecOptions): Promise {
- const k = options?.cwd !== undefined ? this.kaos.withCwd(options.cwd) : this.kaos;
- const env =
- options?.env !== undefined
- ? ({ ...process.env, ...options.env } as Record)
- : undefined;
- return k.backend.execWithEnv([...args], env);
+ async exec(args: readonly string[], options?: ProcessExecOptions): Promise {
+ const command = args[0];
+ if (command === undefined) {
+ throw new Error(
+ 'SessionProcessRunner.exec(): at least one argument (the command to run) is required.',
+ );
+ }
+ const restArgs = args.slice(1);
+
+ const cwd = options?.cwd ?? this.ctx.cwd;
+ const env = this._buildExecEnv(options?.env);
+
+ const child = spawn(command, restArgs, buildLocalSpawnOptions(isWindows, cwd, env));
+ await waitForSpawn(child);
+ return new SpawnedProcess(child);
+ }
+
+ private _buildExecEnv(
+ invocationEnv: Record | undefined,
+ ): Record | undefined {
+ // No overrides at all — inherit process.env verbatim by passing `undefined`
+ // to `spawn`. Mirrors the pre-refactor behaviour when neither the session
+ // context nor the caller wanted to touch the child's environment.
+ if (this.ctx.envLayers.length === 0 && invocationEnv === undefined) {
+ return undefined;
+ }
+ const merged: Record = {
+ ...(process.env as Record),
+ };
+ for (const layer of this.ctx.envLayers) {
+ Object.assign(merged, layer);
+ }
+ if (invocationEnv !== undefined) {
+ Object.assign(merged, invocationEnv);
+ }
+ return merged;
}
}
diff --git a/packages/agent-core-v2/src/session/process/spawnedProcess.ts b/packages/agent-core-v2/src/session/process/spawnedProcess.ts
new file mode 100644
index 000000000..9959aa4e2
--- /dev/null
+++ b/packages/agent-core-v2/src/session/process/spawnedProcess.ts
@@ -0,0 +1,161 @@
+/**
+ * `process` domain (L1) — spawned-process primitives.
+ *
+ * Vendored from the former `@moonshot-ai/kaos` `LocalProcess`. `SpawnedProcess`
+ * wraps a Node `ChildProcess` into the domain-facing `IProcess` handle, and
+ * `buildLocalSpawnOptions` / `waitForSpawn` are the two spawn-time helpers used
+ * by the session process runner. Kept out of the runner file so the runner
+ * only orchestrates cwd/env resolution and delegates the lifetime plumbing
+ * here.
+ */
+
+import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
+import type { Readable, Writable } from 'node:stream';
+
+import { BufferedReadable } from '#/_base/execEnv';
+
+import type { IProcess } from './process';
+
+export const isWindows: boolean = process.platform === 'win32';
+
+export function buildLocalSpawnOptions(
+ isWindowsHost: boolean,
+ cwd: string,
+ env: Record | undefined,
+): SpawnOptions {
+ return {
+ cwd,
+ env,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ detached: !isWindowsHost,
+ windowsHide: true,
+ };
+}
+
+// Wait for a freshly spawned ChildProcess to either emit 'spawn' (success) or
+// 'error' (ENOENT / EACCES / etc.). Until this resolves, callers should not
+// assume the child is running — they may otherwise write to the stdin of a
+// process that never existed.
+export function waitForSpawn(child: ChildProcess): Promise {
+ return new Promise((resolve, reject) => {
+ const onSpawn = (): void => {
+ child.off('error', onError);
+ resolve();
+ };
+ const onError = (err: Error): void => {
+ child.off('spawn', onSpawn);
+ reject(err);
+ };
+ child.once('spawn', onSpawn);
+ child.once('error', onError);
+ });
+}
+
+export class SpawnedProcess implements IProcess {
+ readonly stdin: Writable;
+ readonly stdout: Readable;
+ readonly stderr: Readable;
+ readonly pid: number;
+
+ private readonly _child: ChildProcess;
+ private _exitCode: number | null = null;
+ private readonly _exitPromise: Promise;
+ private _disposed = false;
+
+ constructor(child: ChildProcess) {
+ if (child.stdin === null || child.stdout === null || child.stderr === null) {
+ throw new Error('Process must be created with stdin/stdout/stderr pipes.');
+ }
+
+ this._child = child;
+ this.stdin = child.stdin;
+ this.stdout = new BufferedReadable(child.stdout);
+ this.stderr = new BufferedReadable(child.stderr);
+ this.pid = child.pid ?? -1;
+
+ this._exitPromise = new Promise((resolve, reject) => {
+ child.on('exit', (code: number | null) => {
+ this._exitCode = code ?? -1;
+ resolve(this._exitCode);
+ });
+ child.on('error', (error: Error) => {
+ reject(error);
+ });
+ });
+ }
+
+ get exitCode(): number | null {
+ return this._exitCode;
+ }
+
+ async wait(): Promise {
+ return this._exitPromise;
+ }
+
+ kill(signal?: NodeJS.Signals): Promise {
+ // Reject if the process never actually started (spawn failed).
+ // pid <= 0 indicates ChildProcess.pid was undefined, which happens
+ // when spawn() fails to find/execute the command. Calling
+ // process.kill(-1, ...) on POSIX would signal the entire process
+ // group, potentially killing unrelated processes.
+ if (this.pid <= 0) {
+ return Promise.resolve();
+ }
+
+ // On Windows, `ChildProcess.kill()` only signals the shell parent, leaving
+ // grandchildren alive, so terminate the whole process tree with
+ // `taskkill /T`. A graceful `taskkill /T` (no `/F`) does not actually
+ // terminate a console node.exe tree, and Windows has no real graceful
+ // signal for it — Node's own `ChildProcess.kill()` is always a forceful
+ // TerminateProcess on Windows — so always force-terminate the tree.
+ if (isWindows) {
+ const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)];
+ return new Promise((resolve) => {
+ const killer = spawn('taskkill', taskkillArgs, {
+ stdio: 'ignore',
+ windowsHide: true,
+ });
+ const done = (): void => {
+ resolve();
+ };
+ killer.once('error', done);
+ killer.once('close', done);
+ });
+ }
+
+ // On POSIX, `detached:true` makes the child a process-group leader
+ // (pgid === pid). A plain `ChildProcess.kill()` still only signals the
+ // direct child, so a shell like `bash -c 'sleep 100 & sleep 100'` leaves
+ // grandchildren orphaned. `process.kill(-pid, signal)` signals the group
+ // (negative pid = process-group id under POSIX kill(2)).
+ try {
+ process.kill(-this.pid, signal ?? 'SIGTERM');
+ } catch (error) {
+ const err = error as NodeJS.ErrnoException;
+ // ESRCH = group already gone (child exited + reaped between
+ // `wait()` racing spawn + this call). Treat as successful kill.
+ if (err.code === 'ESRCH') return Promise.resolve();
+ // EPERM is typically a misconfiguration (e.g. non-detached
+ // spawn earlier in the file); fall back to direct `.kill()` so
+ // we at least signal the direct child instead of throwing.
+ if (err.code === 'EPERM') {
+ try {
+ this._child.kill(signal ?? 'SIGTERM');
+ } catch {
+ /* best effort */
+ }
+ return Promise.resolve();
+ }
+ throw error;
+ }
+ return Promise.resolve();
+ }
+
+ dispose(): void {
+ if (this._disposed) return;
+ this._disposed = true;
+ this.stdin.destroy();
+ this.stdout.destroy();
+ this.stderr.destroy();
+ }
+}
diff --git a/packages/agent-core-v2/src/session/session/sessionWarningService.ts b/packages/agent-core-v2/src/session/session/sessionWarningService.ts
index 165cfd0c1..f06dfb899 100644
--- a/packages/agent-core-v2/src/session/session/sessionWarningService.ts
+++ b/packages/agent-core-v2/src/session/session/sessionWarningService.ts
@@ -15,7 +15,9 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IBootstrapService } from '#/app/bootstrap';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
+import { ISessionAgentFileSystem } from '#/session/agentFs';
+import { IExecContext } from '#/session/execContext';
import { IAgentProfileService, prepareSystemPromptContext } from '#/agent/profile';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
@@ -28,7 +30,9 @@ export class SessionWarningService implements ISessionWarningService {
declare readonly _serviceBrand: undefined;
constructor(
- @IKaos private readonly kaos: IKaos,
+ @IHostEnvironment private readonly env: IHostEnvironment,
+ @ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
+ @IExecContext private readonly ctx: IExecContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
@@ -52,9 +56,14 @@ export class SessionWarningService implements ISessionWarningService {
// No live main agent (or it has not applied a profile yet): recompute on
// demand so the warning still surfaces for long-lived / resumed sessions.
try {
- const context = await prepareSystemPromptContext(this.kaos, this.bootstrap.homeDir, {
- additionalDirs: this.workspace.additionalDirs,
- });
+ const context = await prepareSystemPromptContext(
+ { fs: this.fs, homeDir: this.env.homeDir },
+ this.ctx.cwd,
+ this.bootstrap.homeDir,
+ {
+ additionalDirs: this.workspace.additionalDirs,
+ },
+ );
return context.agentsMdWarning;
} catch {
// Best-effort: warning retrieval must not throw to the caller.
diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts
index 59e000484..9ae3845ff 100644
--- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts
+++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts
@@ -10,7 +10,7 @@ import { isAbsolute, relative, resolve } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { IKaos } from '#/app/kaos';
+import { IExecContext } from '#/session/execContext';
import { ISessionWorkspaceContext, type PathAccessOperation } from './workspaceContext';
@@ -19,8 +19,8 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext
private _workDir: string;
private _additionalDirs: string[] = [];
- constructor(@IKaos kaos: IKaos) {
- this._workDir = resolve(kaos.getcwd());
+ constructor(@IExecContext ctx: IExecContext) {
+ this._workDir = resolve(ctx.cwd);
}
get workDir(): string {
diff --git a/packages/agent-core-v2/test/agentFs/agentFsService.test.ts b/packages/agent-core-v2/test/agentFs/agentFsService.test.ts
index 922250935..0bee20b58 100644
--- a/packages/agent-core-v2/test/agentFs/agentFsService.test.ts
+++ b/packages/agent-core-v2/test/agentFs/agentFsService.test.ts
@@ -12,20 +12,13 @@ import {
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { SessionAgentFileSystem, ISessionAgentFileSystem } from '#/session/agentFs';
-import { IKaos, IKaosFactory, KaosFactory } from '#/app/kaos';
+import { IExecContext, createExecContext } from '#/session/execContext';
-describe('SessionAgentFileSystem (backed by IKaos)', () => {
+describe('SessionAgentFileSystem (backed by IExecContext)', () => {
let dir: string;
beforeEach(async () => {
_clearScopedRegistryForTests();
- registerScopedService(
- LifecycleScope.App,
- IKaosFactory,
- KaosFactory,
- InstantiationType.Delayed,
- 'kaos',
- );
registerScopedService(
LifecycleScope.Session,
ISessionAgentFileSystem,
@@ -42,9 +35,11 @@ describe('SessionAgentFileSystem (backed by IKaos)', () => {
async function makeFs(): Promise {
const host = createScopedTestHost();
- const factory = host.app.accessor.get(IKaosFactory);
- const kaos = await factory.createLocal(dir);
- const session = host.child(LifecycleScope.Session, 's', [stubPair(IKaos, kaos)]);
+ const session = host.child(
+ LifecycleScope.Session,
+ 's',
+ [stubPair(IExecContext, createExecContext(dir))],
+ );
return session.accessor.get(ISessionAgentFileSystem);
}
diff --git a/packages/agent-core-v2/test/background/foreground-persistence.test.ts b/packages/agent-core-v2/test/background/foreground-persistence.test.ts
index 74aea379c..e435a8d95 100644
--- a/packages/agent-core-v2/test/background/foreground-persistence.test.ts
+++ b/packages/agent-core-v2/test/background/foreground-persistence.test.ts
@@ -11,7 +11,7 @@ import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { join } from 'pathe';
-import type { KaosProcess } from '@moonshot-ai/kaos';
+import type { IProcess } from '#/session/process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -33,22 +33,22 @@ const MAX_OUTPUT_BYTES = 1024 * 1024;
const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 5));
-function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
+function immediateProcess(exitCode: number, stdoutText = ''): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from(stdoutText ? [stdoutText] : []),
stderr: Readable.from([]),
pid: 60000 + exitCode,
exitCode,
- wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'],
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
/** A process whose stdout and exit are driven by the test, for timing control. */
function controllableProcess(): {
- proc: KaosProcess;
+ proc: IProcess;
pushStdout: (text: string) => void;
finish: (exitCode: number) => void;
} {
@@ -57,15 +57,15 @@ function controllableProcess(): {
const waitPromise = new Promise((resolve) => {
resolveWait = resolve;
});
- const proc: KaosProcess = {
+ const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout,
stderr: Readable.from([]),
pid: 61000,
exitCode: null,
- wait: vi.fn(() => waitPromise) as KaosProcess['wait'],
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ wait: vi.fn(() => waitPromise) as IProcess['wait'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
return {
proc,
@@ -80,7 +80,7 @@ function controllableProcess(): {
function registerForeground(
background: IAgentBackgroundService,
- proc: KaosProcess,
+ proc: IProcess,
command: string,
description: string,
): string {
diff --git a/packages/agent-core-v2/test/background/ids.test.ts b/packages/agent-core-v2/test/background/ids.test.ts
index 40482e2de..ae3c2f775 100644
--- a/packages/agent-core-v2/test/background/ids.test.ts
+++ b/packages/agent-core-v2/test/background/ids.test.ts
@@ -1,7 +1,7 @@
import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
-import type { KaosProcess } from '@moonshot-ai/kaos';
+import type { IProcess } from '#/session/process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -16,7 +16,7 @@ import { createBackgroundTaskPersistence } from './stubs';
function registerProcess(
manager: IAgentBackgroundService,
- proc: KaosProcess,
+ proc: IProcess,
command: string,
description: string,
): string {
@@ -44,7 +44,7 @@ function agentTask(
);
}
-function pendingProcess(): KaosProcess & { resolve(code: number): void } {
+function pendingProcess(): IProcess & { resolve(code: number): void } {
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise((resolve) => {
resolveWait = resolve;
@@ -59,8 +59,8 @@ function pendingProcess(): KaosProcess & { resolve(code: number): void } {
return currentExitCode;
},
wait: () => waitPromise,
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
resolve(code: number): void {
currentExitCode = code;
resolveWait(code);
diff --git a/packages/agent-core-v2/test/background/manager.test.ts b/packages/agent-core-v2/test/background/manager.test.ts
index e85ae9a92..b9f47afa4 100644
--- a/packages/agent-core-v2/test/background/manager.test.ts
+++ b/packages/agent-core-v2/test/background/manager.test.ts
@@ -8,7 +8,7 @@ import { PassThrough, Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { join } from 'pathe';
-import type { KaosProcess } from '@moonshot-ai/kaos';
+import type { IProcess } from '#/session/process';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
@@ -67,7 +67,7 @@ function createBackgroundManager(options: {
function registerProcess(
manager: IAgentBackgroundService,
- proc: KaosProcess,
+ proc: IProcess,
command: string,
description: string,
): string {
@@ -143,33 +143,33 @@ async function waitForOutput(
// ---- test helpers ----
-function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
+function immediateProcess(exitCode: number, stdoutText = ''): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from(stdoutText ? [stdoutText] : []),
stderr: Readable.from([]),
pid: 10000 + exitCode,
exitCode,
- wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'],
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
-function rejectedProcess(error: Error): KaosProcess {
+function rejectedProcess(error: Error): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
pid: 99999,
exitCode: null,
- wait: vi.fn().mockRejectedValue(error) as KaosProcess['wait'],
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ wait: vi.fn().mockRejectedValue(error) as IProcess['wait'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
-function processWithStdoutError(message = 'stdout read failed'): KaosProcess {
+function processWithStdoutError(message = 'stdout read failed'): IProcess {
const stdout = new PassThrough();
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
@@ -180,14 +180,14 @@ function processWithStdoutError(message = 'stdout read failed'): KaosProcess {
wait: vi.fn(async () => {
stdout.destroy(new Error(message));
return 0;
- }) as KaosProcess['wait'],
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ }) as IProcess['wait'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
function processWithStdoutErrorBeforeWait(message = 'stdout read failed'): {
- proc: KaosProcess;
+ proc: IProcess;
failStdout: () => void;
resolveWait: (exitCode: number) => void;
} {
@@ -206,9 +206,9 @@ function processWithStdoutErrorBeforeWait(message = 'stdout read failed'): {
get exitCode(): number | null {
return currentExitCode;
},
- wait: vi.fn(() => waitPromise) as KaosProcess['wait'],
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ wait: vi.fn(() => waitPromise) as IProcess['wait'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
},
failStdout: () => {
stdout.destroy(new Error(message));
@@ -221,7 +221,7 @@ function processWithStdoutErrorBeforeWait(message = 'stdout read failed'): {
}
function pendingProcess(exitOnKill = 143): {
- proc: KaosProcess;
+ proc: IProcess;
killSpy: ReturnType;
} {
let resolveWait: (n: number) => void = () => {};
@@ -234,7 +234,7 @@ function pendingProcess(exitOnKill = 143): {
currentExitCode = exitOnKill;
resolveWait(exitOnKill);
});
- const proc: KaosProcess = {
+ const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
@@ -243,14 +243,14 @@ function pendingProcess(exitOnKill = 143): {
return currentExitCode;
},
wait: () => waitPromise,
- kill: killSpy as unknown as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ kill: killSpy as unknown as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
return { proc, killSpy };
}
function manuallyResolvedProcess(): {
- proc: KaosProcess;
+ proc: IProcess;
killSpy: ReturnType;
resolve: (exitCode: number) => void;
} {
@@ -260,7 +260,7 @@ function manuallyResolvedProcess(): {
});
let currentExitCode: number | null = null;
const killSpy = vi.fn().mockResolvedValue(undefined);
- const proc: KaosProcess = {
+ const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
@@ -269,8 +269,8 @@ function manuallyResolvedProcess(): {
return currentExitCode;
},
wait: () => waitPromise,
- kill: killSpy as unknown as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ kill: killSpy as unknown as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
return {
proc,
@@ -284,11 +284,11 @@ function manuallyResolvedProcess(): {
}
function processWithVisibleExitCodeBeforeWait(exitCode = 143): {
- proc: KaosProcess;
+ proc: IProcess;
markExited: () => void;
} {
let currentExitCode: number | null = null;
- const proc: KaosProcess = {
+ const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
@@ -297,8 +297,8 @@ function processWithVisibleExitCodeBeforeWait(exitCode = 143): {
return currentExitCode;
},
wait: () => new Promise(() => {}),
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
return {
proc,
@@ -603,7 +603,7 @@ describe('BackgroundManager', () => {
const proc = {
...immediateProcess(0, 'hello'),
dispose,
- } as unknown as KaosProcess;
+ } as unknown as IProcess;
const taskId = registerProcess(manager, proc, 'echo hello', 'test echo');
await waitForTerminal(manager, taskId);
@@ -705,7 +705,7 @@ describe('BackgroundManager', () => {
const disposableProc = {
...proc,
dispose,
- } as unknown as KaosProcess;
+ } as unknown as IProcess;
const taskId = registerProcess(manager, disposableProc, 'sleep 60', 'kill test');
await manager.stop(taskId, 'user requested');
@@ -927,7 +927,7 @@ describe('BackgroundManager', () => {
['-e', "process.stdout.write('bg-ok\\n')"],
{ stdio: 'pipe' },
);
- const proc: KaosProcess = {
+ const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: child.stdout,
stderr: child.stderr,
@@ -943,12 +943,12 @@ describe('BackgroundManager', () => {
}),
kill: vi.fn(async (signal?: NodeJS.Signals) => {
child.kill(signal ?? 'SIGTERM');
- }) as unknown as KaosProcess['kill'],
+ }) as unknown as IProcess['kill'],
dispose: vi.fn(async () => {
child.stdin?.destroy();
child.stdout?.destroy();
child.stderr?.destroy();
- }) as KaosProcess['dispose'],
+ }) as IProcess['dispose'],
};
const taskId = registerProcess(manager, proc, 'node -e ', 'real worker');
diff --git a/packages/agent-core-v2/test/background/output-access.test.ts b/packages/agent-core-v2/test/background/output-access.test.ts
index 52ead82e7..efe721a1d 100644
--- a/packages/agent-core-v2/test/background/output-access.test.ts
+++ b/packages/agent-core-v2/test/background/output-access.test.ts
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { join } from 'pathe';
-import type { KaosProcess } from '@moonshot-ai/kaos';
+import type { IProcess } from '#/session/process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IAgentBackgroundService, ProcessBackgroundTask } from '#/agent/background';
import { createBackgroundTaskPersistence, type BackgroundServiceTestManager } from './stubs';
@@ -28,7 +28,7 @@ function createBackgroundService(homedir: string): BackgroundServiceFixture {
function registerProcess(
manager: IAgentBackgroundService,
- proc: KaosProcess,
+ proc: IProcess,
command: string,
description: string,
): string {
@@ -48,16 +48,16 @@ async function waitForOutput(
throw new Error(`Timed out waiting for output: ${expected}`);
}
-function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
+function immediateProcess(exitCode: number, stdoutText = ''): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from(stdoutText ? [stdoutText] : []),
stderr: Readable.from([]),
pid: 50000 + exitCode,
exitCode,
- wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'],
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
diff --git a/packages/agent-core-v2/test/background/rpc-events.test.ts b/packages/agent-core-v2/test/background/rpc-events.test.ts
index 1065211d9..fe889f30c 100644
--- a/packages/agent-core-v2/test/background/rpc-events.test.ts
+++ b/packages/agent-core-v2/test/background/rpc-events.test.ts
@@ -8,7 +8,7 @@ import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { join } from 'pathe';
-import type { KaosProcess } from '@moonshot-ai/kaos';
+import type { IProcess } from '#/session/process';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
@@ -40,20 +40,20 @@ import {
type FireAndForgetTrigger = HookEngine['fireAndForgetTrigger'];
-function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
+function immediateProcess(exitCode: number, stdoutText = ''): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from(stdoutText ? [stdoutText] : []),
stderr: Readable.from([]),
pid: 30000 + exitCode,
exitCode,
- wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'],
- kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
+ kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
-function pendingProcess(): KaosProcess {
+function pendingProcess(): IProcess {
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise((resolve) => {
resolveWait = resolve;
@@ -72,8 +72,8 @@ function pendingProcess(): KaosProcess {
if (currentExitCode !== null) return;
currentExitCode = 143;
resolveWait(143);
- }) as unknown as KaosProcess['kill'],
- dispose: vi.fn().mockResolvedValue(undefined) as KaosProcess['dispose'],
+ }) as unknown as IProcess['kill'],
+ dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
@@ -251,7 +251,7 @@ function firstAppendedContextMessage(agent: FakeBackgroundAgent): TestContextMes
function registerProcess(
manager: IAgentBackgroundService,
- proc: KaosProcess,
+ proc: IProcess,
command: string,
description: string,
): string {
diff --git a/packages/agent-core-v2/test/bootstrap/bootstrapService.test.ts b/packages/agent-core-v2/test/bootstrap/bootstrapService.test.ts
index e2176693e..1e62dbc80 100644
--- a/packages/agent-core-v2/test/bootstrap/bootstrapService.test.ts
+++ b/packages/agent-core-v2/test/bootstrap/bootstrapService.test.ts
@@ -42,17 +42,6 @@ describe('BootstrapService (scoped)', () => {
expect(svc.getEnv('MISSING')).toBeUndefined();
host.dispose();
});
-
- it('detect() returns a cached OS/shell probe', async () => {
- const host = createScopedTestHost(bootstrapSeed({ homeDir: '/tmp/kimi-home' }));
- const svc = host.app.accessor.get(IBootstrapService);
- const a = await svc.detect();
- const b = await svc.detect();
- expect(a).toBe(b);
- expect(typeof a.osKind).toBe('string');
- expect(typeof a.shellPath).toBe('string');
- host.dispose();
- });
});
describe('resolveBootstrapOptions', () => {
diff --git a/packages/agent-core-v2/test/bootstrap/stubs.ts b/packages/agent-core-v2/test/bootstrap/stubs.ts
index eb057c15d..93cc81bc2 100644
--- a/packages/agent-core-v2/test/bootstrap/stubs.ts
+++ b/packages/agent-core-v2/test/bootstrap/stubs.ts
@@ -11,8 +11,6 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap';
/**
* An `IBootstrapService` rooted at the given home dir with the given env bag.
- * `detect()` rejects with "unused in test" so accidental calls surface loudly
- * rather than silently hitting the real OS probe.
*/
export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv = {}): IBootstrapService {
return {
@@ -29,7 +27,6 @@ export function stubBootstrap(homeDir = '/tmp/kimi-home', env: NodeJS.ProcessEnv
cacheDir: `${homeDir}/cache`,
logsDir: `${homeDir}/logs`,
getEnv: (name) => env[name],
- detect: () => Promise.reject(new Error('unused in test')),
};
}
diff --git a/packages/agent-core-v2/test/config/config.test.ts b/packages/agent-core-v2/test/config/config.test.ts
index 5a58c879f..6d23b103e 100644
--- a/packages/agent-core-v2/test/config/config.test.ts
+++ b/packages/agent-core-v2/test/config/config.test.ts
@@ -1,4 +1,3 @@
-import type { Environment } from '@moonshot-ai/kaos';
import type { ModelCapability, ProviderConfig, ToolCall } from '@moonshot-ai/kosong';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -7,13 +6,17 @@ import { AGENT_WIRE_PROTOCOL_VERSION } from '#/agent/wireRecord';
import { createTestAgent, type TestAgentContext } from '../harness';
import { DEFAULT_TEST_SYSTEM_PROMPT } from '../harness/snapshots';
-const TEST_OS_ENV: Environment = {
+// Historical `osEnv` shape carried by `useProfile` context — the test only
+// exercises the profile-service pass-through; the exact fields don't matter to
+// the assertions, so we keep a minimal literal instead of importing an
+// external type.
+const TEST_OS_ENV = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
-};
+} as const;
describe('Agent config', () => {
let ctx: TestAgentContext;
diff --git a/packages/agent-core-v2/test/fileTools/edit.test.ts b/packages/agent-core-v2/test/fileTools/edit.test.ts
index 4027bc449..0d1756068 100644
--- a/packages/agent-core-v2/test/fileTools/edit.test.ts
+++ b/packages/agent-core-v2/test/fileTools/edit.test.ts
@@ -14,17 +14,24 @@ import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import { type EditInput, EditInputSchema, EditTool } from '#/agent/fileTools/tools/edit';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
const PERMISSIVE_WORKSPACE: WorkspaceConfig = { workspaceDir: '/', additionalDirs: [] };
-function createTestKaos(home = '/home'): IKaos {
+function createTestEnv(home = '/home'): IHostEnvironment {
return {
- pathClass: () => 'posix',
- gethome: () => home,
- } as unknown as IKaos;
+ _serviceBrand: undefined,
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir: home,
+ ready: Promise.resolve(),
+ };
}
/**
@@ -76,7 +83,7 @@ async function execute(tool: EditTool, args: EditInput): Promise {
it('exposes before/after on the file_io display so the approval panel can render a diff', () => {
- const tool = new EditTool(createSpiedEditFs().fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(createSpiedEditFs().fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const execution = tool.resolveExecution({
path: '/tmp/foo.ts',
old_string: 'a\nb\nc',
@@ -95,7 +102,7 @@ describe('EditTool', () => {
});
it('declares writeFile access for the edited path', () => {
- const tool = new EditTool(createSpiedEditFs().fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(createSpiedEditFs().fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const execution = tool.resolveExecution({
path: '/tmp/foo.ts',
old_string: 'a',
@@ -108,7 +115,7 @@ describe('EditTool', () => {
});
it('exposes current metadata and schema', () => {
- const tool = new EditTool(createSpiedEditFs().fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(createSpiedEditFs().fs, createTestEnv(), PERMISSIVE_WORKSPACE);
expect(tool.name).toBe('Edit');
expect(tool.description).toContain('Read the target file before every Edit');
@@ -164,7 +171,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha beta'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -180,7 +187,7 @@ describe('EditTool', () => {
const readText = vi.fn().mockResolvedValue('alpha beta');
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({ readText, writeText });
- const tool = new EditTool(fs, createTestKaos('/home/test'), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv('/home/test'), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '~/notes/today.txt',
@@ -199,7 +206,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha beta gamma'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -217,7 +224,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('a b a'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -236,7 +243,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\r\ngamma\r\n'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -254,7 +261,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\r\n'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -272,7 +279,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\ngamma\r\n'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -291,7 +298,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\ngamma\r\n'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -309,7 +316,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('a b a'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -326,7 +333,7 @@ describe('EditTool', () => {
const readText = vi.fn().mockResolvedValue('same');
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({ readText, writeText });
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -347,7 +354,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('alpha beta'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -366,7 +373,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('same same'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
@@ -384,7 +391,7 @@ describe('EditTool', () => {
it('rejects relative traversal edits before reading', async () => {
const readText = vi.fn().mockResolvedValue('secret');
const { fs } = createSpiedEditFs({ readText });
- const tool = new EditTool(fs, createTestKaos(), {
+ const tool = new EditTool(fs, createTestEnv(), {
workspaceDir: '/workspace/project',
additionalDirs: [],
});
@@ -406,7 +413,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('Hello 世界! café'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/u.txt',
@@ -425,7 +432,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue(original),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/n.txt',
@@ -448,7 +455,7 @@ describe('EditTool', () => {
}),
),
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/dir',
@@ -466,7 +473,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('Hello world!'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new EditTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/e.txt',
@@ -484,7 +491,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('old content'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), {
+ const tool = new EditTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@@ -507,7 +514,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('content'),
writeText,
});
- const tool = new EditTool(fs, createTestKaos(), {
+ const tool = new EditTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
diff --git a/packages/agent-core-v2/test/fileTools/fileToolsService.test.ts b/packages/agent-core-v2/test/fileTools/fileToolsService.test.ts
index 0298fa243..73b8fe620 100644
--- a/packages/agent-core-v2/test/fileTools/fileToolsService.test.ts
+++ b/packages/agent-core-v2/test/fileTools/fileToolsService.test.ts
@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest';
import type { ISessionAgentFileSystem, ISessionFsService } from '#/session/agentFs';
import { AgentFileToolsService } from '#/agent/fileTools';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
+import type { ISessionProcessRunner } from '#/session/process';
import { noopTelemetryService } from '#/app/telemetry';
import type { IDisposable } from '#/_base/di';
import type { IAgentToolRegistryService } from '#/agent/toolRegistry';
@@ -23,11 +24,18 @@ function fakeToolRegistry(): { registry: IAgentToolRegistryService; names: () =>
const fakeFs = { cwd: '/workspace' } as unknown as ISessionAgentFileSystem;
const fakeFsService = {} as unknown as ISessionFsService;
-const fakeKaos = {
- cwd: '/workspace',
- pathClass: () => 'posix',
- gethome: () => '/home',
-} as unknown as IKaos;
+const fakeEnv: IHostEnvironment = {
+ _serviceBrand: undefined,
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir: '/home',
+ ready: Promise.resolve(),
+};
+const fakeRunner = { _serviceBrand: undefined, exec: vi.fn() } as unknown as ISessionProcessRunner;
const fakeWorkspace = {
workDir: '/workspace',
additionalDirs: [],
@@ -39,9 +47,10 @@ describe('AgentFileToolsService', () => {
new AgentFileToolsService(
registry,
fakeFs,
- fakeKaos,
+ fakeEnv,
fakeWorkspace,
fakeFsService,
+ fakeRunner,
noopTelemetryService,
);
expect(names()).toEqual(['Edit', 'Glob', 'Grep', 'Read', 'Write']);
diff --git a/packages/agent-core-v2/test/fileTools/glob.test.ts b/packages/agent-core-v2/test/fileTools/glob.test.ts
index 19baab083..f084f422d 100644
--- a/packages/agent-core-v2/test/fileTools/glob.test.ts
+++ b/packages/agent-core-v2/test/fileTools/glob.test.ts
@@ -2,13 +2,10 @@
* GlobTool tests for the v2 fileTools domain.
*
* Ported from v1 (`packages/agent-core/test/tools/glob.test.ts`) and adapted
- * to the v2 constructor `(fs, kaos, workspace)` and the v2 execution
- * environment. The Glob search now runs `rg --files` through the `IKaos`
- * backend (`withCwd(root).backend.exec('rg', ...)`) instead of
- * `ISessionAgentFileSystem.glob`, so tests fake `kaos.backend.exec` to return
- * a scripted `KaosProcess` (stdout/stderr streams + exit code) rather than
- * stubbing `fs.glob`. `fs.readdir` is still faked for the directory
- * pre-check (missing / non-directory roots).
+ * to the v2 constructor `(fs, env, runner, workspace, telemetry?)`. The Glob
+ * search runs `rg --files` through `ISessionProcessRunner.exec` with the
+ * search root passed as `options.cwd` (no more `IKaos.withCwd`); tests fake
+ * the runner and assert on the second-argument `cwd` value.
*/
import { spawnSync } from 'node:child_process';
@@ -17,7 +14,6 @@ import os from 'node:os';
import path from 'node:path';
import { Readable, type Writable } from 'node:stream';
-import { LocalKaos } from '@moonshot-ai/kaos';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureRgPath } from '#/session/agentFs/rgLocator';
@@ -32,8 +28,11 @@ import {
MAX_MATCHES,
splitCompletePaths,
} from '#/agent/fileTools/tools/glob';
-import type { IKaos, KaosProcess } from '#/app/kaos';
-import { KaosService } from '#/app/kaos/kaosService';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
+import { probeHostEnvironmentFromNode } from '#/_base/execEnv';
+import { createExecContext } from '#/session/execContext';
+import { SessionProcessRunner } from '#/session/process/processRunnerService';
+import type { IProcess, ISessionProcessRunner } from '#/session/process';
import type { ITelemetryService } from '#/app/telemetry';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
@@ -64,8 +63,8 @@ function createTestFs(opts: { readdir?: ReturnType } = {}) {
return { fs, readdir };
}
-/** Build a fake `KaosProcess` that emits `stdout` / `stderr` then exits with `exitCode`. */
-function fakeProcess(stdout: string, stderr = '', exitCode = 0): KaosProcess {
+/** Build a fake `IProcess` that emits `stdout` / `stderr` then exits with `exitCode`. */
+function fakeProcess(stdout: string, stderr = '', exitCode = 0): IProcess {
const stdoutStream = Readable.from([Buffer.from(stdout)]);
const stderrStream = Readable.from([Buffer.from(stderr)]);
return {
@@ -74,12 +73,12 @@ function fakeProcess(stdout: string, stderr = '', exitCode = 0): KaosProcess {
stderr: stderrStream,
pid: 123,
exitCode,
- wait: vi.fn().mockResolvedValue(exitCode),
- kill: vi.fn(async () => {}),
+ wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
+ kill: vi.fn(async () => {}) as IProcess['kill'],
dispose: vi.fn(async () => {
stdoutStream.destroy();
stderrStream.destroy();
- }),
+ }) as IProcess['dispose'],
};
}
@@ -87,36 +86,46 @@ function execReturning(stdout: string, stderr = '', exitCode = 0) {
return vi.fn().mockResolvedValue(fakeProcess(stdout, stderr, exitCode));
}
+function createTestEnv(opts: { home?: string; pathClass?: PathClass } = {}): IHostEnvironment {
+ return {
+ _serviceBrand: undefined,
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: opts.pathClass ?? 'posix',
+ homeDir: opts.home ?? '/home/test',
+ ready: Promise.resolve(),
+ };
+}
+
+function createTestRunner(exec: ReturnType): ISessionProcessRunner {
+ return { _serviceBrand: undefined, exec } as unknown as ISessionProcessRunner;
+}
+
/**
- * Fake `IKaos` whose `withCwd(cwd)` returns a derived environment that shares
- * the same `backend.exec` spy — mirroring the real `IKaos.withCwd` semantics
- * (the backend is shared across cwd derivations). The root environment's
- * `withCwd` is exposed so tests can assert the resolved search root.
+ * `withCwd(dir)` shim — the v1 tests asserted on `kaos.withCwd(dir)`; the v2
+ * tool passes the search root via `options.cwd` to `runner.exec`, so translate
+ * that into a `withCwd` spy for the assertion sites.
*/
-function createTestKaos(
- opts: {
- home?: string;
- pathClass?: PathClass;
- exec?: ReturnType;
- } = {},
-) {
- const exec = opts.exec ?? execReturning('');
- const backend = { exec } as unknown as IKaos['backend'];
- function build(cwd: string): IKaos {
- return {
- cwd,
- backend,
- pathClass: () => opts.pathClass ?? 'posix',
- gethome: () => opts.home ?? '/home/test',
- withCwd: vi.fn((next: string) => build(next)),
- } as unknown as IKaos;
- }
- const kaos = build('/workspace');
- return { kaos, exec, withCwd: kaos.withCwd as ReturnType };
+function withCwdOf(exec: ReturnType): { toHaveBeenCalledWith: (dir: string) => void; toHaveBeenCalled: () => void; not: { toHaveBeenCalled: () => void; toHaveBeenCalledWith: (dir: string) => void } } {
+ const cwds = () =>
+ (exec.mock.calls as unknown as ReadonlyArray)
+ .map((call) => call[1]?.cwd)
+ .filter((c): c is string => typeof c === 'string');
+ return {
+ toHaveBeenCalledWith: (dir: string) => expect(cwds()).toContain(dir),
+ toHaveBeenCalled: () => expect(cwds().length).toBeGreaterThan(0),
+ not: {
+ toHaveBeenCalled: () => expect(cwds().length).toBe(0),
+ toHaveBeenCalledWith: (dir: string) => expect(cwds()).not.toContain(dir),
+ },
+ };
}
function execArgs(exec: ReturnType): string[] {
- return exec.mock.calls[0] as string[];
+ return (exec.mock.calls[0] as ReadonlyArray)[0] as string[];
}
function telemetryStub(
@@ -173,11 +182,24 @@ function toolContentString(result: ExecutableToolResult): string {
return c;
}
+/** Build a `GlobTool` with the given exec spy, using a fake env + runner. */
+function makeTool(
+ workspaceConfig: WorkspaceConfig,
+ opts: { home?: string; pathClass?: PathClass; exec?: ReturnType; readdir?: ReturnType; telemetry?: ITelemetryService } = {},
+): { tool: GlobTool; exec: ReturnType; withCwd: ReturnType } {
+ const exec = opts.exec ?? execReturning('');
+ const { fs } = createTestFs({ readdir: opts.readdir });
+ const runner = createTestRunner(exec);
+ const env = createTestEnv({ home: opts.home, pathClass: opts.pathClass });
+ const tool = opts.telemetry !== undefined
+ ? new GlobTool(fs, env, runner, workspaceConfig, opts.telemetry)
+ : new GlobTool(fs, env, runner, workspaceConfig);
+ return { tool, exec, withCwd: withCwdOf(exec) };
+}
+
describe('GlobTool', () => {
it('exposes current metadata and schema', () => {
- const { fs } = createTestFs();
- const { kaos } = createTestKaos();
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace);
expect(tool.name).toBe('Glob');
expect(tool.parameters).toMatchObject({
@@ -189,18 +211,13 @@ describe('GlobTool', () => {
});
it('is files-only and exposes include_ignored; include_dirs is deprecated and ignored', () => {
- const { fs } = createTestFs();
- const { kaos } = createTestKaos();
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace);
const schema = tool.parameters as {
properties: Record;
required?: string[];
};
expect(schema.properties).toHaveProperty('include_ignored');
- // include_dirs is kept only so older calls that still pass it are not
- // rejected by parameter validation. It is deprecated and ignored — results
- // are always files-only regardless of its value, and it carries no default.
expect(schema.properties).toHaveProperty('include_dirs');
expect(schema.properties['include_dirs']?.description?.toLowerCase()).toContain('deprecated');
expect(schema.properties['include_dirs']?.default).toBeUndefined();
@@ -208,9 +225,7 @@ describe('GlobTool', () => {
});
it('injects the Windows path hint into the description on a win32 backend', () => {
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ pathClass: 'win32' });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { pathClass: 'win32' });
expect(tool.description).toContain('Windows');
expect(tool.description).toContain('forward slashes');
@@ -218,18 +233,14 @@ describe('GlobTool', () => {
});
it('omits the Windows path hint from the description on a non-Windows backend', () => {
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ pathClass: 'posix' });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { pathClass: 'posix' });
expect(tool.description).not.toContain('forward slashes');
});
it('requests reverse modified sort and preserves the rg output order', async () => {
const exec = execReturning('/workspace/src/new.ts\n/workspace/src/old.ts\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'src/**/*.ts', path: '/workspace' });
const args = execArgs(exec);
@@ -237,24 +248,20 @@ describe('GlobTool', () => {
expect(args).toContain('--sortr=modified');
expect(args).not.toContain('--sort=modified');
expect(result.output).toBe('src/new.ts\nsrc/old.ts');
- expect(withCwd).toHaveBeenCalledWith('/workspace');
+ withCwd.toHaveBeenCalledWith('/workspace');
});
it('uses the backend path class when displaying paths relative to a windows root', async () => {
const exec = execReturning('C:\\workspace\\src\\old.ts\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ pathClass: 'win32', exec });
- const tool = new GlobTool(fs, kaos, {
- workspaceDir: 'C:\\workspace',
- additionalDirs: [],
- });
+ const { tool, withCwd } = makeTool(
+ { workspaceDir: 'C:\\workspace', additionalDirs: [] },
+ { pathClass: 'win32', exec },
+ );
const result = await execute(tool, { pattern: 'src/**/*.ts', path: 'C:\\WORKSPACE' });
- // pathe.normalize renders Windows paths with forward slashes, so the
- // relativized result keeps `/` regardless of the backend path class.
expect(result.output).toBe('src/old.ts');
- expect(withCwd).toHaveBeenCalledWith('C:/WORKSPACE');
+ withCwd.toHaveBeenCalledWith('C:/WORKSPACE');
});
it('walks pure-wildcard patterns, capping at MAX_MATCHES', async () => {
@@ -262,28 +269,24 @@ describe('GlobTool', () => {
Array.from({ length: MAX_MATCHES + 5 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') +
'\n';
const exec = execReturning(stdout);
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '**' });
expect(result.isError).toBeFalsy();
- expect(withCwd).toHaveBeenCalledWith('/workspace');
+ withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec).at(-1)).toBe('.');
expect(result.output).toContain(`[Truncated at ${String(MAX_MATCHES)} matches`);
});
it('passes a brace pattern through to a single rg --glob', async () => {
const exec = execReturning('/workspace/a.ts\n/workspace/shared.ts\n/workspace/shared.tsx\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.{ts,tsx}' });
expect(result.isError).toBeFalsy();
- expect(withCwd).toHaveBeenCalledWith('/workspace');
+ withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec)).toContain('*.{ts,tsx}');
const output = toolContentString(result);
expect(output).toContain('a.ts');
@@ -292,56 +295,44 @@ describe('GlobTool', () => {
});
it('passes an escaped-brace pattern through unchanged so literal-brace files stay matchable', async () => {
- // `\{a,b\}.ts` opts out of brace expansion — the user wants a file
- // literally named `{a,b}.ts`. The pattern must reach rg with the escapes
- // intact (the tool must not strip or reinterpret the backslashes).
const exec = execReturning('/workspace/{a,b}.ts\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '\\{a,b\\}.ts' });
expect(result.isError).toBeFalsy();
- expect(withCwd).toHaveBeenCalledWith('/workspace');
+ withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec)).toContain('\\{a,b\\}.ts');
expect(result.output).toContain('{a,b}.ts');
});
it('searches only the current workspace when path is omitted', async () => {
const exec = execReturning('/workspace/a.ts\n/workspace/shared.ts\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.ts' });
expect(exec).toHaveBeenCalledTimes(1);
- expect(withCwd).toHaveBeenCalledWith('/workspace');
+ withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec).at(-1)).toBe('.');
expect(result.output).toBe('a.ts\nshared.ts');
});
it('keeps results absolute when searching an additional directory', async () => {
- // additionalDir is outside workspaceDir, so matches stay absolute.
const exec = execReturning('/extra/pkg/a.ts\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'pkg/**/*.ts', path: '/extra' });
expect(result.output).toBe('/extra/pkg/a.ts');
expect(exec).toHaveBeenCalledTimes(1);
- expect(withCwd).toHaveBeenCalledWith('/extra');
+ withCwd.toHaveBeenCalledWith('/extra');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('adds --no-ignore when include_ignored is true', async () => {
const exec = execReturning('/workspace/dist/bundle.js\n');
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
await execute(tool, { pattern: '*.js', include_ignored: true });
@@ -350,9 +341,7 @@ describe('GlobTool', () => {
it('does not pass --no-ignore by default', async () => {
const exec = execReturning('/workspace/a.ts\n');
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
await execute(tool, { pattern: '*.ts' });
@@ -364,12 +353,7 @@ describe('GlobTool', () => {
Array.from({ length: MAX_MATCHES + 1 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') +
'\n';
const exec = execReturning(stdout);
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, {
- workspaceDir: '/workspace',
- additionalDirs: [],
- });
+ const { tool } = makeTool({ workspaceDir: '/workspace', additionalDirs: [] }, { exec });
const result = await execute(tool, { pattern: '*.ts' });
@@ -384,12 +368,7 @@ describe('GlobTool', () => {
'\n',
) + '\n';
const exec = execReturning(stdout);
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, {
- workspaceDir: '/workspace',
- additionalDirs: [],
- });
+ const { tool } = makeTool({ workspaceDir: '/workspace', additionalDirs: [] }, { exec });
const result = await execute(tool, { pattern: '*.txt' });
@@ -401,12 +380,7 @@ describe('GlobTool', () => {
Array.from({ length: MAX_MATCHES }, (_, i) => `/workspace/test_${String(i)}.py`).join('\n') +
'\n';
const exec = execReturning(stdout);
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, {
- workspaceDir: '/workspace',
- additionalDirs: [],
- });
+ const { tool } = makeTool({ workspaceDir: '/workspace', additionalDirs: [] }, { exec });
const result = await execute(tool, { pattern: '*.py' });
@@ -416,9 +390,7 @@ describe('GlobTool', () => {
it('filters sensitive files from results', async () => {
const exec = execReturning('/workspace/.env\n/workspace/src/a.ts\n');
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'src/**' });
@@ -429,9 +401,7 @@ describe('GlobTool', () => {
it('surfaces a "Glob failed" error when rg cannot be spawned', async () => {
const exec = vi.fn().mockRejectedValue(new Error('spawn rg ENOENT'));
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.ts' });
@@ -446,16 +416,14 @@ describe('GlobTool', () => {
fakeProcess('', 'rg: thread pool: Resource temporarily unavailable (os error 11)', 2),
)
.mockResolvedValueOnce(fakeProcess('/workspace/a.ts\n', '', 0));
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.ts', path: '/workspace' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('a.ts');
expect(exec).toHaveBeenCalledTimes(2);
- const retryArgs = exec.mock.calls[1] as string[];
+ const retryArgs = (exec.mock.calls[1] as ReadonlyArray)[0] as string[];
expect(retryArgs).toContain('-j');
expect(retryArgs).toContain('1');
});
@@ -466,9 +434,7 @@ describe('GlobTool', () => {
);
const events: Array<{ event: string; properties: Record }> = [];
const exec = vi.fn();
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace, telemetryStub(events));
+ const { tool } = makeTool(workspace, { exec, telemetry: telemetryStub(events) });
const result = await execute(tool, { pattern: '*.ts' });
@@ -488,15 +454,13 @@ describe('GlobTool', () => {
});
const events: Array<{ event: string; properties: Record }> = [];
const exec = execReturning('/workspace/a.ts\n');
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace, telemetryStub(events));
+ const { tool } = makeTool(workspace, { exec, telemetry: telemetryStub(events) });
const result = await execute(tool, { pattern: '*.ts' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('a.ts');
- expect((exec.mock.calls[0] as string[])[0]).toBe('/mock/cached/rg');
+ expect(((exec.mock.calls[0] as ReadonlyArray)[0] as string[])[0]).toBe('/mock/cached/rg');
expect(events).toContainEqual({
event: 'glob_tool_rg_fallback',
properties: { source: 'share-bin-cached', outcome: 'resolved' },
@@ -511,23 +475,19 @@ describe('GlobTool', () => {
it('searches inside a registered additionalDir entry', async () => {
const exec = execReturning('/skills/read_content.py\n/skills/utils.py\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, skillsWorkspace);
+ const { tool, withCwd } = makeTool(skillsWorkspace, { exec });
const result = await execute(tool, { pattern: '*.py', path: '/skills' });
expect(result.output).toContain('/skills/read_content.py');
expect(result.output).toContain('/skills/utils.py');
- expect(withCwd).toHaveBeenCalledWith('/skills');
+ withCwd.toHaveBeenCalledWith('/skills');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('searches inside a subdirectory of an additionalDir entry', async () => {
const exec = execReturning('/skills/feishu/scripts/read_content.py\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, skillsWorkspace);
+ const { tool, withCwd } = makeTool(skillsWorkspace, { exec });
const result = await execute(tool, {
pattern: '*.py',
@@ -535,31 +495,27 @@ describe('GlobTool', () => {
});
expect(result.output).toContain('/skills/feishu/scripts/read_content.py');
- expect(withCwd).toHaveBeenCalledWith('/skills/feishu/scripts');
+ withCwd.toHaveBeenCalledWith('/skills/feishu/scripts');
});
it('rejects a relative path that escapes both workspace and additionalDirs', async () => {
const exec = vi.fn();
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, {
- workspaceDir: '/workspace/project',
- additionalDirs: ['/skills'],
- });
+ const { tool, withCwd } = makeTool(
+ { workspaceDir: '/workspace/project', additionalDirs: ['/skills'] },
+ { exec },
+ );
const result = await execute(tool, { pattern: '*.py', path: '../../tmp/evil' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('absolute path');
expect(exec).not.toHaveBeenCalled();
- expect(withCwd).not.toHaveBeenCalled();
+ withCwd.not.toHaveBeenCalled();
});
it('accepts a path inside a deeply nested additionalDir entry', async () => {
const exec = execReturning('/skills/my-skill/scripts/helper.py\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, skillsWorkspace);
+ const { tool, withCwd } = makeTool(skillsWorkspace, { exec });
const result = await execute(tool, {
pattern: '*.py',
@@ -567,20 +523,18 @@ describe('GlobTool', () => {
});
expect(result.output).toContain('/skills/my-skill/scripts/helper.py');
- expect(withCwd).toHaveBeenCalledWith('/skills/my-skill/scripts');
+ withCwd.toHaveBeenCalledWith('/skills/my-skill/scripts');
});
});
it('walks "**/" prefix patterns with a literal anchor', async () => {
const exec = execReturning('/workspace/a.py\n/workspace/sub/b.py\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '**/*.py' });
expect(result.isError).toBeFalsy();
- expect(withCwd).toHaveBeenCalledWith('/workspace');
+ withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec)).toContain('**/*.py');
expect(result.output).toContain('a.py');
expect(result.output).toContain('sub/b.py');
@@ -597,9 +551,7 @@ describe('GlobTool', () => {
'/workspace/src/test/test_config.py',
].join('\n') + '\n',
);
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'src/**/*.py', path: '/workspace' });
@@ -613,9 +565,7 @@ describe('GlobTool', () => {
it('surfaces an explicit no-match message when rg exits 1', async () => {
const exec = execReturning('', '', 1);
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.xyz', path: '/workspace' });
@@ -629,9 +579,7 @@ describe('GlobTool', () => {
'rg: ./locked: Permission denied (os error 13)',
2,
);
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.ts', path: '/workspace' });
@@ -644,9 +592,7 @@ describe('GlobTool', () => {
it('keeps ripgrep errors hard failures when no complete path is produced', async () => {
const exec = execReturning('', 'error: invalid glob', 2);
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '[', path: '/workspace' });
@@ -655,60 +601,50 @@ describe('GlobTool', () => {
});
it('reports "does not exist" when the search directory is missing', async () => {
- // The pre-check uses fs.readdir; an ENOENT surfaces before rg runs.
const readdir = vi.fn(async (): Promise => {
throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
});
const exec = vi.fn();
- const { fs } = createTestFs({ readdir });
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec, readdir });
const result = await execute(tool, { pattern: '*.py', path: '/workspace/nonexistent' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('does not exist');
expect(exec).not.toHaveBeenCalled();
- expect(withCwd).not.toHaveBeenCalled();
+ withCwd.not.toHaveBeenCalled();
});
it('reports "is not a directory" when the search target is a file', async () => {
- // The pre-check uses fs.readdir; an ENOTDIR surfaces before rg runs.
const readdir = vi.fn(async (): Promise => {
throw Object.assign(new Error('ENOTDIR: not a directory'), { code: 'ENOTDIR' });
});
const exec = vi.fn();
- const { fs } = createTestFs({ readdir });
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec, readdir });
const result = await execute(tool, { pattern: '*.py', path: '/workspace/file.txt' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('is not a directory');
expect(exec).not.toHaveBeenCalled();
- expect(withCwd).not.toHaveBeenCalled();
+ withCwd.not.toHaveBeenCalled();
});
it('walks "**/" patterns with literal subdirectory anchors after the prefix', async () => {
const exec = execReturning('/workspace/src/main/app.py\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '**/main/*.py' });
expect(result.isError).toBeFalsy();
- expect(withCwd).toHaveBeenCalledWith('/workspace');
+ withCwd.toHaveBeenCalledWith('/workspace');
expect(execArgs(exec)).toContain('**/main/*.py');
expect(result.output).toContain('src/main/app.py');
});
it('matches dotfiles like .gitlab-ci.yml under a simple "*.yml" pattern', async () => {
const exec = execReturning('/workspace/.gitlab-ci.yml\n/workspace/config.yml\n');
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.yml' });
@@ -718,9 +654,7 @@ describe('GlobTool', () => {
it('descends into hidden directories under a recursive pattern', async () => {
const exec = execReturning('/workspace/src/.config/settings.yml\n');
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: 'src/**/*.yml' });
@@ -729,9 +663,7 @@ describe('GlobTool', () => {
it('matches files inside an explicitly addressed hidden directory', async () => {
const exec = execReturning('/workspace/.github/workflows/ci.yml\n');
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '.github/**/*.yml' });
@@ -739,32 +671,22 @@ describe('GlobTool', () => {
});
it('shows absolute paths when explicit search root is outside all workspace roots', async () => {
- // When the search root is not inside workspaceDir, matches must stay
- // absolute in the output. Otherwise the model would resolve a
- // relativized path against the workspace cwd and hit the wrong file.
const exec = execReturning('/extra/test.py\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, {
- workspaceDir: '/workspace',
- additionalDirs: [],
- });
+ const { tool, withCwd } = makeTool(
+ { workspaceDir: '/workspace', additionalDirs: [] },
+ { exec },
+ );
const result = await execute(tool, { pattern: '*.py', path: '/extra' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('/extra/test.py');
- expect(withCwd).toHaveBeenCalledWith('/extra');
+ withCwd.toHaveBeenCalledWith('/extra');
});
it('keeps absolute paths when explicit search root is an additionalDir', async () => {
- // AdditionalDirs are searchable, but model-visible relative paths
- // still resolve against workspaceDir in follow-up Read/Edit calls, so
- // matches under an additionalDir stay absolute.
const registered: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] };
const exec = execReturning('/extra/test.py\n');
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, registered);
+ const { tool } = makeTool(registered, { exec });
const result = await execute(tool, { pattern: '*.py', path: '/extra' });
expect(result.isError).toBeFalsy();
@@ -773,56 +695,48 @@ describe('GlobTool', () => {
it('allows a relative path argument that resolves inside the workspace', async () => {
const exec = execReturning('/workspace/relative/path/test.py\n');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool, withCwd } = makeTool(workspace, { exec });
const result = await execute(tool, { pattern: '*.py', path: 'relative/path' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('test.py');
- expect(withCwd).toHaveBeenCalledWith('/workspace/relative/path');
+ withCwd.toHaveBeenCalledWith('/workspace/relative/path');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('expands a leading "~/" path before searching outside the workspace', async () => {
const exec = execReturning('');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ home: '/home/test', exec });
- const tool = new GlobTool(fs, kaos, {
- workspaceDir: '/workspace',
- additionalDirs: [],
- });
+ const { tool, withCwd } = makeTool(
+ { workspaceDir: '/workspace', additionalDirs: [] },
+ { home: '/home/test', exec },
+ );
const result = await execute(tool, { pattern: '*.py', path: '~/' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('No matches found');
- expect(withCwd).toHaveBeenCalledWith('/home/test');
+ withCwd.toHaveBeenCalledWith('/home/test');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('allows a path sharing the workspace prefix when it is absolute', async () => {
const exec = execReturning('');
- const { fs } = createTestFs();
- const { kaos, withCwd } = createTestKaos({ exec });
- const tool = new GlobTool(fs, kaos, {
- workspaceDir: '/parent/workdir',
- additionalDirs: [],
- });
+ const { tool, withCwd } = makeTool(
+ { workspaceDir: '/parent/workdir', additionalDirs: [] },
+ { exec },
+ );
const result = await execute(tool, { pattern: '*.py', path: '/parent/workdir-sneaky' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('No matches found');
- expect(withCwd).toHaveBeenCalledWith('/parent/workdir-sneaky');
+ withCwd.toHaveBeenCalledWith('/parent/workdir-sneaky');
expect(execArgs(exec).at(-1)).toBe('.');
});
it('locks down brace-expansion mention and large-directory caveats in the description', () => {
- const { fs } = createTestFs();
- const { kaos } = createTestKaos();
- const tool = new GlobTool(fs, kaos, workspace);
+ const { tool } = makeTool(workspace);
expect(tool.description).toContain('**');
expect(tool.description).toMatch(/\*\*\/\*\.py/);
@@ -832,12 +746,10 @@ describe('GlobTool', () => {
});
it('mentions Windows path forms in the description on win32 backends', () => {
- const { fs } = createTestFs();
- const { kaos } = createTestKaos({ pathClass: 'win32' });
- const tool = new GlobTool(fs, kaos, {
- workspaceDir: 'C:\\workspace',
- additionalDirs: [],
- });
+ const { tool } = makeTool(
+ { workspaceDir: 'C:\\workspace', additionalDirs: [] },
+ { pathClass: 'win32' },
+ );
expect(tool.description).toContain('C:\\Users\\foo');
expect(tool.description).toContain('/c/Users/foo');
@@ -850,7 +762,6 @@ describe('splitCompletePaths', () => {
});
it('keeps every line when output is complete even if flagged truncated', () => {
- // A trailing newline means the last path is intact; nothing to drop.
expect(splitCompletePaths('/a/b.ts\n/c/d.ts\n', true)).toEqual(['/a/b.ts', '/c/d.ts']);
});
@@ -859,7 +770,6 @@ describe('splitCompletePaths', () => {
});
it('keeps the trailing path when output is not flagged truncated', () => {
- // Without the truncation flag the final segment is trusted as-is.
expect(splitCompletePaths('/a/b.ts\n/c/d.ts', false)).toEqual(['/a/b.ts', '/c/d.ts']);
});
@@ -869,22 +779,36 @@ describe('splitCompletePaths', () => {
});
describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
- // Spawns the actual `rg` binary through a real `IKaos` so the ripgrep
- // semantics the tool relies on (sort direction, recursion, brace handling,
- // cwd-relative matching) are exercised end-to-end — not just the argument
- // plumbing. Gated with `skipIf` so environments without `rg` skip cleanly.
- // The locator stays mocked (returning `rg`, found on PATH); everything below
- // it — process spawn, ripgrep itself, output parsing — is real.
+ // Spawns the actual `rg` binary through a real `SessionProcessRunner` so the
+ // ripgrep semantics the tool relies on (sort direction, recursion, brace
+ // handling, cwd-relative matching) are exercised end-to-end — not just the
+ // argument plumbing. Gated with `skipIf` so environments without `rg` skip
+ // cleanly. The locator stays mocked (returning `rg`, found on PATH);
+ // everything below it — process spawn, ripgrep itself, output parsing — is
+ // real.
let tmpDir: string | undefined;
- let kaos: IKaos;
+ let realEnv: IHostEnvironment;
+ let realRunner: ISessionProcessRunner;
let realFs: ISessionAgentFileSystem;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-rg-'));
- const base = await LocalKaos.create();
- kaos = new KaosService(base.withCwd(tmpDir));
- realFs = new SessionAgentFileSystem(kaos);
+ const info = await probeHostEnvironmentFromNode();
+ realEnv = {
+ _serviceBrand: undefined,
+ osKind: info.osKind,
+ osArch: info.osArch,
+ osVersion: info.osVersion,
+ shellName: info.shellName,
+ shellPath: info.shellPath,
+ pathClass: info.pathClass,
+ homeDir: info.homeDir,
+ ready: Promise.resolve(),
+ };
+ const ctx = createExecContext(tmpDir);
+ realRunner = new SessionProcessRunner(ctx);
+ realFs = new SessionAgentFileSystem(ctx);
});
afterEach(async () => {
@@ -907,7 +831,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await touch('old.ts', new Date('2020-01-01T00:00:00Z'));
await touch('mid.ts', new Date('2022-01-01T00:00:00Z'));
await touch('new.ts', new Date('2024-01-01T00:00:00Z'));
- const tool = new GlobTool(realFs, kaos, ws());
+ const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '*.ts', path: tmpDir! });
@@ -918,7 +842,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await touch('root.ts', new Date('2024-01-01T00:00:00Z'));
await touch('src/a.ts', new Date('2023-01-01T00:00:00Z'));
await touch('src/sub/b.ts', new Date('2022-01-01T00:00:00Z'));
- const tool = new GlobTool(realFs, kaos, ws());
+ const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '*.ts', path: tmpDir! });
@@ -931,7 +855,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await touch('src/a.ts', new Date('2024-01-01T00:00:00Z'));
await touch('test/a.ts', new Date('2023-01-01T00:00:00Z'));
await touch('other/a.ts', new Date('2022-01-01T00:00:00Z'));
- const tool = new GlobTool(realFs, kaos, ws());
+ const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '{src,test}/*.ts', path: tmpDir! });
@@ -941,14 +865,10 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
});
it('matches a recursive anchored pattern (src/**/*.ts) under an absolute search root', async () => {
- // Regression guard: with an absolute search root, ripgrep matches a
- // `--glob` pattern containing a `/` against the absolute path, so
- // `src/**/*.ts` returns nothing unless the tool runs rg from the search
- // root (cwd) with `.` as the search path.
await touch('src/a.ts', new Date('2024-01-01T00:00:00Z'));
await touch('src/sub/b.ts', new Date('2023-01-01T00:00:00Z'));
await touch('other/c.ts', new Date('2022-01-01T00:00:00Z'));
- const tool = new GlobTool(realFs, kaos, ws());
+ const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: 'src/**/*.ts', path: tmpDir! });
@@ -959,7 +879,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
it('treats an escaped brace as a literal filename', async () => {
await touch('{a,b}.ts', new Date('2024-01-01T00:00:00Z'));
- const tool = new GlobTool(realFs, kaos, ws());
+ const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '\\{a,b\\}.ts', path: tmpDir! });
@@ -971,7 +891,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
try {
const extFile = path.join(externalDir, 'pkg.ts');
await fs.writeFile(extFile, '');
- const tool = new GlobTool(realFs, kaos, ws());
+ const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const result = await execute(tool, { pattern: '*.ts', path: externalDir });
diff --git a/packages/agent-core-v2/test/fileTools/grep.test.ts b/packages/agent-core-v2/test/fileTools/grep.test.ts
index 6d8df95ac..ecbce63a5 100644
--- a/packages/agent-core-v2/test/fileTools/grep.test.ts
+++ b/packages/agent-core-v2/test/fileTools/grep.test.ts
@@ -20,7 +20,7 @@ import {
GrepInputSchema,
GrepTool,
} from '#/agent/fileTools/tools/grep';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
@@ -47,11 +47,18 @@ function createFakeFs(
return { fs, grep };
}
-function createTestKaos(home = '/home'): IKaos {
+function createTestEnv(home = '/home'): IHostEnvironment {
return {
- pathClass: () => 'posix',
- gethome: () => home,
- } as unknown as IKaos;
+ _serviceBrand: undefined,
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir: home,
+ ready: Promise.resolve(),
+ };
}
function isPromiseLike(value: ToolExecution | Promise): value is Promise {
@@ -81,7 +88,7 @@ function toolContentString(result: ExecutableToolResult): string {
describe('GrepTool', () => {
it('exposes current metadata and schema', () => {
const { fs } = createFakeFs(emptyResponse());
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
expect(tool.name).toBe('Grep');
expect(tool.description).toContain('unknown content or unknown file locations');
@@ -114,7 +121,7 @@ describe('GrepTool', () => {
const { fs, grep } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts'), fileHit('src/b.ts')] }),
);
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit' });
@@ -126,7 +133,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts', [10, 20])] }),
);
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', output_mode: 'content' });
@@ -135,7 +142,7 @@ describe('GrepTool', () => {
it('treats the pattern as a regex when calling the fs layer', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'foo|bar' });
@@ -146,7 +153,7 @@ describe('GrepTool', () => {
it('maps -i to a case-insensitive request', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'Hit', '-i': true });
@@ -156,7 +163,7 @@ describe('GrepTool', () => {
it('is case-sensitive by default', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'Hit' });
@@ -166,7 +173,7 @@ describe('GrepTool', () => {
it('maps glob to include_globs and leaves exclude_globs empty', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'hit', glob: '*.ts' });
@@ -177,7 +184,7 @@ describe('GrepTool', () => {
it('passes an exclude-style glob through include_globs verbatim', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'hit', glob: '!**/*.test.ts' });
@@ -187,7 +194,7 @@ describe('GrepTool', () => {
it('maps type to a recursive include glob', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'hit', type: 'ts' });
@@ -197,7 +204,7 @@ describe('GrepTool', () => {
it('maps include_ignored to follow_gitignore=false', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
await execute(tool, { pattern: 'hit', include_ignored: true });
@@ -209,7 +216,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts')], truncated: true }),
);
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit' });
const output = toolContentString(result);
@@ -221,7 +228,7 @@ describe('GrepTool', () => {
it('returns a clean no-match result', async () => {
const { fs, grep } = createFakeFs(emptyResponse());
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'missing' });
@@ -236,7 +243,7 @@ describe('GrepTool', () => {
files: [fileHit('a.ts'), fileHit('b.ts'), fileHit('c.ts'), fileHit('d.ts')],
}),
);
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', offset: 1, head_limit: 2 });
const output = toolContentString(result);
@@ -251,7 +258,7 @@ describe('GrepTool', () => {
it('treats head_limit zero as unlimited', async () => {
const files = Array.from({ length: 260 }, (_, i) => fileHit(`src/${String(i)}.ts`));
const { fs } = createFakeFs(emptyResponse({ files }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', head_limit: 0 });
const output = toolContentString(result);
@@ -263,7 +270,7 @@ describe('GrepTool', () => {
it('limits files_with_matches output to 250 lines by default', async () => {
const files = Array.from({ length: 251 }, (_, i) => fileHit(`src/${String(i)}.ts`));
const { fs } = createFakeFs(emptyResponse({ files }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit' });
const output = toolContentString(result);
@@ -280,7 +287,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts', [1, 2, 3]), fileHit('src/b.ts', [1, 2])] }),
);
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', output_mode: 'count_matches' });
@@ -292,7 +299,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('a.ts', [1]), fileHit('b.ts', [1]), fileHit('c.ts', [1])] }),
);
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, {
pattern: 'hit',
@@ -310,7 +317,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/main.ts'), fileHit('.env')] }),
);
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit' });
const output = toolContentString(result);
@@ -322,7 +329,7 @@ describe('GrepTool', () => {
it('reports no non-sensitive matches when every result is sensitive', async () => {
const { fs } = createFakeFs(emptyResponse({ files: [fileHit('.env')] }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'hit', output_mode: 'content' });
const output = toolContentString(result);
@@ -350,7 +357,7 @@ describe('GrepTool', () => {
],
}),
);
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'match', output_mode: 'content', '-C': 1 });
@@ -361,7 +368,7 @@ describe('GrepTool', () => {
const controller = new AbortController();
controller.abort();
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const resolved = tool.resolveExecution({ pattern: 'hit' });
const execution = isPromiseLike(resolved) ? await resolved : resolved;
@@ -381,7 +388,7 @@ describe('GrepTool', () => {
const { fs } = createFakeFs(() => {
throw new KimiError(ErrorCodes.FS_GREP_TIMEOUT, 'grep timed out after 30000ms');
});
- const tool = new GrepTool(fs, createTestKaos(), workspace);
+ const tool = new GrepTool(fs, createTestEnv(), workspace);
const result = await execute(tool, { pattern: 'slow' });
diff --git a/packages/agent-core-v2/test/fileTools/read.test.ts b/packages/agent-core-v2/test/fileTools/read.test.ts
index bd1f7e7c9..d2f96a0b4 100644
--- a/packages/agent-core-v2/test/fileTools/read.test.ts
+++ b/packages/agent-core-v2/test/fileTools/read.test.ts
@@ -26,7 +26,7 @@ import {
ReadInputSchema,
ReadTool,
} from '#/agent/fileTools/tools/read';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
@@ -60,11 +60,18 @@ function toolContentString(result: ExecutableToolResult): string {
return c;
}
-function createTestKaos(home = '/home'): IKaos {
+function createTestEnv(home = '/home'): IHostEnvironment {
return {
- pathClass: () => 'posix',
- gethome: () => home,
- } as unknown as IKaos;
+ _serviceBrand: undefined,
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir: home,
+ ready: Promise.resolve(),
+ };
}
/**
@@ -130,7 +137,7 @@ function createSpiedMapFs(files: Record) {
}
function toolWithContent(content: string, workspace: WorkspaceConfig = PERMISSIVE_WORKSPACE) {
- return new ReadTool(createSpiedFs(content).fs, createTestKaos(), workspace);
+ return new ReadTool(createSpiedFs(content).fs, createTestEnv(), workspace);
}
function isPromiseLike(value: ToolExecution | Promise): value is Promise {
@@ -298,7 +305,7 @@ describe('ReadTool', () => {
it('rejects relative traversal before reading', async () => {
const { fs, readText } = createSpiedFs('secret');
- const tool = new ReadTool(fs, createTestKaos(), {
+ const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace/project',
additionalDirs: [],
});
@@ -312,7 +319,7 @@ describe('ReadTool', () => {
it('allows explicit absolute paths outside the workspace', async () => {
const { fs, readBytes, readLines } = createSpiedFs('external');
- const tool = new ReadTool(fs, createTestKaos(), {
+ const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@@ -331,7 +338,7 @@ describe('ReadTool', () => {
it('returns a friendly error for missing files before sniffing bytes', async () => {
const { fs, readBytes, readLines } = createSpiedMapFs({});
- const tool = new ReadTool(fs, createTestKaos(), {
+ const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@@ -350,7 +357,7 @@ describe('ReadTool', () => {
const { fs, readBytes, readLines } = createSpiedMapFs({
'/workspace/src': { bytes: Buffer.alloc(0), isFile: false, isDirectory: true },
});
- const tool = new ReadTool(fs, createTestKaos(), {
+ const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@@ -367,7 +374,7 @@ describe('ReadTool', () => {
it('expands leading tilde paths using the kaos home directory', async () => {
const { fs, readBytes, readLines } = createSpiedFs('home note');
- const tool = new ReadTool(fs, createTestKaos('/home/test'), {
+ const tool = new ReadTool(fs, createTestEnv('/home/test'), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@@ -386,7 +393,7 @@ describe('ReadTool', () => {
it('blocks sensitive files independently from workspace access', async () => {
const { fs, readText } = createSpiedFs('SECRET=value');
- const tool = new ReadTool(fs, createTestKaos(), {
+ const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
@@ -403,7 +410,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/sample.png': { bytes: pngHeader },
});
- const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/sample.png' });
const output = toolContentString(result);
@@ -422,7 +429,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/fake.png': { bytes: plainText },
});
- const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/fake.png' });
const output = toolContentString(result);
@@ -439,7 +446,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/sample': { bytes: pngHeader },
});
- const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/sample' });
const output = toolContentString(result);
@@ -460,7 +467,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/sample.mp4': { bytes: mp4Header },
});
- const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/sample.mp4' });
const output = toolContentString(result);
@@ -476,7 +483,7 @@ describe('ReadTool', () => {
const { fs, readText } = createSpiedMapFs({
'/tmp/blob.bin': { bytes: header },
});
- const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/blob.bin' });
const output = toolContentString(result);
@@ -500,7 +507,7 @@ describe('ReadTool', () => {
},
},
});
- const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/blob-with-late-nul' });
const output = toolContentString(result);
@@ -528,7 +535,7 @@ describe('ReadTool', () => {
},
},
});
- const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/not-utf8.txt' });
const output = toolContentString(result);
@@ -588,7 +595,7 @@ describe('ReadTool', () => {
);
const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: bytes.length }));
const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as ISessionAgentFileSystem;
- const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
+ const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/large.txt' });
const output = toolContentString(result);
@@ -665,7 +672,7 @@ describe('ReadTool', () => {
it('reads files inside additional_dirs via absolute path', async () => {
const { fs } = createSpiedFs('extra-dir note');
- const tool = new ReadTool(fs, createTestKaos(), {
+ const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: ['/extra'],
});
@@ -678,7 +685,7 @@ describe('ReadTool', () => {
it('reports nonexistent files with the expected does-not-exist phrasing', async () => {
const { fs } = createSpiedMapFs({});
- const tool = new ReadTool(fs, createTestKaos(), {
+ const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
diff --git a/packages/agent-core-v2/test/fileTools/write.test.ts b/packages/agent-core-v2/test/fileTools/write.test.ts
index af555e6cf..00f757421 100644
--- a/packages/agent-core-v2/test/fileTools/write.test.ts
+++ b/packages/agent-core-v2/test/fileTools/write.test.ts
@@ -18,7 +18,7 @@ import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import type { AgentFileStat, ISessionAgentFileSystem } from '#/session/agentFs';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import { type WriteInput, WriteInputSchema, WriteTool } from '#/agent/fileTools/tools/write';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
@@ -32,11 +32,18 @@ function toolContentString(result: ExecutableToolResult): string {
return c;
}
-function createTestKaos(home = '/home'): IKaos {
+function createTestEnv(home = '/home'): IHostEnvironment {
return {
- pathClass: () => 'posix',
- gethome: () => home,
- } as unknown as IKaos;
+ _serviceBrand: undefined,
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir: home,
+ ready: Promise.resolve(),
+ };
}
interface WriteFsOptions {
@@ -75,7 +82,7 @@ function createWriteFs(options: WriteFsOptions = {}) {
function makeTool(options: WriteFsOptions = {}, workspace: WorkspaceConfig = PERMISSIVE_WORKSPACE) {
const fakes = createWriteFs(options);
- const tool = new WriteTool(fakes.fs, createTestKaos(), workspace);
+ const tool = new WriteTool(fakes.fs, createTestEnv(), workspace);
return { tool, ...fakes };
}
@@ -203,7 +210,7 @@ describe('WriteTool', () => {
it('expands leading tilde paths using the kaos home directory', async () => {
const fakes = createWriteFs();
- const tool = new WriteTool(fakes.fs, createTestKaos('/home/test'), PERMISSIVE_WORKSPACE);
+ const tool = new WriteTool(fakes.fs, createTestEnv('/home/test'), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '~/notes/today.txt', content: 'hello' });
diff --git a/packages/agent-core-v2/test/kaos/kaosFactory.test.ts b/packages/agent-core-v2/test/kaos/kaosFactory.test.ts
deleted file mode 100644
index d94f1e315..000000000
--- a/packages/agent-core-v2/test/kaos/kaosFactory.test.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { beforeEach, describe, expect, it } from 'vitest';
-
-import { InstantiationType } from '#/_base/di/extensions';
-import {
- LifecycleScope,
- _clearScopedRegistryForTests,
- registerScopedService,
-} from '#/_base/di/scope';
-import { createScopedTestHost } from '#/_base/di/test';
-import { IKaosFactory, KaosFactory } from '#/app/kaos';
-
-describe('KaosFactory', () => {
- beforeEach(() => {
- _clearScopedRegistryForTests();
- registerScopedService(
- LifecycleScope.App,
- IKaosFactory,
- KaosFactory,
- InstantiationType.Delayed,
- 'kaos',
- );
- });
-
- it('createLocal builds an IKaos rooted at the given cwd', async () => {
- const host = createScopedTestHost();
- const factory = host.app.accessor.get(IKaosFactory);
- const k = await factory.createLocal(process.cwd());
-
- expect(k.name).toBe('local');
- expect(k.getcwd()).toBe(process.cwd());
- expect(k.cwd).toBe(process.cwd());
- expect(['posix', 'win32']).toContain(k.pathClass());
- expect(typeof k.osEnv.osKind).toBe('string');
- expect(typeof k.osEnv.shellPath).toBe('string');
-
- host.dispose();
- });
-
- it('withCwd derives an independent env without mutating the parent', async () => {
- const host = createScopedTestHost();
- const factory = host.app.accessor.get(IKaosFactory);
- const k = await factory.createLocal('/tmp');
-
- const child = k.withCwd('/var');
- expect(child.getcwd()).toBe('/var');
- expect(k.getcwd()).toBe('/tmp');
-
- host.dispose();
- });
-
- it('backend delegates fs operations to the kaos backend', async () => {
- const host = createScopedTestHost();
- const factory = host.app.accessor.get(IKaosFactory);
- const k = await factory.createLocal(process.cwd());
-
- const st = await k.backend.stat(process.cwd());
- expect(typeof st.stSize).toBe('number');
-
- host.dispose();
- });
-});
diff --git a/packages/agent-core-v2/test/media/read-media.test.ts b/packages/agent-core-v2/test/media/read-media.test.ts
index 4b3f24f2f..dde026494 100644
--- a/packages/agent-core-v2/test/media/read-media.test.ts
+++ b/packages/agent-core-v2/test/media/read-media.test.ts
@@ -9,7 +9,7 @@ import type { ContentPart, ModelCapability } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
import {
ReadMediaFileInputSchema,
ReadMediaFileTool,
@@ -81,11 +81,18 @@ function createTestFs(files: Record): ISessionAgentFileSystem
} as unknown as ISessionAgentFileSystem;
}
-function createTestKaos(): IKaos {
+function createTestEnv(): IHostEnvironment {
return {
- pathClass: () => 'posix',
- gethome: () => '/home',
- } as unknown as IKaos;
+ _serviceBrand: undefined,
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir: '/home',
+ ready: Promise.resolve(),
+ };
}
function makeTool(
@@ -93,7 +100,7 @@ function makeTool(
caps: ModelCapability = capabilities(),
videoUploader?: VideoUploader,
): ReadMediaFileTool {
- return new ReadMediaFileTool(createTestFs(files), createTestKaos(), WORKSPACE, caps, videoUploader);
+ return new ReadMediaFileTool(createTestFs(files), createTestEnv(), WORKSPACE, caps, videoUploader);
}
async function execute(
@@ -261,13 +268,13 @@ describe('ReadMediaFileTool', () => {
describe('registerMediaTools', () => {
const fs = createTestFs({});
- const kaos = createTestKaos();
+ const env = createTestEnv();
it('registers ReadMediaFile when the model supports image input', () => {
const registry = new AgentToolRegistryService();
const disposable = registerMediaTools(registry, {
fs,
- kaos,
+ env,
workspace: WORKSPACE,
capabilities: capabilities({ image_in: true, video_in: false }),
});
@@ -280,7 +287,7 @@ describe('registerMediaTools', () => {
const registry = new AgentToolRegistryService();
registerMediaTools(registry, {
fs,
- kaos,
+ env,
workspace: WORKSPACE,
capabilities: capabilities({ image_in: false, video_in: true }),
});
@@ -291,7 +298,7 @@ describe('registerMediaTools', () => {
const registry = new AgentToolRegistryService();
const disposable = registerMediaTools(registry, {
fs,
- kaos,
+ env,
workspace: WORKSPACE,
capabilities: capabilities({ image_in: false, video_in: false }),
});
diff --git a/packages/agent-core-v2/test/permission/permissionGate.test.ts b/packages/agent-core-v2/test/permission/permissionGate.test.ts
index 466ae302c..a224675a6 100644
--- a/packages/agent-core-v2/test/permission/permissionGate.test.ts
+++ b/packages/agent-core-v2/test/permission/permissionGate.test.ts
@@ -10,7 +10,7 @@ import type { ApprovalResponse } from '#/session/approval/approval';
import type { ApprovalRequest } from '#/session/approval/approval';
import { ISessionApprovalService } from '#/session/approval/approval';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
-import { IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
import type { LLM } from '#/agent/loop/llm';
import type { ResolvedToolExecutionHookContext } from '#/agent/tool';
import { IAgentPermissionGate, AgentPermissionGate } from '#/agent/permissionGate';
@@ -119,8 +119,8 @@ describe('AgentPermissionGate', () => {
reg.definePartialInstance(IAgentSwarmService, {
isActive: false,
});
- reg.definePartialInstance(IKaos, {
- pathClass: () => 'posix',
+ reg.definePartialInstance(IHostEnvironment, {
+ pathClass: 'posix',
});
reg.definePartialInstance(ISessionWorkspaceContext, {
workDir: '/workspace',
diff --git a/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts b/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts
index c49bdf38e..5bbd2eb44 100644
--- a/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts
+++ b/packages/agent-core-v2/test/permissionPolicy/permission-policy-service.test.ts
@@ -16,7 +16,7 @@ import {
matchesPathRuleSubject,
} from '#/_base/tools/support/rule-match';
import type { ResolvedToolExecutionHookContext } from '#/agent/tool';
-import { IKaos, type IKaos as KaosService } from '#/app/kaos';
+import { IHostEnvironment, type IHostEnvironment as HostEnvironmentService } from '#/app/hostEnvironment';
import { IAgentPermissionModeService } from '#/agent/permissionMode';
import {
DenyAllPermissionPolicyService,
@@ -68,7 +68,7 @@ describe('AgentPermissionPolicyService chain', () => {
sessionApprovalRulePatterns: () => sessionApprovalRulePatterns,
}));
reg.defineInstance(ISessionWorkspaceContext, workspace);
- reg.defineInstance(IKaos, kaosStub());
+ reg.defineInstance(IHostEnvironment, kaosStub());
reg.definePartialInstance(IAgentPlanService, planServiceStub(() => plan, () => {
plan = null;
}));
@@ -238,7 +238,7 @@ describe('AgentPermissionPolicyService plan-mode policies', () => {
sessionApprovalRulePatterns: () => sessionApprovalRulePatterns,
}));
reg.defineInstance(ISessionWorkspaceContext, workspaceStub('/workspace'));
- reg.defineInstance(IKaos, kaosStub());
+ reg.defineInstance(IHostEnvironment, kaosStub());
reg.definePartialInstance(IAgentPlanService, planServiceStub(() => plan, () => {
plan = null;
}));
@@ -430,7 +430,7 @@ describe('AgentPermissionPolicyService git cwd write approval', () => {
reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode));
reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub());
reg.defineInstance(ISessionWorkspaceContext, workspace);
- reg.defineInstance(IKaos, kaosStub());
+ reg.defineInstance(IHostEnvironment, kaosStub());
reg.definePartialInstance(IAgentPlanService, planServiceStub(() => null));
reg.definePartialInstance(IAgentSwarmService, swarmServiceStub(() => false));
reg.defineInstance(ITelemetryService, recordingTelemetry([]));
@@ -751,22 +751,18 @@ function workspaceStub(initialWorkDir: string): ISessionWorkspaceContext {
};
}
-function kaosStub(pathClass: ReturnType = 'posix'): KaosService {
- let kaos!: KaosService;
- kaos = {
+function kaosStub(pathClass: HostEnvironmentService['pathClass'] = 'posix'): HostEnvironmentService {
+ return {
_serviceBrand: undefined,
- name: 'test',
- cwd: '/workspace',
- osEnv: {} as KaosService['osEnv'],
- backend: {} as KaosService['backend'],
- pathClass: () => pathClass,
- normpath: (path: string) => path,
- gethome: () => '/home/test',
- getcwd: () => '/workspace',
- withCwd: () => kaos,
- withEnv: () => kaos,
- } satisfies KaosService;
- return kaos;
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass,
+ homeDir: '/home/test',
+ ready: Promise.resolve(),
+ };
}
function planServiceStub(
diff --git a/packages/agent-core-v2/test/plan/injection.test.ts b/packages/agent-core-v2/test/plan/injection.test.ts
index 0d224f64e..d806b2ef7 100644
--- a/packages/agent-core-v2/test/plan/injection.test.ts
+++ b/packages/agent-core-v2/test/plan/injection.test.ts
@@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { createFakeKaos } from '../tools/fixtures/fake-kaos';
+import { createFakeAgentFs } from '../tools/fixtures/fake-exec';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IAgentPlanService } from '#/agent/plan';
import {
createTestAgent,
- kaosServices,
+ execEnvServices,
type TestAgentContext,
} from '../harness';
@@ -61,11 +61,13 @@ describe('PlanModeService dynamic injection content', () => {
beforeEach(() => {
readText = async () => '';
- ctx = createTestAgent(kaosServices(createFakeKaos({
- mkdir: vi.fn().mockResolvedValue(undefined),
- readText: (path: string) => readText(path),
- writeText: vi.fn(async (_path: string, content: string) => content.length),
- })));
+ ctx = createTestAgent(execEnvServices({
+ agentFs: createFakeAgentFs({
+ mkdir: vi.fn().mockResolvedValue(undefined),
+ readText: (path: string) => readText(path),
+ writeText: vi.fn(async () => undefined),
+ }),
+ }));
context = ctx.get(IAgentContextMemoryService);
injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector;
plan = ctx.get(IAgentPlanService);
@@ -141,11 +143,13 @@ describe('PlanModeService dynamic injection cadence', () => {
let plan: IAgentPlanService;
beforeEach(() => {
- ctx = createTestAgent(kaosServices(createFakeKaos({
- mkdir: vi.fn().mockResolvedValue(undefined),
- readText: async () => '',
- writeText: vi.fn(async (_path: string, content: string) => content.length),
- })));
+ ctx = createTestAgent(execEnvServices({
+ agentFs: createFakeAgentFs({
+ mkdir: vi.fn().mockResolvedValue(undefined),
+ readText: async () => '',
+ writeText: vi.fn(async () => undefined),
+ }),
+ }));
context = ctx.get(IAgentContextMemoryService);
injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector;
plan = ctx.get(IAgentPlanService);
diff --git a/packages/agent-core-v2/test/plan/plan-tools-telemetry.test.ts b/packages/agent-core-v2/test/plan/plan-tools-telemetry.test.ts
index 395f13ab7..73626ba85 100644
--- a/packages/agent-core-v2/test/plan/plan-tools-telemetry.test.ts
+++ b/packages/agent-core-v2/test/plan/plan-tools-telemetry.test.ts
@@ -11,10 +11,10 @@ import type { ITelemetryService } from '#/app/telemetry';
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import { executeTool } from '../tools/fixtures/execute-tool';
-import { createFakeKaos } from '../tools/fixtures/fake-kaos';
+import { createFakeAgentFs } from '../tools/fixtures/fake-exec';
import {
createTestAgent,
- kaosServices,
+ execEnvServices,
permissionModeServices,
telemetryServices,
type TestAgentContext,
@@ -216,9 +216,11 @@ describe('AgentPlanService EnterPlanMode telemetry', () => {
beforeEach(() => {
records.splice(0);
ctx = createTestAgent(
- kaosServices(createFakeKaos({
- mkdir: vi.fn().mockResolvedValue(undefined),
- })),
+ execEnvServices({
+ agentFs: createFakeAgentFs({
+ mkdir: vi.fn().mockResolvedValue(undefined),
+ }),
+ }),
permissionModeServices(mode),
telemetryServices(captureTelemetry(records)),
);
diff --git a/packages/agent-core-v2/test/plan/plan.test.ts b/packages/agent-core-v2/test/plan/plan.test.ts
index 445102f22..d6efce891 100644
--- a/packages/agent-core-v2/test/plan/plan.test.ts
+++ b/packages/agent-core-v2/test/plan/plan.test.ts
@@ -1,7 +1,6 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
-import type { Kaos } from '@moonshot-ai/kaos';
import type { ToolCall } from '@moonshot-ai/kosong';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -11,41 +10,61 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentPlanService, type PlanData } from '#/agent/plan';
import { IAgentPermissionRulesService } from '#/agent/permissionRules';
import { IAgentProfileService } from '#/agent/profile';
-import { createFakeKaos } from '../tools/fixtures/fake-kaos';
+import type { ISessionAgentFileSystem } from '#/session/agentFs';
+import type { ISessionProcessRunner } from '#/session/process';
+import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import {
- createCommandKaos,
+ createCommandRunner,
createTestAgent,
- kaosServices,
+ execEnvServices,
type TestAgentContext,
} from '../harness';
-function createPlanKaos(overrides: Parameters[0] = {}) {
- return createFakeKaos({
+interface PlanFakes {
+ readonly fs: ISessionAgentFileSystem;
+ readonly runner: ISessionProcessRunner;
+}
+
+/**
+ * Minimal fs + runner pair with sensible plan-service defaults (mkdir /
+ * readText no-op, runner throws). Individual tests override the specific
+ * methods they need.
+ */
+function createPlanFakes(overrides: Partial = {}): PlanFakes {
+ const fs = createFakeAgentFs({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: vi.fn().mockResolvedValue(''),
...overrides,
});
+ const runner = createFakeProcessRunner();
+ return { fs, runner };
}
-function createPlanCommandKaos(stdout: string): Kaos {
- const commandKaos = createCommandKaos(stdout);
- return createPlanKaos({ execWithEnv: commandKaos.execWithEnv });
+function createPlanCommandFakes(stdout: string): PlanFakes {
+ return {
+ fs: createPlanFakes().fs,
+ runner: createCommandRunner(stdout),
+ };
}
-function createPlanFileKaos(
+function createPlanFileFakes(
files = new Map(),
- overrides: Parameters[0] = {},
-) {
+ overrides: Partial = {},
+): {
+ readonly files: Map;
+ readonly readText: ReturnType;
+ readonly writeText: ReturnType;
+ readonly fakes: PlanFakes;
+} {
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
const writeText = vi.fn(async (path: string, content: string) => {
files.set(path, content);
- return content.length;
});
return {
files,
readText,
writeText,
- kaos: createPlanKaos({
+ fakes: createPlanFakes({
readText,
writeText,
...overrides,
@@ -58,7 +77,7 @@ type InjectableDynamicInjector = {
};
describe('Plan service', () => {
- let activeKaos: Kaos;
+ let activeFakes: PlanFakes;
let context: IAgentContextMemoryService;
let ctx: TestAgentContext;
let injector: InjectableDynamicInjector;
@@ -68,9 +87,14 @@ describe('Plan service', () => {
let tempDirs: string[];
beforeEach(() => {
- activeKaos = createPlanKaos();
+ activeFakes = createPlanFakes();
tempDirs = [];
- ctx = createTestAgent(kaosServices(delegatingKaos()));
+ ctx = createTestAgent(
+ execEnvServices({
+ agentFs: delegatingFs(),
+ processRunner: delegatingRunner(),
+ }),
+ );
context = ctx.get(IAgentContextMemoryService);
injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector;
permissionRules = ctx.get(IAgentPermissionRulesService);
@@ -87,17 +111,30 @@ describe('Plan service', () => {
}
});
- function delegatingKaos(): Kaos {
- return new Proxy(createPlanKaos(), {
+ /**
+ * 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 {
+ return new Proxy(createPlanFakes().fs, {
get(_target, prop, receiver) {
- const value = Reflect.get(activeKaos, prop, receiver);
- return typeof value === 'function' ? value.bind(activeKaos) : value;
+ const value = Reflect.get(activeFakes.fs, prop, receiver);
+ return typeof value === 'function' ? value.bind(activeFakes.fs) : value;
},
- }) as Kaos;
+ }) as ISessionAgentFileSystem;
}
- function useKaos(kaos: Kaos): void {
- activeKaos = kaos;
+ function delegatingRunner(): ISessionProcessRunner {
+ return new Proxy(createPlanFakes().runner, {
+ get(_target, prop, receiver) {
+ const value = Reflect.get(activeFakes.runner, prop, receiver);
+ return typeof value === 'function' ? value.bind(activeFakes.runner) : value;
+ },
+ }) as ISessionProcessRunner;
+ }
+
+ function useFakes(fakes: PlanFakes): void {
+ activeFakes = fakes;
}
function useTools(tools: readonly string[]): void {
@@ -138,7 +175,7 @@ describe('Plan service', () => {
const mkdir = vi.fn().mockResolvedValue(undefined);
const writeText = vi.fn().mockResolvedValue(0);
const cwd = await makeTempDir('kimi-plan-entry-');
- useKaos(createPlanKaos({ mkdir, writeText }));
+ useFakes(createPlanFakes({ mkdir, writeText }));
profile.update({ cwd });
await ctx.rpc.enterPlan({});
@@ -147,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'), { parents: true, existOk: true });
+ expect(mkdir).toHaveBeenCalledWith(join(cwd, 'plan'));
expect(writeText).not.toHaveBeenCalled();
expect(ctx.allEvents.some((event) => event.event === 'turn.started')).toBe(false);
expect(ctx.llmCalls).toHaveLength(0);
@@ -155,8 +192,8 @@ describe('Plan service', () => {
it('derives the no-homedir plan path from cwd on enter and restore', async () => {
const cwd = await makeTempDir('kimi-plan-path-');
- useKaos(createPlanKaos({
- writeText: vi.fn(async (_path: string, content: string) => content.length),
+ useFakes(createPlanFakes({
+ writeText: vi.fn(async (_path: string, _content: string): Promise => {}),
}));
profile.update({ cwd });
await plan.enter('stable-plan');
@@ -183,8 +220,8 @@ describe('Plan service', () => {
it('enters plan mode through the EnterPlanMode tool and reminds the next step', async () => {
const cwd = await makeTempDir('kimi-plan-tool-entry-');
- const { kaos } = createPlanFileKaos();
- useKaos(kaos);
+ const { fakes } = createPlanFileFakes();
+ useFakes(fakes);
useTools(['EnterPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'yolo' });
@@ -210,8 +247,8 @@ describe('Plan service', () => {
describe('plan clear', () => {
it('empties the current plan file without leaving plan mode', async () => {
const cwd = await makeTempDir('kimi-plan-clear-');
- const { files, writeText, kaos } = createPlanFileKaos();
- useKaos(kaos);
+ const { files, writeText, fakes } = createPlanFileFakes();
+ useFakes(fakes);
profile.update({ cwd });
await plan.enter('test-plan', false);
@@ -234,8 +271,8 @@ describe('Plan service', () => {
describe('plan exit tool', () => {
it('reads the current plan file and exits plan mode directly in auto mode', async () => {
const cwd = await makeTempDir('kimi-plan-exit-');
- const { files, kaos } = createPlanFileKaos();
- useKaos(kaos);
+ const { files, fakes } = createPlanFileFakes();
+ useFakes(fakes);
useTools(['ExitPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'auto' });
@@ -266,8 +303,8 @@ describe('Plan service', () => {
it('stops the turn and stays in plan mode when the user rejects the plan', async () => {
const cwd = await makeTempDir('kimi-plan-reject-exit-');
- const { files, kaos } = createPlanFileKaos();
- useKaos(kaos);
+ const { files, fakes } = createPlanFileFakes();
+ useFakes(fakes);
useTools(['ExitPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'manual' });
@@ -296,12 +333,16 @@ describe('Plan service', () => {
});
it('does not execute later tool calls in the same batch after plan rejection', async () => {
- const execWithEnv = vi.fn(() => {
+ const exec = vi.fn(() => {
throw new Error('Bash should not execute after plan rejection');
});
const cwd = await makeTempDir('kimi-plan-reject-skip-tool-');
- const { files, kaos } = createPlanFileKaos(undefined, { execWithEnv });
- useKaos(kaos);
+ const { files, fakes: baseFakes } = createPlanFileFakes(undefined);
+ const fakes: PlanFakes = {
+ fs: baseFakes.fs,
+ runner: createFakeProcessRunner({ exec }),
+ };
+ useFakes(fakes);
useTools(['ExitPlanMode', 'Bash']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'yolo' });
@@ -334,7 +375,7 @@ describe('Plan service', () => {
await ctx.untilTurnEnd();
await expectPlanActive(true);
- expect(execWithEnv).not.toHaveBeenCalled();
+ expect(exec).not.toHaveBeenCalled();
expect(ctx.llmCalls).toHaveLength(1);
expect(toolResultText(context.get())).toContain('Plan rejected by user');
expect(toolResultText(context.get())).toContain(
@@ -344,8 +385,8 @@ describe('Plan service', () => {
it('refuses to exit when the current plan file is empty', async () => {
const cwd = await makeTempDir('kimi-plan-empty-exit-');
- const { files, kaos } = createPlanFileKaos();
- useKaos(kaos);
+ const { files, fakes } = createPlanFileFakes();
+ useFakes(fakes);
useTools(['ExitPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'yolo' });
@@ -376,8 +417,8 @@ describe('Plan service', () => {
describe('plan exit tool options', () => {
it('keeps options for approval when an option omits the optional description', async () => {
const cwd = await makeTempDir('kimi-plan-options-exit-');
- const { files, kaos } = createPlanFileKaos();
- useKaos(kaos);
+ const { files, fakes } = createPlanFileFakes();
+ useFakes(fakes);
useTools(['ExitPlanMode']);
profile.update({ cwd });
await ctx.rpc.setPermission({ mode: 'manual' });
@@ -424,11 +465,10 @@ describe('Plan service', () => {
async (toolName) => {
const files = new Map();
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
- const writeText = vi.fn(async (path: string, content: string) => {
+ const writeText = vi.fn(async (path: string, content: string): Promise => {
files.set(path, content);
- return content.length;
});
- useKaos(createPlanKaos({ readText, writeText }));
+ useFakes(createPlanFakes({ readText, writeText }));
const cwd = await makeTempDir('kimi-plan-write-tool-');
useTools([toolName]);
profile.update({ cwd });
@@ -466,11 +506,10 @@ describe('Plan service', () => {
it('keeps explicit deny rules above active plan file writes', async () => {
const files = new Map();
- const writeText = vi.fn(async (path: string, content: string) => {
+ const writeText = vi.fn(async (path: string, content: string): Promise => {
files.set(path, content);
- return content.length;
});
- useKaos(createPlanKaos({ writeText }));
+ useFakes(createPlanFakes({ writeText }));
const cwd = await makeTempDir('kimi-plan-deny-write-');
useTools(['Write']);
profile.update({ cwd });
@@ -516,7 +555,7 @@ describe('Plan service', () => {
name: 'Bash',
arguments: '{"command":"printf plan-safe","timeout":60}',
};
- useKaos(createPlanCommandKaos('plan-safe'));
+ useFakes(createPlanCommandFakes('plan-safe'));
useTools(['Bash']);
await ctx.rpc.setPermission({ mode: 'yolo' });
await plan.enter('test-plan', false);
@@ -580,7 +619,7 @@ describe('Plan service', () => {
name: 'Bash',
arguments: '{"command":"rm forbidden.txt","timeout":60}',
};
- useKaos(createPlanCommandKaos('removed'));
+ useFakes(createPlanCommandFakes('removed'));
useTools(['Bash']);
await ctx.rpc.setPermission({ mode: 'yolo' });
await plan.enter('test-plan', false);
@@ -651,7 +690,7 @@ describe('Plan service', () => {
});
it('emits a reentry reminder when restored plan mode already has plan content', async () => {
- useKaos(createPlanKaos({
+ useFakes(createPlanFakes({
readText: vi.fn(async () => '# Existing Plan\n\n- Keep this context'),
}));
await ctx.dispatch({
diff --git a/packages/agent-core-v2/test/process/processRunnerService.test.ts b/packages/agent-core-v2/test/process/processRunnerService.test.ts
index 71d418835..0bfa770df 100644
--- a/packages/agent-core-v2/test/process/processRunnerService.test.ts
+++ b/packages/agent-core-v2/test/process/processRunnerService.test.ts
@@ -12,7 +12,7 @@ import {
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
-import { IKaos, IKaosFactory, KaosFactory } from '#/app/kaos';
+import { createExecContext, IExecContext } from '#/session/execContext';
import { ISessionProcessRunner, SessionProcessRunner } from '#/session/process';
async function collect(stream: Readable): Promise {
@@ -23,18 +23,11 @@ async function collect(stream: Readable): Promise {
return Buffer.concat(chunks).toString('utf8');
}
-describe('SessionProcessRunner (backed by IKaos)', () => {
+describe('SessionProcessRunner (backed by IExecContext)', () => {
let dir: string;
beforeEach(async () => {
_clearScopedRegistryForTests();
- registerScopedService(
- LifecycleScope.App,
- IKaosFactory,
- KaosFactory,
- InstantiationType.Delayed,
- 'kaos',
- );
registerScopedService(
LifecycleScope.Session,
ISessionProcessRunner,
@@ -51,9 +44,11 @@ describe('SessionProcessRunner (backed by IKaos)', () => {
async function makeRunner(): Promise {
const host = createScopedTestHost();
- const factory = host.app.accessor.get(IKaosFactory);
- const kaos = await factory.createLocal(dir);
- const session = host.child(LifecycleScope.Session, 's', [stubPair(IKaos, kaos)]);
+ const session = host.child(
+ LifecycleScope.Session,
+ 's',
+ [stubPair(IExecContext, createExecContext(dir))],
+ );
return session.accessor.get(ISessionProcessRunner);
}
diff --git a/packages/agent-core-v2/test/profile/apply-profile.test.ts b/packages/agent-core-v2/test/profile/apply-profile.test.ts
index 65a04e979..454811e75 100644
--- a/packages/agent-core-v2/test/profile/apply-profile.test.ts
+++ b/packages/agent-core-v2/test/profile/apply-profile.test.ts
@@ -2,28 +2,13 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
-import { LocalKaos, type Environment } from '@moonshot-ai/kaos';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { IKaos } from '#/app/kaos';
+import { SessionAgentFileSystem } from '#/session/agentFs/agentFsService';
+import { createExecContext } from '#/session/execContext';
import { IAgentProfileService, type ResolvedAgentProfile } from '#/agent/profile';
-import { createTestAgent, kaosServices, type TestAgentContext } from '../harness';
-
-const TEST_OS_ENV: Environment = {
- osKind: 'Linux',
- osArch: 'x86_64',
- osVersion: 'test',
- shellName: 'bash',
- shellPath: '/bin/bash',
-};
-
-type LocalKaosCtor = new (osEnv: Environment) => LocalKaos;
-
-function createRealKaos(cwd: string): LocalKaos {
- const base = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV);
- return base.withCwd(cwd) as LocalKaos;
-}
+import { createTestAgent, execEnvServices, type TestAgentContext } from '../harness';
const profile: ResolvedAgentProfile = {
name: 'agents-profile',
@@ -43,18 +28,24 @@ describe('AgentProfileService.applyProfile', () => {
});
afterEach(async () => {
- vi.restoreAllMocks();
await ctx?.dispose();
await rm(homeDir, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
function buildContext(): { ctx: TestAgentContext; profile: IAgentProfileService } {
- ctx = createTestAgent(kaosServices(createRealKaos(workDir)));
- // Keep the user-level AGENTS.md discovery hermetic: point the OS home at an
- // empty temp dir so a developer's real ~/.kimi-code / ~/.agents files never
- // leak into the assertions.
- vi.spyOn(ctx.get(IKaos), 'gethome').mockReturnValue(homeDir);
+ // Real session-scoped fs anchored at workDir, plus a hermetic home dir
+ // (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);
+ ctx = createTestAgent(
+ execEnvServices({
+ hostEnvironment: { homeDir },
+ execContext: execCtx,
+ agentFs: fs,
+ }),
+ );
return { ctx, profile: ctx.get(IAgentProfileService) };
}
diff --git a/packages/agent-core-v2/test/profile/context.test.ts b/packages/agent-core-v2/test/profile/context.test.ts
index 133517b0b..af1b2ee14 100644
--- a/packages/agent-core-v2/test/profile/context.test.ts
+++ b/packages/agent-core-v2/test/profile/context.test.ts
@@ -2,69 +2,35 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
-import { LocalKaos, type Environment, type Kaos } from '@moonshot-ai/kaos';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import type { IKaos, PathClass } from '#/app/kaos';
+import { SessionAgentFileSystem } from '#/session/agentFs/agentFsService';
+import type { ISessionAgentFileSystem } from '#/session/agentFs';
+import { createExecContext } from '#/session/execContext';
import { loadAgentsMd, prepareSystemPromptContext } from '#/agent/profile';
-const TEST_OS_ENV: Environment = {
- osKind: 'Linux',
- osArch: 'x86_64',
- osVersion: 'test',
- shellName: 'bash',
- shellPath: '/bin/bash',
-};
-
-// `LocalKaos`'s constructor is `private` at the TS level only — at runtime it's
-// just a function. Skip the singleton/async detection path and build a fresh
-// instance with a stub `osEnv` so tests can hand a real IKaos directly to the
-// profile context loaders (mirrors v1's `testKaos` fixture).
-type LocalKaosCtor = new (osEnv: Environment) => LocalKaos;
-
-function createTestKaos(): IKaos {
- const backend: Kaos = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV);
- return wrapKaos(backend);
+/**
+ * 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`.
+ */
+function createFs(workDir: string): ISessionAgentFileSystem {
+ return new SessionAgentFileSystem(createExecContext(workDir));
}
-function wrapKaos(backend: Kaos): IKaos {
- return {
- _serviceBrand: undefined,
- get name() {
- return backend.name;
- },
- get cwd() {
- return backend.getcwd();
- },
- get osEnv() {
- return backend.osEnv;
- },
- backend,
- pathClass: (): PathClass => backend.pathClass(),
- normpath: (path) => backend.normpath(path),
- gethome: () => backend.gethome(),
- getcwd: () => backend.getcwd(),
- withCwd: (cwd) => wrapKaos(backend.withCwd(cwd)),
- withEnv: (env) => wrapKaos(backend.withEnv(env)),
- };
-}
-
-let kaos: IKaos;
+let fs: ISessionAgentFileSystem;
let homeDir: string;
let workDir: string;
let extraDirs: string[];
beforeEach(async () => {
- kaos = createTestKaos();
homeDir = await mkdtemp(join(tmpdir(), 'kimi-agents-home-'));
workDir = await mkdtemp(join(tmpdir(), 'kimi-agents-work-'));
extraDirs = [];
- vi.spyOn(kaos, 'gethome').mockReturnValue(homeDir);
- vi.spyOn(kaos, 'getcwd').mockReturnValue(workDir);
+ fs = createFs(workDir);
});
afterEach(async () => {
- vi.restoreAllMocks();
await rm(homeDir, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
await Promise.all(extraDirs.map((dir) => rm(dir, { recursive: true, force: true })));
@@ -78,7 +44,7 @@ describe('loadAgentsMd user-level discovery', () => {
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'user generic', 'utf-8');
await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf-8');
- const result = await loadAgentsMd(kaos);
+ const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain('user branded');
expect(result).toContain('user generic');
@@ -91,7 +57,7 @@ describe('loadAgentsMd user-level discovery', () => {
await mkdir(join(homeDir, '.agents'), { recursive: true });
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'dot-agents generic', 'utf-8');
- const result = await loadAgentsMd(kaos);
+ const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain('dot-agents generic');
});
@@ -99,18 +65,17 @@ describe('loadAgentsMd user-level discovery', () => {
it('falls back to project-level only when no user-level files exist', async () => {
await writeFile(join(workDir, 'AGENTS.md'), 'project only', 'utf-8');
- const result = await loadAgentsMd(kaos);
+ const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain('project only');
expect(result).not.toContain(homeDir);
});
it('does not load the same file twice when the work dir is the home dir', async () => {
- vi.spyOn(kaos, 'getcwd').mockReturnValue(homeDir);
await mkdir(join(homeDir, '.kimi-code'), { recursive: true });
await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'home branded', 'utf-8');
- const result = await loadAgentsMd(kaos);
+ const result = await loadAgentsMd({ fs, homeDir }, homeDir);
expect(result.split('home branded').length - 1).toBe(1);
});
@@ -132,7 +97,7 @@ describe('loadAgentsMd brand home (KIMI_CODE_HOME)', () => {
await mkdir(join(homeDir, '.agents'), { recursive: true });
await writeFile(join(homeDir, '.agents', 'AGENTS.md'), 'real home generic', 'utf-8');
- const result = await loadAgentsMd(kaos, brandHome);
+ const result = await loadAgentsMd({ fs, homeDir }, workDir, brandHome);
expect(result).toContain('brand home instructions');
expect(result).toContain('real home generic');
@@ -143,7 +108,7 @@ describe('loadAgentsMd brand home (KIMI_CODE_HOME)', () => {
await mkdir(join(homeDir, '.kimi-code'), { recursive: true });
await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'stale real-home brand', 'utf-8');
- const result = await loadAgentsMd(kaos, brandHome);
+ const result = await loadAgentsMd({ fs, homeDir }, workDir, brandHome);
expect(result).toContain('brand wins');
expect(result).not.toContain('stale real-home brand');
@@ -153,7 +118,7 @@ describe('loadAgentsMd brand home (KIMI_CODE_HOME)', () => {
await mkdir(join(homeDir, '.kimi-code'), { recursive: true });
await writeFile(join(homeDir, '.kimi-code', 'AGENTS.md'), 'fallback branded', 'utf-8');
- const result = await loadAgentsMd(kaos);
+ const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain('fallback branded');
});
@@ -170,9 +135,8 @@ describe('loadAgentsMd nested project hierarchy', () => {
await writeFile(join(projectRoot, 'AGENTS.md'), 'root instructions', 'utf-8');
await writeFile(join(projectRoot, 'packages', 'AGENTS.md'), 'packages instructions', 'utf-8');
await writeFile(join(leaf, 'AGENTS.md'), 'leaf instructions', 'utf-8');
- vi.spyOn(kaos, 'getcwd').mockReturnValue(leaf);
- const result = await loadAgentsMd(kaos);
+ const result = await loadAgentsMd({ fs, homeDir }, leaf);
expect(result).toContain('root instructions');
expect(result).toContain('packages instructions');
@@ -187,7 +151,7 @@ describe('loadAgentsMd oversized content', () => {
const largeContent = 'x'.repeat(40 * 1024);
await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8');
- const result = await loadAgentsMd(kaos);
+ const result = await loadAgentsMd({ fs, homeDir }, workDir);
expect(result).toContain(largeContent);
expect(result).not.toContain('truncated or omitted');
@@ -201,7 +165,7 @@ describe('prepareSystemPromptContext AGENTS.md size warning', () => {
const largeContent = 'x'.repeat(40 * 1024);
await writeFile(join(workDir, 'AGENTS.md'), largeContent, 'utf-8');
- const result = await prepareSystemPromptContext(kaos, brandHome);
+ const result = await prepareSystemPromptContext({ fs, homeDir }, workDir, brandHome);
expect(result.agentsMd).toContain(largeContent);
expect(result.agentsMdWarning).toBeDefined();
@@ -213,7 +177,7 @@ describe('prepareSystemPromptContext AGENTS.md size warning', () => {
extraDirs.push(brandHome);
await writeFile(join(workDir, 'AGENTS.md'), 'small instructions', 'utf-8');
- const result = await prepareSystemPromptContext(kaos, brandHome);
+ const result = await prepareSystemPromptContext({ fs, homeDir }, workDir, brandHome);
expect(result.agentsMdWarning).toBeUndefined();
});
@@ -230,7 +194,7 @@ describe('prepareSystemPromptContext additional directories', () => {
await writeFile(join(extraDir, 'AGENTS.md'), 'extra project instructions', 'utf-8');
await writeFile(join(extraDir, 'extra-file.txt'), 'extra listing entry', 'utf-8');
- const result = await prepareSystemPromptContext(kaos, brandHome, {
+ const result = await prepareSystemPromptContext({ fs, homeDir }, workDir, brandHome, {
additionalDirs: [extraDir],
});
@@ -256,7 +220,7 @@ describe('prepareSystemPromptContext additional directories', () => {
await writeFile(join(extraDirA, 'AGENTS.md'), 'extra A instructions', 'utf-8');
await writeFile(join(extraDirB, 'AGENTS.md'), 'extra B instructions', 'utf-8');
- const result = await prepareSystemPromptContext(kaos, brandHome, {
+ const result = await prepareSystemPromptContext({ fs, homeDir }, workDir, brandHome, {
additionalDirs: [extraDirA, extraDirB],
});
diff --git a/packages/agent-core-v2/test/session-lifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/session-lifecycle/sessionLifecycle.test.ts
index 2240dab2e..9b5063cbb 100644
--- a/packages/agent-core-v2/test/session-lifecycle/sessionLifecycle.test.ts
+++ b/packages/agent-core-v2/test/session-lifecycle/sessionLifecycle.test.ts
@@ -8,7 +8,7 @@ import {
} from '#/_base/di/scope';
import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test';
import { IBootstrapService } from '#/app/bootstrap';
-import { IKaosFactory, type IKaos } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionService } from '#/session/session';
import { ISessionLifecycleService } from '#/app/session-lifecycle/sessionLifecycle';
import { SessionLifecycleService } from '#/app/session-lifecycle/sessionLifecycleService';
@@ -38,23 +38,17 @@ function metadataStub(): ISessionMetadata {
};
}
-function kaosFactoryStub(): IKaosFactory {
- const kaos: IKaos = {
- _serviceBrand: undefined,
- name: 'local',
- cwd: '/tmp/proj',
- osEnv: { osKind: 'test', osArch: 'x64', osVersion: '', shellName: 'sh', shellPath: '/bin/sh' },
- backend: undefined as never,
- pathClass: () => 'posix',
- normpath: (p) => p,
- gethome: () => '/home',
- getcwd: () => '/tmp/proj',
- withCwd: (cwd) => ({ ...kaos, cwd, getcwd: () => cwd }),
- withEnv: () => kaos,
- };
+function hostEnvironmentStub(): IHostEnvironment {
return {
_serviceBrand: undefined,
- createLocal: (cwd) => Promise.resolve({ ...kaos, cwd, getcwd: () => cwd }),
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir: '/home',
+ ready: Promise.resolve(),
};
}
@@ -150,7 +144,7 @@ describe('SessionLifecycleService', () => {
host = createScopedTestHost([
stubPair(IBootstrapService, bootstrapStub()),
stubPair(ISessionMetadata, metadataStub()),
- stubPair(IKaosFactory, kaosFactoryStub()),
+ stubPair(IHostEnvironment, hostEnvironmentStub()),
stubPair(ISessionSkillCatalog, skillCatalogStub()),
stubPair(IWorkspaceRegistry, workspaceRegistryStub()),
stubPair(ISessionIndex, sessionIndexStub()),
diff --git a/packages/agent-core-v2/test/session/session-warning.test.ts b/packages/agent-core-v2/test/session/session-warning.test.ts
index 2b37f883d..63fadfaf2 100644
--- a/packages/agent-core-v2/test/session/session-warning.test.ts
+++ b/packages/agent-core-v2/test/session/session-warning.test.ts
@@ -2,8 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
-import { LocalKaos, type Environment, type Kaos } from '@moonshot-ai/kaos';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { InstantiationType } from '#/_base/di/extensions';
import {
@@ -14,45 +13,24 @@ import {
import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IBootstrapService } from '#/app/bootstrap';
-import { IKaos, type IKaos as IKaosType, type PathClass } from '#/app/kaos';
+import { IHostEnvironment } from '#/app/hostEnvironment';
+import { SessionAgentFileSystem, ISessionAgentFileSystem } from '#/session/agentFs';
+import { createExecContext, IExecContext } from '#/session/execContext';
import { IAgentProfileService } from '#/agent/profile';
import { ISessionWarningService, SessionWarningService } from '#/session/session';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
-const TEST_OS_ENV: Environment = {
- osKind: 'Linux',
- osArch: 'x86_64',
- osVersion: 'test',
- shellName: 'bash',
- shellPath: '/bin/bash',
-};
-
-type LocalKaosCtor = new (osEnv: Environment) => LocalKaos;
-
-function realIKaos(cwd: string): IKaosType {
- const backend: Kaos = new (LocalKaos as unknown as LocalKaosCtor)(TEST_OS_ENV);
- return wrapKaos(backend.withCwd(cwd));
-}
-
-function wrapKaos(backend: Kaos): IKaosType {
+function hostEnvironment(homeDir: string): IHostEnvironment {
return {
_serviceBrand: undefined,
- get name() {
- return backend.name;
- },
- get cwd() {
- return backend.getcwd();
- },
- get osEnv() {
- return backend.osEnv;
- },
- backend,
- pathClass: (): PathClass => backend.pathClass(),
- normpath: (path) => backend.normpath(path),
- gethome: () => backend.gethome(),
- getcwd: () => backend.getcwd(),
- withCwd: (cwd) => wrapKaos(backend.withCwd(cwd)),
- withEnv: (env) => wrapKaos(backend.withEnv(env)),
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir,
+ ready: Promise.resolve(),
};
}
@@ -80,14 +58,20 @@ function bootstrapStub(homeDir: string): IBootstrapService {
* the service exercises the on-demand recompute path.
*/
function build(args: {
- kaos: IKaosType;
+ workDir: string;
homeDir: string;
additionalDirs?: readonly string[];
agentLifecycle?: IAgentLifecycleService;
}): { host: ScopedTestHost; service: ISessionWarningService } {
- const host = createScopedTestHost([stubPair(IBootstrapService, bootstrapStub(args.homeDir))]);
+ const host = createScopedTestHost([
+ stubPair(IBootstrapService, bootstrapStub(args.homeDir)),
+ stubPair(IHostEnvironment, hostEnvironment(args.homeDir)),
+ ]);
+ const ctx = createExecContext(args.workDir);
+ const fs: ISessionAgentFileSystem = new SessionAgentFileSystem(ctx);
const session = host.child(LifecycleScope.Session, 's1', [
- stubPair(IKaos, args.kaos),
+ stubPair(IExecContext, ctx),
+ stubPair(ISessionAgentFileSystem, fs),
stubPair(ISessionWorkspaceContext, workspaceStub(args.additionalDirs ?? [])),
stubPair(
IAgentLifecycleService,
@@ -105,7 +89,6 @@ describe('SessionWarningService.getSessionWarnings', () => {
let host: ScopedTestHost | undefined;
let homeDir: string;
let workDir: string;
- let kaos: IKaosType;
beforeEach(async () => {
_clearScopedRegistryForTests();
@@ -118,13 +101,9 @@ describe('SessionWarningService.getSessionWarnings', () => {
);
homeDir = await mkdtemp(join(tmpdir(), 'kimi-warn-home-'));
workDir = await mkdtemp(join(tmpdir(), 'kimi-warn-work-'));
- kaos = realIKaos(workDir);
- // Keep user-level discovery hermetic.
- vi.spyOn(kaos, 'gethome').mockReturnValue(homeDir);
});
afterEach(async () => {
- vi.restoreAllMocks();
host?.dispose();
host = undefined;
await rm(homeDir, { recursive: true, force: true });
@@ -133,7 +112,7 @@ describe('SessionWarningService.getSessionWarnings', () => {
it('returns an agents-md-oversized warning when AGENTS.md exceeds the 32 KB budget', async () => {
await writeFile(join(workDir, 'AGENTS.md'), 'x'.repeat(40 * 1024), 'utf-8');
- const built = build({ kaos, homeDir });
+ const built = build({ workDir, homeDir });
host = built.host;
const warnings = await built.service.getSessionWarnings();
@@ -149,7 +128,7 @@ describe('SessionWarningService.getSessionWarnings', () => {
it('returns no warnings when AGENTS.md is within the budget', async () => {
await writeFile(join(workDir, 'AGENTS.md'), 'small instructions', 'utf-8');
- const built = build({ kaos, homeDir });
+ const built = build({ workDir, homeDir });
host = built.host;
const warnings = await built.service.getSessionWarnings();
@@ -172,7 +151,7 @@ describe('SessionWarningService.getSessionWarnings', () => {
: undefined,
} as unknown as IAgentLifecycleService;
- const built = build({ kaos, homeDir, agentLifecycle });
+ const built = build({ workDir, homeDir, agentLifecycle });
host = built.host;
const warnings = await built.service.getSessionWarnings();
diff --git a/packages/agent-core-v2/test/shellTools/bash.test.ts b/packages/agent-core-v2/test/shellTools/bash.test.ts
index c04384c3f..5fe745678 100644
--- a/packages/agent-core-v2/test/shellTools/bash.test.ts
+++ b/packages/agent-core-v2/test/shellTools/bash.test.ts
@@ -32,25 +32,34 @@ import {
type RegisterBackgroundTaskOptions,
} from '#/agent/background';
import type { BackgroundTaskSettlement } from '#/agent/background/task';
-import type { Environment, IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
+import { createExecContext, type IExecContext } from '#/session/execContext';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import { type BashInput, BashInputSchema, BashTool } from '#/agent/shellTools/tools/bash';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
-const posixEnv: Environment = {
+const posixEnv: IHostEnvironment = {
+ _serviceBrand: undefined,
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
+ pathClass: 'posix',
+ homeDir: '/home/test',
+ ready: Promise.resolve(),
};
-const windowsBashEnv: Environment = {
+const windowsBashEnv: IHostEnvironment = {
+ _serviceBrand: undefined,
osKind: 'Windows',
osArch: 'x64',
osVersion: 'test',
shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe',
shellName: 'bash',
+ pathClass: 'win32',
+ homeDir: 'C:\\Users\\test',
+ ready: Promise.resolve(),
};
// ── Fake IProcess factories ──────────────────────────────────────────
@@ -275,16 +284,14 @@ function processWithOpenStreamsThatExitOnKill(): IProcess {
};
}
-// ── Fake IKaos ───────────────────────────────────────────────────────
+// ── Fake IHostEnvironment / IExecContext ─────────────────────────────
-function createTestKaos(osEnv: Environment = posixEnv, cwd = '/workspace'): IKaos {
- return {
- name: 'fake',
- cwd,
- osEnv,
- pathClass: () => 'posix',
- gethome: () => '/home/test',
- } as unknown as IKaos;
+function createTestEnv(env: IHostEnvironment = posixEnv): IHostEnvironment {
+ return env;
+}
+
+function createTestCtx(cwd = '/workspace'): IExecContext {
+ return createExecContext(cwd);
}
// ── Fake ISessionProcessRunner ──────────────────────────────────────────────
@@ -668,11 +675,12 @@ async function executeTool(
function bashTool(
runner: ISessionProcessRunner,
- kaos: IKaos = createTestKaos(),
+ env: IHostEnvironment = createTestEnv(),
+ ctx: IExecContext = createTestCtx(),
background: IAgentBackgroundService = createFakeBackgroundService().service,
- options?: ConstructorParameters[3],
+ options?: ConstructorParameters[4],
): BashTool {
- return new BashTool(runner, kaos, background, options);
+ return new BashTool(runner, env, ctx, background, options);
}
// ── Tests ────────────────────────────────────────────────────────────
@@ -797,7 +805,7 @@ describe('BashTool', () => {
it('uses the kaos cwd as the default working directory', async () => {
const { runner, exec } = createTestRunner(processWithOutput({ stdout: '' }));
- const tool = bashTool(runner, createTestKaos(posixEnv, '/var/app'));
+ const tool = bashTool(runner, posixEnv, createTestCtx('/var/app'));
await executeTool(tool, context({ command: 'pwd', timeout: 60 }));
@@ -807,7 +815,7 @@ describe('BashTool', () => {
it('uses Git Bash semantics on Windows', async () => {
const proc = processWithOutput({ stdout: 'ok\n' });
const { runner, exec } = createTestRunner(proc);
- const tool = bashTool(runner, createTestKaos(windowsBashEnv, 'C:\\Users\\me\\project'));
+ const tool = bashTool(runner, windowsBashEnv, createTestCtx('C:\\Users\\me\\project'));
const result = await executeTool(tool, context({ command: 'echo ok 2>nul', timeout: 60 }));
@@ -1108,7 +1116,7 @@ describe('BashTool', () => {
it('rewrites nul-redirect on Windows so the spawned argv has /dev/null', async () => {
const { runner, exec } = createTestRunner(processWithOutput({ stdout: '' }));
- const tool = bashTool(runner, createTestKaos(windowsBashEnv, 'C:\\Users\\me\\project'));
+ const tool = bashTool(runner, windowsBashEnv, createTestCtx('C:\\Users\\me\\project'));
await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 }));
@@ -1146,7 +1154,7 @@ describe('BashTool background mode', () => {
const { proc, finish } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 }));
await vi.waitFor(() => {
@@ -1189,7 +1197,7 @@ describe('BashTool background mode', () => {
const { proc, finish } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const started = vi.fn();
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 }, undefined, started));
@@ -1210,7 +1218,7 @@ describe('BashTool background mode', () => {
const { proc } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 1 }));
await vi.waitFor(() => {
@@ -1235,7 +1243,7 @@ describe('BashTool background mode', () => {
const { proc, finish } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service, { allowBackground: () => false });
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service, { allowBackground: () => false });
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 }));
await vi.waitFor(() => {
@@ -1262,7 +1270,7 @@ describe('BashTool background mode', () => {
const { proc, finish } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const running = executeTool(tool, context({ command: 'yes noisy', timeout: 60 }));
await vi.waitFor(() => {
@@ -1304,7 +1312,7 @@ describe('BashTool background mode', () => {
const { runner, exec } = createTestRunner(proc);
const backgroundDisabled = bashTool(
runner,
- createTestKaos(),
+ createTestEnv(), createTestCtx(),
createFakeBackgroundService().service,
{ allowBackground: () => false },
);
@@ -1318,7 +1326,7 @@ describe('BashTool background mode', () => {
expect(exec).not.toHaveBeenCalled();
const { service } = createFakeBackgroundService();
- const withService = bashTool(runner, createTestKaos(), service);
+ const withService = bashTool(runner, createTestEnv(), createTestCtx(), service);
const missingDescription = await executeTool(
withService,
context({ command: 'sleep 10', run_in_background: true }),
@@ -1333,7 +1341,7 @@ describe('BashTool background mode', () => {
const proc = processWithOutput();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@@ -1350,7 +1358,7 @@ describe('BashTool background mode', () => {
service.registerTask(new ProcessBackgroundTask(processWithOutput(), 'sleep 10', 'existing task'));
const rejectedProc = processWithOutput();
const { runner, exec } = createTestRunner(rejectedProc);
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@@ -1373,7 +1381,7 @@ describe('BashTool background mode', () => {
const secondProc = processWithOutput();
const exec = vi.fn().mockResolvedValueOnce(firstProc).mockResolvedValueOnce(secondProc);
const { runner } = createTestRunner(exec);
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const first = executeTool(
tool,
@@ -1405,7 +1413,7 @@ describe('BashTool background mode', () => {
const secondProc = processWithOutput();
const exec = vi.fn().mockResolvedValueOnce(firstProc).mockResolvedValueOnce(secondProc);
const { runner } = createTestRunner(exec);
- const tool = bashTool(runner, createTestKaos(windowsBashEnv, 'C:\\Users\\me\\project'), service);
+ const tool = bashTool(runner, windowsBashEnv, createTestCtx('C:\\Users\\me\\project'), service);
const first = executeTool(
tool,
@@ -1450,7 +1458,7 @@ describe('BashTool background mode', () => {
const { proc, finishWait, markExited } = processWithVisibleExitBeforeWait(0);
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@@ -1486,7 +1494,7 @@ describe('BashTool background mode', () => {
const proc = processThatNeverExits();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@@ -1512,7 +1520,7 @@ describe('BashTool background mode', () => {
const proc = processThatNeverExits();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@@ -1537,7 +1545,7 @@ describe('BashTool background mode', () => {
const proc = processWithOutput();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@@ -1556,7 +1564,7 @@ describe('BashTool background mode', () => {
it('rejects background command without description (description-required guard)', async () => {
const { service } = createFakeBackgroundService();
const { runner, exec } = createTestRunner(processWithOutput());
- const tool = bashTool(runner, createTestKaos(), service);
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(
tool,
@@ -1581,7 +1589,7 @@ describe('BashTool prompt / runtime consistency', () => {
[...enabledTool.description.matchAll(/`(Task[A-Za-z]+)`/g)].map((match) => match[1]),
);
- const tool = bashTool(runner, createTestKaos(), createFakeBackgroundService().service, {
+ const tool = bashTool(runner, createTestEnv(), createTestCtx(), createFakeBackgroundService().service, {
allowBackground: () => false,
});
const result = await executeTool(
diff --git a/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts b/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts
index 3e38caa55..03fe7373a 100644
--- a/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts
+++ b/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts
@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest';
import type { IAgentBackgroundService } from '#/agent/background';
import type { IDisposable } from '#/_base/di';
-import type { IKaos } from '#/app/kaos';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
+import { createExecContext, type IExecContext } from '#/session/execContext';
import type { ISessionProcessRunner } from '#/session/process';
import type { IAgentProfileService } from '#/agent/profile';
import { AgentShellToolsService } from '#/agent/shellTools';
@@ -22,11 +23,18 @@ function fakeToolRegistry(): { registry: IAgentToolRegistryService; names: () =>
}
const fakeRunner = {} as unknown as ISessionProcessRunner;
-const fakeKaos = {
- cwd: '/workspace',
- osEnv: { osKind: 'Linux', osArch: 'x64', osVersion: '', shellName: 'bash', shellPath: '/bin/bash' },
- pathClass: () => 'posix',
-} as unknown as IKaos;
+const fakeEnv: IHostEnvironment = {
+ _serviceBrand: undefined,
+ osKind: 'Linux',
+ osArch: 'x64',
+ osVersion: '',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir: '/home',
+ ready: Promise.resolve(),
+};
+const fakeCtx: IExecContext = createExecContext('/workspace');
const fakeBackground = {} as unknown as IAgentBackgroundService;
const fakeProfile = {
isToolActive: () => true,
@@ -35,7 +43,7 @@ const fakeProfile = {
describe('AgentShellToolsService', () => {
it('registers Bash into the tool registry', () => {
const { registry, names } = fakeToolRegistry();
- new AgentShellToolsService(registry, fakeRunner, fakeKaos, fakeBackground, fakeProfile);
+ new AgentShellToolsService(registry, fakeRunner, fakeEnv, fakeCtx, fakeBackground, fakeProfile);
expect(names()).toEqual(['Bash']);
});
});
diff --git a/packages/agent-core-v2/test/skill/skill-tool-manager.test.ts b/packages/agent-core-v2/test/skill/skill-tool-manager.test.ts
index 3a029be45..687b4d87c 100644
--- a/packages/agent-core-v2/test/skill/skill-tool-manager.test.ts
+++ b/packages/agent-core-v2/test/skill/skill-tool-manager.test.ts
@@ -14,14 +14,13 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import {
InMemoryWireRecordPersistence,
createTestAgent,
- kaosServices,
+ execEnvServices,
skillServices,
telemetryServices,
wireRecordPersistenceServices,
type TestAgentContext,
} from '../harness';
import { recordingTelemetry } from '../telemetry/stubs';
-import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { stubSkill } from './stubs';
function makeSkill(name: string, metadata: SkillDefinition['metadata'] = {}): SkillDefinition {
@@ -395,7 +394,7 @@ describe('ToolManager SkillTool workspace refresh', () => {
skills.register(skill);
ctx = createTestAgent(
- kaosServices(createFakeKaos().withCwd(workDir)),
+ execEnvServices({ execContext: { cwd: workDir } }),
skillServices(skills),
);
profile = ctx.get(IAgentProfileService);
diff --git a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts
new file mode 100644
index 000000000..6e9c3ad26
--- /dev/null
+++ b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts
@@ -0,0 +1,153 @@
+/**
+ * Fake execution-environment atoms — minimal stubs for tool constructor
+ * injection in tests.
+ *
+ * Replaces the old `fake-kaos.ts` fixture. The v2 tools no longer take a
+ * single god-object `IKaos`; instead they receive the pieces they actually
+ * use:
+ *
+ * - `IHostEnvironment` (App-scope) — sync OS/shell/path/home facts.
+ * - `IExecContext` (Session-scope) — the session cwd and env layers.
+ * - `ISessionAgentFileSystem` (Session-scope) — file IO.
+ * - `ISessionProcessRunner` (Session-scope) — process spawn.
+ *
+ * The `createFake*` factories default every method to a "not implemented"
+ * throw; individual tests override the specific methods they exercise with
+ * `vi.fn()`.
+ *
+ * Also re-exports `PERMISSIVE_WORKSPACE` (`/` as workspaceDir) — most tool
+ * tests care about behaviour, not path safety, so they default to a
+ * workspace that accepts any absolute path. Attack-vector tests create
+ * their own `WorkspaceConfig` with narrower bounds.
+ */
+
+import type { ExecutableToolResult } from '#/agent/tool';
+import type { IHostEnvironment } from '#/app/hostEnvironment';
+import type { ISessionAgentFileSystem } from '#/session/agentFs';
+import { createExecContext, type IExecContext } from '#/session/execContext';
+import type { ISessionProcessRunner } from '#/session/process';
+
+import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
+
+// ── Host environment ─────────────────────────────────────────────────
+
+export const FAKE_HOST_ENVIRONMENT: IHostEnvironment = {
+ _serviceBrand: undefined,
+ osKind: 'Linux',
+ osArch: 'x86_64',
+ osVersion: 'test',
+ shellName: 'bash',
+ shellPath: '/bin/bash',
+ pathClass: 'posix',
+ homeDir: '/home/test',
+ ready: Promise.resolve(),
+};
+
+export function createFakeHostEnvironment(
+ overrides?: Partial,
+): IHostEnvironment {
+ return { ...FAKE_HOST_ENVIRONMENT, ...overrides };
+}
+
+// ── Exec context ─────────────────────────────────────────────────────
+
+export function createFakeExecContext(
+ cwd: string = '/workspace',
+ envLayers: readonly Record[] = [],
+): IExecContext {
+ return createExecContext(cwd, envLayers);
+}
+
+// ── Process runner ───────────────────────────────────────────────────
+
+function notImplemented(surface: string, method: string): never {
+ throw new Error(`${surface}.${method} not implemented — override in test`);
+}
+
+/**
+ * Fake `ISessionProcessRunner`. `exec` throws by default; tests override with
+ * `vi.fn()`. `envLayers` preserves the merge behaviour that the old
+ * `createFakeKaos.execWithEnv` provided — extra layers are applied on top of
+ * the per-call `options.env`, later layers winning, mirroring how the real
+ * `IExecContext` overlays env for every spawned process.
+ */
+export function createFakeProcessRunner(
+ overrides?: Partial,
+ envLayers: readonly Record[] = [],
+): ISessionProcessRunner {
+ const baseExec: ISessionProcessRunner['exec'] = async (args, options) => {
+ if (overrides?.exec !== undefined) {
+ const mergedEnv = mergeEnvLayers(options?.env, envLayers);
+ return overrides.exec(
+ args,
+ mergedEnv !== options?.env ? { ...options, env: mergedEnv } : options,
+ );
+ }
+ return notImplemented('FakeProcessRunner', 'exec');
+ };
+ return {
+ _serviceBrand: undefined,
+ ...overrides,
+ exec: baseExec,
+ };
+}
+
+function mergeEnvLayers(
+ invocationEnv: Record | undefined,
+ envLayers: readonly Record[],
+): Record | undefined {
+ if (envLayers.length === 0) return invocationEnv;
+ const merged: Record = { ...invocationEnv };
+ for (const layer of envLayers) Object.assign(merged, layer);
+ return merged;
+}
+
+// ── Agent filesystem ─────────────────────────────────────────────────
+
+/**
+ * Fake `ISessionAgentFileSystem`. Every method throws by default; tests
+ * override the specific ones they exercise. `withCwd` returns a fresh fake
+ * with the new `cwd` baked in but the same overrides, matching how
+ * consumers use it in tests.
+ */
+export function createFakeAgentFs(
+ overrides?: Partial,
+ cwd: string = '/workspace',
+): ISessionAgentFileSystem {
+ const fake: ISessionAgentFileSystem = {
+ _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: (next: string) => createFakeAgentFs(overrides, next),
+ ...overrides,
+ };
+ return fake;
+}
+
+// ── Test-wide helpers ────────────────────────────────────────────────
+
+export const PERMISSIVE_WORKSPACE: WorkspaceConfig = {
+ workspaceDir: '/',
+ additionalDirs: [],
+};
+
+/**
+ * Assert that a `ToolResult`'s `content` is a string and return it.
+ * Keeps the lint rule `typescript-eslint(no-base-to-string)` happy by
+ * narrowing the `string | ToolResultContent[]` union in one place.
+ */
+export function toolContentString(result: ExecutableToolResult): string {
+ const c = result.output;
+ if (typeof c !== 'string') {
+ throw new TypeError(`expected string content, got ${typeof c}`);
+ }
+ return c;
+}
diff --git a/packages/agent-core-v2/test/tools/fixtures/fake-kaos.ts b/packages/agent-core-v2/test/tools/fixtures/fake-kaos.ts
deleted file mode 100644
index 125402b64..000000000
--- a/packages/agent-core-v2/test/tools/fixtures/fake-kaos.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * Fake Kaos — minimal stub for tool constructor injection in tests.
- *
- * All methods throw by default. Individual tests can override specific
- * methods with vi.fn() to provide scripted responses for the tool
- * under test.
- *
- * Also provides `PERMISSIVE_WORKSPACE` (`/` as workspaceDir) — most tool
- * tests care about behaviour, not path safety, so they default to a
- * workspace that accepts any absolute path. Attack-vector tests create
- * their own `WorkspaceConfig` with narrower bounds.
- */
-
-import type { Environment, Kaos } from '@moonshot-ai/kaos';
-import type { ExecutableToolResult } from '#/agent/tool';
-
-import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
-
-function notImplemented(method: string): never {
- throw new Error(`FakeKaos.${method} not implemented — override in test`);
-}
-
-export const FAKE_OS_ENV: Environment = {
- osKind: 'Linux',
- osArch: 'x86_64',
- osVersion: 'test',
- shellName: 'bash',
- shellPath: '/bin/bash',
-};
-
-export function createFakeKaos(
- overrides?: Partial,
- envLayers: readonly Record[] = [],
-): Kaos {
- // Hold cwd in a closure so `chdir` (which `config.update({cwd})` now
- // routes through) can mutate it and later `getcwd()` calls see the
- // update — mirroring real-kaos semantics without needing a backing fs.
- let cwd = overrides?.getcwd?.() ?? '/workspace';
- const base: Kaos = {
- name: 'fake',
- osEnv: FAKE_OS_ENV,
- pathClass: () => 'posix',
- normpath: (p: string) => p,
- gethome: () => '/home/test',
- getcwd: () => cwd,
- withCwd: (next: string) => createFakeKaos({ ...overrides, getcwd: () => next }, envLayers),
- withEnv: (env: Record) =>
- createFakeKaos({ ...overrides, getcwd: () => cwd }, [...envLayers, env]),
- chdir: async (next: string) => {
- cwd = next;
- },
- stat: () => notImplemented('stat'),
- iterdir: () => notImplemented('iterdir'),
- glob: () => notImplemented('glob'),
- readBytes: () => notImplemented('readBytes'),
- readText: () => notImplemented('readText'),
- readLines: () => notImplemented('readLines'),
- writeBytes: () => notImplemented('writeBytes'),
- writeText: () => notImplemented('writeText'),
- mkdir: () => notImplemented('mkdir'),
- exec: () => notImplemented('exec'),
- execWithEnv: (args, invocationEnv) => {
- const mergedEnv = mergeEnvLayers(invocationEnv, envLayers);
- if (overrides?.execWithEnv) return overrides.execWithEnv(args, mergedEnv);
- return notImplemented('execWithEnv');
- },
- };
- return {
- ...base,
- ...overrides,
- execWithEnv: base.execWithEnv,
- withCwd: base.withCwd,
- withEnv: base.withEnv,
- } as Kaos;
-}
-
-function mergeEnvLayers(
- invocationEnv: Record | undefined,
- envLayers: readonly Record[],
-): Record | undefined {
- if (envLayers.length === 0) return invocationEnv;
- const merged: Record = { ...invocationEnv };
- for (const layer of envLayers) {
- Object.assign(merged, layer);
- }
- return merged;
-}
-
-export const PERMISSIVE_WORKSPACE: WorkspaceConfig = {
- workspaceDir: '/',
- additionalDirs: [],
-};
-
-/**
- * Assert that a `ToolResult`'s `content` is a string and return it.
- * Keeps the lint rule `typescript-eslint(no-base-to-string)` happy by
- * narrowing the `string | ToolResultContent[]` union in one place.
- */
-export function toolContentString(result: ExecutableToolResult): string {
- const c = result.output;
- if (typeof c !== 'string') {
- throw new TypeError(`expected string content, got ${typeof c}`);
- }
- return c;
-}
diff --git a/packages/agent-core-v2/test/wireRecord/resume.test.ts b/packages/agent-core-v2/test/wireRecord/resume.test.ts
index 4d7ae03af..fcf9f8682 100644
--- a/packages/agent-core-v2/test/wireRecord/resume.test.ts
+++ b/packages/agent-core-v2/test/wireRecord/resume.test.ts
@@ -18,12 +18,12 @@ import {
createBackgroundTaskPersistence,
type BackgroundServiceTestManager,
} from '../background/stubs';
-import { createFakeKaos } from '../tools/fixtures/fake-kaos';
+import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import {
DEFAULT_TEST_SYSTEM_PROMPT,
InMemoryWireRecordPersistence,
+ execEnvServices,
homeDirServices,
- kaosServices,
testAgent,
} from '../harness';
@@ -65,7 +65,10 @@ describe('Agent resume', () => {
const persistence = new RecordingAgentPersistence(resumeHistory() as unknown as PersistedWireRecord[]);
const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute on resume'));
const ctx = testAgent(
- kaosServices(createFakeKaos({ execWithEnv, readText: vi.fn().mockResolvedValue('') })),
+ execEnvServices({
+ agentFs: createFakeAgentFs({ readText: vi.fn().mockResolvedValue('') }),
+ processRunner: createFakeProcessRunner({ exec: execWithEnv }),
+ }),
{ autoConfigure: false, persistence },
);
diff --git a/packages/agent-core-v2/tsdown.config.ts b/packages/agent-core-v2/tsdown.config.ts
index cb1685162..ddab1ca7a 100644
--- a/packages/agent-core-v2/tsdown.config.ts
+++ b/packages/agent-core-v2/tsdown.config.ts
@@ -12,7 +12,6 @@ export default defineConfig({
deps: {
neverBundle: [
'@moonshot-ai/kosong',
- '@moonshot-ai/kaos',
'@moonshot-ai/kimi-code-oauth',
'@moonshot-ai/kimi-telemetry',
],