mirror of
https://github.com/gianfrancopiana/openclaw-autoresearch.git
synced 2026-08-14 00:48:06 +00:00
refactor: modernize plugin entry and add preflights
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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<TTool>(
|
||||
return (toolContext: OpenClawPluginToolContext) => createTool(api, toolContext);
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
function registerRequiredTool<TTool>(
|
||||
api: OpenClawPluginApi,
|
||||
name: string,
|
||||
createTool: (
|
||||
api: OpenClawPluginApi,
|
||||
toolContext?: Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "workspaceDir">,
|
||||
) => TTool,
|
||||
) {
|
||||
api.registerTool(
|
||||
createToolFactory(createTool, api) as Parameters<OpenClawPluginApi["registerTool"]>[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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ReturnType<RunCommandWithTimeout>>;
|
||||
|
||||
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<PreparedAutoresearchToolExecution> {
|
||||
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<CommandCheck> {
|
||||
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}`;
|
||||
}
|
||||
@@ -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<void>) | 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) {
|
||||
|
||||
@@ -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<ResolvedAutoresearchScope, "repoDir"> & {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+14
-3
@@ -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: <TEntry extends { id: string; name: string; description: string }>(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"
|
||||
|
||||
@@ -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<TEntry extends {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
register(api: OpenClawPluginApi): void;
|
||||
}>(entry: TEntry): TEntry {
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function emptyPluginConfigSchema(): Record<string, never> {
|
||||
return {};
|
||||
}
|
||||
|
||||
+139
-4
@@ -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<typeof runCommandWithTimeout>) => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user