d24d11a274
- Standardized missing-file read errors and now return `File not found: <path>` for absent edit targets. - Centralized AI provider, usage, and OAuth helpers into shared modules to remove duplicated logic. - Migrated OAuth/API-key login flows to shared factory helpers and removed inline prompt/token-exchange code. - Reused shared tools and formatter utilities for discovery, stream tails, LSP batching, and source formatting. - Consolidated repeated test helpers and fixtures into shared modules, replacing inline helper duplicates.
1186 lines
40 KiB
TypeScript
1186 lines
40 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { Snowflake } from "@oh-my-pi/pi-utils";
|
|
import { parseAutoresearchContract } from "../src/autoresearch/contract";
|
|
import { isAutoresearchShCommand } from "../src/autoresearch/helpers";
|
|
import { createAutoresearchExtension } from "../src/autoresearch/index";
|
|
import { reconstructStateFromJsonl } from "../src/autoresearch/state";
|
|
import { validateAsiRequirements } from "../src/autoresearch/tools/log-experiment";
|
|
import type {
|
|
ExtensionAPI,
|
|
ExtensionCommandContext,
|
|
ExtensionContext,
|
|
RegisteredCommand,
|
|
SessionStartEvent,
|
|
SessionSwitchEvent,
|
|
ToolCallEvent,
|
|
} from "../src/extensibility/extensions";
|
|
import * as git from "../src/utils/git";
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
function makeTempDir(): string {
|
|
const dir = path.join(os.tmpdir(), `pi-autoresearch-test-${Snowflake.next()}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
describe("autoresearch state reconstruction", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("reconstructs the latest segment and current metric definitions from autoresearch.jsonl", () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const jsonlPath = path.join(dir, "autoresearch.jsonl");
|
|
fs.writeFileSync(
|
|
jsonlPath,
|
|
[
|
|
JSON.stringify({
|
|
type: "config",
|
|
name: "First",
|
|
metricName: "runtime_ms",
|
|
metricUnit: "ms",
|
|
bestDirection: "lower",
|
|
}),
|
|
JSON.stringify({
|
|
commit: "aaaaaaa",
|
|
metric: 100,
|
|
metrics: { memory_mb: 32 },
|
|
status: "keep",
|
|
description: "baseline",
|
|
timestamp: 1,
|
|
}),
|
|
JSON.stringify({
|
|
commit: "bbbbbbb",
|
|
metric: 90,
|
|
metrics: { memory_mb: 30 },
|
|
status: "keep",
|
|
description: "improved",
|
|
timestamp: 2,
|
|
}),
|
|
JSON.stringify({
|
|
type: "config",
|
|
name: "Second",
|
|
metricName: "throughput",
|
|
metricUnit: "",
|
|
bestDirection: "higher",
|
|
}),
|
|
JSON.stringify({
|
|
commit: "ccccccc",
|
|
metric: 1200,
|
|
metrics: { latency_ms: 15 },
|
|
status: "keep",
|
|
description: "new baseline",
|
|
timestamp: 3,
|
|
}),
|
|
JSON.stringify({
|
|
commit: "ddddddd",
|
|
metric: 1320,
|
|
metrics: { latency_ms: 18 },
|
|
status: "discard",
|
|
description: "regressed latency",
|
|
timestamp: 4,
|
|
}),
|
|
].join("\n"),
|
|
);
|
|
|
|
const reconstructed = reconstructStateFromJsonl(dir);
|
|
const state = reconstructed.state;
|
|
|
|
expect(reconstructed.hasLog).toBe(true);
|
|
expect(state.name).toBe("Second");
|
|
expect(state.metricName).toBe("throughput");
|
|
expect(state.bestDirection).toBe("higher");
|
|
expect(state.currentSegment).toBe(1);
|
|
expect(state.bestMetric).toBe(1200);
|
|
expect(state.results).toHaveLength(4);
|
|
expect(state.results.filter(result => result.segment === 1)).toHaveLength(2);
|
|
expect(state.secondaryMetrics).toEqual([{ name: "latency_ms", unit: "ms" }]);
|
|
});
|
|
|
|
it("hydrates configured secondary metrics from config entries before later runs add new ones", () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const jsonlPath = path.join(dir, "autoresearch.jsonl");
|
|
fs.writeFileSync(
|
|
jsonlPath,
|
|
[
|
|
JSON.stringify({
|
|
type: "config",
|
|
name: "Baseline",
|
|
metricName: "runtime_ms",
|
|
metricUnit: "ms",
|
|
bestDirection: "lower",
|
|
secondaryMetrics: ["memory_mb", "tokens"],
|
|
}),
|
|
JSON.stringify({
|
|
commit: "aaaaaaa",
|
|
metric: 100,
|
|
metrics: { memory_mb: 32 },
|
|
status: "keep",
|
|
description: "baseline",
|
|
timestamp: 1,
|
|
}),
|
|
].join("\n"),
|
|
);
|
|
|
|
const reconstructed = reconstructStateFromJsonl(dir);
|
|
expect(reconstructed.state.secondaryMetrics).toEqual([
|
|
{ name: "memory_mb", unit: "mb" },
|
|
{ name: "tokens", unit: "" },
|
|
]);
|
|
});
|
|
|
|
it("uses the first kept run as baseline and preserves configured secondary metrics before they appear", () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const jsonlPath = path.join(dir, "autoresearch.jsonl");
|
|
fs.writeFileSync(
|
|
jsonlPath,
|
|
[
|
|
JSON.stringify({
|
|
type: "config",
|
|
name: "Baseline after crash",
|
|
metricName: "runtime_ms",
|
|
metricUnit: "ms",
|
|
bestDirection: "lower",
|
|
secondaryMetrics: ["memory_mb", "tokens"],
|
|
}),
|
|
JSON.stringify({
|
|
commit: "aaaaaaa",
|
|
metric: 0,
|
|
status: "crash",
|
|
description: "broken first run",
|
|
timestamp: 1,
|
|
}),
|
|
JSON.stringify({
|
|
commit: "bbbbbbb",
|
|
metric: 120,
|
|
metrics: { memory_mb: 32 },
|
|
status: "keep",
|
|
description: "baseline",
|
|
timestamp: 2,
|
|
}),
|
|
].join("\n"),
|
|
);
|
|
|
|
const reconstructed = reconstructStateFromJsonl(dir);
|
|
expect(reconstructed.state.bestMetric).toBe(120);
|
|
expect(reconstructed.state.secondaryMetrics).toEqual([
|
|
{ name: "memory_mb", unit: "mb" },
|
|
{ name: "tokens", unit: "" },
|
|
]);
|
|
});
|
|
|
|
it("parses benchmark, scope, off-limits, and constraints from autoresearch.md", () => {
|
|
const contract = parseAutoresearchContract(`
|
|
# Autoresearch
|
|
|
|
## Benchmark
|
|
- command: bash autoresearch.sh
|
|
- primary metric: runtime_ms
|
|
- metric unit: ms
|
|
- direction: lower
|
|
- secondary metrics: memory_mb, tokens
|
|
|
|
## Files in Scope
|
|
- src/core
|
|
- src/feature.ts
|
|
|
|
## Off Limits
|
|
- src/generated
|
|
|
|
## Constraints
|
|
- keep API stable
|
|
- no behavior regressions
|
|
`);
|
|
|
|
expect(contract.benchmark.command).toBe("bash autoresearch.sh");
|
|
expect(contract.benchmark.primaryMetric).toBe("runtime_ms");
|
|
expect(contract.benchmark.metricUnit).toBe("ms");
|
|
expect(contract.benchmark.direction).toBe("lower");
|
|
expect(contract.benchmark.secondaryMetrics).toEqual(["memory_mb", "tokens"]);
|
|
expect(contract.scopePaths).toEqual(["src/core", "src/feature.ts"]);
|
|
expect(contract.offLimits).toEqual(["src/generated"]);
|
|
expect(contract.constraints).toEqual(["keep API stable", "no behavior regressions"]);
|
|
});
|
|
|
|
it("parses nested secondary metric bullets from autoresearch.md", () => {
|
|
const contract = parseAutoresearchContract(`
|
|
# Autoresearch
|
|
|
|
## Benchmark
|
|
- command: bash autoresearch.sh
|
|
- primary metric: runtime_ms
|
|
- metric unit: ms
|
|
- direction: lower
|
|
- secondary metrics:
|
|
- memory_mb
|
|
- rss_mb
|
|
|
|
## Files in Scope
|
|
- src
|
|
`);
|
|
|
|
expect(contract.benchmark.secondaryMetrics).toEqual(["memory_mb", "rss_mb"]);
|
|
});
|
|
|
|
it("allows empty optional sections while preserving an empty off-limits list", () => {
|
|
const contract = parseAutoresearchContract(`
|
|
# Autoresearch
|
|
|
|
## Benchmark
|
|
- command: bash autoresearch.sh
|
|
- primary metric: runtime_ms
|
|
- metric unit:
|
|
- direction: higher
|
|
|
|
## Files in Scope
|
|
- .
|
|
|
|
## Off Limits
|
|
|
|
## Constraints
|
|
`);
|
|
|
|
expect(contract.benchmark.metricUnit).toBe("");
|
|
expect(contract.benchmark.direction).toBe("higher");
|
|
expect(contract.scopePaths).toEqual(["."]);
|
|
expect(contract.offLimits).toEqual([]);
|
|
expect(contract.constraints).toEqual([]);
|
|
});
|
|
|
|
it("preserves free-form constraint text without path normalization", () => {
|
|
const contract = parseAutoresearchContract(`
|
|
# Autoresearch
|
|
|
|
## Benchmark
|
|
- command: bash autoresearch.sh
|
|
- primary metric: runtime_ms
|
|
- metric unit: ms
|
|
- direction: lower
|
|
|
|
## Files in Scope
|
|
- src/
|
|
|
|
## Off Limits
|
|
- generated/
|
|
|
|
## Constraints
|
|
- keep docs/ wording exactly as written
|
|
- do not rewrite ./README.md examples
|
|
`);
|
|
|
|
expect(contract.scopePaths).toEqual(["src"]);
|
|
expect(contract.offLimits).toEqual(["generated"]);
|
|
expect(contract.constraints).toEqual([
|
|
"keep docs/ wording exactly as written",
|
|
"do not rewrite ./README.md examples",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("autoresearch command guard", () => {
|
|
it("accepts autoresearch.sh through common wrappers", () => {
|
|
expect(isAutoresearchShCommand("bash autoresearch.sh")).toBe(true);
|
|
expect(isAutoresearchShCommand("FOO=bar time bash ./autoresearch.sh --quick")).toBe(true);
|
|
expect(isAutoresearchShCommand("nice -n 10 /tmp/project/autoresearch.sh")).toBe(true);
|
|
});
|
|
|
|
it("rejects commands where autoresearch.sh is not the first real command", () => {
|
|
expect(isAutoresearchShCommand("python script.py && ./autoresearch.sh")).toBe(false);
|
|
expect(isAutoresearchShCommand("echo hi; autoresearch.sh")).toBe(false);
|
|
expect(isAutoresearchShCommand("bash -lc 'autoresearch.sh'")).toBe(false);
|
|
});
|
|
|
|
it("rejects chained or redirected benchmark commands even when autoresearch.sh comes first", () => {
|
|
expect(isAutoresearchShCommand("bash autoresearch.sh && touch /tmp/marker")).toBe(false);
|
|
expect(isAutoresearchShCommand("./autoresearch.sh | tee run.log")).toBe(false);
|
|
expect(isAutoresearchShCommand("./autoresearch.sh > run.log")).toBe(false);
|
|
});
|
|
});
|
|
|
|
interface AutoresearchCommandHarness {
|
|
command: RegisteredCommand;
|
|
ctx: ExtensionCommandContext;
|
|
execCalls: Array<{ args: string[]; command: string }>;
|
|
sentMessages: string[];
|
|
inputCalls: Array<{ title: string; placeholder: string | undefined }>;
|
|
notifications: Array<{ message: string; type: "info" | "warning" | "error" | undefined }>;
|
|
}
|
|
|
|
function createAutoresearchCommandHarness(
|
|
cwd: string,
|
|
inputResult: string | string[] | undefined,
|
|
execImpl?: (command: string, args: string[]) => Promise<{ code: number; stderr: string; stdout: string }>,
|
|
): AutoresearchCommandHarness {
|
|
const execCalls: Array<{ args: string[]; command: string }> = [];
|
|
const sentMessages: string[] = [];
|
|
const inputCalls: Array<{ title: string; placeholder: string | undefined }> = [];
|
|
const notifications: Array<{ message: string; type: "info" | "warning" | "error" | undefined }> = [];
|
|
let command: RegisteredCommand | undefined;
|
|
const inputQueue = typeof inputResult === "string" || inputResult === undefined ? [inputResult] : [...inputResult];
|
|
|
|
const runGitMock = async (args: string[]) => {
|
|
execCalls.push({ args: [...args], command: "git" });
|
|
if (execImpl) {
|
|
return execImpl("git", args);
|
|
}
|
|
return { code: 0, stderr: "", stdout: "" };
|
|
};
|
|
|
|
vi.spyOn(git.repo, "root").mockImplementation(async () => {
|
|
const result = await runGitMock(["rev-parse", "--show-toplevel"]);
|
|
if (result.code !== 0) return null;
|
|
const repoRoot = result.stdout.trim();
|
|
return repoRoot.length > 0 ? repoRoot : null;
|
|
});
|
|
vi.spyOn(git.show, "prefix").mockImplementation(async () => {
|
|
const result = await runGitMock(["rev-parse", "--show-prefix"]);
|
|
return result.code === 0 ? result.stdout.trim() : "";
|
|
});
|
|
vi.spyOn(git.branch, "current").mockImplementation(async () => {
|
|
const result = await runGitMock(["branch", "--show-current"]);
|
|
if (result.code !== 0) return null;
|
|
const branch = result.stdout.trim();
|
|
return branch.length > 0 ? branch : null;
|
|
});
|
|
const mockStatus = Object.assign(
|
|
async (_cwd: string, options?: Parameters<typeof git.status>[1]) => {
|
|
const args = ["status", "--porcelain=v1", "--untracked-files=all", "-z"];
|
|
if (options?.pathspecs?.length) {
|
|
args.push("--", ...options.pathspecs);
|
|
}
|
|
const result = await runGitMock(args);
|
|
if (result.code !== 0)
|
|
throw new Error(result.stderr || result.stdout || `git status exited with code ${result.code}`);
|
|
return result.stdout;
|
|
},
|
|
{ parse: git.status.parse, summary: git.status.summary },
|
|
);
|
|
vi.spyOn(git, "status").mockImplementation(mockStatus);
|
|
vi.spyOn(git.ref, "exists").mockImplementation(async (_workDir, refName) => {
|
|
const result = await runGitMock(["show-ref", "--verify", "--quiet", refName]);
|
|
return result.code === 0;
|
|
});
|
|
vi.spyOn(git.branch, "checkoutNew").mockImplementation(async (_workDir, branchName) => {
|
|
const result = await runGitMock(["checkout", "-b", branchName]);
|
|
if (result.code !== 0) {
|
|
throw new Error(result.stderr || result.stdout || `git checkout exited with code ${result.code}`);
|
|
}
|
|
});
|
|
|
|
const api = {
|
|
appendEntry(_customType: string, _data?: unknown): void {},
|
|
exec: async (commandName: string, args: string[]) => {
|
|
execCalls.push({ args: [...args], command: commandName });
|
|
if (execImpl) {
|
|
return execImpl(commandName, args);
|
|
}
|
|
return { code: 0, stderr: "", stdout: "" };
|
|
},
|
|
on(): void {},
|
|
registerCommand(name: string, options: Omit<RegisteredCommand, "name">): void {
|
|
command = { name, ...options };
|
|
},
|
|
registerShortcut(): void {},
|
|
registerTool(): void {},
|
|
getActiveTools(): string[] {
|
|
return [];
|
|
},
|
|
setActiveTools: async (_toolNames: string[]): Promise<void> => {},
|
|
sendUserMessage(content: string | unknown[]): void {
|
|
if (typeof content !== "string") {
|
|
throw new Error("Expected autoresearch command to send plain text");
|
|
}
|
|
sentMessages.push(content);
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
createAutoresearchExtension(api);
|
|
if (!command) throw new Error("Expected autoresearch command to register");
|
|
|
|
const ctx = {
|
|
abort(): void {},
|
|
branch: async () => ({ cancelled: false }),
|
|
compact: async () => {},
|
|
cwd,
|
|
getContextUsage: () => undefined,
|
|
hasUI: false,
|
|
isIdle: () => true,
|
|
model: undefined,
|
|
modelRegistry: {},
|
|
newSession: async () => ({ cancelled: false }),
|
|
reload: async () => {},
|
|
sessionManager: {
|
|
getBranch: () => [],
|
|
getEntries: () => [],
|
|
getSessionId: () => "session-1",
|
|
},
|
|
switchSession: async () => ({ cancelled: false }),
|
|
navigateTree: async () => ({ cancelled: false }),
|
|
ui: {
|
|
confirm: async () => false,
|
|
custom: async () => undefined,
|
|
input: async (title: string, placeholder?: string) => {
|
|
inputCalls.push({ title, placeholder });
|
|
return inputQueue.shift();
|
|
},
|
|
notify(message: string, type?: "info" | "warning" | "error"): void {
|
|
notifications.push({ message, type });
|
|
},
|
|
onTerminalInput: () => () => {},
|
|
select: async () => undefined,
|
|
setFooter(): void {},
|
|
setHeader(): void {},
|
|
setStatus(): void {},
|
|
setTitle(): void {},
|
|
setWidget(): void {},
|
|
setWorkingMessage(): void {},
|
|
},
|
|
waitForIdle: async () => {},
|
|
} as unknown as ExtensionCommandContext;
|
|
|
|
return { command, ctx, execCalls, sentMessages, inputCalls, notifications };
|
|
}
|
|
|
|
interface AutoresearchLifecycleHarness {
|
|
beforeAgentStartHandler:
|
|
| ((event: { systemPrompt: string }, ctx: ExtensionContext) => Promise<unknown> | unknown)
|
|
| undefined;
|
|
sessionStartHandler: ((event: SessionStartEvent, ctx: ExtensionContext) => Promise<void> | void) | undefined;
|
|
sessionSwitchHandler: ((event: SessionSwitchEvent, ctx: ExtensionContext) => Promise<void> | void) | undefined;
|
|
agentEndHandler: ((event: unknown, ctx: ExtensionContext) => Promise<void> | void) | undefined;
|
|
toolCallHandler: ((event: ToolCallEvent, ctx: ExtensionContext) => Promise<unknown> | unknown) | undefined;
|
|
ctx: ExtensionContext;
|
|
setActiveToolsCalls: string[][];
|
|
sentMessages: Array<{ message: unknown; options: unknown }>;
|
|
}
|
|
|
|
function createAutoresearchLifecycleHarness(options: {
|
|
activeTools: string[];
|
|
branchEntries?: Array<{ type: "custom"; customType: string; data?: unknown }>;
|
|
controlEntries?: Array<{ type: "custom"; customType: string; data?: unknown }>;
|
|
cwd?: string;
|
|
}): AutoresearchLifecycleHarness {
|
|
const handlers = new Map<string, (...args: unknown[]) => Promise<void> | void>();
|
|
const activeTools = [...options.activeTools];
|
|
const setActiveToolsCalls: string[][] = [];
|
|
const sentMessages: Array<{ message: unknown; options: unknown }> = [];
|
|
|
|
const api = {
|
|
appendEntry(_customType: string, _data?: unknown): void {},
|
|
on(event: string, handler: (...args: unknown[]) => Promise<void> | void): void {
|
|
handlers.set(event, handler);
|
|
},
|
|
registerCommand(): void {},
|
|
registerShortcut(): void {},
|
|
registerTool(): void {},
|
|
getActiveTools(): string[] {
|
|
return [...activeTools];
|
|
},
|
|
sendMessage(message: unknown, options?: unknown): void {
|
|
sentMessages.push({ message, options });
|
|
},
|
|
async setActiveTools(toolNames: string[]): Promise<void> {
|
|
setActiveToolsCalls.push([...toolNames]);
|
|
activeTools.splice(0, activeTools.length, ...toolNames);
|
|
},
|
|
sendUserMessage(): void {},
|
|
} as unknown as ExtensionAPI;
|
|
createAutoresearchExtension(api);
|
|
|
|
const ctx = {
|
|
abort(): void {},
|
|
compact: async () => {},
|
|
cwd: options.cwd ?? makeTempDir(),
|
|
getContextUsage: () => undefined,
|
|
hasUI: false,
|
|
hasPendingMessages: () => false,
|
|
isIdle: () => true,
|
|
model: undefined,
|
|
modelRegistry: {},
|
|
sessionManager: {
|
|
getBranch: () => options.branchEntries ?? options.controlEntries ?? [],
|
|
getEntries: () => options.controlEntries ?? [],
|
|
getSessionId: () => "session-1",
|
|
},
|
|
shutdown: async () => {},
|
|
ui: {
|
|
confirm: async () => false,
|
|
custom: async () => undefined,
|
|
editor: async () => undefined,
|
|
getEditorText: () => "",
|
|
input: async () => undefined,
|
|
notify(): void {},
|
|
onTerminalInput: () => () => {},
|
|
select: async () => undefined,
|
|
setEditorComponent(): void {},
|
|
setEditorText(): void {},
|
|
setFooter(): void {},
|
|
setHeader(): void {},
|
|
setStatus(): void {},
|
|
setTheme: async () => false,
|
|
setTitle(): void {},
|
|
setToolsExpanded(): void {},
|
|
setWidget(): void {},
|
|
setWorkingMessage(): void {},
|
|
},
|
|
} as unknown as ExtensionContext;
|
|
|
|
return {
|
|
beforeAgentStartHandler: handlers.get("before_agent_start") as
|
|
| ((event: { systemPrompt: string }, ctx: ExtensionContext) => Promise<unknown> | unknown)
|
|
| undefined,
|
|
sessionStartHandler: handlers.get("session_start") as
|
|
| ((event: SessionStartEvent, ctx: ExtensionContext) => Promise<void> | void)
|
|
| undefined,
|
|
sessionSwitchHandler: handlers.get("session_switch") as
|
|
| ((event: SessionSwitchEvent, ctx: ExtensionContext) => Promise<void> | void)
|
|
| undefined,
|
|
agentEndHandler: handlers.get("agent_end") as
|
|
| ((event: unknown, ctx: ExtensionContext) => Promise<void> | void)
|
|
| undefined,
|
|
toolCallHandler: handlers.get("tool_call") as
|
|
| ((event: ToolCallEvent, ctx: ExtensionContext) => Promise<unknown> | unknown)
|
|
| undefined,
|
|
ctx,
|
|
setActiveToolsCalls,
|
|
sentMessages,
|
|
};
|
|
}
|
|
|
|
function createMainBranchHarness(dir: string): AutoresearchCommandHarness {
|
|
let currentBranch = "main";
|
|
const branches = new Set<string>();
|
|
return createAutoresearchCommandHarness(dir, [], async (command, args) => {
|
|
if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" };
|
|
if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` };
|
|
if (args[0] === "branch" && args[1] === "--show-current") {
|
|
return { code: 0, stderr: "", stdout: `${currentBranch}\n` };
|
|
}
|
|
if (args[0] === "status") return { code: 0, stderr: "", stdout: "" };
|
|
if (args[0] === "show-ref") {
|
|
const branchName = args[args.length - 1]?.replace("refs/heads/", "") ?? "";
|
|
return { code: branches.has(branchName) ? 0 : 1, stderr: "", stdout: "" };
|
|
}
|
|
if (args[0] === "checkout" && args[1] === "-b") {
|
|
currentBranch = args[2] ?? currentBranch;
|
|
branches.add(currentBranch);
|
|
return { code: 0, stderr: "", stdout: "" };
|
|
}
|
|
return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" };
|
|
});
|
|
}
|
|
|
|
describe("autoresearch command startup", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("enables autoresearch with notify and no agent turn when no autoresearch.md and no slash args", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const harness = createMainBranchHarness(dir);
|
|
|
|
await harness.command.handler("", harness.ctx);
|
|
|
|
expect(harness.inputCalls).toEqual([]);
|
|
expect(harness.sentMessages).toEqual([]);
|
|
expect(harness.notifications).toEqual([
|
|
{
|
|
message: "Autoresearch enabled—describe what to optimize in your next message.",
|
|
type: "info",
|
|
},
|
|
]);
|
|
const checkoutCall = harness.execCalls.find(call => call.command === "git" && call.args[0] === "checkout");
|
|
expect(checkoutCall?.args[2]).toMatch(/^autoresearch\/session-\d{8}$/);
|
|
});
|
|
|
|
it("toggles autoresearch off when bare command is sent while mode is already enabled", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const harness = createMainBranchHarness(dir);
|
|
|
|
await harness.command.handler("", harness.ctx);
|
|
await harness.command.handler("", harness.ctx);
|
|
|
|
expect(harness.sentMessages).toEqual([]);
|
|
expect(harness.notifications.filter(n => n.type === "info").map(n => n.message)).toEqual([
|
|
"Autoresearch enabled—describe what to optimize in your next message.",
|
|
"Autoresearch mode disabled",
|
|
]);
|
|
});
|
|
|
|
it("submits slash args as the raw user message when no autoresearch.md exists", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const harness = createMainBranchHarness(dir);
|
|
|
|
await harness.command.handler("reduce edit benchmark runtime variance", harness.ctx);
|
|
|
|
expect(harness.inputCalls).toEqual([]);
|
|
expect(harness.sentMessages).toEqual(["reduce edit benchmark runtime variance"]);
|
|
expect(harness.notifications).toEqual([]);
|
|
const checkoutCall = harness.execCalls.find(call => call.command === "git" && call.args[0] === "checkout");
|
|
expect(checkoutCall?.args[2]).toMatch(/^autoresearch\/reduce-edit-benchmark-runtime-variance-\d{8}$/);
|
|
});
|
|
|
|
it("resumes from autoresearch.md without asking for intent when notes already exist", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const autoresearchMdPath = path.join(dir, "autoresearch.md");
|
|
fs.writeFileSync(autoresearchMdPath, "# Autoresearch\n\nExisting notes\n");
|
|
const harness = createAutoresearchCommandHarness(dir, "ignored", async (command, args) => {
|
|
if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" };
|
|
if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` };
|
|
if (args[0] === "status") return { code: 0, stderr: "", stdout: "" };
|
|
if (args[0] === "branch" && args[1] === "--show-current") {
|
|
return { code: 0, stderr: "", stdout: "autoresearch/existing-20260322\n" };
|
|
}
|
|
return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" };
|
|
});
|
|
|
|
await harness.command.handler("", harness.ctx);
|
|
|
|
expect(harness.inputCalls).toEqual([]);
|
|
expect(harness.sentMessages).toHaveLength(1);
|
|
expect(harness.sentMessages[0]).toContain("Resume autoresearch from the attached notes.");
|
|
expect(harness.sentMessages[0]).toContain(`@${autoresearchMdPath}`);
|
|
expect(harness.sentMessages[0]).toContain("Using dedicated git branch `autoresearch/existing-20260322`.");
|
|
expect(harness.sentMessages[0]).toContain(
|
|
"Use the notes as the source of truth for the current direction, scope, and constraints.",
|
|
);
|
|
expect(harness.sentMessages[0]).toContain("- inspect `autoresearch.jsonl` if it exists");
|
|
expect(harness.sentMessages[0]).toContain(
|
|
"- continue the most promising unfinished direction on the current protected branch",
|
|
);
|
|
});
|
|
|
|
it("includes explicit resume context when the user resumes with additional instructions", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const autoresearchMdPath = path.join(dir, "autoresearch.md");
|
|
fs.writeFileSync(autoresearchMdPath, "# Autoresearch\n\nExisting notes\n");
|
|
await Bun.write(path.join(dir, ".autoresearch", "runs", "0001", "run.json"), "{}");
|
|
const harness = createAutoresearchCommandHarness(dir, undefined, async (command, args) => {
|
|
if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" };
|
|
if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` };
|
|
if (args[0] === "status") return { code: 0, stderr: "", stdout: "" };
|
|
if (args[0] === "branch" && args[1] === "--show-current") {
|
|
return { code: 0, stderr: "", stdout: "autoresearch/existing-20260322\n" };
|
|
}
|
|
return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" };
|
|
});
|
|
|
|
await harness.command.handler("focus on memory regressions next", harness.ctx);
|
|
|
|
expect(harness.sentMessages).toHaveLength(1);
|
|
expect(harness.sentMessages[0]).toContain("Additional context from the user:");
|
|
expect(harness.sentMessages[0]).toContain("focus on memory regressions next");
|
|
expect(harness.sentMessages[0]).toContain(`@${autoresearchMdPath}`);
|
|
});
|
|
|
|
it("treats an explicit new intent as a fresh setup when only stale notes remain", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
fs.writeFileSync(path.join(dir, "autoresearch.md"), "# Autoresearch\n\nOld notes\n");
|
|
const harness = createMainBranchHarness(dir);
|
|
|
|
await harness.command.handler("focus on memory regressions next", harness.ctx);
|
|
|
|
expect(harness.inputCalls).toEqual([]);
|
|
expect(harness.sentMessages).toEqual(["focus on memory regressions next"]);
|
|
expect(harness.sentMessages[0]).not.toContain("Resume autoresearch from the attached notes.");
|
|
});
|
|
|
|
it("refuses to resume on an autoresearch branch when non-local files are dirty", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const autoresearchMdPath = path.join(dir, "autoresearch.md");
|
|
fs.writeFileSync(autoresearchMdPath, "# Autoresearch\n\nExisting notes\n");
|
|
const harness = createAutoresearchCommandHarness(dir, "ignored", async (command, args) => {
|
|
if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" };
|
|
if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` };
|
|
if (args[0] === "status") {
|
|
return { code: 0, stderr: "", stdout: " M packages/coding-agent/src/sdk.ts\n" };
|
|
}
|
|
if (args[0] === "branch" && args[1] === "--show-current") {
|
|
return { code: 0, stderr: "", stdout: "autoresearch/existing-20260322\n" };
|
|
}
|
|
return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" };
|
|
});
|
|
|
|
await harness.command.handler("", harness.ctx);
|
|
|
|
expect(harness.sentMessages).toEqual([]);
|
|
expect(harness.notifications).toEqual([
|
|
{
|
|
message:
|
|
"Autoresearch needs a clean git worktree before it can create or reuse an isolated branch. Commit or stash these paths first: packages/coding-agent/src/sdk.ts",
|
|
type: "error",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses slash arguments as intent without validating benchmark command shape", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const harness = createMainBranchHarness(dir);
|
|
|
|
await harness.command.handler("pnpm test", harness.ctx);
|
|
|
|
expect(harness.inputCalls).toEqual([]);
|
|
expect(harness.sentMessages).toEqual(["pnpm test"]);
|
|
expect(harness.notifications).toEqual([]);
|
|
});
|
|
|
|
it("refuses to start when non-autoresearch files are dirty on a non-autoresearch branch", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
const harness = createAutoresearchCommandHarness(dir, [], async (command, args) => {
|
|
if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" };
|
|
if (args[0] === "rev-parse" && args[1] === "--show-prefix") {
|
|
return { code: 0, stderr: "", stdout: "" };
|
|
}
|
|
if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` };
|
|
if (args[0] === "branch" && args[1] === "--show-current") {
|
|
return { code: 0, stderr: "", stdout: "main\n" };
|
|
}
|
|
if (args[0] === "status") {
|
|
return { code: 0, stderr: "", stdout: " M packages/coding-agent/src/sdk.ts\n" };
|
|
}
|
|
return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" };
|
|
});
|
|
|
|
await harness.command.handler("", harness.ctx);
|
|
|
|
expect(harness.sentMessages).toEqual([]);
|
|
expect(harness.notifications).toEqual([
|
|
{
|
|
message:
|
|
"Autoresearch needs a clean git worktree before it can create or reuse an isolated branch. Commit or stash these paths first: packages/coding-agent/src/sdk.ts",
|
|
type: "error",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("ignores autoresearch local state but still blocks dirty control files before creating a branch", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
|
|
const localStateHarness = createAutoresearchCommandHarness(dir, [], async (command, args) => {
|
|
if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" };
|
|
if (args[0] === "rev-parse" && args[1] === "--show-prefix") {
|
|
return { code: 0, stderr: "", stdout: "" };
|
|
}
|
|
if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` };
|
|
if (args[0] === "branch" && args[1] === "--show-current") {
|
|
return { code: 0, stderr: "", stdout: "main\n" };
|
|
}
|
|
if (args[0] === "status") {
|
|
return { code: 0, stderr: "", stdout: "?? autoresearch.jsonl\n?? .autoresearch/runs/0001/run.json\n" };
|
|
}
|
|
if (args[0] === "show-ref") return { code: 1, stderr: "", stdout: "" };
|
|
if (args[0] === "checkout" && args[1] === "-b") return { code: 0, stderr: "", stdout: "" };
|
|
return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" };
|
|
});
|
|
|
|
await localStateHarness.command.handler("", localStateHarness.ctx);
|
|
|
|
expect(localStateHarness.sentMessages).toEqual([]);
|
|
expect(localStateHarness.notifications).toEqual([
|
|
{
|
|
message: "Autoresearch enabled—describe what to optimize in your next message.",
|
|
type: "info",
|
|
},
|
|
]);
|
|
|
|
const dirtyControlHarness = createAutoresearchCommandHarness(dir, [], async (command, args) => {
|
|
if (command !== "git") return { code: 1, stderr: "unexpected command", stdout: "" };
|
|
if (args[0] === "rev-parse") return { code: 0, stderr: "", stdout: `${dir}\n` };
|
|
if (args[0] === "branch" && args[1] === "--show-current") {
|
|
return { code: 0, stderr: "", stdout: "main\n" };
|
|
}
|
|
if (args[0] === "status") {
|
|
return { code: 0, stderr: "", stdout: " M autoresearch.md\n" };
|
|
}
|
|
return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" };
|
|
});
|
|
|
|
await dirtyControlHarness.command.handler("", dirtyControlHarness.ctx);
|
|
|
|
expect(dirtyControlHarness.sentMessages).toEqual([]);
|
|
expect(dirtyControlHarness.notifications).toEqual([
|
|
{
|
|
message:
|
|
"Autoresearch needs a clean git worktree before it can create or reuse an isolated branch. Commit or stash these paths first: autoresearch.md",
|
|
type: "error",
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("autoresearch tool-call guard", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("blocks out-of-scope edits but allows autoresearch control files", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
fs.writeFileSync(
|
|
path.join(dir, "autoresearch.jsonl"),
|
|
`${JSON.stringify({
|
|
type: "config",
|
|
metricName: "runtime_ms",
|
|
metricUnit: "ms",
|
|
scopePaths: ["src"],
|
|
offLimits: ["src/generated"],
|
|
})}\n`,
|
|
);
|
|
|
|
const harness = createAutoresearchLifecycleHarness({
|
|
activeTools: [],
|
|
controlEntries: [{ type: "custom", customType: "autoresearch-control", data: { mode: "on", goal: "x" } }],
|
|
cwd: dir,
|
|
});
|
|
|
|
await harness.sessionStartHandler?.({ type: "session_start" } as SessionStartEvent, harness.ctx);
|
|
|
|
const blockedScope = await harness.toolCallHandler?.(
|
|
{
|
|
type: "tool_call",
|
|
toolCallId: "call-1",
|
|
toolName: "write",
|
|
input: { path: "README.md", content: "nope" },
|
|
},
|
|
harness.ctx,
|
|
);
|
|
expect(blockedScope).toEqual({
|
|
block: true,
|
|
reason: expect.stringContaining("outside Files in Scope"),
|
|
});
|
|
|
|
const blockedLocalState = await harness.toolCallHandler?.(
|
|
{
|
|
type: "tool_call",
|
|
toolCallId: "call-2",
|
|
toolName: "write",
|
|
input: { path: "autoresearch.jsonl", content: "[]" },
|
|
},
|
|
harness.ctx,
|
|
);
|
|
expect(blockedLocalState).toEqual({
|
|
block: true,
|
|
reason: expect.stringContaining("local state files"),
|
|
});
|
|
|
|
const allowedControl = await harness.toolCallHandler?.(
|
|
{
|
|
type: "tool_call",
|
|
toolCallId: "call-3",
|
|
toolName: "write",
|
|
input: { path: "autoresearch.program.md", content: "# Strategy" },
|
|
},
|
|
harness.ctx,
|
|
);
|
|
expect(allowedControl).toBeUndefined();
|
|
});
|
|
|
|
it("requires ast_edit to declare an explicit path during autoresearch", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
fs.writeFileSync(
|
|
path.join(dir, "autoresearch.jsonl"),
|
|
`${JSON.stringify({ type: "config", scopePaths: ["src"] })}\n`,
|
|
);
|
|
|
|
const harness = createAutoresearchLifecycleHarness({
|
|
activeTools: [],
|
|
controlEntries: [{ type: "custom", customType: "autoresearch-control", data: { mode: "on" } }],
|
|
cwd: dir,
|
|
});
|
|
|
|
await harness.sessionStartHandler?.({ type: "session_start" } as SessionStartEvent, harness.ctx);
|
|
|
|
const blocked = await harness.toolCallHandler?.(
|
|
{
|
|
type: "tool_call",
|
|
toolCallId: "call-ast",
|
|
toolName: "ast_edit",
|
|
input: { ops: [{ pat: "a", out: "b" }] },
|
|
},
|
|
harness.ctx,
|
|
);
|
|
expect(blocked).toEqual({
|
|
block: true,
|
|
reason: expect.stringContaining("explicit target path"),
|
|
});
|
|
});
|
|
|
|
it("blocks symlink escapes that point outside the working tree", async () => {
|
|
const dir = makeTempDir();
|
|
const outsideDir = makeTempDir();
|
|
tempDirs.push(dir, outsideDir);
|
|
fs.mkdirSync(path.join(dir, "src"), { recursive: true });
|
|
fs.symlinkSync(outsideDir, path.join(dir, "src", "linked-outside"), "dir");
|
|
fs.writeFileSync(
|
|
path.join(dir, "autoresearch.jsonl"),
|
|
`${JSON.stringify({ type: "config", scopePaths: ["src"] })}\n`,
|
|
);
|
|
|
|
const harness = createAutoresearchLifecycleHarness({
|
|
activeTools: [],
|
|
controlEntries: [{ type: "custom", customType: "autoresearch-control", data: { mode: "on" } }],
|
|
cwd: dir,
|
|
});
|
|
|
|
await harness.sessionStartHandler?.({ type: "session_start" } as SessionStartEvent, harness.ctx);
|
|
|
|
const blocked = await harness.toolCallHandler?.(
|
|
{
|
|
type: "tool_call",
|
|
toolCallId: "call-symlink",
|
|
toolName: "write",
|
|
input: { path: "src/linked-outside/escape.ts", content: "export const value = 1;\n" },
|
|
},
|
|
harness.ctx,
|
|
);
|
|
expect(blocked).toEqual({
|
|
block: true,
|
|
reason: expect.stringContaining("outside the working tree"),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("autoresearch auto-resume", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("includes the pending-run reminder after rehydrate when agent_end schedules an auto-resume", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
fs.writeFileSync(
|
|
path.join(dir, "autoresearch.jsonl"),
|
|
`${JSON.stringify({ type: "config", metricName: "runtime_ms", scopePaths: ["src"] })}\n`,
|
|
);
|
|
await Bun.write(
|
|
path.join(dir, ".autoresearch", "runs", "0001", "run.json"),
|
|
JSON.stringify({
|
|
command: "bash autoresearch.sh",
|
|
exitCode: 0,
|
|
parsedPrimary: 10,
|
|
runNumber: 1,
|
|
}),
|
|
);
|
|
|
|
const harness = createAutoresearchLifecycleHarness({
|
|
activeTools: ["init_experiment", "run_experiment", "log_experiment"],
|
|
controlEntries: [{ type: "custom", customType: "autoresearch-control", data: { mode: "on", goal: "x" } }],
|
|
cwd: dir,
|
|
});
|
|
|
|
await harness.sessionStartHandler?.({ type: "session_start" } as SessionStartEvent, harness.ctx);
|
|
await harness.agentEndHandler?.({}, harness.ctx);
|
|
|
|
expect(harness.sentMessages).toHaveLength(1);
|
|
expect(harness.sentMessages[0]?.message).toMatchObject({
|
|
customType: "autoresearch-resume",
|
|
content: expect.stringContaining("finish the pending `log_experiment` step"),
|
|
});
|
|
expect(harness.sentMessages[0]?.options).toMatchObject({
|
|
deliverAs: "nextTurn",
|
|
triggerTurn: true,
|
|
});
|
|
});
|
|
|
|
it("does not enqueue another hidden turn after a passive autoresearch turn with no pending run", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
fs.writeFileSync(
|
|
path.join(dir, "autoresearch.jsonl"),
|
|
`${JSON.stringify({ type: "config", metricName: "runtime_ms", scopePaths: ["src"] })}\n`,
|
|
);
|
|
|
|
const harness = createAutoresearchLifecycleHarness({
|
|
activeTools: ["init_experiment", "run_experiment", "log_experiment"],
|
|
controlEntries: [{ type: "custom", customType: "autoresearch-control", data: { mode: "on", goal: "x" } }],
|
|
cwd: dir,
|
|
});
|
|
|
|
await harness.sessionStartHandler?.({ type: "session_start" } as SessionStartEvent, harness.ctx);
|
|
await harness.agentEndHandler?.({}, harness.ctx);
|
|
|
|
expect(harness.sentMessages).toEqual([]);
|
|
});
|
|
|
|
it("renders the high-signal prompt sections for playbooks, backlog, recent runs, and pending runs", async () => {
|
|
const dir = makeTempDir();
|
|
tempDirs.push(dir);
|
|
fs.writeFileSync(path.join(dir, "autoresearch.md"), "# Autoresearch\n");
|
|
fs.writeFileSync(path.join(dir, "autoresearch.program.md"), "# Local Playbook\n");
|
|
fs.writeFileSync(path.join(dir, "autoresearch.ideas.md"), "- try batching\n");
|
|
fs.writeFileSync(path.join(dir, "autoresearch.checks.sh"), "#!/usr/bin/env bash\n");
|
|
fs.writeFileSync(
|
|
path.join(dir, "autoresearch.jsonl"),
|
|
[
|
|
JSON.stringify({
|
|
type: "config",
|
|
metricName: "runtime_ms",
|
|
metricUnit: "ms",
|
|
scopePaths: ["src"],
|
|
}),
|
|
JSON.stringify({
|
|
run: 1,
|
|
commit: "aaaaaaa",
|
|
metric: 10,
|
|
status: "keep",
|
|
description: "baseline",
|
|
timestamp: 1,
|
|
asi: { hypothesis: "baseline" },
|
|
}),
|
|
JSON.stringify({
|
|
run: 2,
|
|
commit: "bbbbbbb",
|
|
metric: 9,
|
|
status: "discard",
|
|
description: "too noisy",
|
|
timestamp: 2,
|
|
asi: {
|
|
hypothesis: "raise cache size",
|
|
rollback_reason: "noise",
|
|
next_action_hint: "re-test with more samples",
|
|
},
|
|
}),
|
|
].join("\n"),
|
|
);
|
|
await Bun.write(
|
|
path.join(dir, ".autoresearch", "runs", "0003", "run.json"),
|
|
JSON.stringify({
|
|
command: "bash autoresearch.sh",
|
|
completedAt: new Date().toISOString(),
|
|
durationSeconds: 1,
|
|
exitCode: 0,
|
|
parsedPrimary: 8,
|
|
runNumber: 3,
|
|
}),
|
|
);
|
|
|
|
const harness = createAutoresearchLifecycleHarness({
|
|
activeTools: ["init_experiment", "run_experiment", "log_experiment"],
|
|
controlEntries: [{ type: "custom", customType: "autoresearch-control", data: { mode: "on", goal: "x" } }],
|
|
cwd: dir,
|
|
});
|
|
|
|
await harness.sessionStartHandler?.({ type: "session_start" } as SessionStartEvent, harness.ctx);
|
|
const result = await harness.beforeAgentStartHandler?.({ systemPrompt: "BASE" }, harness.ctx);
|
|
const systemPrompt =
|
|
typeof result === "object" && result !== null && "systemPrompt" in result
|
|
? String((result as { systemPrompt: string }).systemPrompt)
|
|
: "";
|
|
|
|
expect(systemPrompt).toContain("### Local Playbook");
|
|
expect(systemPrompt).toContain("### Current Segment Snapshot");
|
|
expect(systemPrompt).toContain("### Pending Run");
|
|
expect(systemPrompt).toContain("### Ideas backlog");
|
|
expect(systemPrompt).toContain("Recent runs:");
|
|
expect(systemPrompt).toContain("finish the `log_experiment` step before starting another benchmark");
|
|
});
|
|
});
|
|
|
|
describe("autoresearch lifecycle tool activation", () => {
|
|
it("activates experiment tools when rehydrating an autoresearch session", async () => {
|
|
const harness = createAutoresearchLifecycleHarness({
|
|
activeTools: ["read", "write"],
|
|
controlEntries: [{ type: "custom", customType: "autoresearch-control", data: { mode: "on", goal: "speed" } }],
|
|
});
|
|
|
|
if (!harness.sessionStartHandler) throw new Error("Expected session_start handler");
|
|
await harness.sessionStartHandler({ type: "session_start" }, harness.ctx);
|
|
|
|
expect(harness.setActiveToolsCalls).toEqual([
|
|
["read", "write", "init_experiment", "run_experiment", "log_experiment"],
|
|
]);
|
|
});
|
|
|
|
it("removes experiment tools when rehydrating a non-autoresearch session", async () => {
|
|
const harness = createAutoresearchLifecycleHarness({
|
|
activeTools: ["read", "init_experiment", "run_experiment", "log_experiment"],
|
|
});
|
|
|
|
if (!harness.sessionSwitchHandler) throw new Error("Expected session_switch handler");
|
|
await harness.sessionSwitchHandler(
|
|
{ type: "session_switch", reason: "resume", previousSessionFile: "/tmp/previous.jsonl" },
|
|
harness.ctx,
|
|
);
|
|
|
|
expect(harness.setActiveToolsCalls).toEqual([["read"]]);
|
|
});
|
|
|
|
it("rehydrates control state from the active branch only", async () => {
|
|
const harness = createAutoresearchLifecycleHarness({
|
|
activeTools: ["read"],
|
|
branchEntries: [{ type: "custom", customType: "autoresearch-control", data: { mode: "off" } }],
|
|
controlEntries: [{ type: "custom", customType: "autoresearch-control", data: { mode: "on", goal: "speed" } }],
|
|
});
|
|
|
|
if (!harness.sessionStartHandler) throw new Error("Expected session_start handler");
|
|
await harness.sessionStartHandler({ type: "session_start" }, harness.ctx);
|
|
|
|
expect(harness.setActiveToolsCalls).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("autoresearch ASI requirements", () => {
|
|
it("requires a hypothesis for every run", () => {
|
|
expect(validateAsiRequirements(undefined, "keep")).toBe(
|
|
"asi is required. Include at minimum a non-empty hypothesis.",
|
|
);
|
|
expect(validateAsiRequirements({}, "keep")).toBe("asi.hypothesis is required and must be a non-empty string.");
|
|
});
|
|
|
|
it("requires rollback metadata for failed runs", () => {
|
|
expect(validateAsiRequirements({ hypothesis: "try a smaller cache" }, "discard")).toBe(
|
|
"asi.rollback_reason is required for discard, crash, and checks_failed results.",
|
|
);
|
|
expect(
|
|
validateAsiRequirements(
|
|
{ hypothesis: "try a smaller cache", rollback_reason: "metric regressed" },
|
|
"checks_failed",
|
|
),
|
|
).toBe("asi.next_action_hint is required for discard, crash, and checks_failed results.");
|
|
expect(
|
|
validateAsiRequirements(
|
|
{
|
|
hypothesis: "try a smaller cache",
|
|
next_action_hint: "re-run with lower batch size",
|
|
rollback_reason: "metric regressed",
|
|
},
|
|
"crash",
|
|
),
|
|
).toBeNull();
|
|
});
|
|
});
|