diff --git a/README.md b/README.md index d6695fc..19911ca 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ The design is file-first: any agent can pick up the repo-root files and continue ## Install Requires OpenClaw `2026.3.13` or newer. +Needs bash, git, and a git repo. Use OpenClaw's plugin installer: diff --git a/extensions/openclaw-autoresearch/index.ts b/extensions/openclaw-autoresearch/index.ts index ae0f790..8ed36f5 100644 --- a/extensions/openclaw-autoresearch/index.ts +++ b/extensions/openclaw-autoresearch/index.ts @@ -1,9 +1,12 @@ -import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import { + definePluginEntry, + type OpenClawPluginApi, + type OpenClawPluginToolContext, +} from "openclaw/plugin-sdk/core"; import { AUTORESEARCH_PLUGIN_DESCRIPTION, AUTORESEARCH_PLUGIN_ID, AUTORESEARCH_PLUGIN_NAME, - autoresearchPluginConfigSchema, } from "./src/config.js"; import { createInitExperimentTool } from "./src/tools/init-experiment.js"; import { createRunExperimentTool } from "./src/tools/run-experiment.js"; @@ -22,19 +25,33 @@ function createToolFactory( return (toolContext: OpenClawPluginToolContext) => createTool(api, toolContext); } -const plugin = { +function registerRequiredTool( + api: OpenClawPluginApi, + name: string, + createTool: ( + api: OpenClawPluginApi, + toolContext?: Pick, + ) => TTool, +) { + api.registerTool( + createToolFactory(createTool, api) as Parameters[0], + { + name, + optional: false, + }, + ); +} + +export default definePluginEntry({ id: AUTORESEARCH_PLUGIN_ID, name: AUTORESEARCH_PLUGIN_NAME, description: AUTORESEARCH_PLUGIN_DESCRIPTION, - configSchema: autoresearchPluginConfigSchema, register(api: OpenClawPluginApi) { registerAutoresearchHooks(api); registerAutoresearchCommand(api); - api.registerTool(createToolFactory(createInitExperimentTool, api)); - api.registerTool(createToolFactory(createRunExperimentTool, api)); - api.registerTool(createToolFactory(createLogExperimentTool, api)); - api.registerTool(createToolFactory(createAutoresearchStatusTool, api)); + registerRequiredTool(api, "init_experiment", createInitExperimentTool); + registerRequiredTool(api, "run_experiment", createRunExperimentTool); + registerRequiredTool(api, "log_experiment", createLogExperimentTool); + registerRequiredTool(api, "autoresearch_status", createAutoresearchStatusTool); }, -}; - -export default plugin; +}); diff --git a/extensions/openclaw-autoresearch/src/commands/autoresearch.ts b/extensions/openclaw-autoresearch/src/commands/autoresearch.ts index 35e6132..93e5022 100644 --- a/extensions/openclaw-autoresearch/src/commands/autoresearch.ts +++ b/extensions/openclaw-autoresearch/src/commands/autoresearch.ts @@ -1,5 +1,5 @@ import * as fs from "node:fs"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { AUTORESEARCH_ROOT_FILES, getAutoresearchRootFilePath, diff --git a/extensions/openclaw-autoresearch/src/execute.ts b/extensions/openclaw-autoresearch/src/execute.ts index 5c88612..5e1a20b 100644 --- a/extensions/openclaw-autoresearch/src/execute.ts +++ b/extensions/openclaw-autoresearch/src/execute.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; const OUTPUT_TAIL_LINES = 80; const DEFAULT_TIMEOUT_SECONDS = 600; diff --git a/extensions/openclaw-autoresearch/src/git.ts b/extensions/openclaw-autoresearch/src/git.ts index 57cb41e..a6a0efa 100644 --- a/extensions/openclaw-autoresearch/src/git.ts +++ b/extensions/openclaw-autoresearch/src/git.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; const GIT_TIMEOUT_MS = 30_000; diff --git a/extensions/openclaw-autoresearch/src/hooks.ts b/extensions/openclaw-autoresearch/src/hooks.ts index 3153c40..fe266cb 100644 --- a/extensions/openclaw-autoresearch/src/hooks.ts +++ b/extensions/openclaw-autoresearch/src/hooks.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { AUTORESEARCH_ROOT_FILES } from "./files.js"; import { reconstructStateFromJsonl } from "./state.js"; import { readAutoresearchCheckpoint } from "./checkpoint.js"; @@ -30,7 +30,7 @@ type HookContext = { runId?: string; }; -type HookCapablePluginApi = OpenClawPluginApi & { +type HookCapablePluginApi = { on?: (hookName: string, handler: (event: unknown, ctx: HookContext) => unknown) => void; registerHook?: ( hookName: string, @@ -39,7 +39,7 @@ type HookCapablePluginApi = OpenClawPluginApi & { }; export function registerAutoresearchHooks(api: OpenClawPluginApi): void { - const hookApi = api as HookCapablePluginApi; + const hookApi = api as unknown as HookCapablePluginApi; if (typeof hookApi.on === "function") { hookApi.on("before_prompt_build", (_event, ctx) => { const addition = buildBeforePromptBuildContext(resolveHookScope(ctx)); @@ -116,7 +116,7 @@ export function registerAutoresearchHooks(api: OpenClawPluginApi): void { return; } - hookApi.registerHook("before_agent_start", (event, ctx) => { + hookApi.registerHook("before_agent_start", (event: BeforeAgentStartEvent, ctx: HookContext) => { const addition = buildBeforePromptBuildContext(resolveHookScope(ctx)); if (addition === null) { return; diff --git a/extensions/openclaw-autoresearch/src/tools/autoresearch-status.ts b/extensions/openclaw-autoresearch/src/tools/autoresearch-status.ts index 56c6b2e..141214d 100644 --- a/extensions/openclaw-autoresearch/src/tools/autoresearch-status.ts +++ b/extensions/openclaw-autoresearch/src/tools/autoresearch-status.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core"; import { Type } from "@sinclair/typebox"; import { reconstructStateFromJsonl, type AutoresearchStateSnapshot } from "../state.js"; import { @@ -16,6 +16,7 @@ import { getAutoresearchSessionLockStatus, type AutoresearchSessionLockStatus, } from "../session-lock.js"; +import { prepareAutoresearchToolExecution } from "./preflight.js"; export type AutoresearchStatusDiagnostics = { readonly warnings: readonly string[]; @@ -55,10 +56,19 @@ export function createAutoresearchStatusTool( _signal: AbortSignal, _onUpdate: unknown, ) { - const scope = resolveToolExecutionScope({ + const resolvedScope = resolveToolExecutionScope({ toolContext, requestedCwd: params.cwd, }); + const prepared = await prepareAutoresearchToolExecution({ + runCommandWithTimeout: api.runtime.system.runCommandWithTimeout, + scope: resolvedScope, + }); + if (!prepared.ok) { + return prepared.failure; + } + + const scope = prepared.scope; const cwd = scope.repoDir; const state = reconstructStateFromJsonl(cwd); const runtimeState = getAutoresearchRuntimeState(scope); diff --git a/extensions/openclaw-autoresearch/src/tools/init-experiment.ts b/extensions/openclaw-autoresearch/src/tools/init-experiment.ts index 2fe66b1..273acd7 100644 --- a/extensions/openclaw-autoresearch/src/tools/init-experiment.ts +++ b/extensions/openclaw-autoresearch/src/tools/init-experiment.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core"; import { InitExperimentParams } from "./schemas.js"; import { createConfigHeader, writeConfigHeader } from "../logging.js"; import { @@ -13,6 +13,7 @@ import { syncAutoresearchSessionDoc } from "../session-doc.js"; import { readCurrentBranch, readShortHeadCommit } from "../git.js"; import { setAutoresearchPendingRun, setAutoresearchRunInFlight } from "../runtime-state.js"; import { resolveToolExecutionScope } from "./tool-cwd.js"; +import { prepareAutoresearchToolExecution } from "./preflight.js"; import { acquireAutoresearchSessionLock } from "../session-lock.js"; export function createInitExperimentTool( @@ -38,10 +39,19 @@ export function createInitExperimentTool( _signal: AbortSignal, _onUpdate: unknown, ) { - const scope = resolveToolExecutionScope({ + const resolvedScope = resolveToolExecutionScope({ toolContext, requestedCwd: params.cwd, }); + const prepared = await prepareAutoresearchToolExecution({ + runCommandWithTimeout: api.runtime.system.runCommandWithTimeout, + scope: resolvedScope, + }); + if (!prepared.ok) { + return prepared.failure; + } + + const scope = prepared.scope; const cwd = scope.repoDir; const lockStatus = acquireAutoresearchSessionLock(scope); if (lockStatus.state === "active" && !lockStatus.ownedByCurrentSession) { diff --git a/extensions/openclaw-autoresearch/src/tools/log-experiment.ts b/extensions/openclaw-autoresearch/src/tools/log-experiment.ts index cbec73b..cf9925f 100644 --- a/extensions/openclaw-autoresearch/src/tools/log-experiment.ts +++ b/extensions/openclaw-autoresearch/src/tools/log-experiment.ts @@ -1,5 +1,5 @@ import * as fs from "node:fs"; -import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core"; import { LogExperimentParams } from "./schemas.js"; import { commitKeptExperiment, readCurrentBranch, readShortHeadCommit } from "../git.js"; import { appendResultEntry, type AutoresearchResultEntry } from "../logging.js"; @@ -22,6 +22,7 @@ import { readAutoresearchCheckpoint, writeAutoresearchCheckpoint } from "../chec import { syncAutoresearchSessionDoc } from "../session-doc.js"; import { computeConfidence, formatConfidenceLine } from "../confidence.js"; import { acquireAutoresearchSessionLock } from "../session-lock.js"; +import { prepareAutoresearchToolExecution } from "./preflight.js"; export function createLogExperimentTool( api: OpenClawPluginApi, @@ -48,10 +49,19 @@ export function createLogExperimentTool( _signal: AbortSignal, _onUpdate: unknown, ) { - const scope = resolveToolExecutionScope({ + const resolvedScope = resolveToolExecutionScope({ toolContext, requestedCwd: params.cwd, }); + const prepared = await prepareAutoresearchToolExecution({ + runCommandWithTimeout: api.runtime.system.runCommandWithTimeout, + scope: resolvedScope, + }); + if (!prepared.ok) { + return prepared.failure; + } + + const scope = prepared.scope; const cwd = scope.repoDir; const lockStatus = acquireAutoresearchSessionLock(scope); if (lockStatus.state === "active" && !lockStatus.ownedByCurrentSession) { diff --git a/extensions/openclaw-autoresearch/src/tools/preflight.ts b/extensions/openclaw-autoresearch/src/tools/preflight.ts new file mode 100644 index 0000000..f3f9a6f --- /dev/null +++ b/extensions/openclaw-autoresearch/src/tools/preflight.ts @@ -0,0 +1,255 @@ +import path from "node:path"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { resolveAutoresearchScope } from "../scope.js"; +import type { ResolvedToolExecutionScope } from "./tool-cwd.js"; + +const PREFLIGHT_TIMEOUT_MS = 10_000; +const DETAIL_LIMIT = 200; + +type RunCommandWithTimeout = OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"]; +type PreflightRequirement = "bash" | "git" | "git-repo"; +type CommandCheckResult = Awaited>; + +export type AutoresearchToolFailure = { + content: [ + { + type: "text"; + text: string; + }, + ]; + details: { + status: "error"; + phase: "preflight"; + requirement: PreflightRequirement; + cwd: string; + }; +}; + +export type PreparedAutoresearchToolExecution = + | { + ok: true; + scope: ResolvedToolExecutionScope; + } + | { + ok: false; + failure: AutoresearchToolFailure; + }; + +type CommandCheck = + | { + ok: true; + result: CommandCheckResult; + } + | { + ok: false; + error: unknown; + }; + +export async function prepareAutoresearchToolExecution(options: { + runCommandWithTimeout: RunCommandWithTimeout; + scope: ResolvedToolExecutionScope; + requireBash?: boolean; +}): Promise { + const gitCheck = await runCheck(options.runCommandWithTimeout, options.scope.repoDir, [ + "git", + "--version", + ]); + if (!gitCheck.ok) { + return { + ok: false, + failure: buildPreflightFailure({ + requirement: "git", + cwd: options.scope.repoDir, + text: formatMissingCommandMessage({ + command: "git", + fallback: "Autoresearch needs git. Install git and try again.", + error: gitCheck.error, + }), + }), + }; + } + if (gitCheck.result.code !== 0) { + return { + ok: false, + failure: buildPreflightFailure({ + requirement: "git", + cwd: options.scope.repoDir, + text: appendCheckDetails( + "Autoresearch could not run git. Make sure git works on this machine and try again.", + gitCheck.result, + ), + }), + }; + } + + const repoCheck = await runCheck(options.runCommandWithTimeout, options.scope.repoDir, [ + "git", + "rev-parse", + "--show-toplevel", + ]); + if (!repoCheck.ok) { + return { + ok: false, + failure: buildPreflightFailure({ + requirement: "git-repo", + cwd: options.scope.repoDir, + text: formatMissingCommandMessage({ + command: "git", + fallback: + "Autoresearch only works inside a git repo. Run it in the repo you want to optimize, or pass cwd to that repo.", + error: repoCheck.error, + }), + }), + }; + } + if (repoCheck.result.code !== 0 || repoCheck.result.stdout.trim().length === 0) { + return { + ok: false, + failure: buildPreflightFailure({ + requirement: "git-repo", + cwd: options.scope.repoDir, + text: appendCheckDetails( + "Autoresearch only works inside a git repo. Run it in the repo you want to optimize, or pass cwd to that repo.", + repoCheck.result, + ), + }), + }; + } + + const canonicalRepoDir = path.resolve(repoCheck.result.stdout.trim()); + const scope = canonicalizeScope(options.scope, canonicalRepoDir); + + if (options.requireBash) { + const bashCheck = await runCheck(options.runCommandWithTimeout, scope.repoDir, [ + "bash", + "-lc", + "exit 0", + ]); + if (!bashCheck.ok) { + return { + ok: false, + failure: buildPreflightFailure({ + requirement: "bash", + cwd: scope.repoDir, + text: formatMissingCommandMessage({ + command: "bash", + fallback: "Autoresearch needs bash to run experiment commands. Install bash and try again.", + error: bashCheck.error, + }), + }), + }; + } + if (bashCheck.result.code !== 0) { + return { + ok: false, + failure: buildPreflightFailure({ + requirement: "bash", + cwd: scope.repoDir, + text: appendCheckDetails( + "Autoresearch needs bash to run experiment commands. Install bash and try again.", + bashCheck.result, + ), + }), + }; + } + } + + return { + ok: true, + scope, + }; +} + +async function runCheck( + runCommandWithTimeout: RunCommandWithTimeout, + cwd: string, + argv: string[], +): Promise { + try { + return { + ok: true, + result: await runCommandWithTimeout(argv, { + cwd, + timeoutMs: PREFLIGHT_TIMEOUT_MS, + }), + }; + } catch (error) { + return { + ok: false, + error, + }; + } +} + +function canonicalizeScope( + scope: ResolvedToolExecutionScope, + repoDir: string, +): ResolvedToolExecutionScope { + if (scope.repoDir === repoDir) { + return scope; + } + + const resolved = resolveAutoresearchScope({ + sessionKey: scope.sessionKey, + sessionId: scope.sessionId, + workspaceDir: scope.workspaceDir, + repoDir, + runId: scope.runId, + }); + + return { + ...resolved, + repoDir, + }; +} + +function buildPreflightFailure(params: { + requirement: PreflightRequirement; + cwd: string; + text: string; +}): AutoresearchToolFailure { + return { + content: [ + { + type: "text", + text: params.text, + }, + ], + details: { + status: "error", + phase: "preflight", + requirement: params.requirement, + cwd: params.cwd, + }, + }; +} + +function formatMissingCommandMessage(params: { + command: "bash" | "git"; + fallback: string; + error: unknown; +}): string { + const err = params.error as NodeJS.ErrnoException | undefined; + const raw = err?.message ?? String(params.error ?? ""); + if (err?.code === "ENOENT" || raw.includes(`spawn ${params.command} ENOENT`)) { + return params.fallback; + } + return appendInlineDetail(params.fallback, raw); +} + +function appendCheckDetails(message: string, result: CommandCheckResult): string { + const output = `${result.stderr ?? ""}${result.stdout ?? ""}`.trim(); + if (!output) { + return message; + } + return appendInlineDetail(message, output); +} + +function appendInlineDetail(message: string, detail: string): string { + const normalized = detail.trim(); + if (!normalized) { + return message; + } + const clipped = normalized.length > DETAIL_LIMIT ? `${normalized.slice(0, DETAIL_LIMIT)}...` : normalized; + return `${message}\nDetails: ${clipped}`; +} diff --git a/extensions/openclaw-autoresearch/src/tools/run-experiment.ts b/extensions/openclaw-autoresearch/src/tools/run-experiment.ts index 2010a38..2d04a43 100644 --- a/extensions/openclaw-autoresearch/src/tools/run-experiment.ts +++ b/extensions/openclaw-autoresearch/src/tools/run-experiment.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core"; import { RunExperimentParams } from "./schemas.js"; import { executeExperimentCommand } from "../execute.js"; import { @@ -13,6 +13,7 @@ import { readAutoresearchCheckpoint, writeAutoresearchCheckpoint } from "../chec import { readRecentLoggedRuns, reconstructStateFromJsonl } from "../state.js"; import { syncAutoresearchSessionDoc } from "../session-doc.js"; import { acquireAutoresearchSessionLock } from "../session-lock.js"; +import { prepareAutoresearchToolExecution } from "./preflight.js"; export function createRunExperimentTool( api: OpenClawPluginApi, @@ -34,10 +35,20 @@ export function createRunExperimentTool( signal: AbortSignal, onUpdate: ((update: unknown) => void | Promise) | undefined, ) { - const scope = resolveToolExecutionScope({ + const resolvedScope = resolveToolExecutionScope({ toolContext, requestedCwd: params.cwd, }); + const prepared = await prepareAutoresearchToolExecution({ + runCommandWithTimeout: api.runtime.system.runCommandWithTimeout, + scope: resolvedScope, + requireBash: true, + }); + if (!prepared.ok) { + return prepared.failure; + } + + const scope = prepared.scope; const cwd = scope.repoDir; const lockStatus = acquireAutoresearchSessionLock(scope); if (lockStatus.state === "active" && !lockStatus.ownedByCurrentSession) { diff --git a/extensions/openclaw-autoresearch/src/tools/tool-cwd.ts b/extensions/openclaw-autoresearch/src/tools/tool-cwd.ts index 3642ca0..f981a6f 100644 --- a/extensions/openclaw-autoresearch/src/tools/tool-cwd.ts +++ b/extensions/openclaw-autoresearch/src/tools/tool-cwd.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk"; +import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core"; import { type ResolvedAutoresearchScope, resolveAutoresearchScope } from "../scope.js"; export type ResolvedToolExecutionScope = Omit & { diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 2ffc1e7..dee5e6e 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; @@ -14,7 +15,12 @@ import { import { runCommandWithTimeout } from "./helpers/fake-runtime.js"; function createTempDir(prefix: string): string { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const rawCwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const cwd = fs.realpathSync(rawCwd); + execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "pipe" }); + return cwd; } function createAbortSignal(): AbortSignal { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index b8fc458..f5101b3 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it, vi } from "vitest"; -import plugin from "../index.js"; import { runCommandWithTimeout } from "./helpers/fake-runtime.js"; +vi.mock("openclaw/plugin-sdk/core", () => ({ + definePluginEntry: (entry: TEntry) => + entry, +})); + describe("plugin registration", () => { - it("registers the command, OpenClaw hooks, and all tool surfaces", () => { + it("registers the command, OpenClaw hooks, and all tool surfaces", async () => { + const plugin = (await import("../index.js")).default; const api = { resolvePath: vi.fn(() => "/tmp/repo"), runtime: { @@ -16,7 +21,7 @@ describe("plugin registration", () => { on: vi.fn(), }; - plugin.register(api); + plugin.register(api as never); expect(api.registerCommand).toHaveBeenCalledTimes(1); expect(api.on).toHaveBeenCalledTimes(5); @@ -28,6 +33,12 @@ describe("plugin registration", () => { "session_end", ]); expect(api.registerTool).toHaveBeenCalledTimes(4); + expect(api.registerTool.mock.calls.map(([, options]) => options)).toEqual([ + { name: "init_experiment", optional: false }, + { name: "run_experiment", optional: false }, + { name: "log_experiment", optional: false }, + { name: "autoresearch_status", optional: false }, + ]); expect( api.registerTool.mock.calls.map(([tool]) => typeof tool === "function" diff --git a/test/shims/openclaw-plugin-sdk-core.ts b/test/shims/openclaw-plugin-sdk-core.ts index 0ee9a7c..b783c20 100644 --- a/test/shims/openclaw-plugin-sdk-core.ts +++ b/test/shims/openclaw-plugin-sdk-core.ts @@ -22,6 +22,8 @@ export type ToolRegistration = { name: string; }; +export type AnyAgentTool = ToolRegistration; + export type OpenClawPluginApi = { resolvePath(path: string): string; runtime: { @@ -50,7 +52,10 @@ export type OpenClawPluginApi = { }>; }; }; - registerTool(tool: ToolRegistration | ((ctx: OpenClawPluginToolContext) => ToolRegistration)): void; + registerTool( + tool: ToolRegistration | ((ctx: OpenClawPluginToolContext) => ToolRegistration), + options?: { name?: string; optional?: boolean }, + ): void; registerCommand(command: CommandRegistration): void; on?( hookName: string, @@ -80,6 +85,15 @@ export type OpenClawPluginApi = { ): void; }; +export function definePluginEntry(entry: TEntry): TEntry { + return entry; +} + export function emptyPluginConfigSchema(): Record { return {}; } diff --git a/test/tool-execute.test.ts b/test/tool-execute.test.ts index 80103aa..d3e2340 100644 --- a/test/tool-execute.test.ts +++ b/test/tool-execute.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; @@ -9,16 +10,39 @@ import { createLogExperimentTool } from "../extensions/openclaw-autoresearch/src import { AUTORESEARCH_ROOT_FILES } from "../extensions/openclaw-autoresearch/src/files.js"; import { runCommandWithTimeout } from "./helpers/fake-runtime.js"; -function createTempDir(): string { - return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "autoresearch-tool-test-"))); +function initGitRepo(cwd: string): void { + execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "pipe" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "pipe" }); } -function createApi(cwd: string) { +function createTempDir(): string { + const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "autoresearch-tool-test-"))); + initGitRepo(cwd); + return cwd; +} + +function createPlainTempDir(): string { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "autoresearch-plain-tool-test-"))); +} + +function createMissingCommandRuntime(commandName: "bash" | "git") { + return async (...args: Parameters) => { + if ((args[0]?.[0] ?? "") === commandName) { + const error = new Error(`spawn ${commandName} ENOENT`) as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return await runCommandWithTimeout(...args); + }; +} + +function createApi(cwd: string, commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout) { return { resolvePath: vi.fn(() => cwd), runtime: { system: { - runCommandWithTimeout, + runCommandWithTimeout: commandRunner, }, }, }; @@ -200,4 +224,115 @@ describe("autoresearch tools", () => { expect(logResult.details).toMatchObject({ status: "ok" }); expect(logResult.content[0]?.text).toContain("Logged #1: discard - baseline"); }); + + it("normalizes cwd overrides to the enclosing git repo root", async () => { + const workspaceCwd = createTempDir(); + const repoRoot = createTempDir(); + const repoSubdir = path.join(repoRoot, "nested", "project"); + fs.mkdirSync(repoSubdir, { recursive: true }); + + const api = createApi(workspaceCwd); + const toolContext = createToolContext(workspaceCwd, "session:subdir"); + + const initResult = await createInitExperimentTool(api as never, toolContext).execute( + "call-1", + { + cwd: repoSubdir, + name: "Subdir repo root", + metric_name: "escaped_mutations", + metric_unit: "", + direction: "lower", + }, + new AbortController().signal, + undefined, + ); + + expect(initResult.details).toMatchObject({ status: "ok" }); + expect(fs.existsSync(path.join(repoRoot, AUTORESEARCH_ROOT_FILES.resultsLog))).toBe(true); + expect(fs.existsSync(path.join(repoSubdir, AUTORESEARCH_ROOT_FILES.resultsLog))).toBe(false); + + const runResult = await createRunExperimentTool(api as never, toolContext).execute( + "call-2", + { + cwd: repoSubdir, + command: "node -e \"console.log(process.cwd())\"", + timeout_seconds: 10, + }, + new AbortController().signal, + undefined, + ); + + expect(runResult.details).toMatchObject({ passed: true, timedOut: false }); + expect((runResult.details as { stdout: string }).stdout.trim()).toBe(repoRoot); + }); + + it("returns a clear preflight error when the target directory is not a git repo", async () => { + const cwd = createPlainTempDir(); + const api = createApi(cwd); + + const result = await createInitExperimentTool(api as never, createToolContext(cwd)).execute( + "call-1", + { + name: "Not a repo", + metric_name: "escaped_mutations", + metric_unit: "", + direction: "lower", + }, + new AbortController().signal, + undefined, + ); + + expect(result.details).toMatchObject({ + status: "error", + phase: "preflight", + requirement: "git-repo", + }); + expect(result.content[0]?.text).toContain("only works inside a git repo"); + }); + + it("returns a clear preflight error when git is unavailable", async () => { + const cwd = createTempDir(); + const api = createApi(cwd, createMissingCommandRuntime("git")); + + const result = await createInitExperimentTool(api as never, createToolContext(cwd)).execute( + "call-1", + { + name: "Missing git", + metric_name: "escaped_mutations", + metric_unit: "", + direction: "lower", + }, + new AbortController().signal, + undefined, + ); + + expect(result.details).toMatchObject({ + status: "error", + phase: "preflight", + requirement: "git", + }); + expect(result.content[0]?.text).toContain("needs git"); + }); + + it("returns a clear preflight error when bash is unavailable", async () => { + const cwd = createTempDir(); + const api = createApi(cwd, createMissingCommandRuntime("bash")); + + const result = await createRunExperimentTool(api as never, createToolContext(cwd)).execute( + "call-1", + { + command: "node -e \"console.log('ok')\"", + timeout_seconds: 10, + }, + new AbortController().signal, + undefined, + ); + + expect(result.details).toMatchObject({ + status: "error", + phase: "preflight", + requirement: "bash", + }); + expect(result.content[0]?.text).toContain("needs bash"); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 15b29e7..34ad170 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ resolve: { alias: { "openclaw/plugin-sdk": shimPath, + "openclaw/plugin-sdk/core": shimPath, + "openclaw/plugin-sdk/plugin-entry": shimPath, }, }, test: {