mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 14:47:17 +00:00
refactor(agent-core-v2): drop @moonshot-ai/kaos, split IKaos into atoms
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.
This commit is contained in:
parent
e497dd00a4
commit
3606488ea2
79 changed files with 2587 additions and 1380 deletions
|
|
@ -31,7 +31,7 @@ package "App scope (process-wide)" #EAF3FB {
|
|||
rectangle "<b>hostFs</b>\n<size:9><i>App</i></size>\n IHostFileSystem" as hostFs #D6EAF8
|
||||
rectangle "<b>workspaceRegistry</b>\n<size:9><i>App</i></size>\n IWorkspaceRegistry" as workspaceRegistry #D6EAF8
|
||||
rectangle "<b>hostFolderBrowser</b>\n<size:9><i>App</i></size>\n IHostFolderBrowser" as hostFolderBrowser #D6EAF8
|
||||
rectangle "<b>kaos</b>\n<size:9><i>App</i></size>\n IKaosFactory" as kaos_core #D6EAF8
|
||||
rectangle "<b>hostEnvironment</b>\n<size:9><i>App</i></size>\n IHostEnvironment" as hostEnvironment #D6EAF8
|
||||
rectangle "<b>auth</b>\n<size:9><i>App</i></size>\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
|
||||
rectangle "<b>provider</b>\n<size:9><i>App</i></size>\n IProviderService" as provider #D6EAF8
|
||||
rectangle "<b>flag</b>\n<size:9><i>App</i></size>\n IFlagService\n IFlagRegistry" as flag #D6EAF8
|
||||
|
|
@ -52,7 +52,7 @@ package "Session scope (per session)" #EAFAF1 {
|
|||
rectangle "<b>workspaceContext</b>\n<size:9><i>Session</i></size>\n IWorkspaceContext" as workspaceContext #D5F5E3
|
||||
rectangle "<b>sessionLog</b>\n<size:9><i>Session</i></size>\n ISessionLogService\n ILogWriterService" as sessionLog #D5F5E3
|
||||
rectangle "<b>sessionSkillCatalog</b>\n<size:9><i>Session</i></size>\n ISessionSkillCatalog" as sessionSkillCatalog #D5F5E3
|
||||
rectangle "<b>kaos</b>\n<size:9><i>Session</i></size>\n IKaos (seed)" as kaos #D5F5E3
|
||||
rectangle "<b>execContext</b>\n<size:9><i>Session</i></size>\n IExecContext (seed)" as execContext #D5F5E3
|
||||
rectangle "<b>agentFs</b>\n<size:9><i>Session</i></size>\n IAgentFileSystem\n IFsService" as agentFs #D5F5E3
|
||||
rectangle "<b>approval</b>\n<size:9><i>Session</i></size>\n IApprovalService" as approval #D5F5E3
|
||||
rectangle "<b>question</b>\n<size:9><i>Session</i></size>\n IQuestionService" as question #D5F5E3
|
||||
|
|
@ -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) ----
|
||||
|
|
|
|||
|
|
@ -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:^",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
64
packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts
Normal file
64
packages/agent-core-v2/src/_base/execEnv/bufferedReadable.ts
Normal file
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
187
packages/agent-core-v2/src/_base/execEnv/decodeText.ts
Normal file
187
packages/agent-core-v2/src/_base/execEnv/decodeText.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
340
packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts
Normal file
340
packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts
Normal file
|
|
@ -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<string, string | undefined>;
|
||||
readonly isFile: (path: string) => Promise<boolean>;
|
||||
readonly execFileText: (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
) => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
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<HostEnvironmentInfo> {
|
||||
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<string> {
|
||||
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<string | undefined> {
|
||||
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 `<root>\cmd\git.exe`, with
|
||||
// bash at `<root>\bin\bash.exe`. Portable installs sometimes put both in
|
||||
// `<root>\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<string>();
|
||||
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<HostEnvironmentInfo> | undefined;
|
||||
|
||||
export function probeHostEnvironmentFromNode(): Promise<HostEnvironmentInfo> {
|
||||
if (cachedProbe !== undefined) return cachedProbe;
|
||||
const platform = process.platform;
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
const isFile = async (path: string): Promise<boolean> => {
|
||||
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<boolean>,
|
||||
): Promise<readonly string[]> {
|
||||
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<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
nodeExecFile(
|
||||
file,
|
||||
[...args],
|
||||
{ encoding: 'utf8', timeout: timeoutMs, windowsHide: true },
|
||||
(error, stdout) => {
|
||||
if (error !== null) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
66
packages/agent-core-v2/src/_base/execEnv/globPattern.ts
Normal file
66
packages/agent-core-v2/src/_base/execEnv/globPattern.ts
Normal file
|
|
@ -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');
|
||||
}
|
||||
22
packages/agent-core-v2/src/_base/execEnv/index.ts
Normal file
22
packages/agent-core-v2/src/_base/execEnv/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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<IKaos, 'pathClass' | 'gethome'>;
|
||||
readonly env: Pick<IHostEnvironment, 'pathClass' | 'homeDir'>;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<EditInput> {
|
|||
|
||||
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<EditInput> {
|
|||
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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<GlobInput> {
|
|||
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<GlobInput> {
|
|||
// 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<GlobInput> {
|
|||
// 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<GlobInput> {
|
|||
// 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<GlobInput> {
|
|||
// 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<GlobInput> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
|
|
|||
|
|
@ -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<GrepInput> {
|
|||
readonly parameters: Record<string, unknown> = 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<GrepInput> {
|
|||
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 },
|
||||
|
|
|
|||
|
|
@ -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<ReadInput> {
|
|||
readonly parameters: Record<string, unknown> = 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<ReadInput> {
|
|||
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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<WriteInput> {
|
|||
|
||||
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<WriteInput> {
|
|||
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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ReadMediaFileInput> {
|
|||
readonly parameters: Record<string, unknown> = 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<ReadMediaFileInput> {
|
|||
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<ReadMediaFileInput> {
|
|||
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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<PermissionPolicyResult | undefined> {
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PermissionPolicyResult | undefined> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<PreparedSystemPromptContext> {
|
||||
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<string> {
|
||||
const result = await loadAgentsMdForRoots(kaos, brandHome, [kaos.getcwd()]);
|
||||
export async function loadAgentsMd(
|
||||
deps: ProfileContextDeps,
|
||||
workDir: string,
|
||||
brandHome?: string,
|
||||
): Promise<string> {
|
||||
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<LoadedAgentsMd> {
|
||||
|
|
@ -83,9 +97,9 @@ async function loadAgentsMdForRoots(
|
|||
const seen = new Set<string>();
|
||||
|
||||
const collect = async (path: string): Promise<boolean> => {
|
||||
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<string> {
|
||||
async function loadAdditionalDirsInfo(
|
||||
deps: ProfileContextDeps,
|
||||
additionalDirs: readonly string[],
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
const initial = kaos.normpath(workDir);
|
||||
async function findProjectRoot(deps: ProfileContextDeps, workDir: string): Promise<string> {
|
||||
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<AgentFile | undefined> {
|
||||
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<AgentFile | undefined> {
|
||||
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<boolean> {
|
||||
async function pathExists(deps: ProfileContextDeps, path: string): Promise<boolean> {
|
||||
try {
|
||||
await kaos.backend.stat(path);
|
||||
await deps.fs.stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isFile(kaos: IKaos, path: string): Promise<boolean> {
|
||||
async function isFile(deps: ProfileContextDeps, path: string): Promise<boolean> {
|
||||
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<string> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<string, AbortController>();
|
||||
|
|
@ -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<string> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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<BashInput> {
|
|||
|
||||
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<BashInput> {
|
|||
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<BashInput> {
|
|||
private spawn(effectiveCwd: string, command: string): Promise<IProcess> {
|
||||
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<BashInput> {
|
|||
// 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<BashInput> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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<Environment>;
|
||||
}
|
||||
|
||||
export const IBootstrapService: ServiceIdentifier<IBootstrapService> =
|
||||
|
|
|
|||
|
|
@ -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<Environment>;
|
||||
|
||||
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<Environment> {
|
||||
this.detected ??= detectEnvironmentFromNode();
|
||||
return this.detected;
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(LifecycleScope.App, IBootstrapService, BootstrapService, InstantiationType.Eager, 'bootstrap');
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
export const IHostEnvironment: ServiceIdentifier<IHostEnvironment> =
|
||||
createDecorator<IHostEnvironment>('hostEnvironment');
|
||||
|
|
@ -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<void>;
|
||||
|
||||
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',
|
||||
);
|
||||
9
packages/agent-core-v2/src/app/hostEnvironment/index.ts
Normal file
9
packages/agent-core-v2/src/app/hostEnvironment/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<string, string>): IKaos;
|
||||
}
|
||||
|
||||
export const IKaos: ServiceIdentifier<IKaos> = createDecorator<IKaos>('kaos');
|
||||
|
||||
export interface IKaosFactory {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Build a local execution environment rooted at `cwd`. */
|
||||
createLocal(cwd: string): Promise<IKaos>;
|
||||
}
|
||||
|
||||
export const IKaosFactory: ServiceIdentifier<IKaosFactory> =
|
||||
createDecorator<IKaosFactory>('kaosFactory');
|
||||
|
|
@ -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<IKaos> {
|
||||
const base = await LocalKaos.create();
|
||||
return new KaosService(base.withCwd(cwd));
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IKaosFactory,
|
||||
KaosFactory,
|
||||
InstantiationType.Delayed,
|
||||
'kaos',
|
||||
);
|
||||
|
|
@ -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<string, string>): IKaos {
|
||||
return new KaosService(this.backend.withEnv(env));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
writeText(path: string, data: string): Promise<void>;
|
||||
readText(
|
||||
path: string,
|
||||
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
|
||||
): Promise<string>;
|
||||
writeText(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
|
||||
): Promise<void>;
|
||||
readBytes(path: string, n?: number): Promise<Uint8Array>;
|
||||
readLines(
|
||||
path: string,
|
||||
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
|
||||
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
|
||||
): AsyncGenerator<string>;
|
||||
writeBytes(path: string, data: Uint8Array): Promise<void>;
|
||||
stat(path: string): Promise<AgentFileStat>;
|
||||
|
|
|
|||
|
|
@ -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<AgentFileStat, 'isFile' | 'isDirectory'> {
|
||||
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 `"<dev>:0"`.
|
||||
*/
|
||||
function cycleKey(s: { dev: number; ino: number }): string | null {
|
||||
if (s.ino === 0) return null;
|
||||
return `${String(s.dev)}:${String(s.ino)}`;
|
||||
}
|
||||
|
||||
function 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<string> {
|
||||
if (text.length === 0) return;
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text.codePointAt(i) === 0x0a) {
|
||||
yield text.slice(start, i + 1);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (start < text.length) {
|
||||
yield text.slice(start);
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionAgentFileSystem implements ISessionAgentFileSystem {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(@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<string> {
|
||||
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<void> {
|
||||
return this.kaos.backend.writeText(path, data).then(() => undefined);
|
||||
}
|
||||
|
||||
readBytes(path: string, n?: number): Promise<Uint8Array> {
|
||||
return this.kaos.backend.readBytes(path, n);
|
||||
}
|
||||
|
||||
readLines(
|
||||
async readText(
|
||||
path: string,
|
||||
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
|
||||
): AsyncGenerator<string> {
|
||||
return this.kaos.backend.readLines(path, options);
|
||||
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
|
||||
): Promise<string> {
|
||||
const resolved = this._resolvePath(path);
|
||||
const encoding = options?.encoding ?? 'utf-8';
|
||||
const errors = options?.errors ?? 'strict';
|
||||
const data = await readFile(resolved);
|
||||
return decodeTextWithErrors(data, encoding, errors);
|
||||
}
|
||||
|
||||
writeBytes(path: string, data: Uint8Array): Promise<void> {
|
||||
return this.kaos.backend.writeBytes(path, Buffer.from(data)).then(() => undefined);
|
||||
async writeText(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding },
|
||||
): Promise<void> {
|
||||
const resolved = this._resolvePath(path);
|
||||
const encoding = options?.encoding ?? 'utf-8';
|
||||
const mode = options?.mode ?? 'w';
|
||||
if (mode === 'a') {
|
||||
await appendFile(resolved, data, encoding);
|
||||
} else {
|
||||
await writeFile(resolved, data, encoding);
|
||||
}
|
||||
}
|
||||
|
||||
async readBytes(path: string, n?: number): Promise<Uint8Array> {
|
||||
const resolved = this._resolvePath(path);
|
||||
if (n === undefined) {
|
||||
return Buffer.from(await readFile(resolved));
|
||||
}
|
||||
const fh = await open(resolved, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(n);
|
||||
const { bytesRead } = await fh.read(buf, 0, n, 0);
|
||||
return buf.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
async *readLines(
|
||||
path: string,
|
||||
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
|
||||
): AsyncGenerator<string> {
|
||||
const resolved = this._resolvePath(path);
|
||||
const encoding = options?.encoding ?? 'utf-8';
|
||||
const errors = options?.errors ?? 'strict';
|
||||
|
||||
if (!isUtf8Encoding(encoding)) {
|
||||
const content = decodeTextWithErrors(await readFile(resolved), encoding, errors);
|
||||
yield* splitLinesKeepingTerminator(content);
|
||||
return;
|
||||
}
|
||||
|
||||
yield* this._readUtf8Lines(resolved, errors);
|
||||
}
|
||||
|
||||
private async *_readUtf8Lines(
|
||||
resolved: string,
|
||||
errors: TextDecodeErrors,
|
||||
): AsyncGenerator<string> {
|
||||
const fh = await open(resolved, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(READ_CHUNK_SIZE);
|
||||
let pending: Buffer[] = [];
|
||||
let pendingOffset = 0;
|
||||
let fileOffset = 0;
|
||||
|
||||
while (true) {
|
||||
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
|
||||
if (bytesRead === 0) break;
|
||||
const chunk = buf.subarray(0, bytesRead);
|
||||
let lineStart = 0;
|
||||
|
||||
for (let i = 0; i < chunk.length; i += 1) {
|
||||
const byte = chunk[i];
|
||||
if (byte !== 0x0a) continue;
|
||||
const piece = chunk.subarray(lineStart, i + 1);
|
||||
const lineOffset = pending.length === 0 ? fileOffset + lineStart : pendingOffset;
|
||||
const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]);
|
||||
yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0);
|
||||
pending = [];
|
||||
lineStart = i + 1;
|
||||
}
|
||||
|
||||
if (lineStart < chunk.length) {
|
||||
const tail = Buffer.from(chunk.subarray(lineStart));
|
||||
if (pending.length === 0) pendingOffset = fileOffset + lineStart;
|
||||
pending.push(tail);
|
||||
}
|
||||
fileOffset += bytesRead;
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
const line = Buffer.concat(pending);
|
||||
yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0);
|
||||
}
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
async writeBytes(path: string, data: Uint8Array): Promise<void> {
|
||||
const resolved = this._resolvePath(path);
|
||||
await writeFile(resolved, data);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<AgentFileStat> {
|
||||
const 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<readonly string[]> {
|
||||
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<readonly string[]> {
|
||||
const resolved = this._resolvePath('.');
|
||||
const caseSensitive = true;
|
||||
const patternParts = pattern.split('/');
|
||||
// Seed `visited` with basePath's own inode so that a symlink inside
|
||||
// basePath that points back at basePath is caught on its first
|
||||
// encounter (not on the second level — the "+1 depth" off-by-one
|
||||
// that would otherwise leak if the caller globs directly from the
|
||||
// loop root). `stat` failure here is tolerated: `_globWalk` will
|
||||
// hit the same error via readdir and return empty.
|
||||
const initVisited = new Set<string>();
|
||||
try {
|
||||
const rootStat = await stat(resolved);
|
||||
const rootKey = cycleKey(rootStat);
|
||||
if (rootKey !== null) initVisited.add(rootKey);
|
||||
} catch {
|
||||
// base does not exist / not accessible — walker handles via its own catch
|
||||
}
|
||||
const out: string[] = [];
|
||||
for await (const match of this.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 `"<dev>:0"` and the first would "visit" all others. The
|
||||
// module-level `cycleKey` helper returns `null` in that case, which
|
||||
// causes the call sites to skip visited tracking for that descent
|
||||
// — cycle safety is lost on those filesystems, but normal walking
|
||||
// works.
|
||||
private async *_globWalk(
|
||||
basePath: string,
|
||||
patternParts: string[],
|
||||
caseSensitive: boolean,
|
||||
visited: Set<string>,
|
||||
): AsyncGenerator<string> {
|
||||
if (patternParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [currentPattern, ...remainingParts] = patternParts;
|
||||
|
||||
if (currentPattern === '**') {
|
||||
// `**` matches zero or more directory components.
|
||||
//
|
||||
// There are exactly two cases to handle:
|
||||
// (a) `**` matches zero directories → continue at basePath with the
|
||||
// remaining pattern parts (or yield basePath itself when `**`
|
||||
// is the final segment).
|
||||
// (b) `**` matches one or more directories → recurse into each
|
||||
// subdirectory, keeping `**` (i.e. the full patternParts) at
|
||||
// the front. The "zero directories" case is then re-evaluated
|
||||
// at the subdirectory level by that recursive call.
|
||||
//
|
||||
// We must NOT additionally recurse with `remainingParts` on
|
||||
// subdirectories — that would double-count every match at depth ≥ 1
|
||||
// because case (a) inside the child recursion already yields those
|
||||
// results.
|
||||
if (remainingParts.length > 0) {
|
||||
yield* this._globWalk(basePath, remainingParts, caseSensitive, visited);
|
||||
} else {
|
||||
// Pattern ends with `**`: yield basePath itself (zero-dir match).
|
||||
yield basePath;
|
||||
}
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(basePath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// Use join to avoid "//entry" when basePath is a filesystem root.
|
||||
const fullPath = join(basePath, entry);
|
||||
let entryStat;
|
||||
try {
|
||||
entryStat = await stat(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entryStat.isDirectory()) {
|
||||
const key = cycleKey(entryStat);
|
||||
if (key !== null && visited.has(key)) continue;
|
||||
yield* this._globWalk(
|
||||
fullPath,
|
||||
patternParts,
|
||||
caseSensitive,
|
||||
key !== null ? new Set([...visited, key]) : visited,
|
||||
);
|
||||
} else if (remainingParts.length === 0) {
|
||||
// Pattern ends with `**`: non-directory entries match too
|
||||
// (since `**` matches "anything").
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const regex = globPatternToRegex(currentPattern ?? '', caseSensitive);
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(basePath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!regex.test(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use join to avoid "//entry" when basePath is a filesystem root.
|
||||
const fullPath = join(basePath, entry);
|
||||
|
||||
if (remainingParts.length === 0) {
|
||||
yield fullPath;
|
||||
} else {
|
||||
let entryStat;
|
||||
try {
|
||||
entryStat = await stat(fullPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entryStat.isDirectory()) {
|
||||
const key = cycleKey(entryStat);
|
||||
if (key !== null && visited.has(key)) continue;
|
||||
yield* this._globWalk(
|
||||
fullPath,
|
||||
remainingParts,
|
||||
caseSensitive,
|
||||
key !== null ? new Set([...visited, key]) : visited,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async mkdir(
|
||||
path: string,
|
||||
options?: { readonly parents?: boolean; readonly existOk?: boolean },
|
||||
): Promise<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
async function disposeProcess(proc: IProcess): Promise<void> {
|
||||
try {
|
||||
await proc.dispose();
|
||||
} catch {
|
||||
|
|
@ -42,23 +42,24 @@ async function disposeProcess(proc: KaosProcess): Promise<void> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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<RunRgOutcome> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<string, string>[];
|
||||
|
||||
/** 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<string, string>): IExecContext;
|
||||
}
|
||||
|
||||
export const IExecContext: ServiceIdentifier<IExecContext> =
|
||||
createDecorator<IExecContext>('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<string, string>[] = [],
|
||||
): IExecContext {
|
||||
const ctx: IExecContext = {
|
||||
_serviceBrand: undefined,
|
||||
cwd,
|
||||
envLayers,
|
||||
withCwd: (nextCwd: string) => createExecContext(nextCwd, envLayers),
|
||||
withEnv: (env: Record<string, string>) => 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<unknown>, ctx]];
|
||||
}
|
||||
7
packages/agent-core-v2/src/session/execContext/index.ts
Normal file
7
packages/agent-core-v2/src/session/execContext/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IProcess> {
|
||||
const k = options?.cwd !== undefined ? this.kaos.withCwd(options.cwd) : this.kaos;
|
||||
const env =
|
||||
options?.env !== undefined
|
||||
? ({ ...process.env, ...options.env } as Record<string, string>)
|
||||
: undefined;
|
||||
return k.backend.execWithEnv([...args], env);
|
||||
async exec(args: readonly string[], options?: ProcessExecOptions): Promise<IProcess> {
|
||||
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<string, string> | undefined,
|
||||
): Record<string, string> | 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<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
};
|
||||
for (const layer of this.ctx.envLayers) {
|
||||
Object.assign(merged, layer);
|
||||
}
|
||||
if (invocationEnv !== undefined) {
|
||||
Object.assign(merged, invocationEnv);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
161
packages/agent-core-v2/src/session/process/spawnedProcess.ts
Normal file
161
packages/agent-core-v2/src/session/process/spawnedProcess.ts
Normal file
|
|
@ -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<string, string> | undefined,
|
||||
): SpawnOptions {
|
||||
return {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
detached: !isWindowsHost,
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Wait for a freshly spawned ChildProcess to either emit 'spawn' (success) or
|
||||
// 'error' (ENOENT / EACCES / etc.). Until this resolves, callers should not
|
||||
// assume the child is running — they may otherwise write to the stdin of a
|
||||
// process that never existed.
|
||||
export function waitForSpawn(child: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onSpawn = (): void => {
|
||||
child.off('error', onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: Error): void => {
|
||||
child.off('spawn', onSpawn);
|
||||
reject(err);
|
||||
};
|
||||
child.once('spawn', onSpawn);
|
||||
child.once('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
export class SpawnedProcess implements IProcess {
|
||||
readonly stdin: Writable;
|
||||
readonly stdout: Readable;
|
||||
readonly stderr: Readable;
|
||||
readonly pid: number;
|
||||
|
||||
private readonly _child: ChildProcess;
|
||||
private _exitCode: number | null = null;
|
||||
private readonly _exitPromise: Promise<number>;
|
||||
private _disposed = false;
|
||||
|
||||
constructor(child: ChildProcess) {
|
||||
if (child.stdin === null || child.stdout === null || child.stderr === null) {
|
||||
throw new Error('Process must be created with stdin/stdout/stderr pipes.');
|
||||
}
|
||||
|
||||
this._child = child;
|
||||
this.stdin = child.stdin;
|
||||
this.stdout = new BufferedReadable(child.stdout);
|
||||
this.stderr = new BufferedReadable(child.stderr);
|
||||
this.pid = child.pid ?? -1;
|
||||
|
||||
this._exitPromise = new Promise<number>((resolve, reject) => {
|
||||
child.on('exit', (code: number | null) => {
|
||||
this._exitCode = code ?? -1;
|
||||
resolve(this._exitCode);
|
||||
});
|
||||
child.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get exitCode(): number | null {
|
||||
return this._exitCode;
|
||||
}
|
||||
|
||||
async wait(): Promise<number> {
|
||||
return this._exitPromise;
|
||||
}
|
||||
|
||||
kill(signal?: NodeJS.Signals): Promise<void> {
|
||||
// Reject if the process never actually started (spawn failed).
|
||||
// pid <= 0 indicates ChildProcess.pid was undefined, which happens
|
||||
// when spawn() fails to find/execute the command. Calling
|
||||
// process.kill(-1, ...) on POSIX would signal the entire process
|
||||
// group, potentially killing unrelated processes.
|
||||
if (this.pid <= 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// On Windows, `ChildProcess.kill()` only signals the shell parent, leaving
|
||||
// grandchildren alive, so terminate the whole process tree with
|
||||
// `taskkill /T`. A graceful `taskkill /T` (no `/F`) does not actually
|
||||
// terminate a console node.exe tree, and Windows has no real graceful
|
||||
// signal for it — Node's own `ChildProcess.kill()` is always a forceful
|
||||
// TerminateProcess on Windows — so always force-terminate the tree.
|
||||
if (isWindows) {
|
||||
const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)];
|
||||
return new Promise<void>((resolve) => {
|
||||
const killer = spawn('taskkill', taskkillArgs, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
const done = (): void => {
|
||||
resolve();
|
||||
};
|
||||
killer.once('error', done);
|
||||
killer.once('close', done);
|
||||
});
|
||||
}
|
||||
|
||||
// On POSIX, `detached:true` makes the child a process-group leader
|
||||
// (pgid === pid). A plain `ChildProcess.kill()` still only signals the
|
||||
// direct child, so a shell like `bash -c 'sleep 100 & sleep 100'` leaves
|
||||
// grandchildren orphaned. `process.kill(-pid, signal)` signals the group
|
||||
// (negative pid = process-group id under POSIX kill(2)).
|
||||
try {
|
||||
process.kill(-this.pid, signal ?? 'SIGTERM');
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
// ESRCH = group already gone (child exited + reaped between
|
||||
// `wait()` racing spawn + this call). Treat as successful kill.
|
||||
if (err.code === 'ESRCH') return Promise.resolve();
|
||||
// EPERM is typically a misconfiguration (e.g. non-detached
|
||||
// spawn earlier in the file); fall back to direct `.kill()` so
|
||||
// we at least signal the direct child instead of throwing.
|
||||
if (err.code === 'EPERM') {
|
||||
try {
|
||||
this._child.kill(signal ?? 'SIGTERM');
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
this.stdin.destroy();
|
||||
this.stdout.destroy();
|
||||
this.stderr.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<ISessionAgentFileSystem> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> => 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<number>((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 {
|
||||
|
|
|
|||
|
|
@ -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<number>((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);
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>;
|
||||
} {
|
||||
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<typeof vi.fn>;
|
||||
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<number>(() => {}),
|
||||
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 <stdout bg-ok>', 'real worker');
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<number>((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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ExecutableToolR
|
|||
|
||||
describe('EditTool', () => {
|
||||
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: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn> } = {}) {
|
|||
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<typeof vi.fn>): 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<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
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<typeof vi.fn> };
|
||||
function withCwdOf(exec: ReturnType<typeof vi.fn>): { toHaveBeenCalledWith: (dir: string) => void; toHaveBeenCalled: () => void; not: { toHaveBeenCalled: () => void; toHaveBeenCalledWith: (dir: string) => void } } {
|
||||
const cwds = () =>
|
||||
(exec.mock.calls as unknown as ReadonlyArray<readonly [readonly string[], { cwd?: string } | undefined]>)
|
||||
.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<typeof vi.fn>): string[] {
|
||||
return exec.mock.calls[0] as string[];
|
||||
return (exec.mock.calls[0] as ReadonlyArray<unknown>)[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<typeof vi.fn>; readdir?: ReturnType<typeof vi.fn>; telemetry?: ITelemetryService } = {},
|
||||
): { tool: GlobTool; exec: ReturnType<typeof vi.fn>; withCwd: ReturnType<typeof withCwdOf> } {
|
||||
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<string, { description?: string; default?: unknown }>;
|
||||
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<unknown>)[0] as string[];
|
||||
expect(retryArgs).toContain('-j');
|
||||
expect(retryArgs).toContain('1');
|
||||
});
|
||||
|
|
@ -466,9 +434,7 @@ describe('GlobTool', () => {
|
|||
);
|
||||
const events: Array<{ event: string; properties: Record<string, unknown> }> = [];
|
||||
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<string, unknown> }> = [];
|
||||
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<unknown>)[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<readonly string[]> => {
|
||||
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<readonly string[]> => {
|
||||
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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ToolExecution>): value is Promise<ToolExecution> {
|
||||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, FakeFile>) {
|
|||
}
|
||||
|
||||
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<ToolExecution>): value is Promise<ToolExecution> {
|
||||
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, FakeFile>): 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 }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<KaosService['pathClass']> = '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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof createFakeKaos>[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<ISessionAgentFileSystem> = {}): 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<string, string>(),
|
||||
overrides: Parameters<typeof createFakeKaos>[0] = {},
|
||||
) {
|
||||
overrides: Partial<ISessionAgentFileSystem> = {},
|
||||
): {
|
||||
readonly files: Map<string, string>;
|
||||
readonly readText: ReturnType<typeof vi.fn>;
|
||||
readonly writeText: ReturnType<typeof vi.fn>;
|
||||
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<void> => {}),
|
||||
}));
|
||||
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<string, string>();
|
||||
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<void> => {
|
||||
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<string, string>();
|
||||
const writeText = vi.fn(async (path: string, content: string) => {
|
||||
const writeText = vi.fn(async (path: string, content: string): Promise<void> => {
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
|
|
@ -23,18 +23,11 @@ async function collect(stream: Readable): Promise<string> {
|
|||
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<ISessionProcessRunner> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<typeof BashTool>[3],
|
||||
options?: ConstructorParameters<typeof BashTool>[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(
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
153
packages/agent-core-v2/test/tools/fixtures/fake-exec.ts
Normal file
153
packages/agent-core-v2/test/tools/fixtures/fake-exec.ts
Normal file
|
|
@ -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>,
|
||||
): IHostEnvironment {
|
||||
return { ...FAKE_HOST_ENVIRONMENT, ...overrides };
|
||||
}
|
||||
|
||||
// ── Exec context ─────────────────────────────────────────────────────
|
||||
|
||||
export function createFakeExecContext(
|
||||
cwd: string = '/workspace',
|
||||
envLayers: readonly Record<string, string>[] = [],
|
||||
): 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<ISessionProcessRunner>,
|
||||
envLayers: readonly Record<string, string>[] = [],
|
||||
): 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<string, string> | undefined,
|
||||
envLayers: readonly Record<string, string>[],
|
||||
): Record<string, string> | undefined {
|
||||
if (envLayers.length === 0) return invocationEnv;
|
||||
const merged: Record<string, string> = { ...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<ISessionAgentFileSystem>,
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<Kaos>,
|
||||
envLayers: readonly Record<string, string>[] = [],
|
||||
): 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<string, string>) =>
|
||||
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<string, string> | undefined,
|
||||
envLayers: readonly Record<string, string>[],
|
||||
): Record<string, string> | undefined {
|
||||
if (envLayers.length === 0) return invocationEnv;
|
||||
const merged: Record<string, string> = { ...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;
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export default defineConfig({
|
|||
deps: {
|
||||
neverBundle: [
|
||||
'@moonshot-ai/kosong',
|
||||
'@moonshot-ai/kaos',
|
||||
'@moonshot-ai/kimi-code-oauth',
|
||||
'@moonshot-ai/kimi-telemetry',
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue