From 3606488ea245b46d204dd5e84e652192950a67a6 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 1 Jul 2026 21:40:46 +0800 Subject: [PATCH] refactor(agent-core-v2): drop @moonshot-ai/kaos, split IKaos into atoms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the god-object `IKaos` interface and the `@moonshot-ai/kaos` package dependency with atomic services: - `IHostEnvironment` (App scope) — memoised OS / shell / path-style / home probe; sync fields plus a `ready` promise the composition root awaits before seeding Session scopes. - `IExecContext` (Session scope) — immutable cwd + env-layers seed value with `withCwd` / `withEnv` derivations; replaces `IKaos.cwd/withCwd/withEnv` and `IKaosFactory`. - `ISessionAgentFileSystem` / `ISessionProcessRunner` — inline `node:fs/promises` and `child_process.spawn` directly; the `IKaos.backend` leak that let `profile/context.ts`, `fileTools/glob` and `agentFs/runRg` reach around the facades is gone. Vendor the pure helpers into `src/_base/execEnv/` (`BufferedReadable`, `decodeTextWithErrors`, `globPatternToRegex`, host-environment probe). `src/app/kaos/` and `test/kaos/` are deleted; session-lifecycle awaits `hostEnv.ready` and then seeds `IExecContext` into the child scope. Rewrites every `@IKaos` consumer: - `shellTools/bash`, `fileTools/{read,write,edit,glob,grep}`, `media/read-media`, `_base/tools/policies/path-access` - `permissionPolicy/git-cwd-write-approve` and `permissionPolicy/git-control-path-access-ask` - `agent/rpc`, `agent/agentTool`, `agent/profile/{context,profileService}` - `session/{sessionWarning,workspaceContext}` - `app/bootstrap` (drops `IBootstrapService.detect()`) - `fileTools/glob` and `agentFs/runRg` now route rg through `ISessionProcessRunner.exec` with per-call `{ cwd }` overrides `test/tools/fixtures/fake-kaos.ts` is replaced by `fake-exec.ts` with per-atom factories (`createFakeHostEnvironment`, `createFakeExecContext`, `createFakeAgentFs`, `createFakeProcessRunner`). Domain-layer registry, PUML diagram, tsdown externals and `package.json` deps are updated accordingly. `packages/kaos/` stays in the workspace — v1, node-sdk and acp-adapter still depend on it. Only `agent-core-v2` has been detached. --- .../agent-core-v2/docs/di-scope-domains.puml | 18 +- packages/agent-core-v2/package.json | 1 - .../scripts/check-domain-layers.mjs | 2 + .../src/_base/execEnv/bufferedReadable.ts | 64 +++ .../src/_base/execEnv/decodeText.ts | 187 ++++++++ .../src/_base/execEnv/environmentProbe.ts | 340 ++++++++++++++ .../src/_base/execEnv/globPattern.ts | 66 +++ .../agent-core-v2/src/_base/execEnv/index.ts | 22 + .../src/_base/tools/policies/path-access.ts | 16 +- .../src/agent/fileTools/fileToolsService.ts | 19 +- .../src/agent/fileTools/tools/edit.ts | 16 +- .../src/agent/fileTools/tools/glob.ts | 52 +-- .../src/agent/fileTools/tools/grep.ts | 6 +- .../src/agent/fileTools/tools/read.ts | 13 +- .../src/agent/fileTools/tools/write.ts | 12 +- .../src/agent/media/registerMediaTools.ts | 6 +- .../src/agent/media/tools/read-media.ts | 10 +- .../policies/git-control-path-access-ask.ts | 8 +- .../policies/git-cwd-write-approve.ts | 8 +- .../src/agent/profile/context.ts | 109 +++-- .../src/agent/profile/profileService.ts | 19 +- .../agent-core-v2/src/agent/rpc/rpcService.ts | 38 -- .../src/agent/shellTools/shellToolsService.ts | 12 +- .../src/agent/shellTools/tools/bash.ts | 21 +- .../src/app/bootstrap/bootstrap.ts | 9 +- .../src/app/bootstrap/bootstrapService.ts | 11 +- .../app/hostEnvironment/hostEnvironment.ts | 61 +++ .../hostEnvironment/hostEnvironmentService.ts | 78 ++++ .../src/app/hostEnvironment/index.ts | 9 + packages/agent-core-v2/src/app/kaos/index.ts | 8 - packages/agent-core-v2/src/app/kaos/kaos.ts | 61 --- .../src/app/kaos/kaosFactoryService.ts | 31 -- .../agent-core-v2/src/app/kaos/kaosService.ts | 54 --- .../sessionLifecycleService.ts | 15 +- .../src/session/agentFs/agentFs.ts | 21 +- .../src/session/agentFs/agentFsService.ts | 423 +++++++++++++++-- .../src/session/agentFs/rgLocator.ts | 8 +- .../src/session/agentFs/runRg.ts | 25 +- .../src/session/execContext/execContext.ts | 71 +++ .../src/session/execContext/index.ts | 7 + .../src/session/process/process.ts | 2 +- .../session/process/processRunnerService.ts | 66 ++- .../src/session/process/spawnedProcess.ts | 161 +++++++ .../session/session/sessionWarningService.ts | 19 +- .../workspaceContextService.ts | 6 +- .../test/agentFs/agentFsService.test.ts | 19 +- .../background/foreground-persistence.test.ts | 22 +- .../agent-core-v2/test/background/ids.test.ts | 10 +- .../test/background/manager.test.ts | 70 +-- .../test/background/output-access.test.ts | 12 +- .../test/background/rpc-events.test.ts | 18 +- .../test/bootstrap/bootstrapService.test.ts | 11 - .../agent-core-v2/test/bootstrap/stubs.ts | 3 - .../agent-core-v2/test/config/config.test.ts | 9 +- .../agent-core-v2/test/fileTools/edit.test.ts | 61 +-- .../test/fileTools/fileToolsService.test.ts | 23 +- .../agent-core-v2/test/fileTools/glob.test.ts | 432 +++++++----------- .../agent-core-v2/test/fileTools/grep.test.ts | 61 +-- .../agent-core-v2/test/fileTools/read.test.ts | 51 ++- .../test/fileTools/write.test.ts | 21 +- .../test/kaos/kaosFactory.test.ts | 61 --- .../test/media/read-media.test.ts | 27 +- .../test/permission/permissionGate.test.ts | 6 +- .../permission-policy-service.test.ts | 34 +- .../agent-core-v2/test/plan/injection.test.ts | 28 +- .../test/plan/plan-tools-telemetry.test.ts | 12 +- packages/agent-core-v2/test/plan/plan.test.ts | 145 +++--- .../test/process/processRunnerService.test.ts | 19 +- .../test/profile/apply-profile.test.ts | 41 +- .../test/profile/context.test.ts | 88 ++-- .../sessionLifecycle.test.ts | 28 +- .../test/session/session-warning.test.ts | 71 +-- .../test/shellTools/bash.test.ts | 78 ++-- .../test/shellTools/shellToolsService.test.ts | 22 +- .../test/skill/skill-tool-manager.test.ts | 5 +- .../test/tools/fixtures/fake-exec.ts | 153 +++++++ .../test/tools/fixtures/fake-kaos.ts | 105 ----- .../test/wireRecord/resume.test.ts | 9 +- packages/agent-core-v2/tsdown.config.ts | 1 - 79 files changed, 2587 insertions(+), 1380 deletions(-) create mode 100644 packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts create mode 100644 packages/agent-core-v2/src/_base/execEnv/decodeText.ts create mode 100644 packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts create mode 100644 packages/agent-core-v2/src/_base/execEnv/globPattern.ts create mode 100644 packages/agent-core-v2/src/_base/execEnv/index.ts create mode 100644 packages/agent-core-v2/src/app/hostEnvironment/hostEnvironment.ts create mode 100644 packages/agent-core-v2/src/app/hostEnvironment/hostEnvironmentService.ts create mode 100644 packages/agent-core-v2/src/app/hostEnvironment/index.ts delete mode 100644 packages/agent-core-v2/src/app/kaos/index.ts delete mode 100644 packages/agent-core-v2/src/app/kaos/kaos.ts delete mode 100644 packages/agent-core-v2/src/app/kaos/kaosFactoryService.ts delete mode 100644 packages/agent-core-v2/src/app/kaos/kaosService.ts create mode 100644 packages/agent-core-v2/src/session/execContext/execContext.ts create mode 100644 packages/agent-core-v2/src/session/execContext/index.ts create mode 100644 packages/agent-core-v2/src/session/process/spawnedProcess.ts delete mode 100644 packages/agent-core-v2/test/kaos/kaosFactory.test.ts create mode 100644 packages/agent-core-v2/test/tools/fixtures/fake-exec.ts delete mode 100644 packages/agent-core-v2/test/tools/fixtures/fake-kaos.ts 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', ],