feat(coding-agent): added git isolation and keybinding utilities for autoresearch
- Added git branch isolation for autoresearch sessions with automatic branch creation, reuse, and worktree safety checks. - Added scope definition sections (Files in Scope, Off Limits, Constraints) to autoresearch template for explicit session boundaries. - Added keybinding matcher utilities for consistent escape/cancel key handling across interactive components. - Added ASI metadata validation (hypothesis and rollback context) in log_experiment tool for experiment tracking. - Refactored keybinding logic across 13 components to use centralized matcher functions instead of inline key checks.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed hashline edit operation types: `append` → `append_at`, `prepend` → `prepend_at`, `append_eof` → `append_file`, `prepend_bof` → `prepend_file`
|
||||
@@ -10,6 +11,12 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added git branch isolation for autoresearch sessions via `ensureAutoresearchBranch()` to safely revert failed experiments
|
||||
- Added branch status line to autoresearch initialization and resume prompts showing created or reused branch name
|
||||
- Added `Files in Scope`, `Off Limits`, and `Constraints` sections to autoresearch.md template for explicit scope definition
|
||||
- Added validation of ASI metadata requirements in `log_experiment` tool, requiring hypothesis for all runs and rollback context for failed runs
|
||||
- Added keybinding matcher utilities `matchesAppInterrupt()` and `matchesSelectCancel()` for consistent escape key handling across components
|
||||
- Added support for customizable `app.interrupt` and `tui.select.cancel` keybindings in interactive components
|
||||
- Added `defaultInactive` property to `ToolDefinition` to allow tools to be registered but excluded from the initial active set, with extension responsibility for activation/deactivation
|
||||
- Added dynamic tool activation/deactivation in autoresearch mode via `setActiveTools()` API
|
||||
- Added separate initialization and resume workflows for autoresearch with `command-initialize.md` and `command-resume.md` prompts
|
||||
@@ -36,10 +43,17 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop
|
||||
- Changed autoresearch to refuse startup when unrelated worktree changes would make auto-reverts unsafe
|
||||
- Changed autoresearch prompts to emphasize scope and constraints as source of truth for session direction
|
||||
- Changed component escape key handling to use keybinding manager for `app.interrupt` and `tui.select.cancel` with fallback to raw Escape matching
|
||||
- Updated autoresearch prompt guidance to require explicit files in scope, off-limits paths, and session constraints
|
||||
- Changed autoresearch command to use intent-based initialization instead of goal parameter, with user input dialog for new sessions
|
||||
- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop, and to refuse startup when unrelated worktree changes would make auto-reverts unsafe
|
||||
- Changed autoresearch startup to activate experiment tools (`init_experiment`, `run_experiment`, `log_experiment`) only when autoresearch mode is enabled
|
||||
- Changed autoresearch shutdown to deactivate experiment tools when mode is disabled or cleared
|
||||
- Changed autoresearch session rehydration to dynamically manage experiment tool activation based on session state
|
||||
- Changed autoresearch prompts and notes guidance to require explicit files in scope, off-limits paths, and session constraints
|
||||
- Refactored hashline edit validation to enforce stricter anchor requirements per operation type
|
||||
- Updated edit application logic to handle explicit file-level operations (`append_eof`, `prepend_bof`) separately from anchor-based operations
|
||||
- Changed `setWidget` API to accept `ExtensionWidgetOptions` parameter for placement control
|
||||
@@ -62,6 +76,11 @@
|
||||
- Removed `shouldAutocorrect` function and related boundary line deduplication logic from hashline editor
|
||||
- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments
|
||||
- Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments
|
||||
|
||||
## [13.14.0] - 2026-03-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,10 +2,13 @@ Set up autoresearch for this intent:
|
||||
|
||||
{{intent}}
|
||||
|
||||
{{branch_status_line}}
|
||||
|
||||
Explain briefly what autoresearch will do in this repository, then initialize the workspace.
|
||||
|
||||
Your first actions:
|
||||
- write `autoresearch.md`
|
||||
- define `Files in Scope`, `Off Limits`, and `Constraints` in `autoresearch.md`
|
||||
- define the benchmark entrypoint in `autoresearch.sh`
|
||||
- optionally add `autoresearch.checks.sh` if correctness or quality needs a hard gate
|
||||
- run `init_experiment`
|
||||
|
||||
@@ -2,7 +2,9 @@ Resume autoresearch from the attached notes.
|
||||
|
||||
@{{autoresearch_md_path}}
|
||||
|
||||
Use the notes as the source of truth for the current direction.
|
||||
{{branch_status_line}}
|
||||
|
||||
Use the notes as the source of truth for the current direction, scope, and constraints.
|
||||
- inspect recent git history for context
|
||||
- inspect `autoresearch.jsonl` if it exists
|
||||
- continue the most promising unfinished branch
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ExtensionAPI } from "../extensibility/extensions";
|
||||
import { PROTECTED_AUTORESEARCH_FILES } from "./helpers";
|
||||
|
||||
const AUTORESEARCH_BRANCH_PREFIX = "autoresearch/";
|
||||
const BRANCH_NAME_MAX_LENGTH = 48;
|
||||
|
||||
export interface EnsureAutoresearchBranchFailure {
|
||||
error: string;
|
||||
ok: false;
|
||||
}
|
||||
|
||||
export interface EnsureAutoresearchBranchSuccess {
|
||||
branchName: string;
|
||||
created: boolean;
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export type EnsureAutoresearchBranchResult = EnsureAutoresearchBranchFailure | EnsureAutoresearchBranchSuccess;
|
||||
|
||||
export async function ensureAutoresearchBranch(
|
||||
api: ExtensionAPI,
|
||||
workDir: string,
|
||||
goal: string | null,
|
||||
): Promise<EnsureAutoresearchBranchResult> {
|
||||
const repoRootResult = await api.exec("git", ["rev-parse", "--show-toplevel"], { cwd: workDir, timeout: 5_000 });
|
||||
if (repoRootResult.code !== 0) {
|
||||
return {
|
||||
error: "Autoresearch requires a git repository so it can isolate experiments and revert failed runs safely.",
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
const currentBranchResult = await api.exec("git", ["branch", "--show-current"], { cwd: workDir, timeout: 5_000 });
|
||||
const currentBranch = currentBranchResult.stdout.trim();
|
||||
if (currentBranch.startsWith(AUTORESEARCH_BRANCH_PREFIX)) {
|
||||
return {
|
||||
branchName: currentBranch,
|
||||
created: false,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
const dirtyPathsResult = await api.exec("git", ["status", "--porcelain", "--untracked-files=all"], {
|
||||
cwd: workDir,
|
||||
timeout: 5_000,
|
||||
});
|
||||
if (dirtyPathsResult.code !== 0) {
|
||||
return {
|
||||
error: `Unable to inspect git status before starting autoresearch: ${mergeStdoutStderr(dirtyPathsResult).trim() || `exit ${dirtyPathsResult.code}`}`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
const unsafeDirtyPaths = parseUnsafeDirtyPaths(dirtyPathsResult.stdout);
|
||||
if (unsafeDirtyPaths.length > 0) {
|
||||
const preview = unsafeDirtyPaths.slice(0, 5).join(", ");
|
||||
const suffix = unsafeDirtyPaths.length > 5 ? ` (+${unsafeDirtyPaths.length - 5} more)` : "";
|
||||
return {
|
||||
error:
|
||||
"Autoresearch needs a clean git worktree before it can create an isolated branch. " +
|
||||
`Commit or stash these paths first: ${preview}${suffix}`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
const branchName = await allocateBranchName(api, workDir, goal);
|
||||
const checkoutResult = await api.exec("git", ["checkout", "-b", branchName], { cwd: workDir, timeout: 10_000 });
|
||||
if (checkoutResult.code !== 0) {
|
||||
return {
|
||||
error:
|
||||
`Failed to create autoresearch branch ${branchName}: ` +
|
||||
`${mergeStdoutStderr(checkoutResult).trim() || `exit ${checkoutResult.code}`}`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
branchName,
|
||||
created: true,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUnsafeDirtyPaths(statusOutput: string): string[] {
|
||||
const unsafePaths = new Set<string>();
|
||||
for (const line of statusOutput.split("\n")) {
|
||||
const trimmedLine = line.trimEnd();
|
||||
if (trimmedLine.length < 4) continue;
|
||||
const rawPath = trimmedLine.slice(3).trim();
|
||||
if (rawPath.length === 0) continue;
|
||||
const renameParts = rawPath.split(" -> ");
|
||||
const normalizedPath = normalizeStatusPath(renameParts[renameParts.length - 1] ?? rawPath);
|
||||
if (normalizedPath.length === 0) continue;
|
||||
if (PROTECTED_AUTORESEARCH_FILES.some(path => path === normalizedPath)) continue;
|
||||
unsafePaths.add(normalizedPath);
|
||||
}
|
||||
return [...unsafePaths];
|
||||
}
|
||||
|
||||
function normalizeStatusPath(path: string): string {
|
||||
let normalized = path.trim();
|
||||
if (normalized.startsWith('"') && normalized.endsWith('"')) {
|
||||
normalized = normalized.slice(1, -1);
|
||||
}
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.slice(2);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function allocateBranchName(api: ExtensionAPI, workDir: string, goal: string | null): Promise<string> {
|
||||
const baseName = `${AUTORESEARCH_BRANCH_PREFIX}${slugifyGoal(goal)}-${currentDateStamp()}`;
|
||||
let candidate = baseName;
|
||||
let suffix = 2;
|
||||
while (await branchExists(api, workDir, candidate)) {
|
||||
candidate = `${baseName}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function branchExists(api: ExtensionAPI, workDir: string, branchName: string): Promise<boolean> {
|
||||
const result = await api.exec("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
||||
cwd: workDir,
|
||||
timeout: 5_000,
|
||||
});
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
function slugifyGoal(goal: string | null): string {
|
||||
const normalized = (goal ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const trimmed = normalized.slice(0, BRANCH_NAME_MAX_LENGTH).replace(/-+$/g, "");
|
||||
return trimmed || "session";
|
||||
}
|
||||
|
||||
function currentDateStamp(): string {
|
||||
const now = new Date();
|
||||
const year = String(now.getFullYear());
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
function mergeStdoutStderr(result: { stderr: string; stdout: string }): string {
|
||||
return `${result.stdout}${result.stderr}`;
|
||||
}
|
||||
@@ -9,6 +9,13 @@ export const METRIC_LINE_PREFIX = "METRIC";
|
||||
export const ASI_LINE_PREFIX = "ASI";
|
||||
export const EXPERIMENT_MAX_LINES = 10;
|
||||
export const EXPERIMENT_MAX_BYTES = 4 * 1024;
|
||||
export const PROTECTED_AUTORESEARCH_FILES = [
|
||||
"autoresearch.jsonl",
|
||||
"autoresearch.md",
|
||||
"autoresearch.ideas.md",
|
||||
"autoresearch.sh",
|
||||
"autoresearch.checks.sh",
|
||||
] as const;
|
||||
|
||||
const DENIED_KEY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ExtensionContext, ExtensionFactory } from "../extensibility/extens
|
||||
import commandInitializeTemplate from "./command-initialize.md" with { type: "text" };
|
||||
import commandResumeTemplate from "./command-resume.md" with { type: "text" };
|
||||
import { createDashboardController } from "./dashboard";
|
||||
import { ensureAutoresearchBranch } from "./git";
|
||||
import { readMaxExperiments, resolveWorkDir, validateWorkDir } from "./helpers";
|
||||
import promptTemplate from "./prompt.md" with { type: "text" };
|
||||
import resumeMessageTemplate from "./resume-message.md" with { type: "text" };
|
||||
@@ -131,6 +132,12 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
|
||||
const hasAutoresearchMd = fs.existsSync(autoresearchMdPath);
|
||||
|
||||
if (hasAutoresearchMd) {
|
||||
const branchResult = await ensureAutoresearchBranch(api, workDir, runtime.goal);
|
||||
if (!branchResult.ok) {
|
||||
ctx.ui.notify(branchResult.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(ctx, true, runtime.goal, "on");
|
||||
runtime.experimentsThisSession = 0;
|
||||
runtime.autoResumeTurns = 0;
|
||||
@@ -139,6 +146,9 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
|
||||
api.sendUserMessage(
|
||||
renderPromptTemplate(commandResumeTemplate, {
|
||||
autoresearch_md_path: autoresearchMdPath,
|
||||
branch_status_line: branchResult.created
|
||||
? `Created and checked out dedicated git branch \`${branchResult.branchName}\` before resuming.`
|
||||
: `Using dedicated git branch \`${branchResult.branchName}\`.`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -156,12 +166,25 @@ export const createAutoresearchExtension: ExtensionFactory = api => {
|
||||
return;
|
||||
}
|
||||
|
||||
const branchResult = await ensureAutoresearchBranch(api, workDir, intent);
|
||||
if (!branchResult.ok) {
|
||||
ctx.ui.notify(branchResult.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(ctx, true, intent, "on");
|
||||
runtime.experimentsThisSession = 0;
|
||||
runtime.autoResumeTurns = 0;
|
||||
dashboard.updateWidget(ctx, runtime);
|
||||
await api.setActiveTools([...new Set([...api.getActiveTools(), ...EXPERIMENT_TOOL_NAMES])]);
|
||||
api.sendUserMessage(renderPromptTemplate(commandInitializeTemplate, { intent }));
|
||||
api.sendUserMessage(
|
||||
renderPromptTemplate(commandInitializeTemplate, {
|
||||
branch_status_line: branchResult.created
|
||||
? `Created and checked out dedicated git branch \`${branchResult.branchName}\`.`
|
||||
: `Using dedicated git branch \`${branchResult.branchName}\`.`,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ You are running an autonomous experiment loop. Keep iterating until the user int
|
||||
- Identify the true bottleneck or quality constraint.
|
||||
- Check existing scripts, benchmark harnesses, and config files.
|
||||
2. Keep your notes in `autoresearch.md`.
|
||||
- Record the goal, the benchmark command, the primary metric, important secondary metrics, and the running ideas backlog.
|
||||
- Record the goal, the benchmark command, the primary metric, important secondary metrics, the files in scope, hard constraints, and the running ideas backlog.
|
||||
- Update the notes whenever the strategy changes.
|
||||
3. Use `autoresearch.sh` as the canonical benchmark entrypoint.
|
||||
- If it does not exist yet, create it.
|
||||
@@ -87,6 +87,15 @@ Suggested structure:
|
||||
- primary metric:
|
||||
- secondary metrics:
|
||||
|
||||
## Files in Scope
|
||||
- path:
|
||||
|
||||
## Off Limits
|
||||
- path:
|
||||
|
||||
## Constraints
|
||||
- rule:
|
||||
|
||||
## Baseline
|
||||
- metric:
|
||||
- notes:
|
||||
@@ -104,6 +113,7 @@ Suggested structure:
|
||||
- Do not game the benchmark.
|
||||
- Do not overfit to synthetic inputs if the real workload is broader.
|
||||
- Preserve correctness.
|
||||
- Only modify files that are explicitly in scope for the current session.
|
||||
- If you create `autoresearch.checks.sh`, treat it as a hard gate for `keep`.
|
||||
- If the user sends another message while a run is in progress, finish the current run and logging cycle first, then address the new input in the next iteration.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
The autoresearch loop ended unexpectedly. Resume it now.
|
||||
|
||||
- Read `autoresearch.md` and `autoresearch.jsonl`.
|
||||
- Treat `autoresearch.md` as the source of truth for the current direction, scope, and constraints.
|
||||
- Inspect recent git history for context.
|
||||
- Continue from the most promising unfinished direction.
|
||||
{{#if has_ideas}}
|
||||
|
||||
@@ -5,7 +5,14 @@ import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ToolDefinition } from "../../extensibility/extensions";
|
||||
import type { Theme } from "../../modes/theme/theme";
|
||||
import { formatNum, inferMetricUnitFromName, mergeAsi, resolveWorkDir, validateWorkDir } from "../helpers";
|
||||
import {
|
||||
formatNum,
|
||||
inferMetricUnitFromName,
|
||||
mergeAsi,
|
||||
PROTECTED_AUTORESEARCH_FILES,
|
||||
resolveWorkDir,
|
||||
validateWorkDir,
|
||||
} from "../helpers";
|
||||
import {
|
||||
cloneExperimentState,
|
||||
computeConfidence,
|
||||
@@ -52,14 +59,6 @@ const logExperimentSchema = Type.Object({
|
||||
),
|
||||
});
|
||||
|
||||
const PROTECTED_AUTORESEARCH_FILES = [
|
||||
"autoresearch.jsonl",
|
||||
"autoresearch.md",
|
||||
"autoresearch.ideas.md",
|
||||
"autoresearch.sh",
|
||||
"autoresearch.checks.sh",
|
||||
] as const;
|
||||
|
||||
interface PreservedFile {
|
||||
content: Buffer;
|
||||
path: string;
|
||||
@@ -107,6 +106,12 @@ export function createLogExperimentTool(
|
||||
}
|
||||
|
||||
const mergedAsi = mergeAsi(runtime.lastRunAsi, sanitizeAsi(params.asi));
|
||||
const asiValidationError = validateAsiRequirements(mergedAsi, params.status);
|
||||
if (asiValidationError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${asiValidationError}` }],
|
||||
};
|
||||
}
|
||||
const experiment: ExperimentResult = {
|
||||
commit: params.commit.slice(0, 7),
|
||||
metric: params.metric,
|
||||
@@ -220,6 +225,23 @@ function sanitizeAsiValue(value: unknown): ASIData[string] | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateAsiRequirements(asi: ASIData | undefined, status: ExperimentResult["status"]): string | null {
|
||||
if (!asi) {
|
||||
return "asi is required. Include at minimum a non-empty hypothesis.";
|
||||
}
|
||||
if (typeof asi.hypothesis !== "string" || asi.hypothesis.trim().length === 0) {
|
||||
return "asi.hypothesis is required and must be a non-empty string.";
|
||||
}
|
||||
if (status === "keep") return null;
|
||||
if (typeof asi.rollback_reason !== "string" || asi.rollback_reason.trim().length === 0) {
|
||||
return "asi.rollback_reason is required for discard, crash, and checks_failed results.";
|
||||
}
|
||||
if (typeof asi.next_action_hint !== "string" || asi.next_action_hint.trim().length === 0) {
|
||||
return "asi.next_action_hint is required for discard, crash, and checks_failed results.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateSecondaryMetrics(state: ExperimentState, metrics: NumericMetricMap, force: boolean): string | null {
|
||||
if (state.secondaryMetrics.length === 0) return null;
|
||||
const knownNames = new Set(state.secondaryMetrics.map(metric => metric.name));
|
||||
|
||||
@@ -50,6 +50,7 @@ import { discoverAgents } from "../../task/discovery";
|
||||
import type { AgentDefinition, AgentSource } from "../../task/types";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { theme } from "../theme/theme";
|
||||
import { matchesAppInterrupt } from "../utils/keybinding-matchers";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
type SourceTabId = "all" | AgentSource;
|
||||
@@ -993,7 +994,7 @@ export class AgentDashboard extends Container {
|
||||
}
|
||||
|
||||
if (this.#createSpec) {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
this.#clearCreateFlow();
|
||||
this.#buildLayout();
|
||||
return;
|
||||
@@ -1017,7 +1018,7 @@ export class AgentDashboard extends Container {
|
||||
}
|
||||
|
||||
if (this.#createInput || this.#createGenerating) {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
if (!this.#createGenerating) {
|
||||
this.#clearCreateFlow();
|
||||
this.#buildLayout();
|
||||
@@ -1037,7 +1038,7 @@ export class AgentDashboard extends Container {
|
||||
}
|
||||
|
||||
if (this.#editInput) {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
this.#cancelModelEdit();
|
||||
return;
|
||||
}
|
||||
@@ -1048,7 +1049,7 @@ export class AgentDashboard extends Container {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
if (this.#searchQuery.length > 0) {
|
||||
this.#searchQuery = "";
|
||||
this.#applyFilters();
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { Settings } from "../../../config/settings";
|
||||
import { DynamicBorder } from "../../../modes/components/dynamic-border";
|
||||
import { theme } from "../../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
|
||||
import { ExtensionList } from "./extension-list";
|
||||
import { InspectorPanel } from "./inspector-panel";
|
||||
import { applyFilter, createInitialState, filterByProvider, refreshState, toggleProvider } from "./state-manager";
|
||||
@@ -251,7 +252,7 @@ export class ExtensionDashboard extends Container {
|
||||
}
|
||||
|
||||
// Escape - clear search first, then close
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
if (matchesAppInterrupt(data)) {
|
||||
if (this.#state.searchQuery.length > 0) {
|
||||
this.#state.searchQuery = "";
|
||||
this.#state.searchFiltered = this.#state.tabFiltered;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import type { HistoryEntry, HistoryStorage } from "../../session/history-storage";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
@@ -137,7 +138,7 @@ export class HistorySearchComponent extends Container {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
if (matchesAppInterrupt(keyData)) {
|
||||
this.#onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { getEditorTheme, theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
@@ -67,7 +68,7 @@ export class HookEditorComponent extends Container {
|
||||
}
|
||||
|
||||
// Escape to cancel
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
if (matchesAppInterrupt(keyData)) {
|
||||
this.#onCancelCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { Container, Input, Markdown, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import { CountdownTimer } from "./countdown-timer";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
@@ -65,7 +66,7 @@ export class HookInputComponent extends Container {
|
||||
this.#countdown?.reset();
|
||||
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
||||
this.#onSubmitCallback(this.#input.getValue());
|
||||
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
} else if (matchesAppInterrupt(keyData)) {
|
||||
this.#onCancelCallback();
|
||||
} else {
|
||||
this.#input.handleInput(keyData);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
||||
import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
|
||||
import { CountdownTimer } from "./countdown-timer";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
@@ -173,7 +174,7 @@ export class HookSelectorComponent extends Container {
|
||||
this.#onLeftCallback?.();
|
||||
} else if (matchesKey(keyData, "right")) {
|
||||
this.#onRightCallback?.();
|
||||
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
|
||||
} else if (matchesSelectCancel(keyData)) {
|
||||
this.#onCancelCallback();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { analyzeAuthError, discoverOAuthEndpoints } from "../../mcp/oauth-discov
|
||||
import type { MCPHttpServerConfig, MCPServerConfig, MCPSseServerConfig, MCPStdioServerConfig } from "../../mcp/types";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { theme } from "../theme/theme";
|
||||
import { matchesAppInterrupt } from "../utils/keybinding-matchers";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
type TransportType = "stdio" | "http" | "sse";
|
||||
@@ -452,7 +453,7 @@ export class MCPAddWizard extends Container {
|
||||
}
|
||||
|
||||
// Handle Escape (always handled by wizard)
|
||||
if (matchesKey(keyData, "escape")) {
|
||||
if (matchesAppInterrupt(keyData)) {
|
||||
if (this.#currentStep === "name") {
|
||||
// Cancel wizard
|
||||
this.#onCancelCallback();
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
|
||||
import { Container, Input, matchesKey, Spacer, type Tab, TabBar, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import {
|
||||
Container,
|
||||
getKeybindings,
|
||||
Input,
|
||||
matchesKey,
|
||||
Spacer,
|
||||
type Tab,
|
||||
TabBar,
|
||||
Text,
|
||||
type TUI,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRegistry, type ModelRole } from "../../config/model-registry";
|
||||
import { resolveModelRoleValue } from "../../config/model-resolver";
|
||||
import type { Settings } from "../../config/settings";
|
||||
@@ -647,7 +658,7 @@ export class ModelSelectorComponent extends Container {
|
||||
}
|
||||
|
||||
// Escape or Ctrl+C - close selector
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
|
||||
if (getKeybindings().matches(keyData, "tui.select.cancel")) {
|
||||
this.#onCancelCallback();
|
||||
return;
|
||||
}
|
||||
@@ -698,7 +709,7 @@ export class ModelSelectorComponent extends Container {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
|
||||
if (getKeybindings().matches(keyData, "tui.select.cancel")) {
|
||||
if (this.#menuStep === "thinking" && this.#menuSelectedRole !== null) {
|
||||
this.#menuStep = "role";
|
||||
const roleIndex = MENU_ROLE_ACTIONS.findIndex(action => action.role === this.#menuSelectedRole);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
|
||||
import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
|
||||
import type { AuthStorage } from "../../session/auth-storage";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
/**
|
||||
@@ -202,7 +203,7 @@ export class OAuthSelectorComponent extends Container {
|
||||
}
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
|
||||
else if (matchesSelectCancel(keyData)) {
|
||||
this.stopValidation();
|
||||
this.#onCancelCallback();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import type { SessionInfo } from "../../session/session-manager";
|
||||
import { fuzzyFilter } from "../../utils/fuzzy";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
@@ -219,7 +220,7 @@ class SessionList implements Component {
|
||||
return;
|
||||
}
|
||||
// Escape - cancel
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
if (matchesAppInterrupt(keyData)) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
} from "../../config/settings-schema";
|
||||
import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema";
|
||||
import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import { getTabBarTheme } from "../shared";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
import { PluginSettingsComponent } from "./plugin-settings";
|
||||
@@ -521,7 +522,7 @@ export class SettingsSelectorComponent extends Container {
|
||||
}
|
||||
|
||||
// Escape at top level cancels
|
||||
if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && !this.#currentSubmenu) {
|
||||
if (matchesAppInterrupt(data) && !this.#currentSubmenu) {
|
||||
this.callbacks.onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Container, matchesKey, padding } from "@oh-my-pi/pi-tui";
|
||||
import type { StatusLineSegmentId } from "../../config/settings-schema";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import { ALL_SEGMENT_IDS } from "./status-line/segments";
|
||||
|
||||
// Segment display names and short descriptions
|
||||
@@ -239,7 +240,7 @@ export class StatusLineSegmentEditorComponent extends Container {
|
||||
const left = this.#getSegmentsForColumn("left").map(s => s.id);
|
||||
const right = this.#getSegmentsForColumn("right").map(s => s.id);
|
||||
this.callbacks.onSave(left, right);
|
||||
} else if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
||||
} else if (matchesAppInterrupt(data)) {
|
||||
this.callbacks.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import type { TreeFilterMode } from "../../config/settings-schema";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
import type { SessionTreeNode } from "../../session/session-manager";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
@@ -702,7 +703,7 @@ class TreeList implements Component {
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.node.entry.id);
|
||||
}
|
||||
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
} else if (matchesAppInterrupt(keyData)) {
|
||||
if (this.#searchQuery) {
|
||||
this.#searchQuery = "";
|
||||
this.#applyFilter();
|
||||
@@ -807,7 +808,7 @@ class LabelInput implements Component {
|
||||
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
||||
const value = this.#input.getValue().trim();
|
||||
this.onSubmit?.(this.entryId, value || undefined);
|
||||
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
} else if (matchesAppInterrupt(keyData)) {
|
||||
this.onCancel?.();
|
||||
} else {
|
||||
this.#input.handleInput(keyData);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
interface UserMessageItem {
|
||||
@@ -91,14 +92,8 @@ class UserMessageList implements Component {
|
||||
this.onSelect(selected.id);
|
||||
}
|
||||
}
|
||||
// Escape - cancel
|
||||
else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
// Ctrl+C - cancel
|
||||
else if (matchesKey(keyData, "ctrl+c")) {
|
||||
// Escape / cancel
|
||||
else if (matchesSelectCancel(keyData)) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui";
|
||||
|
||||
/**
|
||||
* Match the coding-agent interrupt key.
|
||||
*
|
||||
* Interactive mode installs a keybinding manager that exposes `app.interrupt`
|
||||
* globally, but some isolated component tests still run with only TUI
|
||||
* keybindings registered. In that case, fall back to raw Escape matching.
|
||||
*/
|
||||
export function matchesAppInterrupt(data: string): boolean {
|
||||
const keybindings = getKeybindings();
|
||||
const interruptKeys = keybindings.getKeys("app.interrupt");
|
||||
if (interruptKeys.length > 0) {
|
||||
return keybindings.matches(data, "app.interrupt");
|
||||
}
|
||||
return matchesKey(data, "escape") || matchesKey(data, "esc");
|
||||
}
|
||||
|
||||
export function matchesSelectCancel(data: string): boolean {
|
||||
return getKeybindings().matches(data, "tui.select.cancel");
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Snowflake } from "@oh-my-pi/pi-utils";
|
||||
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,
|
||||
@@ -118,12 +119,18 @@ describe("autoresearch command guard", () => {
|
||||
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 | undefined): AutoresearchCommandHarness {
|
||||
function createAutoresearchCommandHarness(
|
||||
cwd: string,
|
||||
inputResult: 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 }> = [];
|
||||
@@ -131,6 +138,13 @@ function createAutoresearchCommandHarness(cwd: string, inputResult: string | und
|
||||
|
||||
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 };
|
||||
@@ -191,7 +205,7 @@ function createAutoresearchCommandHarness(cwd: string, inputResult: string | und
|
||||
waitForIdle: async () => {},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
|
||||
return { command, ctx, sentMessages, inputCalls, notifications };
|
||||
return { command, ctx, execCalls, sentMessages, inputCalls, notifications };
|
||||
}
|
||||
|
||||
interface AutoresearchLifecycleHarness {
|
||||
@@ -289,7 +303,30 @@ describe("autoresearch command startup", () => {
|
||||
it("asks for intent and sends an initialization prompt when no autoresearch.md exists", async () => {
|
||||
const dir = makeTempDir();
|
||||
tempDirs.push(dir);
|
||||
const harness = createAutoresearchCommandHarness(dir, "reduce edit benchmark runtime variance");
|
||||
let currentBranch = "main";
|
||||
const branches = new Set<string>();
|
||||
const harness = createAutoresearchCommandHarness(
|
||||
dir,
|
||||
"reduce edit benchmark runtime variance",
|
||||
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: "" };
|
||||
},
|
||||
);
|
||||
|
||||
await harness.command.handler("", harness.ctx);
|
||||
|
||||
@@ -299,8 +336,12 @@ describe("autoresearch command startup", () => {
|
||||
expect(harness.sentMessages).toHaveLength(1);
|
||||
expect(harness.sentMessages[0]).toContain("Set up autoresearch for this intent:");
|
||||
expect(harness.sentMessages[0]).toContain("reduce edit benchmark runtime variance");
|
||||
expect(harness.sentMessages[0]).toContain("Created and checked out dedicated git branch");
|
||||
expect(harness.sentMessages[0]).toContain("Explain briefly what autoresearch will do in this repository");
|
||||
expect(harness.sentMessages[0]).toContain("Files in Scope");
|
||||
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 () => {
|
||||
@@ -308,7 +349,14 @@ describe("autoresearch command startup", () => {
|
||||
tempDirs.push(dir);
|
||||
const autoresearchMdPath = path.join(dir, "autoresearch.md");
|
||||
fs.writeFileSync(autoresearchMdPath, "# Autoresearch\n\nExisting notes\n");
|
||||
const harness = createAutoresearchCommandHarness(dir, "ignored");
|
||||
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] === "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);
|
||||
|
||||
@@ -319,7 +367,9 @@ describe("autoresearch command startup", () => {
|
||||
"",
|
||||
`@${autoresearchMdPath}`,
|
||||
"",
|
||||
"Use the notes as the source of truth for the current direction.",
|
||||
"Using dedicated git branch `autoresearch/existing-20260322`.",
|
||||
"",
|
||||
"Use the notes as the source of truth for the current direction, scope, and constraints.",
|
||||
"- inspect recent git history for context",
|
||||
"- inspect `autoresearch.jsonl` if it exists",
|
||||
"- continue the most promising unfinished branch",
|
||||
@@ -338,6 +388,37 @@ describe("autoresearch command startup", () => {
|
||||
expect(harness.sentMessages).toEqual([]);
|
||||
expect(harness.notifications).toEqual([{ message: "Autoresearch intent is required", type: "info" }]);
|
||||
});
|
||||
|
||||
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,
|
||||
"reduce edit benchmark runtime variance",
|
||||
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 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 an isolated branch. Commit or stash these paths first: packages/coding-agent/src/sdk.ts",
|
||||
type: "error",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoresearch lifecycle tool activation", () => {
|
||||
@@ -369,3 +450,34 @@ describe("autoresearch lifecycle tool activation", () => {
|
||||
expect(harness.setActiveToolsCalls).toEqual([["read"]]);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai";
|
||||
import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings";
|
||||
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { ModelSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/model-selector";
|
||||
import { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { setKeybindings, type TUI } from "@oh-my-pi/pi-tui";
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setKeybindings(KeybindingsManager.inMemory());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createSession(id: string, title: string): SessionInfo {
|
||||
return {
|
||||
path: `/tmp/${id}.jsonl`,
|
||||
id,
|
||||
cwd: "/tmp",
|
||||
title,
|
||||
created: new Date("2024-01-01T00:00:00Z"),
|
||||
modified: new Date("2024-01-02T00:00:00Z"),
|
||||
messageCount: 1,
|
||||
firstMessage: `${title} first message`,
|
||||
allMessagesText: `${title} first message`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("component escape bindings", () => {
|
||||
it("uses app.interrupt for session selector cancel without changing Ctrl+C exit", () => {
|
||||
const keybindings = KeybindingsManager.inMemory({
|
||||
"app.interrupt": "alt+x",
|
||||
});
|
||||
setKeybindings(keybindings);
|
||||
|
||||
const onCancel = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
const selector = new SessionSelectorComponent(
|
||||
[createSession("session-a", "Alpha"), createSession("session-b", "Beta")],
|
||||
() => {},
|
||||
onCancel,
|
||||
onExit,
|
||||
);
|
||||
|
||||
selector.handleInput("\x1b");
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
|
||||
selector.handleInput("\x1bx");
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
|
||||
selector.handleInput("\x03");
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses tui.select.cancel for model selector cancellation", async () => {
|
||||
const keybindings = KeybindingsManager.inMemory({
|
||||
"tui.select.cancel": "ctrl+g",
|
||||
});
|
||||
setKeybindings(keybindings);
|
||||
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) {
|
||||
throw new Error("Expected bundled model anthropic/claude-sonnet-4-5");
|
||||
}
|
||||
|
||||
const settings = Settings.isolated({
|
||||
modelRoles: {
|
||||
default: `${model.provider}/${model.id}`,
|
||||
},
|
||||
});
|
||||
const modelRegistry = {
|
||||
getAll: () => [model],
|
||||
getDiscoverableProviders: () => [],
|
||||
} as unknown as ModelRegistry;
|
||||
const ui = {
|
||||
requestRender: vi.fn(),
|
||||
} as unknown as TUI;
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const selector = new ModelSelectorComponent(
|
||||
ui,
|
||||
model,
|
||||
settings,
|
||||
modelRegistry,
|
||||
[{ model, thinkingLevel: "off" }],
|
||||
() => {},
|
||||
onCancel,
|
||||
);
|
||||
|
||||
await Bun.sleep(0);
|
||||
|
||||
selector.handleInput("\x1b");
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
|
||||
selector.handleInput("\x07");
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user