Merge remote-tracking branch 'upstream/main' into feat/secret-friendly-names

# Conflicts:
#	packages/coding-agent/src/eval/__tests__/julia-prelude.test.ts
This commit is contained in:
Mathews-Tom
2026-06-23 17:12:13 +05:30
130 changed files with 4220 additions and 1958 deletions
+37 -1
View File
@@ -2,6 +2,13 @@
## [Unreleased]
## [16.1.16] - 2026-06-23
### Breaking Changes
- Renamed the eval `agent()` helper parameters `agent_type` → `agent` and `return_handle` → `handle` across every workflow runtime (Python, JavaScript, Ruby, Julia), so the names are identical in every language (no camelCase/snake_case split) and the agent-selection parameter matches the `task` tool's `agent`. The `__agent__` eval bridge wire protocol was renamed to match.
- Changed the `eval` tool to take a single cell per call (`{ language, code, title?, timeout?, reset? }`) instead of a `cells` array. State still persists per language across separate eval calls, tool calls, and `task` subagents, so each call is one logical step that reuses everything earlier calls defined — the array only encouraged re-importing/re-declaring the same setup in every batch. The schema, field descriptions, examples, system `eval.md`/`workflowz` helper docs, and the `[i/n]` cell-counter (now hidden for single cells) were updated to match; the renderer, ACP start-text, copy-targets, and collab-web tool view still parse legacy multi-cell transcripts.
### Added
- Added `friendlyName` support for hidden secrets so model-visible placeholders can carry sanitized semantic labels, content-derived hashes, and case hints while preserving exact deobfuscation ([#2465](https://github.com/can1357/oh-my-pi/issues/2465)).
@@ -14,13 +21,42 @@
- Added `isolated`, `apply`, and `merge` options to eval `agent()` across every workflow runtime (Python, JavaScript, Ruby, Julia) so `workflowz`-driven fan-outs can request the same copy-on-write worktree isolation the `task` tool offers (strict opt-in via `isolated: true`, matching the `task` tool; `apply: false` keeps captured patches/branches without merging back; `merge: false` forces patch mode). Extracted the task-isolation lifecycle into `task/isolation-runner.ts` so the eval bridge and `TaskTool` share one implementation ([#3196](https://github.com/can1357/oh-my-pi/issues/3196))
### Changed
- Made the session picker fullscreen with mouse support for clicking rows and scrolling
- Pinned the session picker footer to the bottom of the screen to prevent layout flickering
- Simplified `eval` tool to accept a single logical step (code block) instead of an array of cells
- Updated `eval` tool documentation to emphasize incremental, single-step execution
- Restricted `bash` tool from using `ls` or `find`, requiring the use of `read` or `find` tools
- Simplified `todo` tool interface to accept a single operation directly instead of an array of ops
- Reinforced routing of fragile, multi-step shell logic to the `eval` tool over `bash`. The system-prompt tool policy, `bash.md`, and `eval.md` now treat loops, conditionals, heredocs, inline `-e`/`-c` scripts, multi-stage pipelines, and quote/JSON escaping as the signal to write an `eval` cell; bash's "compute a fact" carveout is narrowed to single short pipelines, and `eval.md` now actively claims that territory with runtime-templated examples (only enabled backends are advertised).
- Made `eval` an essential built-in tool (`loadMode: "essential"`, added to the default essential tool set) so it stays active under `tools.discoveryMode: "all"` instead of being hidden behind `search_tool_bm25`.
- Made the `--resume` session picker fullscreen on the terminal's alternate screen, so the list scrolls with the mouse wheel and a row resumes its session on left click. Rows are hit-tested against the live scroll window, and the keybinding hint + bottom border are now pinned to the screen bottom instead of drifting up and down as the visible window changes height.
### Fixed
- Fixed `local://` URLs decoding images as corrupted text (mojibake) instead of showing the image
- Fixed `omp --resume` hanging instead of exiting when the startup session picker is cancelled (Esc) or there are no sessions to resume. Startup arms long-lived handles (theme/appearance listeners, settings save timer, model registry), so the cancel/empty paths' bare `return` left the event loop alive and the process stuck after the picker cleared the alternate screen. These paths now exit cleanly via `process.exit(0)`, matching the `--version`/`--export` early-exit convention. The in-session `/resume` picker is unaffected — it keeps its own cancel handler that just closes the overlay.
- Fixed the `/resume` session picker scrolling down after a session is deleted. The delete-confirmation dialog was mounted as a sibling below the picker's bottom border, briefly growing the picker past the terminal height; the TUI committed the picker's header rows into native scrollback to fit, and when the dialog closed `windowTop` stayed pinned at the new commit boundary, leaving the header stranded above the viewport. The picker now hosts the `SessionList` in a single content slot and swaps the dialog INTO that slot (replacing the `SessionList`) while it is open, so the dialog only competes with the `SessionList`'s rendered budget — not the `SessionList` AND the picker chrome — and the picker frame stays inside the viewport. ([#3283](https://github.com/can1357/oh-my-pi/issues/3283))
- Fixed the `eval` tool card not streaming a still-running cell's stdout: a long-running cell (e.g. a `time.sleep()` monitor loop) showed nothing until it returned or was interrupted, then dumped everything at once. The renderer draws cell output from `details.cells[i].output`, which was only populated after `backend.execute()` resolved — live stdout streamed into the transient result `content` tail (and `renderContext.output`), which the per-cell render branch ignores. Streamed chunks now append to the active cell's `output` (a dedicated per-cell tail buffer, capped like the aggregate) as they arrive, so the card shows progress live; on completion the authoritative full output overwrites the live tail. `log()`/`phase()`/`display()` and status ops were unaffected because they already stream via the status channel.
- Fixed Escape doing nothing in the Settings text-input fields (e.g. "Python Interpreter") on terminals with the kitty keyboard protocol active (ghostty/kitty). Inside the fullscreen settings overlay the protocol reports Escape as the CSI-u sequence `\x1b[27u`, which the text-input submenu's raw `\x1b` compare missed; `handleInputOrEscape` now decodes Escape via `matchesKey`, matching every other Escape-to-cancel path.
- Fixed Julia `eval` graph/plot visualization (Plots.jl, GraphRecipes, Makie, etc.) never rendering inline. Two bugs: (1) the runner's `build_mime_bundle`/`emit_error` dispatched `show`/`showable`/`showerror` directly from the long-lived `main()` loop, whose world age is frozen before any cell ran, so rich `show(::IO, ::MIME"image/png", …)` methods registered when a plotting package is `using`-ed inside a cell were invisible — `show` fell back to the default struct repr (which itself threw on Julia 1.12, aborting the whole result). These calls now route through `Base.invokelatest`, and the `text/plain` probe is guarded so a failing repr can no longer suppress the image MIME. (2) The default GR backend popped up a native `gksqt` GUI window on each plot; the runner now defaults `GKSwstype=100` (headless, overridable) so plots render only as inline PNGs, mirroring the Python runner's `MPLBACKEND=Agg` default.
- Fixed streaming output blocks incorrectly calculating preview height, preventing flickering banners
- Fixed streaming `bash`/`eval` tool output duplicating its `… (N earlier lines, showing 10 of M) (ctrl+o to expand)` preview into native scrollback. The collapsed output is a sliding tail window fixed at 10 lines, so when the box outgrew the live viewport (a tall command/output under a still-live predecessor such as a parallel tool) its mutating tail scrolled above the commit window and the renderer re-committed a fresh snapshot every frame, stacking dozens of stale preview banners and chunks. The output preview is now clamped to the viewport tail (`Math.min(10, previewWindowRows())`) and measured in visual rows at the box's inner content width (via the new `outputBlockContentWidth` helper), so on short terminals the volatile tail shrinks to stay on-screen and is never committed. Fixes the duplication introduced when scroll-off commits were made loss-free.
- Prevented `/handoff` from executing while a response is streaming to avoid session corruption
- Fixed `/handoff` cold-missing the provider prompt cache. Handoff generation now builds its request through the same pipeline a live turn uses (`convertMessagesToLlm` + `Agent.buildSideRequestContext` + `prepareSimpleStreamOptions`, via the new `generateHandoffFromContext`), so it reuses the live system prompt, normalized tools, transformed/obfuscated message history, and — critically — a stable `promptCacheKey` with a unique side `sessionId`. Previously the oneshot sent no cache-routing key and skipped the `transformContext`/`transformProviderContext` and tool/message normalization the loop applies, so its prefix never matched what the turn populated and every handoff re-read the whole context uncached. Mirrors the cache-preserving path already used by `/btw` and `/omfg`.
- Fixed `/handoff` (and the RPC `handoff` command) resetting the agent while a response was still streaming, which let the live turn keep emitting into the torn-down session. Manual handoff now refuses while a prompt is in flight (matching `/fork` and `/move`); the auto-handoff path is unaffected.
- Fixed Exa web search requests firing back-to-back with no client-side pacing by adding a configurable `exa.searchDelayMs` delay (default 1000ms) between Exa search requests. ([#3271](https://github.com/can1357/oh-my-pi/issues/3271))
- Fixed `ask` returning `(cancelled)` or aborting the tool when Escape dismissed `Other (type your own)` custom input; it now returns to the option selector so the user can pick a listed answer instead. ([#3269](https://github.com/can1357/oh-my-pi/issues/3269))
- Fixed `/goal` threshold auto-compaction skipping real sessions through three paths: per-turn supersede/drop-useless pruning no longer deflates the threshold trigger below the last provider-billed context; active-goal text stops now attempt threshold maintenance before unexpected-stop retry continuations can return from post-turn handling; and empty `toolUse` stops keep the existing cleanup pass that strips the orphan assistant from active context + session history before any compaction continuation. Active-goal compaction continuations now also resolve completed retry gates before returning, preventing `isRetrying` from staying stuck after a retry succeeds over the threshold. Added `agent_end maintenance routing` and `Auto-compaction threshold decision` debug logs so future no-start reports identify the exact early-return branch and the billed/stored/resolved/post-maintenance token counts that fed `shouldCompact`. ([#3174](https://github.com/can1357/oh-my-pi/issues/3174))
- Fixed active `/goal` runs that never reached `agent_end` because the model kept emitting tool calls inside one agent run. Threshold maintenance now runs between tool-call turns, compacts the live loop context in place, and suppresses queued continuations that would race the still-running goal loop. ([#3174](https://github.com/can1357/oh-my-pi/issues/3174))
### Removed
- Removed `append`, `tree`, and `diff` eval helper functions from Python, JavaScript, and Ruby
- Removed `sort`, `uniq`, and `counter` text processing eval helpers from Python, JavaScript, and Ruby
- Removed the `append(path, content)`, `tree(path, max_depth?, show_hidden?)`, and `diff(a, b)` eval prelude helpers from every workflow runtime (Python, JavaScript, Ruby, Julia), along with their status renderers, icon entries, and tool/`docs` references. Use `write`/`read` for file mutation and `tool.<name>(...)` for richer filesystem operations.
- Removed the `sort(text, reverse?, unique?)`, `uniq(text, count?)`, and `counter(items, limit?, reverse?)` eval text helpers from the Python, JavaScript, and Ruby prelude surfaces (Julia never defined them), along with the JS `HelperBundle`/`HelperOptions` members and `docs` references. Sort/dedupe/count inline in cell code instead.
## [16.1.15] - 2026-06-22
@@ -12360,4 +12396,4 @@ Initial public release.
## [0.7.6] - 2025-11-13
Previous releases did not maintain a changelog.
Previous releases did not maintain a changelog.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-coding-agent",
"version": "16.1.15",
"version": "16.1.16",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+18 -4
View File
@@ -5,7 +5,15 @@ import * as path from "node:path";
const packageDir = path.join(import.meta.dir, "..");
const repoRoot = path.join(packageDir, "..", "..");
const outputPath = path.join(packageDir, "dist", "omp");
// Optional cross-compile target, e.g. CROSS_TARGET=linux-arm64 → bun build
// --target=bun-linux-arm64, embeds the matching native, outputs dist/omp-<target>.
const crossTarget = Bun.env.CROSS_TARGET || null;
const [crossPlatform, crossArch] = crossTarget ? crossTarget.split("-") : [null, null];
// x64 uses the baseline bun runtime so it runs under Rosetta / pre-AVX2 CPUs
// (the modern bun-linux-x64 target SIGILLs under Apple-Silicon Rosetta).
const bunTarget = crossTarget ? (crossTarget === "linux-x64" ? "bun-linux-x64-baseline" : `bun-${crossTarget}`) : null;
const outName = crossTarget ? `omp-${crossTarget}` : "omp";
const outputPath = path.join(packageDir, "dist", outName);
// Transformers.js is an optional, native-heavy dependency that is never bundled
// into the binary; the tiny-model worker `bun install`s it into a runtime cache
@@ -17,7 +25,7 @@ const transformersVersion = (
).version;
function shouldAdhocSignDarwinBinary(): boolean {
return process.platform === "darwin";
return process.platform === "darwin" && !crossTarget;
}
async function runCommand(
@@ -43,7 +51,12 @@ async function main(): Promise<void> {
try {
await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--generate"]);
await runCommand(["bun", "scripts/generate-docs-index.ts", "--generate"]);
await runCommand(["bun", "--cwd=../natives", "run", "embed:native"]);
await runCommand(
["bun", "--cwd=../natives", "run", "embed:native"],
crossTarget
? { ...Bun.env, TARGET_PLATFORM: crossPlatform as string, TARGET_ARCH: crossArch as string }
: Bun.env,
);
await runCommand(["bun", "scripts/embed-mupdf-wasm.ts", "--generate"]);
try {
const buildEnv = shouldAdhocSignDarwinBinary() ? { ...Bun.env, BUN_NO_CODESIGN_MACHO_BINARY: "1" } : Bun.env;
@@ -52,6 +65,7 @@ async function main(): Promise<void> {
"bun",
"build",
"--compile",
...(bunTarget ? ["--target", bunTarget] : []),
"--no-compile-autoload-bunfig",
"--no-compile-autoload-dotenv",
"--no-compile-autoload-tsconfig",
@@ -85,7 +99,7 @@ async function main(): Promise<void> {
"./packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts",
"./packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts",
"--outfile",
"packages/coding-agent/dist/omp",
`packages/coding-agent/dist/${outName}`,
],
buildEnv,
repoRoot,
+4 -5
View File
@@ -1,10 +1,9 @@
/**
* CLI argument parsing and help display
*/
import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-catalog/effort";
import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { parseEffort } from "../thinking";
import { CLI_THINKING_LEVELS, type ConfiguredThinkingLevel, parseCliThinkingLevel } from "../thinking";
import { BUILTIN_TOOL_NAMES } from "../tools/builtin-names";
import {
OPTIONAL_FLAGS,
@@ -32,7 +31,7 @@ export interface Args {
apiKey?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
thinking?: Effort;
thinking?: ConfiguredThinkingLevel;
hideThinking?: boolean;
advisor?: boolean;
continue?: boolean;
@@ -89,9 +88,9 @@ export interface Args {
*/
const PARSE_DEPS: ParseDeps = {
logger,
parseEffort,
parseThinking: parseCliThinkingLevel,
builtinToolNames: BUILTIN_TOOL_NAMES,
thinkingEfforts: THINKING_EFFORTS,
thinkingEfforts: CLI_THINKING_LEVELS,
};
export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
+3 -3
View File
@@ -30,7 +30,7 @@
* real implementations at the dispatch site.
*/
import type { Effort } from "@oh-my-pi/pi-ai";
import type { ConfiguredThinkingLevel } from "../thinking";
import type { Args } from "./args";
/**
@@ -44,7 +44,7 @@ import type { Args } from "./args";
*/
export interface ParseDeps {
logger: { warn: (message: string, meta?: Record<string, unknown>) => void };
parseEffort: (value: string | null | undefined) => Effort | undefined;
parseThinking: (value: string | null | undefined) => ConfiguredThinkingLevel | undefined;
builtinToolNames: readonly string[];
thinkingEfforts: readonly string[];
}
@@ -165,7 +165,7 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
result.tools = valid;
},
"--thinking": (result, value, deps) => {
const thinking = deps.parseEffort(value);
const thinking = deps.parseThinking(value);
if (thinking !== undefined) {
result.thinking = thinking;
} else {
@@ -5,17 +5,14 @@ export const interactionFixtures: Record<string, GalleryFixture> = {
todo: {
label: "Todo",
streamingArgs: {
ops: [{ op: "init", list: [{ phase: "Foundation", items: ["Scaffold crate"] }] }],
op: "init",
list: [{ phase: "Foundation", items: ["Scaffold crate"] }],
},
args: {
ops: [
{
op: "init",
list: [
{ phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
{ phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
],
},
op: "init",
list: [
{ phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
{ phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
],
},
result: {
@@ -59,31 +59,23 @@ export const shellFixtures: Record<string, GalleryFixture> = {
eval: {
label: "Eval",
streamingArgs: {
cells: [
{
language: "py",
code: 'import json\nfrom pathlib import Path\n\ndata = json.loads(Path("package.js',
title: "load config",
},
],
language: "py",
code: 'import json\nfrom pathlib import Path\n\ndata = json.loads(Path("package.js',
title: "load config",
},
args: {
cells: [
{
language: "py",
title: "load config",
code: [
"import json",
"from pathlib import Path",
"",
'data = json.loads(Path("package.json").read_text())',
'deps = data.get("dependencies", {})',
'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
'print(f"{len(deps)} dependencies")',
"display(sorted(deps)[:3])",
].join("\n"),
},
],
language: "py",
title: "load config",
code: [
"import json",
"from pathlib import Path",
"",
'data = json.loads(Path("package.json").read_text())',
'deps = data.get("dependencies", {})',
'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
'print(f"{len(deps)} dependencies")',
"display(sorted(deps)[:3])",
].join("\n"),
},
result: {
content: [
@@ -8,8 +8,10 @@ import { FileSessionStorage } from "../session/session-storage";
/**
* Show the TUI session selector and return the selected session, or null if
* cancelled. Tab toggles between current-folder and all-projects scope; the
* all-projects list is loaded lazily via `SessionManager.listAll`.
* cancelled. Rendered as a fullscreen overlay on the terminal's alternate
* screen, so the list scrolls and rows are clickable with the mouse. Tab
* toggles between current-folder and all-projects scope; the all-projects list
* is loaded lazily via `SessionManager.listAll`.
*/
export async function selectSession(
sessions: SessionInfo[],
@@ -65,6 +67,7 @@ export async function selectSession(
loadAllSessions: () => SessionManager.listAll(storage),
allSessions: options?.allSessions,
getTerminalRows: () => ui.terminal.rows,
fillHeight: true,
},
);
return selector;
@@ -72,7 +75,18 @@ export async function selectSession(
const selector = showSelector();
selector.setOnRequestRender(() => ui.requestRender());
ui.addChild(selector);
// Present as a fullscreen overlay so the picker borrows the terminal's
// alternate screen buffer (vim/less idiom): the list scrolls and rows are
// clickable via the mouse tracking the overlay enables for its lifetime.
// Anchored top-left at full size so a mouse row maps directly to a rendered
// line (the overlay paints from screen row 0).
ui.showOverlay(selector, {
anchor: "top-left",
width: "100%",
maxHeight: "100%",
margin: 0,
fullscreen: true,
});
ui.setFocus(selector);
ui.start();
return promise;
+3 -3
View File
@@ -2,12 +2,12 @@
* Root command for the coding agent CLI.
*/
import { THINKING_EFFORTS } from "@oh-my-pi/pi-catalog/effort";
import { APP_NAME } from "@oh-my-pi/pi-utils";
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
import { parseArgs } from "../cli/args";
import { runRootCommand } from "../main";
import { prepareAcpTerminalAuthArgs } from "../modes/acp/terminal-auth";
import { CLI_THINKING_LEVELS } from "../thinking";
export default class Index extends Command {
static description = "AI coding assistant";
@@ -100,8 +100,8 @@ export default class Index extends Command {
description: "Comma-separated list of tools to enable (default: all)",
}),
thinking: Flags.string({
description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`,
options: [...THINKING_EFFORTS],
description: `Set thinking level: ${CLI_THINKING_LEVELS.join(", ")}`,
options: [...CLI_THINKING_LEVELS],
}),
"hide-thinking": Flags.boolean({
description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)",
@@ -3572,7 +3572,7 @@ export const SETTINGS_SCHEMA = {
group: "Discovery & MCP",
label: "Essential Tools Override",
description:
"Override the always-loaded built-in tools (default: read, bash, edit). Leave empty to use defaults.",
"Override the always-loaded built-in tools (default: read, bash, edit, write, find, eval). Leave empty to use defaults.",
},
},
@@ -4479,6 +4479,17 @@ export const SETTINGS_SCHEMA = {
},
},
"exa.searchDelayMs": {
type: "number",
default: 1_000,
ui: {
tab: "providers",
group: "Services",
label: "Exa Search Delay",
description: "Minimum delay between Exa web search requests in milliseconds; set 0 to disable pacing",
},
},
"exa.enableResearcher": {
type: "boolean",
default: false,
@@ -4785,6 +4796,7 @@ export interface TtsrSettings {
export interface ExaSettings {
enabled: boolean;
enableSearch: boolean;
searchDelayMs: number;
enableResearcher: boolean;
enableWebsets: boolean;
}
+34 -12
View File
@@ -22,6 +22,7 @@ import {
invalidateRenderedStringCache,
type LspBatchRequest,
PREVIEW_LIMITS,
previewWindowRows,
type RenderedStringCache,
replaceTabs,
shortenPath,
@@ -340,6 +341,7 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string
function formatStreamingDiff(
diff: string,
rawPath: string,
width: number,
uiTheme: Theme,
expanded: boolean,
label = "streaming",
@@ -347,15 +349,32 @@ function formatStreamingDiff(
cache?: RenderedStringCache,
): string {
if (!diff) return "";
let text = cachedRenderedString(cache, uiTheme, expanded, rawPath, diff, () => {
// Collapsed uses a "Cursor" tail window: pin the last
// EDIT_STREAMING_PREVIEW_LINES rows to the bottom so freshly streamed changes
// stay on screen. The whole-file diff is recomputed on every streamed chunk
// and its Myers alignment is not monotonic in payload length, so a hunk-aware
// window stutters as rows move between hunks. Expanded deliberately lifts that
// cap for the approval-time full view.
// Clamp the collapsed tail to the viewport so a tall or fast-growing diff
// cannot outgrow the live window. Otherwise its mutating tail scrolls above
// the native-scrollback commit boundary and the engine re-commits a fresh
// snapshot every streamed frame, stacking duplicate "… more lines above"
// previews in history. The budget is VISUAL rows (a long wrapped line counts
// for more than one) at the framed block's inner width (border only —
// contentPaddingLeft is 0); only the visible suffix is syntax-colored, so the
// cheap raw-line wrap walk keeps the per-chunk cost bounded. innerWidth/budget
// are in the cache salt so a resize re-slices.
const innerWidth = Math.max(1, width - 2);
const budget = expanded ? Number.POSITIVE_INFINITY : Math.min(EDIT_STREAMING_PREVIEW_LINES, previewWindowRows());
let text = cachedRenderedString(cache, uiTheme, expanded, `${rawPath}:${innerWidth}:${budget}`, diff, () => {
// "Cursor" tail window: pin the last rows to the bottom so freshly streamed
// changes stay on screen. The whole-file diff is recomputed every chunk and
// its Myers alignment is not monotonic in payload length, so a hunk-aware
// window stutters as rows move between hunks. Expanded lifts the cap.
const allLines = diff.replace(/\n+$/u, "").split("\n");
const hiddenLines = expanded ? 0 : Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
let visualUsed = 0;
let cut = allLines.length;
for (let i = allLines.length - 1; i >= 0; i--) {
const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(allLines[i]!), innerWidth).length);
if (visualUsed + lineRows > budget && visualUsed > 0) break;
visualUsed += lineRows;
cut = i;
}
const hiddenLines = cut;
const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
let rendered = "\n\n";
if (hiddenLines > 0) {
@@ -384,6 +403,7 @@ function formatStreamingDiff(
function formatMultiFileStreamingDiff(
previews: PerFileDiffPreview[],
width: number,
uiTheme: Theme,
expanded: boolean,
spinnerFrame?: number,
@@ -405,7 +425,7 @@ function formatMultiFileStreamingDiff(
const isLast = index === previews.length - 1;
const cache = previewCacheAt(caches, index);
parts.push(
`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview", isLast ? spinnerFrame : undefined, cache)}`,
`${header}${formatStreamingDiff(preview.diff, preview.path, width, uiTheme, expanded, "preview", isLast ? spinnerFrame : undefined, cache)}`,
);
}
}
@@ -415,6 +435,7 @@ function formatMultiFileStreamingDiff(
function getCallPreview(
args: EditRenderArgs,
rawPath: string,
width: number,
uiTheme: Theme,
renderContext: EditRenderContext | undefined,
expanded: boolean,
@@ -423,14 +444,14 @@ function getCallPreview(
): string {
const multi = renderContext?.perFileDiffPreview;
if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
return formatMultiFileStreamingDiff(multi, uiTheme, expanded, spinnerFrame, caches);
return formatMultiFileStreamingDiff(multi, width, uiTheme, expanded, spinnerFrame, caches);
}
const cache = previewCacheAt(caches, 0);
if (args.previewDiff) {
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview", spinnerFrame, cache);
return formatStreamingDiff(args.previewDiff, rawPath, width, uiTheme, expanded, "preview", spinnerFrame, cache);
}
if (args.diff && args.op) {
return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded, "streaming", spinnerFrame, cache);
return formatStreamingDiff(args.diff, rawPath, width, uiTheme, expanded, "streaming", spinnerFrame, cache);
}
if (args.diff) {
return renderPlainTextPreview(args.diff, uiTheme, rawPath);
@@ -628,6 +649,7 @@ export const editToolRenderer = {
let body = getCallPreview(
editArgs,
rawPath,
width,
uiTheme,
renderContext,
options.expanded,
@@ -156,7 +156,7 @@ describe("runEvalAgent", () => {
vi.restoreAllMocks();
});
it("resolves the default task agent and agentType overrides", async () => {
it("resolves the default task agent and agent overrides", async () => {
mockAgents();
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
singleResult(options, {
@@ -166,7 +166,7 @@ describe("runEvalAgent", () => {
const session = makeSession();
const defaultResult = await runEvalAgent({ prompt: "hello" }, { session });
const overrideResult = await runEvalAgent({ prompt: "hello", agentType: "reviewer" }, { session });
const overrideResult = await runEvalAgent({ prompt: "hello", agent: "reviewer" }, { session });
expect(defaultResult.text).toBe("task");
expect(overrideResult.text).toBe("reviewer");
@@ -178,7 +178,7 @@ describe("runEvalAgent", () => {
mockAgents([taskAgent]);
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
await expect(runEvalAgent({ prompt: "hello", agentType: "missing" }, { session: makeSession() })).rejects.toThrow(
await expect(runEvalAgent({ prompt: "hello", agent: "missing" }, { session: makeSession() })).rejects.toThrow(
'Unknown agent "missing"',
);
});
@@ -844,12 +844,12 @@ describe("runEvalAgent isolation", () => {
expect(mergeSpy).toHaveBeenCalledTimes(1);
});
it("preserves temp artifacts for non-isolated returnHandle outputs", async () => {
it("preserves temp artifacts for non-isolated handle outputs", async () => {
mockAgents();
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
await runEvalAgent({ prompt: "plain handle", returnHandle: true }, { session: makeSession() });
await runEvalAgent({ prompt: "plain handle", handle: true }, { session: makeSession() });
const removedArtifactsDir = rmSpy.mock.calls.some(
([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
@@ -1204,7 +1204,7 @@ describe("runEvalAgent isolation", () => {
expect(removedArtifactsDir).toBe(true);
});
it("preserves the temp artifacts dir after a successful apply when returnHandle is requested", async () => {
it("preserves the temp artifacts dir after a successful apply when handle is requested", async () => {
mockAgents();
mockIsolationContext();
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
@@ -1218,7 +1218,7 @@ describe("runEvalAgent isolation", () => {
mergedBranchForNestedPatches: false,
});
await runEvalAgent({ prompt: "scout", isolated: true, returnHandle: true }, { session: isolatedSession() });
await runEvalAgent({ prompt: "scout", isolated: true, handle: true }, { session: isolatedSession() });
const removedArtifactsDir = rmSpy.mock.calls.some(
([target]) => typeof target === "string" && target.includes("omp-eval-agent-"),
@@ -4,7 +4,7 @@ import { TempDir } from "@oh-my-pi/pi-utils/temp";
import { createHelpers, type HelperContext } from "../js/shared/helpers";
/**
* The eval helpers (`read`/`write`/`append`) must substitute injected on-disk
* The eval helpers (`read`/`write`) must substitute injected on-disk
* roots for internal-URL schemes. Without it, `write("local://x.md")` hits a
* stdlib `path.resolve` that collapses `local://` to `local:/`, creating a junk
* `local:` directory under the cwd instead of landing where `read local://x.md`
@@ -20,7 +20,7 @@ function makeCtx(cwd: string, roots: Record<string, string>): HelperContext {
}
describe("eval js helpers internal-url resolution", () => {
it("writes, reads, and appends local:// under the injected root", async () => {
it("writes and reads local:// under the injected root", async () => {
using tmp = TempDir.createSync("@eval-helpers-local-");
const root = path.join(tmp.path(), "local");
const helpers = createHelpers(makeCtx(tmp.path(), { local: root }));
@@ -30,9 +30,6 @@ describe("eval js helpers internal-url resolution", () => {
expect(await Bun.file(written).text()).toBe("hello");
expect(await helpers.read("local://notes/merge-map.md")).toBe("hello");
await helpers.append("local://notes/merge-map.md", " world");
expect(await helpers.read("local://notes/merge-map.md")).toBe("hello world");
// Regression: no literal `local:` directory created under the cwd.
expect(await Bun.file(path.join(tmp.path(), "local:")).exists()).toBe(false);
expect(await Bun.file(path.join(tmp.path(), "local:", "notes", "merge-map.md")).exists()).toBe(false);
@@ -11,35 +11,6 @@ describe.skipIf(!HAS_JULIA)("eval Julia prelude helpers", () => {
await disposeJuliaKernelSessionsByOwner(OWNER_ID);
});
it("supports tree keyword options and unified diff", async () => {
using tempDir = TempDir.createSync("@omp-eval-julia-helpers-");
await Bun.write(path.join(tempDir.path(), "a.txt"), "same\nold\n");
await Bun.write(path.join(tempDir.path(), "b.txt"), "same\nnew\n");
await Bun.write(path.join(tempDir.path(), "dir", "child.txt"), "child");
const result = await executeJulia(
`
d = diff("a.txt", "b.txt")
println("DIFF_DELETE=", occursin("-old", d))
println("DIFF_ADD=", occursin("+new", d))
t = tree(".", max_depth=2)
println("TREE_CHILD=", occursin("child.txt", t))
nothing
`,
{
cwd: tempDir.path(),
sessionId: `julia-prelude-diff:${crypto.randomUUID()}`,
kernelOwnerId: OWNER_ID,
reset: true,
},
);
expect(result.exitCode).toBe(0);
expect(result.output).toContain("DIFF_DELETE=true");
expect(result.output).toContain("DIFF_ADD=true");
expect(result.output).toContain("TREE_CHILD=true");
}, 120_000);
it("supports output ranges, JSON queries, metadata, and ANSI stripping", async () => {
using tempDir = TempDir.createSync("@omp-eval-julia-output-");
const artifactsDir = path.join(tempDir.path(), "session-artifacts");
@@ -3,7 +3,7 @@ import * as vm from "node:vm";
import { JAVASCRIPT_PRELUDE_SOURCE } from "../js/shared/prelude";
/**
* The eval `agent()` helper grows a `returnHandle` option that turns its bare
* The eval `agent()` helper grows a `handle` option that turns its bare
* text result into a DAG node dict carrying the spawned agent's recoverable
* `agent://<id>` handle, so a downstream `pipeline`/`parallel` stage can wire
* the transcript by reference instead of re-inlining it. These lock the node
@@ -23,8 +23,8 @@ function loadPrelude(callTool: (name: string, args: unknown) => Promise<unknown>
type AgentHelper = (prompt: string, opts?: Record<string, unknown>) => Promise<unknown>;
describe("eval js agent() returnHandle", () => {
it("returns a DAG node carrying the agent:// handle when returnHandle is set", async () => {
describe("eval js agent() handle", () => {
it("returns a DAG node carrying the agent:// handle when handle is set", async () => {
let seenName: string | undefined;
let seenArgs: Record<string, unknown> | undefined;
const sandbox = loadPrelude(async (name, args) => {
@@ -32,9 +32,9 @@ describe("eval js agent() returnHandle", () => {
seenArgs = args as Record<string, unknown>;
return { text: "hello world", details: { agent: "task", id: "abc123", model: "m", structured: false } };
});
const node = await (sandbox.agent as AgentHelper)("say hi", { returnHandle: true });
const node = await (sandbox.agent as AgentHelper)("say hi", { handle: true });
expect(seenName).toBe("__agent__");
expect(seenArgs?.returnHandle).toBe(true);
expect(seenArgs?.handle).toBe(true);
expect(node).toEqual({
text: "hello world",
output: "hello world",
@@ -53,7 +53,7 @@ describe("eval js agent() returnHandle", () => {
expect(out).toBe("hello world");
});
it("carries the parsed object under data when schema and returnHandle combine", async () => {
it("carries the parsed object under data when schema and handle combine", async () => {
const payload = JSON.stringify({ k: 1 });
const sandbox = loadPrelude(async () => ({
text: payload,
@@ -61,7 +61,7 @@ describe("eval js agent() returnHandle", () => {
}));
const node = (await (sandbox.agent as AgentHelper)("emit", {
schema: { type: "object" },
returnHandle: true,
handle: true,
})) as Record<string, unknown>;
expect(node.handle).toBe("agent://id-9");
expect(node.data).toEqual({ k: 1 });
@@ -70,7 +70,7 @@ describe("eval js agent() returnHandle", () => {
it("falls back to a null handle without throwing when the bridge omits details", async () => {
const sandbox = loadPrelude(async () => ({ text: "lonely" }));
const node = await (sandbox.agent as AgentHelper)("x", { returnHandle: true });
const node = await (sandbox.agent as AgentHelper)("x", { handle: true });
expect(node).toEqual({ text: "lonely", output: "lonely", handle: null, id: null, agent: null });
});
@@ -93,7 +93,7 @@ describe("eval js agent() returnHandle", () => {
schema: { type: "object" },
isolated: true,
apply: false,
returnHandle: true,
handle: true,
})) as Record<string, unknown>;
expect(node.handle).toBe("agent://iso-1");
expect(node.data).toEqual({ ok: true });
@@ -43,19 +43,19 @@ const DEFAULT_AGENT_LABEL = "EvalAgent";
const agentArgsSchema = type({
prompt: "string>0",
"agentType?": "string>0",
"agent?": "string>0",
"model?": "string>0|string>0[]",
"label?": "string",
"schema?": "unknown",
"isolated?": "boolean",
"apply?": "boolean",
"merge?": "boolean",
"returnHandle?": "boolean",
"handle?": "boolean",
});
interface EvalAgentArgs {
prompt: string;
agentType?: string;
agent?: string;
model?: string | string[];
label?: string;
schema?: unknown;
@@ -83,7 +83,7 @@ interface EvalAgentArgs {
*/
merge?: boolean;
/** True when a runtime helper will return an `agent://` handle backed by the output artifacts. */
returnHandle?: boolean;
handle?: boolean;
}
export interface EvalAgentBridgeOptions {
@@ -276,7 +276,7 @@ function buildSubagentFailureMessage(agentName: string, result: SingleResult): s
*/
export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOptions): Promise<EvalAgentResult> {
const parsed = parseAgentArgs(args);
const agentName = parsed.agentType ?? DEFAULT_AGENT_TYPE;
const agentName = parsed.agent ?? DEFAULT_AGENT_TYPE;
const structured = Object.hasOwn(parsed, "schema");
assertNotPlanMode(options.session);
@@ -521,8 +521,7 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
// consumes `details.patchPath` / `details.branchName` /
// `details.nestedPatches` out of band. Failed isolated applies throw
// earlier with a recovery hint, so they never reach this gate.
const shouldCleanupTempArtifacts =
tempArtifactsDir && !parsed.returnHandle && (!isIsolated || changesApplied === true);
const shouldCleanupTempArtifacts = tempArtifactsDir && !parsed.handle && (!isIsolated || changesApplied === true);
if (shouldCleanupTempArtifacts) {
await fs.rm(artifactsDir, { recursive: true, force: true });
}
+6 -228
View File
@@ -146,221 +146,6 @@ function Base.write(path::AbstractString, content::Any)
return resolved
end
function append(path, content)
resolved = __omp_resolve_path(string(path))
mkpath(dirname(resolved))
open(resolved, "a") do f
Base.write(f, string(content))
end
Main.emit_frame(Dict(
"type" => "display",
"id" => Main.current_rid,
"bundle" => Dict(
"application/x-omp-status" => Dict(
"op" => "append",
"path" => resolved,
"chars" => length(string(content))
)
)
))
return resolved
end
function tree(path=".", positional_max_depth=3, positional_show_hidden=false; max_depth=positional_max_depth, show_hidden=positional_show_hidden)
base = string(path)
resolved = __omp_resolve_path(base)
lines = String[]
function walk(dir, prefix, depth)
if depth > max_depth
return
end
entries = try
readdir(dir)
catch
String[]
end
if !show_hidden
entries = filter(e -> !startswith(e, '.'), entries)
end
sort!(entries, by = e -> (ispath(joinpath(dir, e)) && isdir(joinpath(dir, e)) ? 0 : 1, lowercase(e)))
for (i, name) in enumerate(entries)
full = joinpath(dir, name)
is_last = i == length(entries)
is_dir = isdir(full)
push!(lines, "$(prefix)$(is_last ? "└── " : "├── ")$(name)$(is_dir ? "/" : "")")
if is_dir
walk(full, prefix * (is_last ? " " : "│ "), depth + 1)
end
end
end
walk(resolved, "", 1)
out = join(lines, '\n')
Main.emit_frame(Dict(
"type" => "display",
"id" => Main.current_rid,
"bundle" => Dict(
"application/x-omp-status" => Dict(
"op" => "tree",
"path" => resolved,
"lines" => length(lines)
)
)
))
return out
end
function __omp_lines_keepends(content::String)
parts = split(content, '\n'; keepempty=true)
if length(parts) == 1 && isempty(parts[1])
return String[]
end
lines = String[]
for i in eachindex(parts)
if i < length(parts)
push!(lines, string(parts[i], "\n"))
elseif !isempty(parts[i])
push!(lines, string(parts[i]))
end
end
return lines
end
function __omp_diff_ops(a::Vector{String}, b::Vector{String})
n = length(a)
m = length(b)
ops = Vector{Tuple{Symbol, Int, Int}}()
if n * m > 4_000_000
for i in 1:n
push!(ops, (:delete, i, 1))
end
for j in 1:m
push!(ops, (:insert, n + 1, j))
end
return ops
end
dp = [zeros(Int, m + 1) for _ in 1:(n + 1)]
for i in n:-1:1
for j in m:-1:1
dp[i][j] = a[i] == b[j] ? dp[i + 1][j + 1] + 1 : max(dp[i + 1][j], dp[i][j + 1])
end
end
i = 1
j = 1
while i <= n && j <= m
if a[i] == b[j]
push!(ops, (:equal, i, j))
i += 1
j += 1
elseif dp[i + 1][j] >= dp[i][j + 1]
push!(ops, (:delete, i, j))
i += 1
else
push!(ops, (:insert, i, j))
j += 1
end
end
while i <= n
push!(ops, (:delete, i, j))
i += 1
end
while j <= m
push!(ops, (:insert, i, j))
j += 1
end
return ops
end
function __omp_unified_diff(a::Vector{String}, b::Vector{String}, from_file::String, to_file::String, context::Int=3)
ops = __omp_diff_ops(a, b)
if !any(op -> op[1] != :equal, ops)
return ""
end
entries = [Dict{Symbol, Any}(:tag => tag, :ai => ai, :bi => bi, :text => tag == :insert ? b[bi] : a[ai]) for (tag, ai, bi) in ops]
changed = [i for i in eachindex(entries) if entries[i][:tag] != :equal]
groups = Vector{Tuple{Int, Int}}()
start = nothing
prev = nothing
for idx in changed
if start === nothing
start = idx
prev = idx
elseif idx - prev <= (2 * context) + 1
prev = idx
else
push!(groups, (start, prev))
start = idx
prev = idx
end
end
if start !== nothing
push!(groups, (start, prev))
end
out = IOBuffer()
write(out, "--- $from_file\n")
write(out, "+++ $to_file\n")
for (group_start, group_end) in groups
lo = max(group_start - context, 1)
hi = min(group_end + context, length(entries))
slice = entries[lo:hi]
a_start = nothing
a_count = 0
b_start = nothing
b_count = 0
for entry in slice
if entry[:tag] != :insert
if a_start === nothing
a_start = entry[:ai]
end
a_count += 1
end
if entry[:tag] != :delete
if b_start === nothing
b_start = entry[:bi]
end
b_count += 1
end
end
write(out, "@@ -$(a_start === nothing ? 1 : a_start),$a_count +$(b_start === nothing ? 1 : b_start),$b_count @@\n")
for entry in slice
prefix = entry[:tag] == :equal ? " " : (entry[:tag] == :delete ? "-" : "+")
text = string(entry[:text])
if !endswith(text, "\n")
text *= "\n"
end
write(out, prefix * text)
end
end
return String(take!(out))
end
function Base.diff(a::AbstractString, b::AbstractString)
path_a = __omp_resolve_path(string(a))
path_b = __omp_resolve_path(string(b))
lines_a = __omp_lines_keepends(open(path_a, "r") do io
Base.read(io, String)
end)
lines_b = __omp_lines_keepends(open(path_b, "r") do io
Base.read(io, String)
end)
out = __omp_unified_diff(lines_a, lines_b, path_a, path_b)
__omp_emit_status("diff", Dict{String, Any}(
"file_a" => path_a,
"file_b" => path_b,
"identical" => isempty(out),
"preview" => first(out, min(500, length(out)))
))
return out
end
function __omp_apply_query(data, query)
if query === nothing || isempty(string(query))
return data
@@ -734,10 +519,10 @@ function completion(prompt::String; model="default", system=nothing, schema=noth
return schema === nothing ? text : Main.json_parse(string(text))
end
function agent(prompt::String; agent_type="task", model=nothing, label=nothing, schema=nothing, isolated=nothing, apply=nothing, merge=nothing, return_handle=false, kwargs...)
function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...)
args_dict = Dict{String, Any}("prompt" => prompt)
if agent_type !== nothing
args_dict["agentType"] = agent_type
if agent !== nothing
args_dict["agent"] = agent
end
if model !== nothing
args_dict["model"] = model
@@ -759,20 +544,13 @@ function agent(prompt::String; agent_type="task", model=nothing, label=nothing,
if merge !== nothing
args_dict["merge"] = Bool(merge)
end
handle_result = return_handle
handle_result = handle
for (k, v) in kwargs
key = string(k)
if key == "agent_type" || key == "agentType"
args_dict["agentType"] = v
elseif key == "return_handle" || key == "returnHandle"
handle_result = Bool(v)
else
args_dict[key] = v
end
args_dict[string(k)] = v
end
# Tell the bridge a handle is wanted so it preserves the backing artifacts.
if handle_result
args_dict["returnHandle"] = true
args_dict["handle"] = true
end
res = __omp_call_bridge("__agent__", args_dict)
text = res isa AbstractDict ? get(res, "text", res) : res
+38 -12
View File
@@ -3,6 +3,12 @@
using Base64
# Force GR (the default Plots.jl backend) into a headless workstation so a plot
# never pops up a native gksqt GUI window — the harness renders the inline PNG
# from `show(io, MIME"image/png", plt)` itself. `get!` keeps an explicit
# user-provided value, mirroring the Python runner's MPLBACKEND=Agg default.
get!(ENV, "GKSwstype", "100")
const ORIGINAL_STDOUT = stdout
const ORIGINAL_STDERR = stderr
const ORIGINAL_STDIN = stdin
@@ -441,24 +447,38 @@ end
function build_mime_bundle(value)
bundle = Dict{String, Any}()
# text/plain
io_plain = IOBuffer()
show(io_plain, MIME"text/plain"(), value)
bundle["text/plain"] = String(take!(io_plain))
# text/plain — every mime probe below uses `Base.invokelatest` because this
# function runs from the frozen-world `main()` loop: `show`/`showable`
# methods that a package adds when it is `using`-ed *inside* a cell (e.g.
# Plots/Makie/GraphRecipes registering rich `show` for their plot types) are
# invisible to direct dispatch here and fall back to the default struct show,
# which can itself throw. Guard text/plain too so a failing repr never aborts
# the whole bundle before the image mime is reached.
try
io_plain = IOBuffer()
Base.invokelatest(show, io_plain, MIME"text/plain"(), value)
bundle["text/plain"] = String(take!(io_plain))
catch
bundle["text/plain"] = try
summary(value)
catch
string(typeof(value))
end
end
# rich mime types
for mime_str in ["text/html", "text/markdown", "image/png", "image/jpeg"]
m = MIME(Symbol(mime_str))
if showable(m, value)
if Base.invokelatest(showable, m, value)
try
io = IOBuffer()
if mime_str in ["image/png", "image/jpeg"]
b64_io = Base64EncodePipe(io)
show(b64_io, m, value)
Base.invokelatest(show, b64_io, m, value)
close(b64_io)
else
show(io, m, value)
Base.invokelatest(show, io, m, value)
end
bundle[mime_str] = String(take!(io))
catch
@@ -466,7 +486,7 @@ function build_mime_bundle(value)
end
end
end
if value isa AbstractDict || value isa AbstractVector
try
bundle["application/json"] = value
@@ -474,7 +494,7 @@ function build_mime_bundle(value)
# ignore
end
end
return bundle
end
@@ -493,7 +513,13 @@ pushdisplay(OmpDisplay())
function emit_error(rid, err, bt)
io = IOBuffer()
showerror(io, err)
# invokelatest + guard: custom error types from packages loaded inside the
# cell define `showerror` methods invisible to this frozen-world function.
try
Base.invokelatest(showerror, io, err)
catch
print(io, string(err))
end
err_str = String(take!(io))
tb = String[]
@@ -1,19 +1,11 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as Diff from "diff";
import { ToolError } from "../../../tools/tool-errors";
import type { JsStatusEvent } from "./types";
export interface HelperOptions {
path?: string;
hidden?: boolean;
maxDepth?: number;
limit?: number;
offset?: number;
reverse?: boolean;
unique?: boolean;
count?: boolean;
}
/**
@@ -35,18 +27,12 @@ export interface HelperContext {
/**
* The set of functions exposed to user code via `globalThis.__omp_helpers__`. The JS
* prelude reads from this bag and attaches short aliases (`read`, `write`, `tree`, ...)
* prelude reads from this bag and attaches short aliases (`read`, `write`, `env`, ...)
* onto the global scope.
*/
export interface HelperBundle {
read(rawPath: string, options?: HelperOptions): Promise<string>;
writeFile(rawPath: string, data: unknown): Promise<string>;
append(rawPath: string, content: string): Promise<string>;
sortText(text: string, options?: HelperOptions): string;
uniqText(text: string, options?: HelperOptions): string | Array<[number, string]>;
counter(items: string | string[], options?: HelperOptions): Array<[number, string]>;
diff(rawA: string, rawB: string): Promise<string>;
tree(searchPath?: string, options?: HelperOptions): Promise<string>;
env(key?: string, value?: string): string | Record<string, string> | undefined;
}
@@ -81,105 +67,6 @@ export function createHelpers(ctx: HelperContext): HelperBundle {
ctx.emitStatus({ op: "write", path: filePath, bytes: getDataSize(data) });
return filePath;
},
append: async (rawPath, content) => {
const target = resolveHelperPath(ctx, rawPath, "write");
// O(1) append; read-all+rewrite both raced concurrent writers and went
// quadratic when called in a loop. Bun.write creates parent dirs, so
// keep that behavior for the append path too.
await fs.promises.mkdir(path.dirname(target), { recursive: true });
await fs.promises.appendFile(target, content, "utf-8");
ctx.emitStatus({
op: "append",
path: target,
chars: content.length,
bytes: utf8Encoder.encode(content).byteLength,
});
return target;
},
sortText: (text, options = {}) => {
const lines = String(text).split(/\r?\n/);
const deduped = options.unique ? Array.from(new Set(lines)) : lines;
const sorted = deduped.sort((a, b) => a.localeCompare(b));
if (options.reverse) sorted.reverse();
const result = sorted.join("\n");
ctx.emitStatus({
op: "sort",
lines: sorted.length,
reverse: options.reverse === true,
unique: options.unique === true,
});
return result;
},
uniqText: (text, options = {}) => {
const lines = String(text)
.split(/\r?\n/)
.filter(line => line.length > 0);
const groups: Array<[number, string]> = [];
for (const line of lines) {
const last = groups.at(-1);
if (last && last[1] === line) {
last[0] += 1;
continue;
}
groups.push([1, line]);
}
ctx.emitStatus({ op: "uniq", groups: groups.length, count_mode: options.count === true });
if (options.count) return groups;
return groups.map(([, line]) => line).join("\n");
},
counter: (items, options = {}) => {
const values = Array.isArray(items) ? items : String(items).split(/\r?\n/).filter(Boolean);
const counts = new Map<string, number>();
for (const item of values) counts.set(item, (counts.get(item) ?? 0) + 1);
const entries = Array.from(counts.entries())
.map(([item, count]) => [count, item] as [number, string])
.sort((a, b) => (options.reverse === false ? a[0] - b[0] : b[0] - a[0]) || a[1].localeCompare(b[1]));
const limited = entries.slice(0, options.limit ?? entries.length);
ctx.emitStatus({ op: "counter", unique: counts.size, total: values.length, top: limited.slice(0, 10) });
return limited;
},
diff: async (rawA, rawB) => {
const fileA = resolvePath(ctx, rawA);
const fileB = resolvePath(ctx, rawB);
const [a, b] = await Promise.all([Bun.file(fileA).text(), Bun.file(fileB).text()]);
const result = Diff.createTwoFilesPatch(fileA, fileB, a, b, "", "", { context: 3 });
ctx.emitStatus({
op: "diff",
file_a: fileA,
file_b: fileB,
identical: a === b,
preview: result.slice(0, 500),
});
return result;
},
tree: async (searchPath = ".", options = {}) => {
const root = resolvePath(ctx, searchPath);
const maxDepth = options.maxDepth ?? 3;
const showHidden = options.hidden ?? false;
const lines: string[] = [`${root}/`];
let entryCount = 0;
const walk = async (dir: string, prefix: string, depth: number): Promise<void> => {
if (depth > maxDepth) return;
const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
.filter(entry => showHidden || !entry.name.startsWith("."))
.sort((a, b) => a.name.localeCompare(b.name));
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
const isLast = index === entries.length - 1;
const connector = isLast ? "└── " : "├── ";
const suffix = entry.isDirectory() ? "/" : "";
lines.push(`${prefix}${connector}${entry.name}${suffix}`);
entryCount += 1;
if (entry.isDirectory()) {
await walk(path.join(dir, entry.name), `${prefix}${isLast ? " " : "│ "}`, depth + 1);
}
}
};
await walk(root, "", 1);
const result = lines.join("\n");
ctx.emitStatus({ op: "tree", path: root, entries: entryCount, preview: result.slice(0, 1000) });
return result;
},
env: (key, value) => {
if (!key) {
const merged = Object.fromEntries(Object.entries(getMergedEnv(ctx)).sort(([a], [b]) => a.localeCompare(b)));
@@ -63,23 +63,6 @@ if (!globalThis.__omp_js_prelude_loaded__) {
return callHelper("read", path, options);
};
const write = async (path, data) => callHelper("writeFile", path, data);
const append = (path, content) => callHelper("append", path, content);
const sort = (text, opts, ...rest) =>
callHelper("sortText", text, optionsArg("sort", opts, rest, ["reverse", "unique"], "{ reverse, unique }"));
const uniq = (text, opts, ...rest) => callHelper("uniqText", text, optionsArg("uniq", opts, rest, ["count"], "{ count }"));
const counter = (items, opts, ...rest) =>
callHelper("counter", items, optionsArg("counter", opts, rest, ["limit", "reverse"], "{ limit, reverse }"));
const diff = (a, b) => callHelper("diff", a, b);
const tree = (path = ".", opts, ...rest) => {
if (isPlainObject(path) && opts === undefined && rest.length === 0) {
return callHelper("tree", ".", path);
}
return callHelper(
"tree",
isNil(path) ? "." : path,
optionsArg("tree", opts, rest, ["maxDepth", "showHidden"], "{ maxDepth, showHidden }"),
);
};
const env = (key, value) => callHelper("env", key, value);
const tool = new Proxy(
@@ -121,14 +104,14 @@ if (!globalThis.__omp_js_prelude_loaded__) {
"agent",
opts,
rest,
["agentType", "model", "label", "schema", "isolated", "apply", "merge"],
"{ agentType, model, label, schema, isolated, apply, merge, returnHandle }",
["agent", "model", "label", "schema", "isolated", "apply", "merge"],
"{ agent, model, label, schema, isolated, apply, merge, handle }",
);
const { returnHandle, ...callArgs } = o;
const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...callArgs, returnHandle: Boolean(returnHandle) });
const { handle, ...callArgs } = o;
const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...callArgs, handle: Boolean(handle) });
const text = res && typeof res === "object" ? res.text : res;
const parsed = hasOwn(callArgs, "schema") ? JSON.parse(text) : text;
if (!returnHandle) return parsed;
if (!handle) return parsed;
const details = res && typeof res === "object" ? res.details : undefined;
if (!details || typeof details !== "object" || details.id == null) {
return { text, output: text, handle: null, id: null, agent: null };
@@ -306,11 +289,5 @@ if (!globalThis.__omp_js_prelude_loaded__) {
globalThis.__pool = __pool;
globalThis.read = read;
globalThis.write = write;
globalThis.append = append;
globalThis.sort = sort;
globalThis.uniq = uniq;
globalThis.counter = counter;
globalThis.diff = diff;
globalThis.tree = tree;
globalThis.env = env;
}
@@ -72,12 +72,6 @@ const PRELUDE_GLOBAL_KEYS = [
"__pool",
"read",
"write",
"append",
"sort",
"uniq",
"counter",
"diff",
"tree",
"env",
];
@@ -1,5 +1,5 @@
/**
* Structured status payload emitted by helpers (`read`, `write`, `tree`, etc.) and the
* Structured status payload emitted by helpers (`read`, `write`, `env`, etc.) and the
* tool-call bridge. Surfaces to the model as part of `displays` so it has machine-readable
* context about what side effects happened.
*/
@@ -7,7 +7,7 @@ export interface SessionSnapshot {
sessionId: string;
/**
* On-disk roots the helpers substitute for internal-URL schemes
* (e.g. `{ local: "/…/artifacts/local" }`). Lets `read`/`write`/`append`
* (e.g. `{ local: "/…/artifacts/local" }`). Lets `read`/`write`
* accept `local://…` paths instead of writing a literal `local:/` directory.
*/
localRoots?: Record<string, string>;
@@ -17,8 +17,8 @@ describe("python prelude", () => {
expect(signature).toContain("limit");
});
it("exposes isolation artifacts on the agent() return_handle node", () => {
// agent(..., return_handle=True) is the only escape hatch for
it("exposes isolation artifacts on the agent() handle node", () => {
// agent(..., handle=True) is the only escape hatch for
// recovering apply=False patch/branch/nested artifacts (the bare
// schema return is just the parsed object), so the helper MUST
// translate the bridge's camelCase details onto the node — otherwise
@@ -71,7 +71,7 @@ export interface PythonExecutorOptions {
artifactPath?: string;
artifactId?: string;
/**
* On-disk roots the prelude helpers (`read`/`write`/`append`) substitute for
* On-disk roots the prelude helpers (`read`/`write`) substitute for
* internal-URL schemes (e.g. `{ local: "/…/artifacts/local" }`). Exported to
* the kernel as `PI_EVAL_LOCAL_ROOTS` (JSON) so `write("local://x")` lands
* where `read local://x` resolves instead of a literal `local:/` directory.
+9 -106
View File
@@ -115,103 +115,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("write", path=str(p), chars=len(content))
return p
def append(path: str | Path, content: str) -> Path:
"""Append to file."""
p = _resolve_omp_path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a", encoding="utf-8") as f:
f.write(content)
_emit_status("append", path=str(p), chars=len(content))
return p
def sort(text: str, *, reverse: bool = False, unique: bool = False) -> str:
"""Sort lines of text."""
lines = text.splitlines()
if unique:
lines = list(dict.fromkeys(lines))
lines = sorted(lines, reverse=reverse)
out = "\n".join(lines)
_emit_status("sort", lines=len(lines), unique=unique, reverse=reverse)
return out
def uniq(text: str, *, count: bool = False) -> str | list[tuple[int, str]]:
"""Remove duplicate adjacent lines (like uniq)."""
lines = text.splitlines()
if not lines:
_emit_status("uniq", groups=0)
return [] if count else ""
groups: list[tuple[int, str]] = []
current = lines[0]
current_count = 1
for line in lines[1:]:
if line == current:
current_count += 1
continue
groups.append((current_count, current))
current = line
current_count = 1
groups.append((current_count, current))
_emit_status("uniq", groups=len(groups), count_mode=count)
if count:
return groups
return "\n".join(line for _, line in groups)
def counter(
items: str | list,
*,
limit: int | None = None,
reverse: bool = True,
) -> list[tuple[int, str]]:
"""Count occurrences and sort by frequency. Like sort | uniq -c | sort -rn.
items: text (splits into lines) or list of strings
reverse: True for descending (most common first), False for ascending
Returns: [(count, item), ...] sorted by count
"""
from collections import Counter
if isinstance(items, str):
items = items.splitlines()
counts = Counter(items)
sorted_items = sorted(counts.items(), key=lambda x: (x[1], x[0]), reverse=reverse)
if limit is not None:
sorted_items = sorted_items[:limit]
result = [(count, item) for item, count in sorted_items]
_emit_status("counter", unique=len(counts), total=sum(counts.values()), top=result[:10])
return result
def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str:
"""Return directory tree."""
base = Path(path)
lines = []
def walk(p: Path, prefix: str, depth: int):
if depth > max_depth:
return
items = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
items = [i for i in items if show_hidden or not i.name.startswith(".")]
for i, item in enumerate(items):
is_last = i == len(items) - 1
connector = "└── " if is_last else "├── "
suffix = "/" if item.is_dir() else ""
lines.append(f"{prefix}{connector}{item.name}{suffix}")
if item.is_dir():
ext = " " if is_last else "│ "
walk(item, prefix + ext, depth + 1)
lines.append(str(base) + "/")
walk(base, "", 1)
out = "\n".join(lines)
_emit_status("tree", path=str(base), entries=len(lines) - 1, preview=out[:1000])
return out
def diff(a: str | Path, b: str | Path) -> str:
"""Compare two files, return unified diff."""
import difflib
path_a, path_b = Path(a), Path(b)
lines_a = path_a.read_text(encoding="utf-8").splitlines(keepends=True)
lines_b = path_b.read_text(encoding="utf-8").splitlines(keepends=True)
result = difflib.unified_diff(lines_a, lines_b, fromfile=str(path_a), tofile=str(path_b))
out = "".join(result)
_emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=not out, preview=out[:500])
return out
def output(
*ids: str,
format: str = "raw",
@@ -520,10 +423,10 @@ if "__omp_prelude_loaded__" not in globals():
text = res.get("text") if isinstance(res, dict) else res
return json.loads(text) if schema is not None else text
def agent(prompt, *, agent_type="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, return_handle=False):
def agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False):
"""Run a subagent and return its final output.
`agent_type` selects the subagent definition (default "task"). Pass
`agent` selects the subagent definition (default "task"). Pass
`model` to override that agent's model, `label` for the output artifact
id, and `schema` to request structured JSON output; when `schema` is
supplied the parsed object is returned. Share background by writing a
@@ -539,13 +442,13 @@ if "__omp_prelude_loaded__" not in globals():
When isolated, `apply=False` keeps captured changes inside the
worktree and surfaces the root patch path, branch name, and nested
repository patches through the DAG node dict (combine with
`return_handle=True` to receive them — see below; the bare return type
`handle=True` to receive them — see below; the bare return type
stays bytes/string/parsed object and has nowhere to expose artifacts).
`merge=False` forces patch mode even when `task.isolation.merge` is
`"branch"`, avoiding the per-call git lock + repo mutation that branch
mode performs.
Set `return_handle=True` to receive a DAG node dict instead of bare
Set `handle=True` to receive a DAG node dict instead of bare
text: ``{"text", "output", "handle", "id", "agent"}`` where ``handle``
is the spawned agent's recoverable ``agent://<id>`` URI. A downstream
``pipeline``/``parallel`` stage embeds that ``handle`` (or ``output``)
@@ -560,8 +463,8 @@ if "__omp_prelude_loaded__" not in globals():
``handle=None`` — the helper never throws.
"""
args = {"prompt": prompt}
if agent_type is not None:
args["agentType"] = agent_type
if agent is not None:
args["agent"] = agent
if model is not None:
args["model"] = model
if label is not None:
@@ -574,12 +477,12 @@ if "__omp_prelude_loaded__" not in globals():
args["apply"] = bool(apply)
if merge is not None:
args["merge"] = bool(merge)
if return_handle:
args["returnHandle"] = True
if handle:
args["handle"] = True
res = _bridge_call("__agent__", args)
text = res.get("text") if isinstance(res, dict) else res
parsed = json.loads(text) if schema is not None else text
if not return_handle:
if not handle:
return parsed
details = res.get("details") if isinstance(res, dict) else None
if not isinstance(details, dict) or details.get("id") is None:
@@ -557,12 +557,6 @@ def _magic_run(args: str) -> None:
def _magic_cell_bash(args: str, body: str) -> int:
return _run_shell_body(body, shell_arg="/bin/bash")
@cell_magic("sh")
def _magic_cell_sh(args: str, body: str) -> int:
return _run_shell_body(body, shell_arg="/bin/sh")
@cell_magic("capture")
def _magic_cell_capture(args: str, body: str) -> str:
"""Capture stdout/stderr of body; bind to ``args`` (a name) if provided."""
+5 -190
View File
@@ -2,7 +2,7 @@
# OMP Ruby prelude helpers (loaded once into the runner's TOPLEVEL_BINDING).
#
# Mirrors eval/py/prelude.py: defines the cross-runtime helper surface
# (display/read/write/append/tree/diff/env/output, the `tool` bridge proxy,
# (display/read/write/env/output, the `tool` bridge proxy,
# completion/agent/parallel/pipeline/log/phase/budget). Host-side helpers reach
# the coding-agent over the same loopback HTTP tool bridge the Python prelude
# uses (PI_TOOL_BRIDGE_URL/TOKEN/SESSION). Path helpers honor PI_EVAL_LOCAL_ROOTS
@@ -112,191 +112,6 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
resolved.to_s
end
def append(path, content)
resolved = __omp_resolve_path(path)
require "fileutils"
FileUtils.mkdir_p(File.dirname(resolved.to_s))
File.open(resolved.to_s, "a") { |f| f.write(content.to_s) }
__omp_emit_status("append", "path" => resolved.to_s, "chars" => content.to_s.length)
resolved.to_s
end
def tree(path = ".", max_depth: 3, show_hidden: false)
base = path.to_s
lines = []
walk = lambda do |dir, prefix, depth|
return if depth > max_depth
entries = (Dir.children(dir) rescue [])
entries = entries.reject { |e| e.start_with?(".") } unless show_hidden
entries = entries.sort_by { |e| [File.directory?(File.join(dir, e)) ? 0 : 1, e.downcase] }
entries.each_with_index do |name, i|
full = File.join(dir, name)
is_last = i == entries.length - 1
is_dir = File.directory?(full)
lines << "#{prefix}#{is_last ? "└── " : "├── "}#{name}#{is_dir ? "/" : ""}"
walk.call(full, prefix + (is_last ? " " : "│ "), depth + 1) if is_dir
end
end
lines << "#{base}/"
walk.call(base, "", 1)
out = lines.join("\n")
__omp_emit_status("tree", "path" => base, "entries" => lines.length - 1, "preview" => __omp_scrub(out[0, 1000].to_s))
out
end
def diff(a, b)
pa = a.to_s
pb = b.to_s
lines_a = File.read(pa, encoding: Encoding::UTF_8).lines
lines_b = File.read(pb, encoding: Encoding::UTF_8).lines
out = __omp_unified_diff(lines_a, lines_b, pa, pb)
__omp_emit_status("diff", "file_a" => pa, "file_b" => pb, "identical" => out.empty?, "preview" => __omp_scrub(out[0, 500].to_s))
out
end
# LCS-based op list ([:equal/:delete/:insert, aIndex, bIndex]) per consumed line.
def __omp_diff_ops(a, b)
n = a.length
m = b.length
if n * m > 4_000_000
# Too large for the DP table — fall back to a coarse all-delete/all-insert.
ops = []
n.times { |i| ops << [:delete, i, 0] }
m.times { |j| ops << [:insert, n, j] }
return ops
end
dp = Array.new(n + 1) { Array.new(m + 1, 0) }
(n - 1).downto(0) do |i|
row = dp[i]
nrow = dp[i + 1]
(m - 1).downto(0) do |j|
row[j] = a[i] == b[j] ? nrow[j + 1] + 1 : (nrow[j] >= row[j + 1] ? nrow[j] : row[j + 1])
end
end
ops = []
i = 0
j = 0
while i < n && j < m
if a[i] == b[j]
ops << [:equal, i, j]; i += 1; j += 1
elsif dp[i + 1][j] >= dp[i][j + 1]
ops << [:delete, i, j]; i += 1
else
ops << [:insert, i, j]; j += 1
end
end
ops << [:delete, i, j].tap { i += 1 } while i < n
ops << [:insert, i, j].tap { j += 1 } while j < m
ops
end
def __omp_unified_diff(a, b, from_file, to_file, context = 3)
ops = __omp_diff_ops(a, b)
return "" unless ops.any? { |tag, _, _| tag != :equal }
entries = ops.map do |tag, ai, bi|
{ tag: tag, ai: ai, bi: bi, text: (tag == :insert ? b[bi] : a[ai]) }
end
changed = entries.each_index.select { |k| entries[k][:tag] != :equal }
groups = []
start = nil
prev = nil
changed.each do |k|
if start.nil?
start = k
prev = k
elsif k - prev <= (2 * context) + 1
prev = k
else
groups << [start, prev]
start = k
prev = k
end
end
groups << [start, prev] unless start.nil?
out = +""
out << "--- #{from_file}\n"
out << "+++ #{to_file}\n"
groups.each do |gs, ge|
lo = [gs - context, 0].max
hi = [ge + context, entries.length - 1].min
slice = entries[lo..hi]
a_start = nil
a_count = 0
b_start = nil
b_count = 0
slice.each do |e|
if e[:tag] != :insert
a_start ||= e[:ai]
a_count += 1
end
if e[:tag] != :delete
b_start ||= e[:bi]
b_count += 1
end
end
out << "@@ -#{(a_start || 0) + 1},#{a_count} +#{(b_start || 0) + 1},#{b_count} @@\n"
slice.each do |e|
prefix = e[:tag] == :equal ? " " : (e[:tag] == :delete ? "-" : "+")
text = e[:text].to_s
text = "#{text}\n" unless text.end_with?("\n")
out << "#{prefix}#{text}"
end
end
out
end
# -------------------------------------------------------------------------
# Text helpers (sort / uniq / counter)
# -------------------------------------------------------------------------
def sort(text, reverse: false, unique: false)
lines = text.to_s.lines.map(&:chomp)
lines = lines.uniq if unique
lines = lines.sort
lines = lines.reverse if reverse
out = lines.join("\n")
__omp_emit_status("sort", "lines" => lines.length, "unique" => unique, "reverse" => reverse)
out
end
def uniq(text, count: false)
lines = text.to_s.lines.map(&:chomp)
if lines.empty?
__omp_emit_status("uniq", "groups" => 0)
return count ? [] : ""
end
groups = []
current = lines[0]
run = 1
lines[1..].each do |line|
if line == current
run += 1
else
groups << [run, current]
current = line
run = 1
end
end
groups << [run, current]
__omp_emit_status("uniq", "groups" => groups.length, "count_mode" => count)
count ? groups : groups.map { |_, l| l }.join("\n")
end
def counter(items, limit: nil, reverse: true)
arr = items.is_a?(String) ? items.lines.map(&:chomp) : items.to_a
counts = Hash.new(0)
arr.each { |i| counts[i] += 1 }
sorted = counts.sort_by { |item, c| [c, item] }
sorted = sorted.reverse if reverse
sorted = sorted.first(limit) if limit
result = sorted.map { |item, c| [c, item] }
__omp_emit_status("counter", "unique" => counts.size, "total" => arr.length, "top" => result.first(10))
result
end
# -------------------------------------------------------------------------
# Task/agent output reader
# -------------------------------------------------------------------------
@@ -577,9 +392,9 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
schema.nil? ? text : JSON.parse(text)
end
def agent(prompt, agent_type: "task", model: nil, label: nil, schema: nil, isolated: nil, apply: nil, merge: nil, return_handle: false)
def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, isolated: nil, apply: nil, merge: nil, handle: false)
args = { "prompt" => prompt }
args["agentType"] = agent_type unless agent_type.nil?
args["agent"] = agent unless agent.nil?
args["model"] = model unless model.nil?
args["label"] = label unless label.nil?
args["schema"] = schema unless schema.nil?
@@ -589,11 +404,11 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
args["apply"] = !!apply unless apply.nil?
args["merge"] = !!merge unless merge.nil?
# Tell the bridge a handle is wanted so it preserves the backing artifacts.
args["returnHandle"] = true if return_handle
args["handle"] = true if handle
res = OmpBridge.call("__agent__", args)
text = res.is_a?(Hash) ? res["text"] : res
parsed = schema.nil? ? text : JSON.parse(text)
return parsed unless return_handle
return parsed unless handle
details = res.is_a?(Hash) ? res["details"] : nil
if !details.is_a?(Hash) || details["id"].nil?
return { "text" => text, "output" => text, "handle" => nil, "id" => nil, "agent" => nil }
+116 -9
View File
@@ -129,10 +129,122 @@ def __omp_emit_status(op, data = {})
__omp_emit_display({ "application/x-omp-status" => status }, "display")
end
OMP_IMAGE_MIMES = %w[image/png image/jpeg].freeze
# True when `str` already looks like base64 text (ASCII, base64 alphabet, length
# a multiple of 4). Raw image blobs (PNG/JPEG bytes) contain high bytes, so they
# fail the ASCII check and get encoded instead of passed through unchanged.
def __omp_base64?(str)
s = str.to_s
# ascii_only? is safe on any encoding (no regex over invalid bytes). Raw image
# blobs carry high bytes and fail here, so they get encoded rather than scanned.
return false unless s.ascii_only?
stripped = s.gsub(/\s+/, "")
return false if stripped.empty? || (stripped.bytesize % 4) != 0
stripped.match?(%r{\A[A-Za-z0-9+/]*={0,2}\z})
end
# Coerce an image payload to the base64 ASCII the host renders. IRuby-style
# `to_iruby` hands back raw binary blobs (Gruff#to_blob, ChunkyPNG, RMagick),
# which would also break JSON.generate; strict-encode them unless already base64.
def __omp_image_payload(content)
require "base64"
s = content.to_s
return s.gsub(/\s+/, "") if __omp_base64?(s)
Base64.strict_encode64(s.b)
end
# Detect a host-renderable image MIME from a binary blob's magic bytes. Lets us
# treat the generic `to_blob` (Gruff/RMagick/ChunkyPNG/Vips) as an image only
# when it really is one, avoiding false positives on unrelated `to_blob` methods.
def __omp_sniff_image_mime(bytes)
b = bytes.to_s.b
return "image/png" if b.start_with?("\x89PNG\r\n\x1a\n".b)
return "image/jpeg" if b.start_with?("\xFF\xD8\xFF".b)
nil
end
# Stringify keys, base64-encode image payloads, and scrub text payloads so the
# bundle is always JSON-safe before it reaches __omp_emit.
def __omp_normalize_bundle(hash)
bundle = {}
hash.each do |key, val|
k = key.to_s
bundle[k] =
if OMP_IMAGE_MIMES.include?(k)
__omp_image_payload(val)
elsif val.is_a?(String)
__omp_scrub(val)
else
val
end
end
bundle
end
# Guarantee a text/plain entry so the model always sees a textual hint, even for
# image-only bundles (mirrors the Python runner).
def __omp_finalize_bundle(bundle, value)
bundle["text/plain"] ||= __omp_scrub((value.inspect rescue value.class.name))
bundle
end
# Rich-display resolution for non-collection objects. Honors the repo
# `to_omp_mime` convention first, then the IRuby protocol
# (`to_iruby_mimebundle` -> [data, metadata], `to_iruby` -> [mime, data]) so plot
# and image objects (gruff, rubyplot, gnuplotrb, chunky_png, daru, ...) render
# inline — the Ruby analog of IPython's _repr_*_ methods. Returns nil when the
# value advertises no rich representation.
def __omp_rich_mime_bundle(value)
if value.respond_to?(:to_omp_mime)
mime = (value.to_omp_mime rescue nil)
return __omp_finalize_bundle(__omp_normalize_bundle(mime), value) if mime.is_a?(Hash) && !mime.empty?
end
if value.respond_to?(:to_iruby_mimebundle)
data =
begin
value.to_iruby_mimebundle
rescue ArgumentError
(value.to_iruby_mimebundle(include: []) rescue nil)
rescue StandardError
nil
end
data = data.first if data.is_a?(Array)
return __omp_finalize_bundle(__omp_normalize_bundle(data), value) if data.is_a?(Hash) && !data.empty?
end
if value.respond_to?(:to_iruby)
pair = (value.to_iruby rescue nil)
if pair.is_a?(Array) && pair.size == 2 && !pair[0].nil?
return __omp_finalize_bundle(__omp_normalize_bundle({ pair[0].to_s => pair[1] }), value)
end
end
# Last resort: probe well-known image emitters. Named methods (to_png/to_jpeg)
# are trusted; the generic to_blob is accepted only when its bytes sniff as an
# image. Covers gems that render via IRuby's registry rather than to_iruby
# (Gruff#to_blob, ChunkyPNG#to_blob, RMagick, Vips, ...).
if value.respond_to?(:to_png)
png = (value.to_png rescue nil)
return __omp_finalize_bundle({ "image/png" => __omp_image_payload(png) }, value) if png
end
jpeg_method = %i[to_jpeg to_jpg].find { |m| value.respond_to?(m) }
if jpeg_method
jpg = (value.public_send(jpeg_method) rescue nil)
return __omp_finalize_bundle({ "image/jpeg" => __omp_image_payload(jpg) }, value) if jpg
end
if value.respond_to?(:to_blob)
blob = (value.to_blob rescue nil)
if blob.is_a?(String) && (mime = __omp_sniff_image_mime(blob))
return __omp_finalize_bundle({ mime => __omp_image_payload(blob) }, value)
end
end
nil
end
# Build a Jupyter-style MIME bundle for a value. Strings render as plain text,
# Hash/Array render as JSON (plus a text/plain repr) so the model sees structure,
# and anything else falls back to its inspect string. Objects may opt into a
# richer bundle by defining `to_omp_mime` returning a Hash of mime => value.
# Hash/Array render as JSON (plus a text/plain repr) so the model sees structure.
# Other objects may expose a rich representation via `to_omp_mime` or the IRuby
# protocol (`to_iruby`/`to_iruby_mimebundle`); otherwise they fall back to inspect.
def __omp_mime_bundle(value)
case value
when String
@@ -151,12 +263,7 @@ def __omp_mime_bundle(value)
when nil
{ "text/plain" => "nil" }
else
if value.respond_to?(:to_omp_mime)
mime = (value.to_omp_mime rescue nil)
mime.is_a?(Hash) ? mime : { "text/plain" => __omp_scrub(value.inspect) }
else
{ "text/plain" => __omp_scrub(value.inspect) }
end
__omp_rich_mime_bundle(value) || { "text/plain" => __omp_scrub(value.inspect) }
end
end
@@ -157,7 +157,7 @@ export function resolveLocalUrlToPath(
}
/**
* On-disk roots the eval helpers (`read`/`write`/`append`) substitute for
* On-disk roots the eval helpers (`read`/`write`) substitute for
* internal-URL schemes so e.g. `write("local://x.md")` lands where a later
* `read local://x.md` resolves — instead of a literal `local:/` directory under
* the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to
@@ -169,6 +169,96 @@ export function buildEvalUrlRoots(options: LocalProtocolOptions): Record<string,
return { local: resolveLocalRoot(options) };
}
const LOCAL_WRITE_NOTE = "Use write path local://<file> to persist large intermediate artifacts across turns.";
type ResolvedLocalTarget =
| { kind: "listing"; root: string }
| { kind: "directory"; path: string }
| { kind: "file"; path: string; size: number };
/**
* Resolve a local:// URL to its on-disk target with realpath + containment
* checks on the root, parent, and target so symlinks cannot escape the session
* local root. Does NOT read or decode file contents — callers decide how to
* consume the resolved path. Shared by {@link LocalProtocolHandler.resolve} and
* {@link resolveLocalUrlToFile}.
*/
async function resolveLocalTarget(url: InternalUrl, opts: LocalProtocolOptions): Promise<ResolvedLocalTarget> {
const localRoot = path.resolve(resolveLocalRoot(opts));
await fs.mkdir(localRoot, { recursive: true });
let resolvedRoot: string;
try {
resolvedRoot = await fs.realpath(localRoot);
} catch (error) {
if (isEnoent(error)) {
throw new Error("Unable to initialize local:// root");
}
throw error;
}
const relativePath = extractRelativePath(url);
const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
ensureWithinRoot(targetPath, resolvedRoot);
if (targetPath === resolvedRoot) {
return { kind: "listing", root: resolvedRoot };
}
const parentDir = path.dirname(targetPath);
try {
const realParent = await fs.realpath(parentDir);
ensureWithinRoot(realParent, resolvedRoot);
} catch (error) {
if (!isEnoent(error)) throw error;
}
let realTargetPath: string;
try {
realTargetPath = await fs.realpath(targetPath);
} catch (error) {
if (isEnoent(error)) {
throw new Error(`Local file not found: ${url.href}`);
}
throw error;
}
ensureWithinRoot(realTargetPath, resolvedRoot);
const stat = await fs.stat(realTargetPath);
if (stat.isDirectory()) {
return { kind: "directory", path: realTargetPath };
}
if (!stat.isFile()) {
throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
}
return { kind: "file", path: realTargetPath, size: stat.size };
}
/**
* Resolve a local:// URL to a regular on-disk file, applying the same
* realpath + containment guarantees as {@link LocalProtocolHandler.resolve}
* but WITHOUT reading or UTF-8-decoding its contents. Returns null when there
* is no active session or when the URL targets the root listing or a directory;
* throws the handler's not-found and "escapes local root" errors for missing
* files and symlink escapes.
*
* Options are resolved via {@link LocalProtocolHandler.resolveOptions} so the
* caller-options → override → registry order matches router resolution exactly.
* The read tool uses this to detect and emit image files from their real path
* before the text-only resource contract would decode the binary into mojibake.
*/
export async function resolveLocalUrlToFile(
input: string | InternalUrl,
context?: ResolveContext,
): Promise<{ path: string; size: number } | null> {
const opts = LocalProtocolHandler.resolveOptions(context);
if (!opts) return null;
const url = typeof input === "string" ? parseLocalUrl(input) : input;
const resolved = await resolveLocalTarget(url, opts);
return resolved.kind === "file" ? { path: resolved.path, size: resolved.size } : null;
}
/**
* Protocol handler for local:// URLs.
*
@@ -238,65 +328,22 @@ export class LocalProtocolHandler implements ProtocolHandler {
throw new Error("No session - local:// unavailable");
}
const localRoot = path.resolve(resolveLocalRoot(opts));
await fs.mkdir(localRoot, { recursive: true });
let resolvedRoot: string;
try {
resolvedRoot = await fs.realpath(localRoot);
} catch (error) {
if (isEnoent(error)) {
throw new Error("Unable to initialize local:// root");
}
throw error;
const resolved = await resolveLocalTarget(url, opts);
if (resolved.kind === "listing") {
return buildListing(url, resolved.root);
}
if (resolved.kind === "directory") {
return buildDirectoryResource(url.href, resolved.path, [LOCAL_WRITE_NOTE]);
}
const relativePath = extractRelativePath(url);
const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
ensureWithinRoot(targetPath, resolvedRoot);
if (targetPath === resolvedRoot) {
return buildListing(url, resolvedRoot);
}
const parentDir = path.dirname(targetPath);
try {
const realParent = await fs.realpath(parentDir);
ensureWithinRoot(realParent, resolvedRoot);
} catch (error) {
if (!isEnoent(error)) throw error;
}
let realTargetPath: string;
try {
realTargetPath = await fs.realpath(targetPath);
} catch (error) {
if (isEnoent(error)) {
throw new Error(`Local file not found: ${url.href}`);
}
throw error;
}
ensureWithinRoot(realTargetPath, resolvedRoot);
const stat = await fs.stat(realTargetPath);
if (stat.isDirectory()) {
return buildDirectoryResource(url.href, realTargetPath, [
"Use write path local://<file> to persist large intermediate artifacts across turns.",
]);
}
if (!stat.isFile()) {
throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
}
const content = await Bun.file(realTargetPath).text();
const content = await Bun.file(resolved.path).text();
return {
url: url.href,
content,
contentType: getContentType(realTargetPath),
contentType: getContentType(resolved.path),
size: Buffer.byteLength(content, "utf-8"),
sourcePath: realTargetPath,
notes: ["Use write path local://<file> to persist large intermediate artifacts across turns."],
sourcePath: resolved.path,
notes: [LOCAL_WRITE_NOTE],
};
}
+15 -4
View File
@@ -945,6 +945,7 @@ async function buildSessionOptions(
interface RunRootCommandDependencies {
createAgentSession?: typeof createAgentSession;
discoverAuthStorage?: typeof discoverAuthStorage;
selectSession?: typeof selectSession;
runAcpMode?: RunAcpMode;
settings?: Settings;
forceSetupWizard?: boolean;
@@ -1131,7 +1132,8 @@ export async function runRootCommand(
// (see issue #1668).
if (typeof parsedArgs.resume === "string" && !sessionManager) {
writeStartupNotice(parsedArgs, `${chalk.dim("Resume cancelled: session is in another project.")}\n`);
return;
stopStartupWatchdog();
process.exit(0);
}
// Handle --resume (no value): show session picker
@@ -1147,17 +1149,26 @@ export async function runRootCommand(
preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll);
if (preloadedAllSessions.length === 0) {
writeStartupNotice(parsedArgs, `${chalk.dim("No sessions found")}\n`);
return;
stopStartupWatchdog();
process.exit(0);
}
}
pauseStartupWatchdog();
const selected = await logger.time("selectSession", selectSession, folderSessions, {
const selected = await logger.time("selectSession", deps.selectSession ?? selectSession, folderSessions, {
allSessions: preloadedAllSessions,
});
resumeStartupWatchdog();
if (!selected) {
writeStartupNotice(parsedArgs, `${chalk.dim("No session selected")}\n`);
return;
// Quit instead of returning: startup already armed long-lived handles
// (theme watcher + SIGWINCH/macOS appearance listeners via initTheme,
// settings save timer, model registry) that keep the event loop alive,
// so a bare return hangs the process after the picker leaves the alt
// screen. No session was built here, so there is nothing to flush. The
// in-session `/resume` picker (selector-controller.ts) takes a different
// onCancel that just closes the overlay — only this startup path exits.
stopStartupWatchdog();
process.exit(0);
}
// Resuming a session from another project: switch the process into that
// project's directory and refresh cwd-derived caches before the session is
@@ -468,8 +468,13 @@ function buildEvalStartText(args: unknown): string | undefined {
if (typeof args !== "object" || args === null || Array.isArray(args)) {
return undefined;
}
const cells = (args as EvalCellContainer).cells;
if (!Array.isArray(cells) || cells.length === 0) {
const container = args as EvalCellContainer & EvalCellLike;
const cells = Array.isArray(container.cells)
? container.cells
: typeof container.code === "string"
? [container]
: [];
if (cells.length === 0) {
return undefined;
}
const lines: string[] = [];
@@ -11,6 +11,7 @@
import {
Container,
Input,
matchesKey,
type SelectItem,
SelectList,
type SettingItem,
@@ -36,13 +37,18 @@ import { DynamicBorder } from "./dynamic-border";
/**
* Forwards a keystroke to `input`, but cancels via `onCancel` when the user presses Escape.
*
* Escape is decoded via `matchesKey` rather than a raw `\x1b` compare: inside the
* fullscreen settings overlay the kitty keyboard protocol is active (ghostty/kitty),
* where the Escape key arrives as the CSI-u sequence `\x1b[27u`, not a bare `\x1b`.
* The literal fallbacks preserve legacy single/double-escape on terminals without it.
*/
export function handleInputOrEscape(
data: string,
input: { handleInput(data: string): void },
onCancel: () => void,
): void {
if (data === "\x1b" || data === "\x1b\x1b") {
if (data === "\x1b" || data === "\x1b\x1b" || matchesKey(data, "escape")) {
onCancel();
return;
}
@@ -5,6 +5,7 @@ import {
Input,
matchesKey,
padding,
parseSgrMouse,
replaceTabs,
ScrollView,
Spacer,
@@ -161,6 +162,12 @@ export function mergeSessionRanking(
class SessionList implements Component {
#filteredSessions: SessionInfo[] = [];
#selectedIndex: number = 0;
// Maps a 0-based line within this list's own render to a filtered-session
// index, or undefined for chrome rows (search line, blanks, scrollbar gap).
// Rebuilt every render so the picker's mouse hit-testing tracks the live
// scroll window. Only consulted while the picker holds the alternate screen
// (where the overlay enables mouse tracking and paints from screen row 0).
#hitRows: (number | undefined)[] = [];
readonly #searchInput: Input;
onSelect?: (session: SessionInfo) => void;
onCancel?: () => void;
@@ -257,12 +264,32 @@ class SessionList implements Component {
}
}
/** Resolve a list-local rendered-line index to a filtered-session index. */
hitTestSession(line: number): number | undefined {
return this.#hitRows[line];
}
/** Wheel notch: move the selection one step (clamped, no wrap). */
handleWheel(delta: -1 | 1): void {
if (this.#filteredSessions.length === 0) return;
this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta));
}
/** Mouse click: select the session under the pointer and resume it. */
selectAndConfirm(index: number): void {
const session = this.#filteredSessions[index];
if (!session) return;
this.#selectedIndex = index;
this.onSelect?.(session);
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): readonly string[] {
const lines: string[] = [];
this.#hitRows = [];
// Render search input
lines.push(...this.#searchInput.render(width));
@@ -311,9 +338,11 @@ class SessionList implements Component {
// Each session block is built into sessionLines, then wrapped by ScrollView
// so the right-edge scrollbar is proportional at the physical-line level.
const sessionLines: string[] = [];
const sessionRowIndex: number[] = [];
const overflow = this.#filteredSessions.length > maxVisible;
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
for (let i = startIndex; i < endIndex; i++) {
const blockStart = sessionLines.length;
const session = this.#filteredSessions[i];
const isSelected = i === this.#selectedIndex;
@@ -363,6 +392,7 @@ class SessionList implements Component {
sessionLines.push(metadataLine);
sessionLines.push(""); // Blank line between sessions
for (let k = blockStart; k < sessionLines.length; k++) sessionRowIndex[k] = i;
}
// Wrap the rendered window in a ScrollView for a proportional right-edge bar.
@@ -375,16 +405,10 @@ class SessionList implements Component {
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
});
sv.setScrollOffset(Math.round(startIndex * linesPerItem));
lines.push(...sv.render(width));
// Add keybinding hint
lines.push("");
lines.push(
theme.fg(
"muted",
` [Del delete · Enter select · Tab ${this.#showCwd ? "current folder" : "all projects"} · Esc cancel]`,
),
);
const sessionRegionStart = lines.length;
const svLines = sv.render(width);
for (let k = 0; k < svLines.length; k++) this.#hitRows[sessionRegionStart + k] = sessionRowIndex[k];
lines.push(...svLines);
return lines;
}
@@ -462,6 +486,13 @@ export interface SessionSelectorOptions {
* Omitted only in tests; defaults to a conservative 24 rows.
*/
getTerminalRows?: () => number;
/**
* Fill the whole viewport and pin the footer (hint + bottom border) to the
* last rows, so the footer stops drifting as the list window changes height.
* Set by the standalone `--resume` picker (fullscreen alternate screen); the
* in-editor selector leaves it off and renders compactly.
*/
fillHeight?: boolean;
}
/**
@@ -470,6 +501,13 @@ export interface SessionSelectorOptions {
export class SessionSelectorComponent extends Container {
#sessionList: SessionList;
#confirmationDialog: HookSelectorComponent | null = null;
// Hosts whichever of `#sessionList` / `#confirmationDialog` is live this
// frame. The delete dialog REPLACES the list in this slot rather than being
// appended below the picker chrome, so the picker is always
// `chrome + max(list, dialog) + chrome` and never overflows the viewport
// (issue #3283: an overflowing dialog frame committed the header into
// scrollback, stranding it above the viewport once the dialog closed).
#contentSlot: Container;
#messageContainer: Container;
#headerText: Text;
#onDelete?: (session: SessionInfo) => Promise<boolean>;
@@ -479,6 +517,18 @@ export class SessionSelectorComponent extends Container {
#globalSessions: SessionInfo[] | null = null;
#scope: "folder" | "all" = "folder";
#toggling = false;
// 0-based line where the session list begins within this component's own
// render, captured each frame. The fullscreen picker overlay paints from
// screen row 0, so a mouse row maps to `row - #listLineOffset` inside the
// list. Only meaningful while the picker holds the alternate screen.
#listLineOffset = 0;
// 0-based line where the pinned footer begins; clicks at or below it never
// hit-test the list, so a footer click on a cramped (trimmed) frame can't
// resume a session scrolled off-screen.
#footerStart = 0;
readonly #getTerminalRows: () => number;
readonly #fillHeight: boolean;
readonly #bottomBorder = new DynamicBorder();
constructor(
sessions: SessionInfo[],
@@ -494,6 +544,8 @@ export class SessionSelectorComponent extends Container {
this.#loadAllSessions = options.loadAllSessions;
this.#folderSessions = sessions;
this.#globalSessions = options.allSessions ?? null;
this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
this.#fillHeight = options.fillHeight ?? false;
// Add header
this.addChild(new Spacer(1));
this.#headerText = new Text(this.#headerLabel(), 1, 0);
@@ -517,11 +569,9 @@ export class SessionSelectorComponent extends Container {
void this.#toggleScope();
};
}
this.addChild(this.#sessionList);
// Add bottom border
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.#contentSlot = new Container();
this.#contentSlot.addChild(this.#sessionList);
this.addChild(this.#contentSlot);
}
#headerLabel(): string {
@@ -582,6 +632,15 @@ export class SessionSelectorComponent extends Container {
#showDeleteConfirmation(session: SessionInfo): void {
const displayName = session.title || session.firstMessage.slice(0, 40) || session.id;
const closeDialog = () => {
this.#confirmationDialog = null;
// Restore the SessionList into the content slot so the picker is back
// to its normal layout on the very next render — the same frame the
// dialog disappears.
this.#contentSlot.clear();
this.#contentSlot.addChild(this.#sessionList);
this.#onRequestRender?.();
};
this.#confirmationDialog = new HookSelectorComponent(
`Delete session?\n${displayName}`,
["Yes", "No"],
@@ -597,25 +656,61 @@ export class SessionSelectorComponent extends Container {
this.#showError(err instanceof Error ? err.message : String(err));
}
}
// Close confirmation dialog
this.removeChild(this.#confirmationDialog!);
this.#confirmationDialog = null;
// Request rerender
this.#onRequestRender?.();
},
() => {
// Cancel - close confirmation dialog
this.removeChild(this.#confirmationDialog!);
this.#confirmationDialog = null;
// Request rerender
this.#onRequestRender?.();
closeDialog();
},
closeDialog,
);
// Show confirmation dialog
this.addChild(this.#confirmationDialog);
// Swap the SessionList out of the content slot and mount the dialog in its
// place: the dialog competes only with the SessionList's rendered budget,
// never the SessionList AND the picker chrome, so the picker frame stays
// inside the terminal viewport and the TUI never commits the header into
// scrollback (issue #3283).
this.#contentSlot.clear();
this.#contentSlot.addChild(this.#confirmationDialog);
this.#onRequestRender?.();
}
/**
* Concatenate the children's renders (like {@link Container}) while recording
* the line where the session list begins, so the fullscreen picker can hit-
* test mouse rows against the live list window. SessionList rebuilds its lines
* every frame, so Container's reference-memoization never applied here.
*
* In fill-height mode the body is padded (or, on a cramped terminal, trimmed)
* to leave exactly enough room for the footer at the screen bottom, so the
* footer is always visible and never drifts as the list window resizes. The
* in-editor selector just appends the footer directly.
*/
render(width: number): readonly string[] {
const lines: string[] = [];
for (const child of this.children) {
const childLines = child.render(width);
if (child === this.#contentSlot) this.#listLineOffset = lines.length;
for (const line of childLines) lines.push(line);
}
const footer = this.#footerLines(width);
if (this.#fillHeight) {
const target = Math.max(0, this.#getTerminalRows() - footer.length);
if (lines.length > target) lines.length = target;
else for (let i = lines.length; i < target; i++) lines.push("");
}
this.#footerStart = lines.length;
for (const line of footer) lines.push(line);
return lines;
}
/** Blank · keybinding hint · bottom border. Rendered by {@link render}. */
#footerLines(width: number): string[] {
const scopeHint = this.#scope === "all" ? "current folder" : "all projects";
const hint = theme.fg("muted", ` [Del delete · Enter select · Tab ${scopeHint} · Esc cancel]`);
return ["", hint, "", ...this.#bottomBorder.render(width)];
}
handleInput(keyData: string): void {
if (keyData.startsWith("\x1b[<")) {
this.#handleMouse(keyData);
return;
}
if (this.#confirmationDialog) {
this.#confirmationDialog.handleInput(keyData);
} else {
@@ -623,6 +718,25 @@ export class SessionSelectorComponent extends Container {
}
}
/**
* SGR mouse reports, delivered only while the picker holds the alternate
* screen (the fullscreen overlay enables tracking and paints from screen row
* 0). Wheel scrolls the list; a left click resumes the session under the
* pointer. Mouse is inert while the delete-confirmation dialog is open.
*/
#handleMouse(data: string): void {
if (this.#confirmationDialog) return;
const event = parseSgrMouse(data);
if (!event) return;
if (event.wheel !== null) {
this.#sessionList.handleWheel(event.wheel);
return;
}
if (!event.leftClick || event.row >= this.#footerStart) return;
const index = this.#sessionList.hitTestSession(event.row - this.#listLineOffset);
if (index !== undefined) this.#sessionList.selectAndConfirm(index);
}
getSessionList(): SessionList {
return this.#sessionList;
}
@@ -127,8 +127,13 @@ export function extractQuoteBlocks(text: string): QuoteBlock[] {
function extractEvalCode(args: unknown): { code: string; language: string } | undefined {
if (!args || typeof args !== "object") return undefined;
const cells = (args as { cells?: unknown }).cells;
if (!Array.isArray(cells)) return undefined;
const argsObj = args as { cells?: unknown; code?: unknown };
const cells = Array.isArray(argsObj.cells)
? argsObj.cells
: typeof argsObj.code === "string"
? [argsObj]
: undefined;
if (!cells) return undefined;
const codeBlocks: string[] = [];
let language = "python";
@@ -109,9 +109,9 @@ You MUST use the specialized tool over its shell equivalent:
{{#has tools "lsp"}}- Code intelligence → `{{toolRefs.lsp}}`.{{/has}}
{{#has tools "search"}}- Regex search → `{{toolRefs.search}}`, not `grep`, `rg`, or `awk`.{{/has}}
{{#has tools "find"}}- Globbing → `{{toolRefs.find}}`, not `ls **/*.ext` or `fd`.{{/has}}
{{#has tools "eval"}}- Quick compute → `{{toolRefs.eval}}`; you SHOULD go step by step.{{/has}}
{{#has tools "bash"}}- Use `{{toolRefs.bash}}` for terminal work—builds, tests, git, package managers—and pipelines that COMPUTE a fact: `wc -l`, `sort | uniq -c`, `comm`, `diff a b`, checksums. Commands shadowing the tools above are blocked.
- Litmus: produces a count, frequency, set difference, or checksum no tool returns → bash. Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
{{#has tools "eval"}}- Default for any compute: `{{toolRefs.eval}}` cells. Bash is the EXCEPTION — only single binary calls or short fact-computing pipelines (`wc -l`, `sort | uniq -c`, `diff`, checksums). The moment a command grows a loop, conditional, heredoc, `-e`/`-c` script, `$(…)` nesting, or >2 pipe stages, it's a program → `{{toolRefs.eval}}`. NEVER write multiline or inline-script bash.{{/has}}
{{#has tools "bash"}}- `{{toolRefs.bash}}`: real binaries and short fact pipelines only. Commands shadowing the specialized tools above are blocked.{{/has}}
{{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum → bash.{{#has tools "eval"}} Needs control flow, state, or fights shell quoting → `{{toolRefs.eval}}`.{{/has}} Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
{{#has tools "report_tool_issue"}}
<critical>
@@ -11,16 +11,16 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
</when>
<helpers>
State persists across cells, so scout in one cell and fan out in the next. Every cell has:
State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
- `agent(prompt, *, agent_type="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, return_handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent_type` picks a discovered agent ("explore", "reviewer", "oracle", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `return_handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
- `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer", "oracle", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
- `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
- `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
- `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
- `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it.
- `budget` — `budget.total` (output-token ceiling, or `None` when none is set), `budget.spent()` (tokens spent this turn — main loop + eval subagents), `budget.remaining()` (`math.inf` when total is `None`), `budget.hard` (whether it's enforced). A ceiling is set by the user: `+Nk` in their message is advisory (you self-limit via `budget.remaining()`), `+Nk!` (or Goal Mode) is hard — `agent()` refuses to spawn once spent reaches it. Gate loops on `budget.total` first, since it's `None` when the user set no budget.
Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across cells and turns for multi-phase work, reading each result before you decide the next phase.
Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across calls and turns for multi-phase work, reading each result before you decide the next phase.
</helpers>
<structure>
@@ -1,5 +1,19 @@
Runs bash in a shell session — terminal ops: git, bun, cargo, python.
# When to use bash — and when not to
Bash invokes **real binaries** with simple args. It is NOT a scripting surface.
Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a fact (`wc -l`, `sort | uniq -c`, `comm`, `diff`, a checksum, `git status`).
Anything below → `eval` cell, not bash:
- Inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists for that language
- Heredocs (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
- `$(…)` command substitution nested inside another command
- Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
- Multiline commands, `&&`-chains mixing control flow
- Quote/JSON escaping that fights the shell
<instruction>
- `cwd` sets the working dir, not `cd dir && …`
- `env: { NAME: "…" }` for multiline / quote-heavy / untrusted values; reference `$NAME`
@@ -14,7 +28,9 @@ Runs bash in a shell session — terminal ops: git, bun, cargo, python.
</instruction>
<critical>
- Bash invokes real binaries with simple args; it is NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.
- NEVER shell out to search content or files: `grep/rg` → `search`.
- NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `find` tool (globbing). This is non-negotiable, even for a single quick listing.
- Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
</critical>
+19 -19
View File
@@ -1,21 +1,23 @@
Run code in a persistent kernel using a list of cells.
Run one step of code in a persistent kernel.
<instruction>
Cells run in array order. State persists per language across cells, tool calls, and `task` subagents — stage helpers/datasets/clients once, subagents reuse directly, no re-import/serialize.
**One eval call = one cell = one logical step.** State persists per language across separate eval calls, tool calls, and `task` subagents — define helpers/datasets/clients in one call, then later calls reuse them directly.
Cell fields:
Work incrementally: imports in one call, define in the next, test, then use — each its own eval call. Re-run setup ONLY after `reset`, a kernel crash, or a `NameError`/`ReferenceError` proving the state is gone. Parallelize work *within* a cell with the `parallel(thunks)` helper, not by batching steps.
Fields:
- `language` — {{#if py}}`"py"` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}`"rb"` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}`"jl"` persistent Julia kernel{{/if}}.
- `code` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers.
- `title` (optional) — short transcript label (e.g. `"imports"`).
- `timeout` (optional) — per-cell seconds. Raise only for heavy compute or long non-agent tool calls.
- `reset` (optional) — wipe this cell's language kernel first.{{#ifAll py js}} Per-language: a `py` reset never touches the JS VM.{{/ifAll}}
- `timeout` (optional) — seconds. Raise only for heavy compute or long non-agent tool calls.
- `reset` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a `py` reset never touches the JS VM.{{/ifAll}}
Work incrementally — one logical step per cell (imports, define, test, use), many small cells per call; workflow notes in the assistant message or `title`, never in cell code.
{{#if py}}Live event loop: use top-level `await` directly; `asyncio.run(…)` raises "cannot be called from a running event loop".{{/if}}
{{#if rb}}Ruby: synchronous; helper options are keyword args (e.g. `tree(".", max_depth: 2)`); the last expression auto-displays unless it is `nil`, an assignment, or a definition (like IRB).{{/if}}
{{#if jl}}Julia: synchronous; helper options are standard keyword args (e.g. `tree(max_depth=2)`); the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
Errors name the failing cell ("Cell 3 failed") — resubmit the fixed cell + any remaining.
{{#if js}}JS runs under **Bun**: Bun globals/APIs are available (`Bun.file`, `Bun.write`, `Bun.$`, `fetch`, `Buffer`); top-level `await`/`return` work directly.{{/if}}
{{#if rb}}Ruby: synchronous; helper options are keyword args (e.g. `output("id", limit: 2)`); the last expression auto-displays unless it is `nil`, an assignment, or a definition (like IRB).{{/if}}
{{#if jl}}Julia: synchronous; helper options are standard keyword args (e.g. `output("id", limit=2)`); the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
On error, fix and re-run only the failing step — prior calls' state survives.
</instruction>
<prelude>
@@ -29,12 +31,6 @@ read(path, offset?=1, limit?=None) → str
File as text; offset/limit 1-indexed lines. Accepts `local://…`.
write(path, content) → str
Write file (creates parents) → resolved path. `local://…` persists across turns/subagents.
append(path, content) → str
Append → resolved path. Accepts `local://…`.
tree(path?=".", max_depth?=3, show_hidden?=False) → str
Directory tree.
diff(a, b) → str
Unified diff of two files.
env(key?=None, value?=None) → str | None | dict
No args → full env dict; one → value of `key`; two → set `key=value`, return value.
output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
@@ -43,9 +39,9 @@ tool.<name>(args) → unknown
Invoke any session tool; `args` = its parameter object.
completion(prompt, model?="default", system?=None, schema?=None) → str | dict
Oneshot, stateless (no history/tools). `model`: "smol" fast | "default" session | "slow" most capable. `schema` (JSON-Schema) → structured output, parsed object.
{{#if spawns}}agent(prompt, agent_type?="task", model?=None, label?=None, schema?=None, return_handle?=False) → str | dict
Run a subagent → final output. `agent_type`/`agentType` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `return_handle`/`returnHandle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
{{#if js}} JS: options are ONE trailing object — agent(prompt, { agentType, schema, returnHandle }).
{{#if spawns}}agent(prompt, agent?="task", model?=None, label?=None, schema?=None, handle?=False) → str | dict
Run a subagent → final output. `agent` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `handle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
{{#if js}} JS: options are ONE trailing object — agent(prompt, { agent, schema, handle }).
{{/if}}
{{/if}}
parallel(thunks) → list
@@ -63,10 +59,14 @@ budget → per-turn token budget
{{#if spawns}}
<dag>
Pipe handles through stage helpers to build a dependency graph — acyclic waves:
- **Name nodes.** Capture each `agent(…, {{#if py}}return_handle=True{{/if}}{{#if js}}{ returnHandle: true }{{/if}}{{#if jl}}return_handle=true{{/if}})` result; carries `handle` (`agent://<id>`) + `output`.
- **Name nodes.** Capture each `agent(…, {{#if py}}handle=True{{/if}}{{#if js}}{ handle: true }{{/if}}{{#if jl}}handle=true{{/if}})` result; carries `handle` (`agent://<id>`) + `output`.
- **Wire edges by reference.** Put an upstream node's `handle`/`output` in the dependent stage's prompt — large transcript never re-inlined. Bulk: `write("local://<name>.md", …)`, pass the URI.
- **`pipeline(items, *stages)` = staged waves**, barrier between stages (every item clears stage N before any enters N+1). **`parallel(thunks)` = one wave** of independent nodes.
- **Isolate failure.** A raising node re-raises the lowest-index error, aborts its wave; wrap risky nodes in try/except so a failure degrades only its dependent subtree, independent branches finish.
- **Acyclic only.** A node never waits on its own descendant.
</dag>
{{/if}}
<critical>
Prior top-level names (`data`, `sessions`, helpers, imports) survive into the next eval call — reuse them; NEVER re-import, re-require, or re-declare a helper. Re-read a file only if it may have changed since the last read. Re-run setup only after `reset`, a crash, or a `NameError`/`ReferenceError`.
</critical>
@@ -1,6 +1,6 @@
**Tasks referenced by verbatim content string, NEVER an auto-generated ID — no "task-1"/"task-N" exists. Pass the content text in the `task` field.**
Manages a phased task list. Pass `ops`: flat array of operations. Next pending task auto-promotes to `in_progress` on each completion. `pending` is a status, not an `op` — leave not-yet-started tasks implicit in `init`/`append`.
Next pending task auto-promotes to `in_progress` on each completion.
## Operations
@@ -1621,6 +1621,7 @@ export class AgentSession {
await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
}
}
await this.#maintainContextMidRun(messages, signal);
});
this.yieldQueue = new YieldQueue({
isStreaming: () => this.isStreaming,
@@ -2779,10 +2780,32 @@ export class AgentSession {
this.#lastAssistantMessage = undefined;
if (!msg) {
this.#lastSuccessfulYieldToolCallId = undefined;
logger.debug("agent_end maintenance routing", {
reason: "no-assistant-message",
goalModeEnabled: this.#goalModeState?.enabled === true,
goalStatus: this.#goalModeState?.goal.status,
});
await emitAgentEndNotification();
return;
}
const maintenanceRoute = (route: string, extra?: Record<string, unknown>) => {
logger.debug("agent_end maintenance routing", {
route,
stopReason: msg.stopReason,
provider: msg.provider,
model: msg.model,
contentBlocks: msg.content.length,
hasToolCalls: msg.content.some(content => content.type === "toolCall"),
hasText: msg.content.some(content => content.type === "text"),
goalModeEnabled: this.#goalModeState?.enabled === true,
goalStatus: this.#goalModeState?.goal.status,
successfulYield: this.#assistantEndedWithSuccessfulYield(msg),
...extra,
});
};
maintenanceRoute("entered");
// Invalidate GitHub Copilot credentials on auth failure so stale tokens
// aren't reused on the next request
if (
@@ -2796,27 +2819,61 @@ export class AgentSession {
if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
this.#lastSuccessfulYieldToolCallId = undefined;
maintenanceRoute("skip-post-turn-maintenance");
await emitAgentEndNotification();
return;
}
const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
if (this.#assistantEndedWithSuccessfulYield(msg)) {
this.#lastSuccessfulYieldToolCallId = undefined;
if (this.#goalModeState?.enabled && this.#goalModeState.goal.status === "active") {
if (activeGoal) {
maintenanceRoute("successful-yield-active-goal-checkCompaction");
const compactionTask = this.#checkCompaction(msg);
this.#trackPostPromptTask(compactionTask);
await compactionTask;
} else {
maintenanceRoute("successful-yield-no-active-goal");
}
await emitAgentEndNotification();
return;
}
this.#lastSuccessfulYieldToolCallId = undefined;
// Empty-stop cleanup MUST run before any compaction continuation: an
// empty toolUse stop must be stripped from active context + session
// history before we schedule another turn, otherwise the next
// Anthropic turn carries a tool_use block with no matching
// tool_result and corrupts message history. The handler also
// schedules its own retry, so a real empty stop never needs the
// active-goal threshold pre-empt below.
if (await this.#handleEmptyAssistantStop(msg)) {
maintenanceRoute("empty-stop-handled");
await emitAgentEndNotification();
return;
}
let compactionResult = COMPACTION_CHECK_NONE;
let checkedCompaction = false;
if (activeGoal) {
maintenanceRoute("active-goal-pre-empt-checkCompaction");
const compactionTask = this.#checkCompaction(msg);
this.#trackPostPromptTask(compactionTask);
compactionResult = await compactionTask;
checkedCompaction = true;
if (compactionResult.deferredHandoff || compactionResult.continuationScheduled) {
maintenanceRoute("active-goal-pre-empt-continuation-scheduled", {
deferredHandoff: compactionResult.deferredHandoff,
continuationScheduled: compactionResult.continuationScheduled,
});
this.#resolveRetry();
await emitAgentEndNotification();
return;
}
}
if (await this.#handleUnexpectedAssistantStop(msg)) {
maintenanceRoute("unexpected-stop-handled");
await emitAgentEndNotification();
return;
}
@@ -2856,9 +2913,12 @@ export class AgentSession {
}
this.#resolveRetry();
const compactionTask = this.#checkCompaction(msg);
this.#trackPostPromptTask(compactionTask);
const compactionResult = await compactionTask;
if (!checkedCompaction) {
maintenanceRoute("bottom-checkCompaction");
const compactionTask = this.#checkCompaction(msg);
this.#trackPostPromptTask(compactionTask);
compactionResult = await compactionTask;
}
// Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
const hasToolCalls = msg.content.some(content => content.type === "toolCall");
if (hasToolCalls) {
@@ -8253,6 +8313,58 @@ export class AgentSession {
});
}
/**
* Compact active `/goal` runs that never settle to `agent_end`.
*
* Long autonomous goals can keep producing tool calls inside one agent run.
* The post-turn `agent_end` threshold check never fires in that shape, so
* context can grow until provider overflow. `onTurnEnd` is the safe boundary:
* tool results for the just-finished turn are already paired in
* `activeMessages`, the live array the agent loop reads before its next
* model call. Run maintenance here and splice the compacted state back into
* that array, mirroring [`AgentSession.#applyRewind`].
*/
async #maintainContextMidRun(activeMessages: AgentMessage[], signal?: AbortSignal): Promise<void> {
if (signal?.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) return;
if (!(this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active")) return;
const model = this.model;
const contextWindow = model?.contextWindow ?? 0;
if (contextWindow <= 0) return;
const compactionSettings = this.settings.getGroup("compaction");
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
const lastAssistant = [...activeMessages]
.reverse()
.find((message): message is AssistantMessage => message.role === "assistant");
if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
const billedContextTokens = calculateContextTokens(lastAssistant.usage);
const storedContextTokens = this.#estimateStoredContextTokens();
const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
const messagesBefore = activeMessages.length;
await this.#runAutoCompaction("threshold", false, false, false, {
autoContinue: false,
suppressContinuation: true,
triggerContextTokens: contextTokens,
});
if (signal?.aborted) return;
const compactedMessages = this.agent.state.messages;
if (compactedMessages !== activeMessages) {
activeMessages.splice(0, activeMessages.length, ...compactedMessages);
}
logger.debug("Mid-run goal compaction ran between tool-call turns", {
contextTokens,
contextWindow,
strategy: compactionSettings.strategy,
messagesBefore,
messagesAfter: activeMessages.length,
});
}
/**
* Check if context maintenance or promotion is needed and run it.
* Called after agent_end and before prompt submission.
@@ -8378,28 +8490,58 @@ export class AgentSession {
// Skip if this was an error (non-overflow errors don't have usage data)
if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
const pruneResult = await this.#pruneToolOutputs();
let contextTokens = calculateContextTokens(assistantMessage.usage);
if (supersedeResult) {
contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
}
if (pruneResult) {
contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
}
// Floor by the real stored-conversation estimate so a payload-shrinking
// before_provider_request hook (e.g. a compression extension such as
// Headroom) can't deflate the provider-reported usage below the true
// history size and skip the threshold. The estimate runs after the prune
// passes above, so it reflects the post-prune message set.
contextTokens = compactionContextTokens(contextTokens, this.#estimateStoredContextTokens());
if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
const assistantUsageContextTokens = calculateContextTokens(assistantMessage.usage);
const storedContextTokens = this.#estimateStoredContextTokens();
// Pruning frees bytes for the NEXT prompt; it does not change the size of
// the prompt the LLM just billed for. Earlier revisions subtracted the
// per-turn supersede/prune `tokensSaved` from the threshold input, which
// let a long-running `/goal` session sit above `compaction.thresholdTokens`
// indefinitely whenever per-turn pruning saved enough to drop the
// post-prune estimate below the user-configured trigger — the visible
// context (anchored to the same provider billing) still showed >threshold,
// but `shouldCompact` no-op'd (#3174). Anchor the initial trigger on the
// last turn's billed context tokens, floored by the post-prune
// stored-conversation estimate so a payload-compression hook still can't
// deflate the trigger.
const contextTokens = compactionContextTokens(assistantUsageContextTokens, storedContextTokens);
const postMaintenanceContextTokens = compactionContextTokens(
Math.max(0, assistantUsageContextTokens - maintenanceTokensFreed),
storedContextTokens,
);
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
const shouldThresholdCompact = shouldCompact(contextTokens, contextWindow, compactionSettings);
logger.debug("Auto-compaction threshold decision", {
phase: "post-agent-end",
goalModeEnabled: this.#goalModeState?.enabled === true,
goalStatus: this.#goalModeState?.goal.status,
stopReason: assistantMessage.stopReason,
sameModel: sameModel === true,
contextWindow,
strategy: compactionSettings.strategy,
thresholdTokens,
assistantUsageContextTokens,
storedContextTokens,
resolvedContextTokens: contextTokens,
postMaintenanceContextTokens,
maintenanceTokensFreed,
shouldCompact: shouldThresholdCompact,
contextPromotionEnabled: this.settings.get("contextPromotion.enabled") === true,
});
if (shouldThresholdCompact) {
// Try promotion first — if a larger model is available, switch instead of compacting
const promoted = await this.#tryContextPromotion(assistantMessage);
if (!promoted) {
return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
autoContinue,
triggerContextTokens: contextTokens,
triggerContextTokens: postMaintenanceContextTokens,
});
}
logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
contextTokens,
contextWindow,
model: `${assistantMessage.provider}/${assistantMessage.model}`,
});
}
return COMPACTION_CHECK_NONE;
}
@@ -9523,13 +9665,15 @@ export class AgentSession {
willRetry: boolean,
deferred = false,
allowDefer = true,
options: { autoContinue?: boolean; triggerContextTokens?: number } = {},
options: { autoContinue?: boolean; triggerContextTokens?: number; suppressContinuation?: boolean } = {},
): Promise<CompactionCheckResult> {
const compactionSettings = this.settings.getGroup("compaction");
if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
const generation = this.#promptGeneration;
const shouldAutoContinue = options.autoContinue !== false && compactionSettings.autoContinue !== false;
const suppressContinuation = options.suppressContinuation === true;
const shouldAutoContinue =
!suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
// Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
// reclaims nothing we fall through to the summary-compaction body below so
// the oversized input still gets resolved.
@@ -9540,6 +9684,7 @@ export class AgentSession {
generation,
shouldAutoContinue,
options.triggerContextTokens,
suppressContinuation,
);
if (outcome !== "fallback") return outcome;
}
@@ -9993,7 +10138,7 @@ export class AgentSession {
this.#scheduleAgentContinue({ delayMs: 100, generation });
continuationScheduled = true;
} else if (this.agent.hasQueuedMessages()) {
} else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
// Kick the loop so queued messages are actually delivered.
this.#scheduleAgentContinue({
@@ -10053,6 +10198,7 @@ export class AgentSession {
generation: number,
autoContinue: boolean,
triggerContextTokens?: number,
suppressContinuation = false,
): Promise<CompactionCheckResult | "fallback"> {
const action = "shake";
this.#autoCompactionAbortController?.abort();
@@ -10083,15 +10229,16 @@ export class AgentSession {
// situation actually resolves; "idle" is exempt because its 60s+ timer
// re-checks usage before re-firing and cannot dead-loop on its own.
//
// #2275: the post-shake check MUST be anchored on the same metric that
// triggered compaction. The local estimator (`#estimatePendingPromptTokens`)
// undercounts thinking-signature payloads, so on thinking-heavy sessions it
// reads well below the provider-reported usage that fired the threshold.
// When that estimate slips under the threshold, the fallback never fires
// and the auto-continue prompt re-injects every turn. Prefer the trigger's
// own `contextTokens` (provider-anchored) when the caller supplies it, and
// add hysteresis (80% recovery band) so we don't oscillate at the boundary
// while shake keeps reclaiming a trickle of the previous turn's output.
// #2275: the post-shake check MUST stay provider-anchored when caller
// usage and local estimates diverge. The local estimator undercounts
// thinking-signature payloads, so thinking-heavy sessions can read well
// below the provider usage that fired the threshold. Prefer the caller's
// context figure when supplied, then subtract shake's own savings and add
// hysteresis (80% recovery band) so we don't oscillate at the boundary.
// Threshold callers pass the provider-billed trigger after accounting for
// any supersede/drop-useless pruning that already rewrote the next prompt;
// without that pre-shake savings, shake can fall through to context-full
// even though the post-prune history is already inside the recovery band.
const contextWindow = this.model?.contextWindow ?? 0;
const compactionSettings = this.settings.getGroup("compaction");
let stillOverThreshold = false;
@@ -10151,7 +10298,7 @@ export class AgentSession {
}
this.#scheduleAgentContinue({ delayMs: 100, generation });
continuationScheduled = true;
} else if (this.agent.hasQueuedMessages()) {
} else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
this.#scheduleAgentContinue({
delayMs: 100,
generation,
+20
View File
@@ -153,6 +153,26 @@ export function getConfiguredThinkingLevelMetadata(level: ConfiguredThinkingLeve
return level === AUTO_THINKING ? AUTO_THINKING_METADATA : getThinkingLevelMetadata(level);
}
/**
* Thinking selectors accepted by the `--thinking` CLI flag, in display order:
* `off`, every concrete effort (`minimal`..`xhigh`), then `auto`. Single source
* for the flag's `options` list, shell completions, and the "invalid level"
* warning so all three stay in sync.
*/
export const CLI_THINKING_LEVELS: readonly string[] = [ThinkingLevel.Off, ...THINKING_EFFORTS, AUTO_THINKING];
/**
* Parses a `--thinking` CLI value. Accepts every {@link parseConfiguredThinkingLevel}
* selector (`off`, `auto`, `minimal`..`xhigh`, plus the `max` alias) but rejects
* `inherit`: an explicit `inherit` on the command line would suppress the
* settings/scoped-model fallback during startup resolution only to resolve back
* to the provider default, which is never what the user means.
*/
export function parseCliThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined {
const level = parseConfiguredThinkingLevel(value);
return level === ThinkingLevel.Inherit ? undefined : level;
}
/**
* Resolves an auto-classified effort against the active model's supported
* range. Unlike {@link clampThinkingLevelForModel}, `auto` never resolves below
+46 -40
View File
@@ -308,7 +308,7 @@ async function askSingleQuestion(
}
const customResult = await promptForCustomInput();
if (customResult.input === undefined) {
break;
continue;
}
customInput = customResult.input;
break;
@@ -332,51 +332,57 @@ async function askSingleQuestion(
}
selectedOptions = Array.from(selected);
} else {
const displayOptions = addRecommendedSuffix(questionOptions, recommended);
const optionsWithNavigation: ExtensionUISelectItem[] = [...displayOptions, OTHER_OPTION];
while (true) {
const displayOptions = addRecommendedSuffix(questionOptions, recommended);
const optionsWithNavigation: ExtensionUISelectItem[] = [...displayOptions, OTHER_OPTION];
let initialIndex = recommended;
const previouslySelected = selectedOptions[0];
if (previouslySelected) {
const selectedIndex = questionOptions.findIndex(option => option.label === previouslySelected);
if (selectedIndex >= 0) initialIndex = selectedIndex;
} else if (customInput !== undefined) {
initialIndex = displayOptions.length;
}
if (initialIndex !== undefined) {
const maxIndex = Math.max(optionsWithNavigation.length - 1, 0);
initialIndex = Math.max(0, Math.min(initialIndex, maxIndex));
}
const {
choice,
timedOut: selectTimedOut,
navigation: arrowNavigation,
} = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex, {
selectionMarker: "radio",
markableCount: displayOptions.length,
});
timedOut = selectTimedOut;
if (arrowNavigation) {
return { selectedOptions, customInput, timedOut, navigation: arrowNavigation };
}
if (choice === undefined) {
if (!timedOut) {
return { selectedOptions, customInput, timedOut, cancelled: true };
let initialIndex = recommended;
const previouslySelected = selectedOptions[0];
if (previouslySelected) {
const selectedIndex = questionOptions.findIndex(option => option.label === previouslySelected);
if (selectedIndex >= 0) initialIndex = selectedIndex;
} else if (customInput !== undefined) {
initialIndex = displayOptions.length;
}
} else if (choice === OTHER_OPTION) {
if (!selectTimedOut) {
const customResult = await promptForCustomInput();
if (customResult.input !== undefined) {
customInput = customResult.input;
selectedOptions = [];
if (initialIndex !== undefined) {
const maxIndex = Math.max(optionsWithNavigation.length - 1, 0);
initialIndex = Math.max(0, Math.min(initialIndex, maxIndex));
}
const {
choice,
timedOut: selectTimedOut,
navigation: arrowNavigation,
} = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex, {
selectionMarker: "radio",
markableCount: displayOptions.length,
});
timedOut = selectTimedOut;
if (arrowNavigation) {
return { selectedOptions, customInput, timedOut, navigation: arrowNavigation };
}
if (choice === undefined) {
if (!timedOut) {
return { selectedOptions, customInput, timedOut, cancelled: true };
}
// If editor was dismissed (undefined), keep prior selectedOptions/customInput intact
break;
}
if (choice === OTHER_OPTION) {
if (selectTimedOut) {
break;
}
const customResult = await promptForCustomInput();
if (customResult.input === undefined) {
continue;
}
customInput = customResult.input;
selectedOptions = [];
break;
}
} else {
selectedOptions = [stripRecommendedSuffix(choice)];
customInput = undefined;
break;
}
if (navigation?.allowForward) {
return { selectedOptions, customInput, timedOut, navigation: "forward" };
@@ -800,7 +800,7 @@ export class WorkerCore {
displays.push({ type: "text", text: safeJsonStringify(output.data) });
return;
}
// status — surface as compact JSON so helper side effects (read/write/tree) appear in
// status — surface as compact JSON so helper side effects (read/write/env) appear in
// the cell result alongside explicit display() output.
displays.push({ type: "text", text: safeJsonStringify(output.event) });
}
+5 -14
View File
@@ -56,6 +56,9 @@ interface EvalRenderCellArg {
}
interface EvalRenderArgs {
language?: string;
code?: string;
title?: string;
cells?: EvalRenderCellArg[];
__partialJson?: string;
}
@@ -81,8 +84,8 @@ function normalizeRenderLanguage(value: string | undefined): EvalLanguage {
}
function getRenderCells(args: EvalRenderArgs | undefined): EvalRenderCell[] {
const raw = args?.cells;
if (!Array.isArray(raw)) return [];
if (!args) return [];
const raw = Array.isArray(args.cells) ? args.cells : typeof args.code === "string" ? [args] : [];
const out: EvalRenderCell[] = [];
for (const cell of raw) {
if (!cell || typeof cell !== "object") continue;
@@ -238,14 +241,12 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
const opIcons: Record<string, AvailableIcon> = {
read: "icon.file",
write: "icon.file",
append: "icon.file",
cat: "icon.file",
touch: "icon.file",
ls: "icon.folder",
cd: "icon.folder",
pwd: "icon.folder",
mkdir: "icon.folder",
tree: "icon.folder",
git_status: "icon.git",
git_diff: "icon.git",
git_log: "icon.git",
@@ -277,7 +278,6 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
if (data.path) parts.push(`from ${shortenPath(String(data.path))}`);
break;
case "write":
case "append":
parts.push(`${data.chars ?? data.bytes ?? 0} chars`);
if (data.path) parts.push(`to ${shortenPath(String(data.path))}`);
break;
@@ -316,13 +316,6 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
parts.push(`${data.lines} line${(data.lines as number) !== 1 ? "s" : ""}`);
if (data.staged) parts.push("(staged)");
break;
case "diff":
if (data.identical) {
parts.push("files identical");
} else {
parts.push("files differ");
}
break;
case "batch":
parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""} processed`);
break;
@@ -412,8 +405,6 @@ function formatStatusEventExpanded(event: EvalStatusEvent, theme: Theme): string
case "cat":
case "head":
case "tail":
case "tree":
case "diff":
case "git_diff":
case "sh":
if (data.preview) addPreview(String(data.preview));
+101 -104
View File
@@ -38,8 +38,6 @@ const EVAL_LANGUAGE_NAME: Record<EvalLanguageToken, string> = {
rb: "Ruby",
jl: "Julia",
};
const EVAL_CELLS_DESCRIPTION =
"cells executed in order. State persists within each language across cells and tool calls.";
/** Join names as an English "or" list: ["A"]→"A", ["A","B"]→"A or B", 3+→"A, B, or C". */
function joinWithOr(items: readonly string[]): string {
@@ -56,12 +54,12 @@ function describeCodeField(langs: readonly EvalLanguageToken[]): string {
const replLangs = langs.filter(lang => lang === "rb" || lang === "jl");
// No persistent REPL backends → keep the original py/js phrasing verbatim so the
// default (rb/jl off) wire schema stays byte-identical to the pre-feature one.
if (replLangs.length === 0) return "cell body, verbatim. Use top-level await freely.";
if (replLangs.length === 0) return "code to run in this eval call, verbatim. Use top-level await freely.";
const awaitLangs = langs.filter(lang => lang === "py" || lang === "js");
const clauses: string[] = [];
if (awaitLangs.length > 0) clauses.push(`Top-level \`await\` is available in ${awaitLangs.join("/")}`);
clauses.push(`${replLangs.join("/")} auto-display the last expression like a REPL`);
return `cell body, verbatim. ${clauses.join("; ")}.`;
return `code to run in this eval call, verbatim. ${clauses.join("; ")}.`;
}
/** One-line discovery summary listing the runtimes available this session. */
@@ -86,29 +84,24 @@ function enabledEvalLanguages(backends: EvalBackendsAllowance): EvalLanguageToke
const evalCellCommonFields = {
"title?": type("string").describe('short label shown in transcript (e.g. "imports", "load config")'),
"timeout?": type("number").describe("per-cell timeout in seconds"),
"reset?": type("boolean").describe(
"wipe this cell's language kernel before running. Other languages are untouched.",
),
"timeout?": type("number").describe("timeout for this eval call in seconds"),
"reset?": type("boolean").describe("wipe this language's kernel before running. Other languages are untouched."),
};
/**
* Per-cell input. Each cell runs in order; state persists within a language
* across cells and across tool calls. This static schema carries the full
* language union for typing; {@link buildEvalSchema} narrows the wire copy per
* session so disabled backends are never advertised to the model.
* Per-call input: a single cell. State persists within a language across
* separate eval calls and across tool calls, so each call is one logical step
* and later calls reuse what earlier ones defined. This static schema carries
* the full language union for typing; {@link buildEvalSchema} narrows the wire
* copy per session so disabled backends are never advertised to the model.
*/
const evalCellSchema = type({
language: type("'py' | 'js' | 'rb' | 'jl'").describe(describeLanguageField(EVAL_LANGUAGE_ORDER)),
code: type("string").describe(describeCodeField(EVAL_LANGUAGE_ORDER)),
...evalCellCommonFields,
});
export type EvalCellInput = typeof evalCellSchema.infer;
export const evalSchema = type({
cells: evalCellSchema.array().atLeastLength(1).describe(EVAL_CELLS_DESCRIPTION),
language: type("'py' | 'js' | 'rb' | 'jl'").describe(describeLanguageField(EVAL_LANGUAGE_ORDER)),
...evalCellCommonFields,
code: type("string").describe(describeCodeField(EVAL_LANGUAGE_ORDER)),
});
export type EvalToolParams = typeof evalSchema.infer;
export type EvalCellInput = EvalToolParams;
/**
* Build a session-scoped copy of the eval schema whose `language` enum and field
@@ -118,14 +111,11 @@ export type EvalToolParams = typeof evalSchema.infer;
* {@link evalSchema} (full union) remains the type-level source of truth.
*/
function buildEvalSchema(langs: readonly EvalLanguageToken[]): typeof evalSchema {
const cellSchema = type({
const schema = type({
language: type.enumerated(...langs).describe(describeLanguageField(langs)),
code: type("string").describe(describeCodeField(langs)),
...evalCellCommonFields,
});
const schema = type({
cells: cellSchema.array().atLeastLength(1).describe(EVAL_CELLS_DESCRIPTION),
});
return schema as unknown as typeof evalSchema;
}
@@ -290,22 +280,15 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
readonly approval = "exec" as const;
readonly formatApprovalDetails = (args: unknown): string[] => {
const params = args as Partial<EvalToolParams>;
const cells = Array.isArray(params.cells) ? params.cells : [];
const firstCell = cells[0] as Partial<EvalCellInput> | undefined;
if (!firstCell) return [];
const language =
typeof firstCell.language === "string" ? formatEvalInputLanguage(firstCell.language) : "javascript (default)";
const code = typeof firstCell.code === "string" ? firstCell.code : "";
const lines = [`Language: ${language}`, `Code:\n${truncateForPrompt(code)}`];
if (cells.length > 1) {
lines.push(`+${cells.length - 1} more cell${cells.length === 2 ? "" : "s"}`);
}
return lines;
typeof params.language === "string" ? formatEvalInputLanguage(params.language) : "javascript (default)";
const code = typeof params.code === "string" ? params.code : "";
return [`Language: ${language}`, `Code:\n${truncateForPrompt(code)}`];
};
get summary(): string {
return summarizeEvalLanguages(this.#enabledLanguages());
}
readonly loadMode = "discoverable";
readonly loadMode = "essential";
readonly label = "Eval";
get description(): string {
if (!this.session) return getEvalToolDescription();
@@ -320,25 +303,53 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
spawns: spawnsAllowed,
});
}
readonly examples: readonly ToolExample<typeof evalSchema.infer>[] = [
/** All reuse-chain examples; the `examples` getter filters by enabled languages. */
private static readonly ALL_EXAMPLES: readonly ToolExample<typeof evalSchema.infer>[] = [
{
caption: "First call — set up once",
call: {
cells: [
{
language: "py",
title: "imports",
timeout: 10,
code: "import json\nfrom pathlib import Path",
},
{
language: "py",
title: "load config",
code: "data = json.loads(read('package.json'))\ndisplay(data)",
},
],
language: "py",
title: "imports",
code: "import json\nfrom pathlib import Path",
},
},
{
caption: "Second call — reuse, do NOT re-import",
call: {
language: "py",
title: "load config",
code: "data = json.loads(read('package.json'))\ndisplay(data)",
},
},
{
caption: "Third call — reuse the loaded config",
call: {
language: "py",
title: "scan deps",
code: "display(sorted(data['dependencies']))",
},
},
{
caption: "Ruby first call — set up once",
call: {
language: "rb",
title: "setup",
code: "require 'json'\npkg_path = 'package.json'",
},
},
{
caption: "Ruby second call — reuse, do NOT re-require",
call: {
language: "rb",
title: "load config",
code: "pkg = JSON.parse(read(pkg_path))\ndisplay(pkg.keys.sort)",
},
},
];
get examples(): readonly ToolExample<typeof evalSchema.infer>[] {
const langs = new Set(this.#enabledLanguages());
return EvalTool.ALL_EXAMPLES.filter(ex => "call" in ex && langs.has(ex.call.language as EvalLanguageToken));
}
get parameters(): typeof evalSchema {
const langs = this.#enabledLanguages();
if (langs.length === 0 || langs.length === EVAL_LANGUAGE_ORDER.length) return evalSchema;
@@ -352,13 +363,9 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
readonly concurrency = "exclusive";
readonly strict = true;
readonly intent = (args: Partial<typeof evalSchema.infer>): string | undefined => {
const cells = Array.isArray(args.cells) ? args.cells : [];
const first = cells.find(c => c && typeof c === "object");
if (!first) return "evaluating";
const title = typeof first.title === "string" ? first.title : undefined;
const language = typeof first.language === "string" ? formatEvalInputLanguage(first.language) : "javascript";
const label = title || `running ${language}`;
return cells.length > 1 ? `${label} (+${cells.length - 1})` : label;
const title = typeof args.title === "string" ? args.title : undefined;
const language = typeof args.language === "string" ? formatEvalInputLanguage(args.language) : "javascript";
return title || `running ${language}`;
};
readonly #proxyExecutor?: EvalProxyExecutor;
@@ -398,27 +405,25 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
const session = this.session;
const excludeWebP = webpExclusionForModel(session.getActiveModel?.());
const cells: ResolvedEvalCell[] = [];
for (let i = 0; i < params.cells.length; i++) {
const cell = params.cells[i];
const language: EvalLanguage =
cell.language === "py"
? "python"
: cell.language === "rb"
? "ruby"
: cell.language === "jl"
? "julia"
: "js";
const resolved = await resolveBackend(session, language);
cells.push({
index: i,
title: cell.title,
code: cell.code,
timeoutMs: (cell.timeout ?? 30) * 1000,
reset: cell.reset ?? false,
const cellLanguage: EvalLanguage =
params.language === "py"
? "python"
: params.language === "rb"
? "ruby"
: params.language === "jl"
? "julia"
: "js";
const resolved = await resolveBackend(session, cellLanguage);
const cells: ResolvedEvalCell[] = [
{
index: 0,
title: params.title,
code: params.code,
timeoutMs: (params.timeout ?? 30) * 1000,
reset: params.reset ?? false,
resolved,
});
}
},
];
const languages = uniqueEvalLanguages(cells);
const notice = detailsNotice(cells);
const sessionAbortController = new AbortController();
@@ -453,6 +458,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
status: "pending",
}));
const cellOutputs: string[] = [];
// The cell currently inside backend.execute(). Streamed stdout is
// appended to its rendered `output` live so a long-running cell (e.g. a
// sleep loop) shows progress instead of nothing until it returns. A
// dedicated per-cell tail buffer keeps attribution correct and avoids
// double-counting against the aggregate `tailBuffer`; on completion the
// authoritative `cellResult.output` (below) overwrites this live tail.
let activeLiveCell: { result: EvalCellResult; buf: TailBuffer } | undefined;
const appendTail = (text: string) => {
tailBuffer.append(text);
@@ -502,6 +514,10 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
maxColumns: resolveOutputMaxColumns(session.settings),
onChunk: chunk => {
appendTail(chunk);
if (activeLiveCell) {
activeLiveCell.buf.append(chunk);
activeLiveCell.result.output = activeLiveCell.buf.text();
}
pushUpdate();
},
});
@@ -529,6 +545,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
cellResult.statusEvents = undefined;
cellResult.exitCode = undefined;
cellResult.durationMs = undefined;
activeLiveCell = { result: cellResult, buf: new TailBuffer(DEFAULT_MAX_BYTES * 2) };
pushUpdate();
const startTime = Date.now();
@@ -562,6 +579,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
});
} finally {
idle.dispose();
activeLiveCell = undefined;
}
const durationMs = Date.now() - startTime;
@@ -623,24 +641,9 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
cellResult.statusEvents = cellStatusEvents.length > 0 ? cellStatusEvents : undefined;
cellResult.hasMarkdown = cellHasMarkdown || undefined;
let combinedCellOutput = "";
if (cells.length > 1) {
const cellHeader = `[${i + 1}/${cells.length}]`;
const cellTitle = cell.title ? ` ${cell.title}` : "";
if (cellOutput) {
combinedCellOutput = `${cellHeader}${cellTitle}\n${cellOutput}`;
} else {
combinedCellOutput = `${cellHeader}${cellTitle} (ok)`;
}
cellOutputs.push(combinedCellOutput);
} else if (cellOutput) {
combinedCellOutput = cellOutput;
cellOutputs.push(combinedCellOutput);
}
if (combinedCellOutput) {
const prefix = cellOutputs.length > 1 ? "\n\n" : "";
appendTail(`${prefix}${combinedCellOutput}`);
if (cellOutput) {
cellOutputs.push(cellOutput);
appendTail(cellOutput);
}
if (result.cancelled) {
@@ -648,10 +651,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
pushUpdate();
const errorMsg = result.output || "Command aborted";
const combinedOutput = cellOutputs.join("\n\n");
const outputText =
cells.length > 1
? `${combinedOutput}\n\nCell ${i + 1} aborted: ${errorMsg}`
: combinedOutput || errorMsg;
const outputText = combinedOutput || errorMsg;
const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
const details: EvalToolDetails = {
@@ -674,12 +674,9 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
cellResult.status = "error";
pushUpdate();
const combinedOutput = cellOutputs.join("\n\n");
const outputText =
cells.length > 1
? `${combinedOutput}\n\nCell ${i + 1} failed (exit code ${result.exitCode}). Earlier cells succeeded—their state persists. Fix only cell ${i + 1}.`
: combinedOutput
? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`
: `Command exited with code ${result.exitCode}`;
const outputText = combinedOutput
? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`
: `Command exited with code ${result.exitCode}`;
const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
const details: EvalToolDetails = {
+8 -1
View File
@@ -377,7 +377,14 @@ export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool |
export type BuiltinToolLoadMode = "essential" | "discoverable";
/** Default essential tool names when tools.essentialOverride is empty. */
export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = ["read", "bash", "edit", "write", "find"] as const;
export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = [
"read",
"bash",
"edit",
"write",
"find",
"eval",
] as const;
/**
* Resolve the active essential built-in tool names from settings.
+136 -60
View File
@@ -8,7 +8,7 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
import { getRemoteDir, type ImageMetadata, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
import { LRUCache } from "lru-cache/raw";
import {
@@ -22,7 +22,7 @@ import {
import { normalizeToLF } from "../edit/normalize";
import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import { InternalUrlRouter, resolveLocalUrlToFile } from "../internal-urls";
import { parseInternalUrl } from "../internal-urls/parse";
import type { InternalUrl } from "../internal-urls/types";
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -1112,6 +1112,79 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
.done();
}
/**
* Build content blocks for an on-disk image file: an `inspect_image`
* metadata note when inspection is enabled, otherwise the decoded image
* block. Shared by the plain-file read path and the `local://` image fast
* path so both honor `inspect_image.enabled`, the size cap, and auto-resize
* identically. Too-large / unsupported images surface as {@link ToolError}.
*/
async #loadImageContent(options: {
readPath: string;
absolutePath: string;
mimeType: string;
imageMetadata: ImageMetadata | null;
fileSize: number;
}): Promise<{ content: Array<TextContent | ImageContent>; details: ReadToolDetails; sourcePath: string }> {
const { readPath, absolutePath, mimeType, imageMetadata, fileSize } = options;
if (this.#inspectImageEnabled) {
const outputMime = imageMetadata?.mimeType ?? mimeType;
const metadataLines = [
"Image metadata:",
`- MIME: ${outputMime}`,
`- Bytes: ${fileSize} (${formatBytes(fileSize)})`,
imageMetadata?.width !== undefined && imageMetadata.height !== undefined
? `- Dimensions: ${imageMetadata.width}x${imageMetadata.height}`
: "- Dimensions: unknown",
imageMetadata?.channels !== undefined ? `- Channels: ${imageMetadata.channels}` : "- Channels: unknown",
imageMetadata?.hasAlpha === true
? "- Alpha: yes"
: imageMetadata?.hasAlpha === false
? "- Alpha: no"
: "- Alpha: unknown",
"",
`If you want to analyze the image, call inspect_image with path="${formatPathRelativeToCwd(
absolutePath,
this.session.cwd,
)}" and a question describing what to inspect and the desired output format.`,
];
return { content: [{ type: "text", text: metadataLines.join("\n") }], details: {}, sourcePath: absolutePath };
}
if (fileSize > MAX_IMAGE_SIZE) {
const sizeStr = formatBytes(fileSize);
const maxStr = formatBytes(MAX_IMAGE_SIZE);
throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
}
try {
const imageInput = await loadImageInput({
path: readPath,
cwd: this.session.cwd,
autoResize: this.#autoResizeImages,
maxBytes: MAX_IMAGE_SIZE,
resolvedPath: absolutePath,
detectedMimeType: mimeType,
excludeWebP: webpExclusionForModel(this.session.getActiveModel?.()),
});
if (!imageInput) {
throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`);
}
return {
content: [
{ type: "text", text: imageInput.textNote },
{ type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
],
details: {},
sourcePath: imageInput.resolvedPath,
};
} catch (error) {
if (error instanceof ImageInputTooLargeError) {
throw new ToolError(error.message);
}
throw error;
}
}
#buildInMemoryTextResult(
text: string,
offset: number | undefined,
@@ -2132,64 +2205,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
| undefined;
if (mimeType) {
if (this.#inspectImageEnabled) {
const metadata = imageMetadata;
const outputMime = metadata?.mimeType ?? mimeType;
const outputBytes = fileSize;
const metadataLines = [
"Image metadata:",
`- MIME: ${outputMime}`,
`- Bytes: ${outputBytes} (${formatBytes(outputBytes)})`,
metadata?.width !== undefined && metadata.height !== undefined
? `- Dimensions: ${metadata.width}x${metadata.height}`
: "- Dimensions: unknown",
metadata?.channels !== undefined ? `- Channels: ${metadata.channels}` : "- Channels: unknown",
metadata?.hasAlpha === true
? "- Alpha: yes"
: metadata?.hasAlpha === false
? "- Alpha: no"
: "- Alpha: unknown",
"",
`If you want to analyze the image, call inspect_image with path="${formatPathRelativeToCwd(
absolutePath,
this.session.cwd,
)}" and a question describing what to inspect and the desired output format.`,
];
content = [{ type: "text", text: metadataLines.join("\n") }];
details = {};
sourcePath = absolutePath;
} else {
if (fileSize > MAX_IMAGE_SIZE) {
const sizeStr = formatBytes(fileSize);
const maxStr = formatBytes(MAX_IMAGE_SIZE);
throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
}
try {
const imageInput = await loadImageInput({
path: readPath,
cwd: this.session.cwd,
autoResize: this.#autoResizeImages,
maxBytes: MAX_IMAGE_SIZE,
resolvedPath: absolutePath,
detectedMimeType: mimeType,
excludeWebP: webpExclusionForModel(this.session.getActiveModel?.()),
});
if (!imageInput) {
throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`);
}
content = [
{ type: "text", text: imageInput.textNote },
{ type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
];
details = {};
sourcePath = imageInput.resolvedPath;
} catch (error) {
if (error instanceof ImageInputTooLargeError) {
throw new ToolError(error.message);
}
throw error;
}
}
({ content, details, sourcePath } = await this.#loadImageContent({
readPath,
absolutePath,
mimeType,
imageMetadata,
fileSize,
}));
} else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
const notebookText = await readEditableNotebookText(absolutePath, localReadPath);
if (isMultiRange(parsed) && parsed.kind === "lines") {
@@ -2727,6 +2749,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
hasExtraction = hasPathExtraction || hasQueryExtraction;
}
// local:// files are real on-disk paths. Detect image files and emit a
// decoded image block before the text-only resource contract UTF-8
// decodes the binary into mojibake. The fast path returns null for
// non-images, directories, listings, or any resolution failure, so the
// text path below reproduces the router's not-found / symlink-escape
// behavior unchanged.
if (scheme === "local") {
const imageResult = await this.#tryReadLocalImage(urlMeta, signal);
if (imageResult) return imageResult;
}
// Reject line selectors when query extraction is used
if (hasExtraction && parsedSel.kind !== "none" && parsedSel.kind !== "raw") {
throw new ToolError("Cannot combine query extraction with line selectors");
@@ -2770,6 +2803,49 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
});
}
/**
* Fast path for `local://` image files. Resolves the URL to its real
* on-disk path with the same realpath + containment checks as
* {@link LocalProtocolHandler.resolve} (via {@link resolveLocalUrlToFile}),
* and — only when the target is a genuine image — emits a decoded image
* block. Returns null for non-images, directories, listings, or any
* resolution failure (not-found, symlink escape) so the caller falls back to
* normal text resolution, which reproduces the router's errors. Errors from
* a confirmed image (too large / unsupported) propagate rather than
* degrading into a corrupted text read.
*/
async #tryReadLocalImage(url: InternalUrl, signal?: AbortSignal): Promise<AgentToolResult<ReadToolDetails> | null> {
let file: { path: string; size: number } | null;
try {
file = await resolveLocalUrlToFile(url, {
cwd: this.session.cwd,
settings: this.session.settings,
signal,
localProtocolOptions: this.session.localProtocolOptions,
});
} catch {
// Not found / containment escape / no session — let the text path
// surface the router's canonical error.
return null;
}
if (!file) return null;
const imageMetadata = await readImageMetadata(file.path);
const mimeType = imageMetadata?.mimeType;
if (!mimeType) return null;
const { content, details, sourcePath } = await this.#loadImageContent({
readPath: url.href,
absolutePath: file.path,
mimeType,
imageMetadata,
fileSize: file.size,
});
const resultBuilder = toolResult(details).content(content).sourceInternal(url.href);
if (sourcePath) resultBuilder.sourcePath(sourcePath);
return resultBuilder.done();
}
/** Read directory contents as a formatted listing */
async #readDirectory(
absolutePath: string,
+60 -64
View File
@@ -52,21 +52,18 @@ const InitListEntry = type({
items: type("string").describe("task content").array().atLeastLength(1).describe("tasks for this phase"),
});
const TodoOpEntry = type({
const todoSchema = type({
op: TodoOp,
"list?": InitListEntry.array().describe("phased task list (init)"),
"task?": type("string").describe("task content"),
"phase?": type("string").describe("phase name"),
"items?": type("string").describe("task content").array().atLeastLength(1).describe("tasks to append"),
});
const todoSchema = type({
ops: TodoOpEntry.array().atLeastLength(1).describe("ordered todo operations"),
}).describe("apply ordered todo operations");
}).describe("apply a single todo operation");
type TodoParams = TodoSchema;
type TodoSchema = typeof todoSchema.infer;
type TodoOpEntryValue = TodoParams["ops"][number];
/** A single todo op entry (the params object itself). */
type TodoOpEntryValue = TodoParams;
// =============================================================================
// State helpers
@@ -402,10 +399,7 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string
function applyParams(phases: TodoPhase[], params: TodoParams): { phases: TodoPhase[]; errors: string[] } {
const errors: string[] = [];
let next = phases;
for (const entry of params.ops) {
next = applyEntry(next, entry, errors);
}
const next = applyEntry(phases, params, errors);
normalizeInProgressTask(next);
return { phases: next, errors };
}
@@ -413,9 +407,15 @@ function applyParams(phases: TodoPhase[], params: TodoParams): { phases: TodoPha
/** Apply an array of `todo`-style ops to existing phases. Used by /todo slash command. */
export function applyOpsToPhases(
currentPhases: TodoPhase[],
ops: TodoParams["ops"],
ops: TodoParams[],
): { phases: TodoPhase[]; errors: string[] } {
return applyParams(clonePhases(currentPhases), { ops });
const errors: string[] = [];
let next = clonePhases(currentPhases);
for (const op of ops) {
next = applyEntry(next, op, errors);
}
normalizeInProgressTask(next);
return { phases: next, errors };
}
// =============================================================================
@@ -572,64 +572,44 @@ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
{
caption: "Initial setup (multi-phase)",
call: {
ops: [
{
op: "init",
list: [
{ phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
{ phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
{ phase: "Verification", items: ["Run cargo test"] },
],
},
op: "init",
list: [
{ phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
{ phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
{ phase: "Verification", items: ["Run cargo test"] },
],
},
},
{
caption: "View current state (read-only)",
call: {
ops: [{ op: "view" }],
},
call: { op: "view" },
},
{
caption: "Initial setup (single phase)",
call: {
ops: [
{
op: "init",
list: [{ phase: "Implementation", items: ["Apply fix", "Run tests"] }],
},
],
op: "init",
list: [{ phase: "Implementation", items: ["Apply fix", "Run tests"] }],
},
},
{
caption: "Complete one task",
call: {
ops: [{ op: "done", task: "Wire workspace" }],
},
call: { op: "done", task: "Wire workspace" },
},
{
caption: "Complete a whole phase",
call: {
ops: [{ op: "done", phase: "Auth" }],
},
call: { op: "done", phase: "Auth" },
},
{
caption: "Remove all tasks",
call: {
ops: [{ op: "rm" }],
},
call: { op: "rm" },
},
{
caption: "Drop one task",
call: {
ops: [{ op: "drop", task: "Run cargo test" }],
},
call: { op: "drop", task: "Run cargo test" },
},
{
caption: "Append tasks to a phase",
call: {
ops: [{ op: "append", phase: "Auth", items: ["Handle retries", "Run tests"] }],
},
call: { op: "append", phase: "Auth", items: ["Handle retries", "Run tests"] },
},
];
readonly loadMode = "discoverable";
@@ -646,7 +626,7 @@ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
): Promise<AgentToolResult<TodoToolDetails>> {
const previousPhases = clonePhases(this.session.getTodoPhases?.() ?? []);
// Pure-view calls are reads: no normalization, no state write.
const readOnly = params.ops.every(entry => entry.op === "view");
const readOnly = params.op === "view";
const { phases: updated, errors } = readOnly
? { phases: previousPhases, errors: [] as string[] }
: applyParams(clonePhases(previousPhases), params);
@@ -673,15 +653,32 @@ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
// TUI Renderer
// =============================================================================
type TodoRenderArgs = {
ops?: Array<{
op?: string;
task?: string;
phase?: string;
items?: string[];
}>;
type TodoRenderOp = {
op?: string;
task?: string;
phase?: string;
items?: string[];
};
/** New single-op shape `{op,...}`; legacy `{ops:[...]}` still seen in old transcripts. */
type TodoRenderArgs = TodoRenderOp & {
ops?: TodoRenderOp[];
};
/**
* Normalize streaming/legacy render args to a flat op list. Accepts the new
* top-level `{op,...}` shape (returned as a one-element list), the legacy
* `{ops:[...]}` batch from old transcripts/collab-web, and partially-parsed
* streaming deltas (non-array `ops`, non-object entries) without crashing.
*/
function normalizeTodoArg(args: TodoRenderArgs | undefined): TodoRenderOp[] {
if (!args || typeof args !== "object") return [];
if (Array.isArray(args.ops)) {
return args.ops.filter((entry): entry is TodoRenderOp => !!entry && typeof entry === "object");
}
return typeof args.op === "string" ? [args] : [];
}
// =============================================================================
// Phase numbering (display-only)
// =============================================================================
@@ -794,7 +791,7 @@ function computeTouchedPhases(
for (const transition of completedTasks) touched.add(transition.phase);
// Phases explicitly named by the ops that ran. `init` replaces the whole
// list, so the entire plan is fresh and every phase counts as touched.
const ops = Array.isArray(args?.ops) ? args.ops : [];
const ops = normalizeTodoArg(args);
for (const op of ops) {
if (!op || typeof op !== "object") continue;
if (op.op === "init") {
@@ -823,18 +820,17 @@ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, uiTheme: Th
export const todoToolRenderer = {
renderCall(args: TodoRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
// `args` here is the raw partially-parsed JSON from the streaming
// tool-call delta and may not satisfy `TodoRenderArgs` at runtime:
// `parseStreamingJson` can hand back `{ ops: "[" }` mid-delta, or
// entries that are `null` / strings before fields stream. Guard
// against non-array `ops` and non-object entries so a malformed
// delta never breaks the TUI render loop (#2005).
const opsList = Array.isArray(args?.ops) ? args.ops : [];
// `args` is the raw partially-parsed JSON from the streaming tool-call
// delta and may not satisfy `TodoRenderArgs` at runtime:
// `parseStreamingJson` can hand back `{ op: 1 }` mid-delta, or a legacy
// `{ ops: "[" }` shape before fields stream. `normalizeTodoArg` guards
// both the new single-op and legacy batch shapes so a malformed delta
// never breaks the TUI render loop (#2005).
const opsList = normalizeTodoArg(args);
const ops =
opsList.length === 0
? ["update"]
: opsList.map(entry => {
const e = entry && typeof entry === "object" ? entry : ({} as NonNullable<typeof entry>);
: opsList.map(e => {
const parts = [e.op ?? "update"];
if (e.task) parts.push(e.task);
if (e.phase) parts.push(e.phase);
+1 -1
View File
@@ -80,7 +80,7 @@ function formatHeader(options: CodeCellOptions, theme: Theme): { title: string;
parts.push(icon);
}
}
if (index !== undefined && total !== undefined) {
if (index !== undefined && total !== undefined && total > 1) {
parts.push(theme.fg("accent", `[${index + 1}/${total}]`));
}
if (title) {
@@ -3,6 +3,8 @@ import type { ImageContent } from "@oh-my-pi/pi-ai";
export interface ImageResizeOptions {
maxWidth?: number;
maxHeight?: number;
/** Smallest allowed edge length (px). Inputs below this are scaled up. */
minDimension?: number;
maxBytes?: number;
jpegQuality?: number;
excludeWebP?: boolean;
@@ -23,6 +25,13 @@ export interface ResizedImage {
// binding constraint once images are downsized to 1568px (Anthropic's internal threshold).
const DEFAULT_MAX_BYTES = 500 * 1024;
// Smallest edge length (px) vision backends reliably accept. They tile images into
// fixed patches (Anthropic uses 28px) and reject degenerate sub-patch images — e.g.
// the 1x1 PNG an empty chart render emits — with a hard 400 ("Could not process
// image") that can poison the whole request. 200px is the smallest size Anthropic
// documents as valid (200x200 = 64 visual tokens); undersized images are scaled up.
const DEFAULT_MIN_DIMENSION = 200;
const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
// Anthropic's "internal recommended size" — Claude internally caps images at
// 1568px on the longest edge before vision processing.
@@ -30,6 +39,7 @@ const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
maxHeight: 1568,
maxBytes: DEFAULT_MAX_BYTES,
jpegQuality: 80,
minDimension: DEFAULT_MIN_DIMENSION,
};
/**
@@ -87,7 +97,12 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
// still get JPEG-compressed.
const originalSize = inputBuffer.length;
const comfortableSize = opts.maxBytes / 4;
// Clamp the floor to the caps so an unusually small max can't demand an
// impossible "≥ min and ≤ max" target.
const minDimension = Math.min(opts.minDimension, opts.maxWidth, opts.maxHeight);
if (
originalWidth >= minDimension &&
originalHeight >= minDimension &&
originalWidth <= opts.maxWidth &&
originalHeight <= opts.maxHeight &&
originalSize <= comfortableSize &&
@@ -120,6 +135,21 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
targetHeight = opts.maxHeight;
}
// Lift undersized inputs up to the minimum. A uniform scale covers the
// common case (icons, the 1x1 chart) without distortion; an aspect ratio
// too extreme to satisfy both floor and cap falls back to stretching the
// lagging edge up to the floor via the default fit:"fill" resize.
if (targetWidth < minDimension || targetHeight < minDimension) {
const shortEdge = Math.min(targetWidth, targetHeight);
const upscale = Math.min(minDimension / shortEdge, opts.maxWidth / targetWidth, opts.maxHeight / targetHeight);
if (upscale > 1) {
targetWidth = Math.round(targetWidth * upscale);
targetHeight = Math.round(targetHeight * upscale);
}
targetWidth = Math.min(opts.maxWidth, Math.max(minDimension, targetWidth));
targetHeight = Math.min(opts.maxHeight, Math.max(minDimension, targetHeight));
}
// First-attempt encoder: try PNG and JPEG (+ WebP if not excluded) — return smallest.
// PNG wins for line art / few-color UI; JPEG wins for photographic content;
// WebP usually beats JPEG by 25–35% but is disabled when OMP_NO_WEBP is set
@@ -7,7 +7,7 @@
* them into a combined `answer` string on the SearchResponse.
*/
import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
import { settings } from "../../../config/settings";
import { getDefault, settings } from "../../../config/settings";
import { findApiKey, isSearchResponse } from "../../../exa/mcp-client";
import { parseSSE } from "../../../mcp/json-rpc";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
@@ -18,6 +18,88 @@ import { SearchProvider } from "./base";
import { classifyProviderHttpError, withHardTimeout } from "./utils";
const EXA_API_URL = "https://api.exa.ai/search";
const DEFAULT_EXA_SEARCH_DELAY_MS = getDefault("exa.searchDelayMs");
let nextExaSearchRequestAt = 0;
let exaSearchThrottle = Promise.resolve();
function configuredExaSearchDelayMs(): number {
try {
const delayMs = settings.get("exa.searchDelayMs");
return Number.isFinite(delayMs) && delayMs > 0 ? Math.floor(delayMs) : 0;
} catch {
return DEFAULT_EXA_SEARCH_DELAY_MS;
}
}
function rejectWithAbortReason(reject: (reason?: unknown) => void, signal: AbortSignal): void {
try {
signal.throwIfAborted();
reject(new DOMException("The operation was aborted.", "AbortError"));
} catch (error) {
reject(error);
}
}
function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
if (ms <= 0) return Promise.resolve();
signal?.throwIfAborted();
const { promise, resolve, reject } = Promise.withResolvers<void>();
let timer: NodeJS.Timeout | undefined;
const cleanup = (): void => {
if (timer) {
clearTimeout(timer);
timer = undefined;
}
signal?.removeEventListener("abort", onAbort);
};
const onAbort = (): void => {
cleanup();
if (signal) rejectWithAbortReason(reject, signal);
};
timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
return promise;
}
function waitUntilDoneOrAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
signal.throwIfAborted();
const { promise: aborted, reject } = Promise.withResolvers<never>();
const onAbort = (): void => rejectWithAbortReason(reject, signal);
signal.addEventListener("abort", onAbort, { once: true });
return Promise.race([promise, aborted]).finally(() => {
signal.removeEventListener("abort", onAbort);
});
}
async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise<void> {
const delayMs = configuredExaSearchDelayMs();
if (delayMs <= 0) return;
const prior = exaSearchThrottle.catch(() => {});
const queued = prior.then(async () => {
signal?.throwIfAborted();
const waitMs = Math.max(0, nextExaSearchRequestAt - Date.now());
if (waitMs > 0) {
await abortableSleep(waitMs, signal);
}
signal?.throwIfAborted();
nextExaSearchRequestAt = Date.now() + delayMs;
});
exaSearchThrottle = queued.catch(() => {});
await waitUntilDoneOrAborted(queued, signal);
}
/** Reset Exa request pacing state for isolated provider tests. */
export function resetExaSearchThrottleForTest(): void {
nextExaSearchRequestAt = 0;
exaSearchThrottle = Promise.resolve();
}
type ExaSearchType = "neural" | "fast" | "auto" | "deep";
@@ -224,6 +306,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
const body = buildExaRequestBody(params);
const fetchImpl = params.fetch ?? fetch;
await waitForExaSearchSlot(params.signal);
const response = await fetchImpl(EXA_API_URL, {
method: "POST",
headers: {
@@ -260,6 +343,7 @@ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchRespo
if (apiKey) query.set("exaApiKey", apiKey);
query.set("tools", "web_search_exa");
const fetchImpl = params.fetch ?? fetch;
await waitForExaSearchSlot(params.signal);
const response = await fetchImpl(`https://mcp.exa.ai/mcp?${query.toString()}`, {
method: "POST",
headers: {
@@ -247,7 +247,7 @@ describe("ACP event mapper", () => {
type: "tool_execution_start",
toolCallId: "tc-eval-start",
toolName: "eval",
args: { cells: [{ language: "js", title: "sum", code: "return 1 + 1;" }] },
args: { language: "js", title: "sum", code: "return 1 + 1;" },
intent: "sum",
} as AgentSessionEvent,
"session-1",
@@ -267,7 +267,7 @@ describe("ACP event mapper", () => {
expect(update.title).toBe("[js] sum\nreturn 1 + 1;");
expect(update.kind).toBe("execute");
expect(update.status).toBe("pending");
expect(update.rawInput).toEqual({ cells: [{ language: "js", title: "sum", code: "return 1 + 1;" }] });
expect(update.rawInput).toEqual({ language: "js", title: "sum", code: "return 1 + 1;" });
expect(update.content).toContainEqual({
type: "content",
content: { type: "text", text: "[js] sum\nreturn 1 + 1;" },
@@ -305,7 +305,7 @@ describe("ACP event mapper", () => {
type: "tool_execution_start",
toolCallId: "tc-eval-long-source",
toolName: "eval",
args: { cells: [{ language: "js", code: source }] },
args: { language: "js", code: source },
} as AgentSessionEvent,
"session-1",
);
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
@@ -10,6 +11,7 @@ import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensi
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import * as unexpectedStopClassifier from "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier";
import { getProjectAgentDir, TempDir, withTimeout } from "@oh-my-pi/pi-utils";
const runtimeSignalStoreKey = "__ompRuntimeSignals";
@@ -273,6 +275,382 @@ describe("AgentSession auto-compaction queue resume", () => {
expect(runtimeSignals.some(signal => signal.startsWith("compaction:end:"))).toBe(true);
});
it("triggers threshold compaction in active goals even when per-turn pruning shaves the post-prune estimate below threshold", async () => {
// Regression for #3174. Goal mode is the most common scenario: the agent
// runs many tool-result-heavy turns and the per-turn "useless" /
// "supersede" passes shave tokens off every check. Pre-fix
// `#checkCompaction` subtracted those savings from the threshold input, so
// with the reporter's fixed `compaction.thresholdTokens: 76384`, the
// threshold input fell below the trigger even when the provider-billed
// prompt (and the visible context anchored to it) sat above 90k tokens —
// auto-compaction silently no-op'd indefinitely while the loop kept
// running.
//
// This seeds one large `useless` tool result whose suffix sits inside the
// 8k cache-warm window so `#pruneStaleToolResults` actually returns ≥20k
// savings (well above the buggy code's mis-subtraction needed to drop
// 91000 below 76384). Compaction MUST still fire because the last turn's
// billed context tokens (91k) are above the configured threshold.
const now = Date.now();
// Seed: small user, small toolCall, ONE big useless tool result, then a
// handful of small turns that keep the suffix after the big result under
// the 8000-token cache-warm cutoff. The big result is the only viable
// prune candidate, and it alone saves well over 20k tokens — enough to
// drag the pre-fix threshold input from 91k well below 76384.
sessionManager.appendMessage({
role: "user",
content: "Investigate every module of the project.",
timestamp: now - 200,
});
const bigCallId = "call-big-useless";
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: bigCallId, name: "search", arguments: { pattern: "TODO" } }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "toolUse",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now - 180,
});
sessionManager.appendMessage({
role: "toolResult",
toolCallId: bigCallId,
toolName: "search",
content: [{ type: "text", text: "match line\n".repeat(20000) }], // ~40k+ tokens
isError: false,
useless: true,
timestamp: now - 170,
});
// A few small follow-up turns so the big result's suffix stays inside the
// 8000-token cache-warm window. Each pair is well under a hundred tokens.
for (let i = 0; i < 4; i++) {
const smallId = `call-small-${i}`;
const ts = now - 160 + i * 2;
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: smallId, name: "read", arguments: { path: `note-${i}.md` } }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "toolUse",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: ts,
});
sessionManager.appendMessage({
role: "toolResult",
toolCallId: smallId,
toolName: "read",
content: [{ type: "text", text: `tiny note ${i}` }],
isError: false,
timestamp: ts + 1,
});
}
session.agent.replaceMessages(session.buildDisplaySessionContext().messages);
session.setGoalModeState({
enabled: true,
mode: "active",
goal: {
id: "goal-threshold-pruneable",
objective: "continue until compacted",
status: "active",
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
},
});
vi.spyOn(session.agent, "continue").mockImplementation(async () => {
session.agent.clearAllQueues();
});
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.strategy", "context-full");
session.settings.set("compaction.dropUseless", true);
session.settings.set("compaction.supersedeReads", true);
session.settings.set("compaction.keepRecentTokens", 10000);
session.settings.set("compaction.reserveTokens", 16384);
// Final assistant turn: billed at ~91k context tokens, just over the
// reporter's threshold. The pre-fix code would have subtracted ≥20k of
// prune savings and dropped the threshold input below 76384, skipping
// compaction. Post-fix it must trigger.
const finalAssistant = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "Investigated module-7; continuing." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "stop" as const,
usage: {
input: 5000,
output: 1000,
cacheRead: 85000,
cacheWrite: 0,
totalTokens: 91000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now,
};
session.agent.emitExternalEvent({ type: "message_end", message: finalAssistant });
session.agent.emitExternalEvent({ type: "agent_end", messages: [finalAssistant] });
await session.waitForIdle();
const runtimeSignals = getRuntimeSignals();
expect(runtimeSignals).toContain("compaction:start:threshold");
expect(runtimeSignals.some(signal => signal.startsWith("compaction:end:"))).toBe(true);
});
it("runs active-goal threshold compaction before unexpected-stop retry continuation", async () => {
const now = Date.now();
session.setGoalModeState({
enabled: true,
mode: "active",
goal: {
id: "goal-unexpected-stop-threshold",
objective: "continue until compacted",
status: "active",
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
},
});
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.autoContinue", true);
session.settings.set("contextPromotion.enabled", false);
session.settings.set("features.unexpectedStopDetection", true);
session.settings.set("providers.unexpectedStopModel", "online");
vi.spyOn(unexpectedStopClassifier, "classifyUnexpectedStop").mockResolvedValue(true);
vi.spyOn(session.agent, "continue").mockImplementation(async () => {
session.agent.clearAllQueues();
});
const assistantMsg = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "I should continue investigating another module." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "stop" as const,
usage: {
input: 5000,
output: 1000,
cacheRead: 85000,
cacheWrite: 0,
totalTokens: 91000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now,
};
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await session.waitForIdle();
expect(getRuntimeSignals()).toContain("compaction:start:threshold");
});
it("resolves a pending retry before active-goal compaction continuation returns", async () => {
// Codex review on #3175: a retry can succeed with a non-empty text stop
// that is already over the active-goal compaction threshold. If the
// compaction pre-empt schedules its own continuation before the normal
// bottom-of-handler `#resolveRetry()` call runs, the session stays
// `isRetrying` and later prompt/idle gates remain blocked.
vi.useRealTimers();
const now = Date.now();
session.setGoalModeState({
enabled: true,
mode: "active",
goal: {
id: "goal-retry-threshold",
objective: "recover from retry and compact",
status: "active",
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
},
});
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.autoContinue", true);
session.settings.set("contextPromotion.enabled", false);
session.settings.set("retry.enabled", true);
session.settings.set("retry.baseDelayMs", 5);
session.settings.set("retry.maxDelayMs", 5_000);
session.settings.set("retry.maxRetries", 1);
session.settings.set("retry.modelFallback", false);
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
vi.spyOn(session.agent, "continue").mockImplementation(async () => {
session.agent.clearAllQueues();
});
const { promise: retryStarted, resolve: onRetryStarted } = Promise.withResolvers<void>();
const { promise: retryEnded, resolve: onRetryEnded } = Promise.withResolvers<void>();
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
if (event.type === "auto_retry_start") onRetryStarted();
if (event.type === "auto_retry_end") onRetryEnded();
if (event.type === "auto_compaction_end") onCompactionDone();
});
const retryableError = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "Transient provider failure." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "error" as const,
errorMessage: "503 service unavailable: overloaded_error retry-after-ms=50",
usage: {
input: 100,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now - 1,
};
session.agent.emitExternalEvent({ type: "message_end", message: retryableError });
session.agent.emitExternalEvent({ type: "agent_end", messages: [retryableError] });
await withTimeout(retryStarted, 1000, "Retry start timed out");
expect(session.isRetrying).toBe(true);
const recoveredOverThreshold = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "Recovered; continuing the active goal." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "stop" as const,
usage: {
input: 5000,
output: 1000,
cacheRead: 85000,
cacheWrite: 0,
totalTokens: 91000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now,
};
session.agent.emitExternalEvent({ type: "message_end", message: recoveredOverThreshold });
await withTimeout(retryEnded, 1000, "Retry end timed out");
expect(session.isRetrying).toBe(true);
session.agent.emitExternalEvent({ type: "agent_end", messages: [recoveredOverThreshold] });
await withTimeout(compactionDone, 1000, "Compaction end timed out");
await session.waitForIdle();
expect(getRuntimeSignals()).toContain("compaction:start:threshold");
expect(session.isRetrying).toBe(false);
});
it("removes orphan toolUse assistant before active-goal threshold compaction continuation", async () => {
// Codex review on #3175: when an active goal turn is over threshold AND
// stops with an empty `toolUse` (no tool call), the new ordering must NOT
// skip `#handleEmptyAssistantStop` — that handler is the only path that
// strips the orphan assistant from active context + session history. If a
// compaction continuation runs with the orphan still in place, the next
// Anthropic turn carries a `tool_use` block with no matching
// `tool_result` and corrupts the message history.
const now = Date.now();
session.setGoalModeState({
enabled: true,
mode: "active",
goal: {
id: "goal-orphan-toolUse-threshold",
objective: "continue until compacted",
status: "active",
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
},
});
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.autoContinue", true);
session.settings.set("contextPromotion.enabled", false);
vi.spyOn(session.agent, "continue").mockImplementation(async () => {
session.agent.clearAllQueues();
});
const orphanToolUse = {
role: "assistant" as const,
// Empty toolUse stop: stopReason says a tool was requested but the
// content block is empty (no toolCall). This is the case the empty-stop
// cleanup defends against.
content: [] as never[],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "toolUse" as const,
usage: {
input: 5000,
output: 1000,
cacheRead: 85000,
cacheWrite: 0,
totalTokens: 91000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now,
};
session.agent.emitExternalEvent({ type: "message_end", message: orphanToolUse });
session.agent.emitExternalEvent({ type: "agent_end", messages: [orphanToolUse] });
await session.waitForIdle();
// Empty-stop cleanup short-circuits before any compaction continuation, so
// the threshold compaction MUST NOT fire on this turn — the next turn
// starts from the cleaned-up branch with the retry-reminder developer
// message instead. The pre-fix ordering let compaction reach
// `auto_compaction_start` first, scheduling a continuation while the
// orphan `toolUse` entry was still the session leaf.
const signals = getRuntimeSignals();
expect(signals).not.toContain("compaction:start:threshold");
// `#removeEmptyStopFromActiveContext` rewinds the session leaf past the
// orphan via `sessionManager.branch(parentId)` / `resetLeaf()`. If the
// cleanup is skipped, the orphan is still the leaf when the compaction
// continuation runs and the next Anthropic turn sends a `tool_use` block
// with no matching `tool_result`.
const branch = sessionManager.getBranch();
const orphanInBranch = branch.some(entry => {
if (entry.type !== "message") return false;
const message = entry.message as { role: string; stopReason?: string };
return message.role === "assistant" && message.stopReason === "toolUse";
});
expect(orphanInBranch).toBe(false);
});
it("has isCompacting true when the auto_compaction_start event fires", async () => {
// Defect 1: the compaction AbortController (which backs isCompacting) must be
// installed before auto_compaction_start is emitted. If it is installed after,
@@ -220,12 +220,8 @@ describe("AgentSession eager todo enforcement", () => {
it("initializes todos once, then continues within the same user turn", async () => {
scriptedResponses = [
createToolCallAssistantMessage("todo", {
ops: [
{
op: "init",
list: [{ phase: "List worktrees", items: ["List all git worktrees in the current repository"] }],
},
],
op: "init",
list: [{ phase: "List worktrees", items: ["List all git worktrees in the current repository"] }],
}),
createAssistantMessage("real user turn handled"),
];
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { GoalModeState } from "@oh-my-pi/pi-coding-agent/goals/state";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
function activeGoalState(): GoalModeState {
const now = Date.now();
return {
enabled: true,
mode: "active",
goal: {
id: "goal-midrun-compaction",
objective: "Ship the release",
status: "active",
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
},
};
}
function highUsage(input: number) {
return {
input,
output: 100,
cacheRead: 0,
cacheWrite: 0,
totalTokens: input + 100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
describe("AgentSession mid-run goal compaction", () => {
let tempDir: TempDir;
const cleanups: Array<() => Promise<void>> = [];
beforeEach(() => {
tempDir = TempDir.createSync("@pi-agent-goal-midrun-compaction-");
cleanups.length = 0;
});
afterEach(async () => {
for (const cleanup of cleanups) await cleanup();
cleanups.length = 0;
tempDir.removeSync();
vi.restoreAllMocks();
});
async function createHarness(settingsOverride: Record<string, unknown> = {}): Promise<{
session: AgentSession;
observedContexts: string[][];
}> {
const observedContexts: string[][] = [];
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`));
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.strategy": "context-full",
"compaction.autoContinue": true,
"compaction.thresholdTokens": 1000,
"compaction.thresholdPercent": -1,
"todo.enabled": false,
"todo.reminders": false,
...settingsOverride,
});
const sessionManager = SessionManager.inMemory(tempDir.path());
const mockBashTool: AgentTool = {
name: "bash",
label: "Bash",
description: "Mock bash tool",
parameters: type({}),
execute: async () => ({ content: [{ type: "text" as const, text: "tool output" }] }),
};
let call = 0;
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["Test"], tools: [mockBashTool], messages: [] },
convertToLlm,
streamFn: (_model, context) => {
const index = call++;
observedContexts.push(context.messages.map(message => JSON.stringify(message)));
const stream = new AssistantMessageEventStream();
const isToolTurn = index === 0;
const message = isToolTurn
? {
role: "assistant" as const,
content: [
{ type: "toolCall" as const, id: `tc-${index}`, name: "bash", arguments: { cmd: "ls" } },
],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
usage: highUsage(50_000),
stopReason: "toolUse" as const,
timestamp: Date.now(),
}
: {
role: "assistant" as const,
content: [{ type: "text" as const, text: "All done." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
usage: highUsage(200),
stopReason: "stop" as const,
timestamp: Date.now(),
};
queueMicrotask(() => {
stream.push({ type: "start", partial: message });
stream.push({ type: "done", reason: message.stopReason, message });
});
return stream;
},
});
const session = new AgentSession({
agent,
sessionManager,
settings,
modelRegistry,
toolRegistry: new Map([[mockBashTool.name, mockBashTool]]),
});
cleanups.push(async () => {
await session.dispose();
authStorage.close();
});
return { session, observedContexts };
}
it("compacts in place between tool-call turns during an active goal run", async () => {
const { session, observedContexts } = await createHarness();
session.setGoalModeState(activeGoalState());
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "MID-RUN-COMPACTED",
shortSummary: undefined,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
await session.prompt("work on the release");
expect(compactSpy).toHaveBeenCalledTimes(1);
expect(observedContexts.length).toBeGreaterThanOrEqual(2);
expect(observedContexts[1].join("\n")).toContain("MID-RUN-COMPACTED");
});
it("does not compact mid-run when no goal is active", async () => {
const { session } = await createHarness();
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "SHOULD-NOT-RUN",
shortSummary: undefined,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
await session.prompt("work on the release");
expect(compactSpy).not.toHaveBeenCalled();
});
});
@@ -426,7 +426,7 @@ describe("AgentSession python cleanup", () => {
expect(EvalTool).toBeDefined();
let toolExecutionSettled = false;
const toolExecution = EvalTool!
.execute("call-id", { cells: [{ language: "py", code: "print('tool')" }] }, undefined, undefined, undefined)
.execute("call-id", { language: "py", code: "print('tool')" }, undefined, undefined, undefined)
.finally(() => {
toolExecutionSettled = true;
});
@@ -652,13 +652,7 @@ describe("AgentSession python cleanup", () => {
expect(EvalTool).toBeDefined();
const disposeSession = session.dispose();
await expect(
EvalTool!.execute(
"call-id",
{ cells: [{ language: "py", code: "print('late')" }] },
undefined,
undefined,
undefined,
),
EvalTool!.execute("call-id", { language: "py", code: "print('late')" }, undefined, undefined, undefined),
).rejects.toThrow("Python execution is unavailable while session disposal is in progress");
await disposeSession;
expect(executeSpy).not.toHaveBeenCalled();
@@ -693,7 +687,7 @@ describe("AgentSession python cleanup", () => {
expect(EvalTool).toBeDefined();
const execution = EvalTool!.execute(
"call-id",
{ cells: [{ language: "py", code: "print('late after artifact')" }] },
{ language: "py", code: "print('late after artifact')" },
undefined,
undefined,
undefined,
@@ -14,6 +14,7 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import {
AUTO_THINKING,
clampAutoThinkingEffort,
parseCliThinkingLevel,
parseConfiguredThinkingLevel,
parseEffort,
parseThinkingLevel,
@@ -65,6 +66,14 @@ describe("auto thinking classifier helpers", () => {
expect(parseThinkingLevel(ThinkingLevel.Off)).toBe(ThinkingLevel.Off);
});
it("parses CLI --thinking selectors while rejecting inherit", () => {
expect(parseCliThinkingLevel(ThinkingLevel.Off)).toBe(ThinkingLevel.Off);
expect(parseCliThinkingLevel(AUTO_THINKING)).toBe(AUTO_THINKING);
expect(parseCliThinkingLevel("max")).toBe(ThinkingLevel.XHigh);
expect(parseCliThinkingLevel(ThinkingLevel.Inherit)).toBeUndefined();
expect(parseCliThinkingLevel("bogus")).toBeUndefined();
});
it("maps online 4-way classifier labels to effort levels", () => {
expect(parseDifficultyLevel("x-high")).toBe(Effort.XHigh);
expect(parseDifficultyLevel("The answer is HIGH.")).toBe(Effort.High);
@@ -974,7 +974,7 @@ describe("executeBash :async: background retention", () => {
});
it.skipIf(process.platform === "win32")(
"keeps a per-job :async: shell's background process alive across turns",
"keeps a per-job :async: shell's plain-`&` background process alive across turns",
async () => {
const pidFile = path.join(tmp, "pid");
const sleepBin = fs.existsSync("/bin/sleep") ? "/bin/sleep" : "sleep";
@@ -982,10 +982,11 @@ describe("executeBash :async: background retention", () => {
try {
// A per-job `:async:` key: its shell is removed from the reuse map at
// teardown, which would SIGKILL the backgrounded child (kill-on-drop).
// The retain logic keeps the shell alive while a background process is
// still running. `$!` is the external child's pid (nohup is a
// transparent background wrapper).
const res = await executeBash(`nohup ${sleepBin} 30 >/dev/null 2>&1 & echo $! > ${shellQuote(pidFile)}`, {
// A plain `&` job stays a child of the shell, so `liveBackgroundJobCount`
// sees it and the retain logic keeps the shell alive while the child
// runs. `$!` is the external child's own pid (no transparent wrapper to
// unwrap), so it is the process we assert on.
const res = await executeBash(`${sleepBin} 30 >/dev/null 2>&1 & echo $! > ${shellQuote(pidFile)}`, {
sessionKey: "retain-probe:async:job1",
cwd: tmp,
});
@@ -1012,4 +1013,48 @@ describe("executeBash :async: background retention", () => {
}
},
);
it.skipIf(process.platform === "win32")(
"keeps a nohup-detached background process alive across turns (reparenting)",
async () => {
const pidFile = path.join(tmp, "nohup-pid");
const sleepBin = fs.existsSync("/bin/sleep") ? "/bin/sleep" : "sleep";
let pid: number | undefined;
try {
// `nohup cmd &` is a transparent background wrapper: brush unwraps it and
// double-forks the operand so it reparents to init and survives teardown
// independently of the retain map. The shell only ever tracked the
// short-lived intermediate fork, so `$!` is NOT the surviving process —
// the operand writes its own pid before `exec`ing the long sleep, and
// that pid (unchanged across exec) is the one we assert stays alive.
const operand = `echo $$ > ${pidFile}; exec ${sleepBin} 30`;
const res = await executeBash(`nohup sh -c ${shellQuote(operand)} >/dev/null 2>&1 &`, {
sessionKey: "reparent-probe:async:job1",
cwd: tmp,
});
expect(res.cancelled).toBe(false);
await pollUntil(() => fs.existsSync(pidFile), Date.now() + 4000);
pid = Number.parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
expect(Number.isInteger(pid)).toBe(true);
// A later turn on a different per-job shell must not have killed it.
await executeBash("true", { sessionKey: "reparent-probe:async:job2", cwd: tmp });
let alive = true;
try {
process.kill(pid, 0);
} catch {
alive = false;
}
expect(alive).toBe(true);
} finally {
if (pid !== undefined) {
try {
process.kill(pid, "SIGKILL");
} catch {}
}
}
},
);
});
@@ -1,6 +1,8 @@
import { describe, expect, it } from "bun:test";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { Effort } from "@oh-my-pi/pi-ai";
import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args";
import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking";
describe("parseArgs — --hide-thinking flag", () => {
it("parses --hide-thinking as a boolean flag", () => {
@@ -43,3 +45,21 @@ describe("parseArgs — --hide-thinking flag", () => {
expect(result.messages).toEqual([]);
});
});
describe("parseArgs — --thinking flag", () => {
it("accepts off so reasoning can be disabled from the CLI", () => {
expect(parseArgs(["--thinking", "off"]).thinking).toBe(ThinkingLevel.Off);
expect(parseArgs(["--thinking=off"]).thinking).toBe(ThinkingLevel.Off);
});
it("accepts auto, concrete efforts, and the max alias", () => {
expect(parseArgs(["--thinking", "auto"]).thinking).toBe(AUTO_THINKING);
expect(parseArgs(["--thinking", "medium"]).thinking).toBe(Effort.Medium);
expect(parseArgs(["--thinking", "max"]).thinking).toBe(ThinkingLevel.XHigh);
});
it("ignores invalid levels and the internal inherit selector", () => {
expect(parseArgs(["--thinking", "bogus"]).thinking).toBeUndefined();
expect(parseArgs(["--thinking", "inherit"]).thinking).toBeUndefined();
});
});
@@ -211,7 +211,7 @@ describe("omp completions (integration / drift)", () => {
}
expect(stdout).toContain("{-r,--resume}");
// Real enum option sets flow through unchanged.
expect(stdout).toContain(":value:(minimal low medium high xhigh)");
expect(stdout).toContain(":value:(off minimal low medium high xhigh auto)");
expect(stdout).toContain(":value:(always-ask write yolo)");
// Real subcommands present; dynamic callbacks wired.
expect(stdout).toContain("_omp_cmd_commit");
@@ -144,13 +144,10 @@ describe.skipIf(!SHOULD_RUN)("ruby runner subprocess", () => {
}
});
it("exposes prelude file + text helpers", async () => {
it("exposes prelude file helpers", async () => {
using tempDir = TempDir.createSync("@ruby-runner-prelude-");
const kernel = await RubyKernel.start({ cwd: tempDir.path() });
try {
const sorted = await executeRubyWithKernel(kernel, 'sort("b\\na\\nb", unique: true)', {});
expect(sorted.output).toContain("a\nb");
const written = await executeRubyWithKernel(kernel, 'write("note.txt", "hello"); read("note.txt")', {});
expect(written.output).toContain("hello");
} finally {
@@ -55,7 +55,7 @@ describe("runEvalAgent", () => {
getAgentId: () => "BridgeParent",
} as unknown as ToolSession;
await runEvalAgent({ prompt: "do work", agentType: "task" }, { session });
await runEvalAgent({ prompt: "do work", agent: "task" }, { session });
expect(runSubprocessSpy).toHaveBeenCalledTimes(1);
const options = runSubprocessSpy.mock.calls[0]?.[0];
@@ -0,0 +1,84 @@
/**
* Regression: cancelling the startup `--resume` session picker (e.g. pressing
* Esc) must terminate the process cleanly. Startup arms long-lived handles
* (theme/appearance listeners via initTheme, settings save timer, model
* registry), so the previous bare `return` left the event loop with live
* handles and the process hung after the picker left the alternate screen.
*
* The fix exits via `process.exit(0)` — matching the `--version`/`--export`
* early-exit convention in the same function. Only this startup call site
* exits; the in-session `/resume` picker (selector-controller.ts) keeps its own
* onCancel that just closes the overlay.
*/
import { describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { runRootCommand } from "@oh-my-pi/pi-coding-agent/main";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { TempDir } from "@oh-my-pi/pi-utils";
class ProcessExitSignal extends Error {
constructor(readonly code: number) {
super(`process.exit(${code})`);
this.name = "ProcessExitSignal";
}
}
describe("runRootCommand — startup --resume picker cancellation", () => {
it("exits cleanly (process.exit 0) when the picker is cancelled instead of returning and hanging", async () => {
using tempDir = TempDir.createSync("@omp-resume-cancel-");
const sessionDir = tempDir.path();
// One valid session so folderSessions is non-empty and the picker (not the
// "No sessions found" probe) is the path under test.
await Bun.write(
path.join(sessionDir, "existing.jsonl"),
`${JSON.stringify({ type: "session", id: "existing-session", cwd: sessionDir, timestamp: new Date().toISOString() })}\n`,
);
const authStorage = await AuthStorage.create(path.join(sessionDir, "auth.db"));
const settings = Settings.isolated({ "marketplace.autoUpdate": "off" });
// --print keeps initTheme non-interactive so no global appearance/SIGWINCH
// listeners leak into the rest of the suite; the picker branch is gated on
// `resume === true`, not on interactivity, so it still runs.
const parsed = parseArgs(["--resume", "--print"]);
parsed.noExtensions = true;
parsed.noSkills = true;
parsed.noRules = true;
parsed.noTools = true;
parsed.noLsp = true;
parsed.sessionDir = sessionDir;
const exitCodes: number[] = [];
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
exitCodes.push(code ?? 0);
throw new ProcessExitSignal(code ?? 0);
}) as typeof process.exit);
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
let pickerCalled = false;
let thrown: unknown;
try {
await runRootCommand(parsed, ["--resume", "--print"], {
discoverAuthStorage: async () => authStorage,
settings,
selectSession: async () => {
pickerCalled = true;
return null; // user cancelled (Esc)
},
});
} catch (err) {
thrown = err;
} finally {
vi.restoreAllMocks();
authStorage.close();
}
expect(pickerCalled).toBe(true);
expect(thrown).toBeInstanceOf(ProcessExitSignal);
// Exactly one clean exit — proves the cancel branch terminates instead of
// falling through to session creation or returning into a hang.
expect(exitCodes).toEqual([0]);
}, 15_000);
});
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from "bun:test";
import { handleInputOrEscape } from "@oh-my-pi/pi-coding-agent/modes/components/plugin-settings";
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui";
afterEach(() => {
setKittyProtocolActive(false);
});
describe("handleInputOrEscape", () => {
it("cancels on a kitty CSI-u escape (the fullscreen settings overlay encoding)", () => {
// Ghostty/kitty report Escape as `\x1b[27u` once the keyboard protocol is
// active (which it is inside the fullscreen settings overlay). A raw `\x1b`
// compare misses it, so Esc looked dead in the text-input submenu.
setKittyProtocolActive(true);
let cancelled = false;
const forwarded: string[] = [];
handleInputOrEscape("\x1b[27u", { handleInput: data => forwarded.push(data) }, () => {
cancelled = true;
});
expect(cancelled).toBe(true);
expect(forwarded).toEqual([]);
});
it("cancels on a legacy bare escape", () => {
let cancelled = false;
const forwarded: string[] = [];
handleInputOrEscape("\x1b", { handleInput: data => forwarded.push(data) }, () => {
cancelled = true;
});
expect(cancelled).toBe(true);
expect(forwarded).toEqual([]);
});
it("forwards a printable keystroke to the input instead of cancelling", () => {
setKittyProtocolActive(true);
let cancelled = false;
const forwarded: string[] = [];
handleInputOrEscape("g", { handleInput: data => forwarded.push(data) }, () => {
cancelled = true;
});
expect(cancelled).toBe(false);
expect(forwarded).toEqual(["g"]);
});
});
@@ -0,0 +1,140 @@
import { beforeAll, describe, expect, it } from "bun:test";
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-listing";
beforeAll(async () => {
await initTheme();
});
function makeSession(id: string, title: string | undefined): SessionInfo {
return {
path: `/work/${id}.jsonl`,
id,
cwd: "/work",
title,
created: new Date("2024-01-01T00:00:00Z"),
modified: new Date("2024-01-02T00:00:00Z"),
messageCount: 1,
size: 1024,
firstMessage: `body for ${id}`,
allMessagesText: `body for ${id}`,
};
}
/** SGR left-button press at a 1-based screen row (column is irrelevant for row hit-testing). */
function leftClick(row1Based: number, col1Based = 4): string {
return `\x1b[<0;${col1Based};${row1Based}M`;
}
/** SGR wheel notch: button 64 = up, 65 = down. */
function wheel(direction: "up" | "down"): string {
return `\x1b[<${direction === "down" ? 65 : 64};1;1M`;
}
function makeSelector(
sessions: SessionInfo[],
onSelect: (s: SessionInfo) => void,
rows = 40,
): SessionSelectorComponent {
return new SessionSelectorComponent(
sessions,
onSelect,
() => {},
() => {},
{
getTerminalRows: () => rows,
fillHeight: true,
},
);
}
describe("SessionSelectorComponent mouse", () => {
it("resumes the session under a left click", () => {
const sessions = [
makeSession("aaaa", "Alpha session"),
makeSession("bbbb", "Beta session"),
makeSession("cccc", "Gamma session"),
];
let picked: SessionInfo | undefined;
const selector = makeSelector(sessions, s => {
picked = s;
});
// Render first so the hit-test map and list offset reflect this frame.
const lines = selector.render(80);
const betaRow = lines.findIndex(line => line.includes("Beta session"));
expect(betaRow).toBeGreaterThanOrEqual(0);
// Mouse rows are 1-based; the fullscreen overlay paints from screen row 0.
selector.handleInput(leftClick(betaRow + 1));
expect(picked?.id).toBe("bbbb");
});
it("scrolls the selection with the wheel, then resumes it on Enter", () => {
const sessions = [
makeSession("aaaa", "Alpha session"),
makeSession("bbbb", "Beta session"),
makeSession("cccc", "Gamma session"),
];
let picked: SessionInfo | undefined;
const selector = makeSelector(sessions, s => {
picked = s;
});
selector.render(80);
// Selection starts at the first row; two notches down lands on Gamma.
selector.handleInput(wheel("down"));
selector.handleInput(wheel("down"));
selector.handleInput("\n");
expect(picked?.id).toBe("cccc");
});
it("ignores a click on the pinned footer (never resumes a hidden session)", () => {
const sessions = Array.from({ length: 20 }, (_, i) => makeSession(`s${i}`, `Title ${i}`));
let picked: SessionInfo | undefined;
const selector = makeSelector(
sessions,
s => {
picked = s;
},
40,
);
const lines = selector.render(80);
const footerRow = lines.findIndex(line => line.includes("Esc cancel"));
expect(footerRow).toBeGreaterThanOrEqual(0);
// Click directly on the footer hint row: must not resume anything.
selector.handleInput(leftClick(footerRow + 1));
expect(picked).toBeUndefined();
});
});
describe("SessionSelectorComponent fill-height footer", () => {
// First half titled (4 rows each), second half untitled (3 rows each), so the
// scrolled window changes height — the regression that made the footer drift.
function mixedSessions(count: number): SessionInfo[] {
return Array.from({ length: count }, (_, i) => makeSession(`s${i}`, i < count / 2 ? `Titled ${i}` : undefined));
}
it("fills the viewport and pins the footer to the bottom regardless of scroll", () => {
const rows = 40;
const selector = makeSelector(mixedSessions(20), () => {}, rows);
const top = selector.render(80);
const topHint = top.findIndex(line => line.includes("Esc cancel"));
expect(top.length).toBe(rows);
expect(topHint).toBe(rows - 3);
expect(top[rows - 1]!.trim().length).toBeGreaterThan(0); // bottom border on the last row
// Scroll to the bottom of the list (now an untitled window of a different
// height); the footer must not move.
for (let i = 0; i < 25; i++) selector.handleInput(wheel("down"));
const bottom = selector.render(80);
const bottomHint = bottom.findIndex(line => line.includes("Esc cancel"));
expect(bottom.length).toBe(rows);
expect(bottomHint).toBe(topHint);
expect(bottom[rows - 1]!.trim().length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,166 @@
import { beforeAll, describe, expect, it } from "bun:test";
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-listing";
import { TUI } from "@oh-my-pi/pi-tui";
import { StressRenderScheduler } from "../../../../tui/test/render-stress-scheduler";
import { VirtualTerminal } from "../../../../tui/test/virtual-terminal";
beforeAll(() => {
initTheme();
});
function makeSessions(count: number): SessionInfo[] {
return Array.from({ length: count }, (_, i) => ({
path: `/work/SESSION_${i}.jsonl`,
id: `id-${i}`,
cwd: "/work",
title: `SESSION_${i}`,
created: new Date("2024-01-01T00:00:00Z"),
modified: new Date("2024-01-02T00:00:00Z"),
messageCount: 1,
size: 1024,
firstMessage: `body content ${i}`,
allMessagesText: `body content ${i}`,
}));
}
describe("issue #3283: /resume picker scrolls down after deleting a session", () => {
it("keeps the picker header pinned at the same viewport row before and after a delete", async () => {
const term = new VirtualTerminal(80, 24, 4096);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const selector = new SessionSelectorComponent(
makeSessions(20),
() => {},
() => {},
() => {},
{
getTerminalRows: () => term.rows,
onDelete: async () => true,
},
);
selector.setOnRequestRender(() => tui.requestRender());
tui.addChild(selector);
tui.setFocus(selector);
try {
tui.start();
await scheduler.drain(term);
const headerRowBefore = term.getViewport().findIndex(row => Bun.stripANSI(row).includes("Resume Session"));
expect(headerRowBefore).toBeGreaterThanOrEqual(0);
// Press Delete (CSI 3 ~) to open the confirmation dialog, then
// Enter to accept "Yes".
selector.handleInput("\x1b[3~");
tui.requestRender();
await scheduler.drain(term);
selector.handleInput("\n");
// onDelete is async; let its microtasks flush before draining renders.
for (let i = 0; i < 8; i++) await Promise.resolve();
await scheduler.drain(term);
const viewport = term.getViewport().map(row => Bun.stripANSI(row).trimEnd());
const headerRowAfter = viewport.findIndex(row => row.includes("Resume Session"));
// Regression: dialog growing the frame and then shrinking must
// not push the picker header further down into the viewport
// (committed scrollback rows from the dialog frame).
expect(headerRowAfter).toBeGreaterThanOrEqual(0);
expect(headerRowAfter).toBe(headerRowBefore);
} finally {
tui.stop();
await term.flush();
}
});
it("keeps the picker header pinned even when the delete dialog is canceled", async () => {
const term = new VirtualTerminal(80, 24, 4096);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const selector = new SessionSelectorComponent(
makeSessions(20),
() => {},
() => {},
() => {},
{
getTerminalRows: () => term.rows,
onDelete: async () => true,
},
);
selector.setOnRequestRender(() => tui.requestRender());
tui.addChild(selector);
tui.setFocus(selector);
try {
tui.start();
await scheduler.drain(term);
const headerRowBefore = term.getViewport().findIndex(row => Bun.stripANSI(row).includes("Resume Session"));
expect(headerRowBefore).toBeGreaterThanOrEqual(0);
// Open dialog, then Esc to cancel without deleting.
selector.handleInput("\x1b[3~");
tui.requestRender();
await scheduler.drain(term);
selector.handleInput("\x1b");
await scheduler.drain(term);
const viewport = term.getViewport().map(row => Bun.stripANSI(row).trimEnd());
const headerRowAfter = viewport.findIndex(row => row.includes("Resume Session"));
expect(headerRowAfter).toBe(headerRowBefore);
// Dialog gone, no scroll-down artefact.
expect(viewport.some(row => row.includes("Delete session?"))).toBe(false);
} finally {
tui.stop();
await term.flush();
}
});
it("keeps the picker frame bounded on a narrow terminal where the dialog title wraps", () => {
// Direct structural contract: even on a small terminal with a
// session name that wraps the dialog past any plausible fixed
// reserve, the picker's total rendered output stays within the
// terminal height. The dialog REPLACES the SessionList inside
// the picker frame, so its rows compete only with the
// SessionList's rendered budget — not with both the SessionList
// AND picker chrome (PR #3285 second review round). Without the
// structural fix the dialog could push the picker past the
// viewport once the SessionList reserve could not shrink any
// further, and the TUI committed the header into scrollback.
const longName = "a-very-very-very-very-long-session-title-that-must-wrap-on-a-narrow-terminal";
const sessions: SessionInfo[] = [
{
path: `/work/${longName}.jsonl`,
id: "id-long",
cwd: "/work",
title: longName,
created: new Date("2024-01-01T00:00:00Z"),
modified: new Date("2024-01-02T00:00:00Z"),
messageCount: 1,
size: 1024,
firstMessage: longName,
allMessagesText: longName,
},
...makeSessions(10),
];
const NARROW_WIDTH = 30;
const TERMINAL_ROWS = 24;
const selector = new SessionSelectorComponent(
sessions,
() => {},
() => {},
() => {},
{ getTerminalRows: () => TERMINAL_ROWS, onDelete: async () => true },
);
// Baseline: picker fits the viewport before the dialog opens.
expect(selector.render(NARROW_WIDTH).length).toBeLessThanOrEqual(TERMINAL_ROWS);
// Open the delete confirmation; the dialog takes the SessionList
// slot inside the picker frame. The picker's rendered total must
// STILL fit the viewport even though the dialog wraps past the
// previous 12-row reserve guess.
selector.handleInput("\x1b[3~");
expect(selector.render(NARROW_WIDTH).length).toBeLessThanOrEqual(TERMINAL_ROWS);
});
});
@@ -1,284 +0,0 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector";
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-listing";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { FileSessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage";
type TestContext = InteractiveModeContext & {
editorContainer: {
children: unknown[];
clear: () => void;
addChild: (child: unknown) => void;
};
};
function makeSessionInfo(path: string): SessionInfo {
return {
path,
id: path,
cwd: "/tmp/project",
title: "Active session",
created: new Date("2025-01-01T00:00:00Z"),
modified: new Date("2025-01-01T00:00:00Z"),
messageCount: 1,
size: 0,
firstMessage: "hello",
allMessagesText: "hello",
};
}
function createContext(currentSessionFile: string): {
ctx: TestContext;
calls: string[];
setCurrentSessionFile: (path: string) => void;
showHookConfirm: (title: string, message: string) => Promise<boolean>;
newSession: () => Promise<boolean>;
} {
const calls: string[] = [];
let sessionFile = currentSessionFile;
const editorContainer = {
children: [] as unknown[],
clear() {
this.children = [];
calls.push("editorContainer.clear");
},
addChild(child: unknown) {
this.children.push(child);
calls.push("editorContainer.addChild");
},
};
const showHookConfirm = vi.fn(async () => true);
const newSession = vi.fn(async () => {
calls.push("session.newSession");
sessionFile = "/tmp/project/sessions/detached.jsonl";
return true;
});
const session = {
newSession,
switchSession: vi.fn(async () => true),
};
const ctx = {
editorContainer,
editor: {},
ui: {
setFocus: vi.fn(),
requestRender: vi.fn(() => {
calls.push("ui.requestRender");
}),
terminal: { columns: 120 },
},
session,
get viewSession() {
return session;
},
sessionManager: {
getCwd: () => "/tmp/project",
getSessionDir: () => "/tmp/project/sessions",
getSessionFile: () => sessionFile,
},
statusContainer: {
clear: vi.fn(() => {
calls.push("statusContainer.clear");
}),
},
pendingMessagesContainer: {
clear: vi.fn(() => {
calls.push("pendingMessagesContainer.clear");
}),
},
compactionQueuedMessages: [] as unknown[],
streamingComponent: { active: true },
streamingMessage: { active: true },
pendingTools: {
clear: vi.fn(() => {
calls.push("pendingTools.clear");
}),
},
loadingAnimation: {
stop: vi.fn(() => {
calls.push("loadingAnimation.stop");
}),
},
statusLine: {
invalidate: vi.fn(() => {
calls.push("statusLine.invalidate");
}),
setSessionStartTime: vi.fn(() => {
calls.push("statusLine.setSessionStartTime");
}),
},
updateEditorTopBorder: vi.fn(() => {
calls.push("updateEditorTopBorder");
}),
updateEditorBorderColor: vi.fn(() => {
calls.push("updateEditorBorderColor");
}),
renderInitialMessages: vi.fn(() => {
calls.push("renderInitialMessages");
}),
reloadTodos: vi.fn(async () => {
calls.push("reloadTodos");
}),
showStatus: vi.fn((message: string) => {
calls.push(`showStatus:${message}`);
}),
showError: vi.fn(),
showHookConfirm,
shutdown: vi.fn(async () => undefined),
clearTransientSessionUi() {
ctx.loadingAnimation?.stop();
ctx.statusContainer.clear();
ctx.pendingMessagesContainer.clear();
ctx.pendingTools.clear();
},
} as unknown as TestContext;
return {
ctx,
calls,
setCurrentSessionFile(path: string) {
sessionFile = path;
},
showHookConfirm,
newSession,
};
}
function renderText(selector: SessionSelectorComponent): string {
return selector.render(120).join("\n");
}
beforeAll(() => {
initTheme();
});
describe("SelectorController session deletion", () => {
beforeEach(() => {
vi.spyOn(SessionManager, "list").mockResolvedValue([]);
vi.spyOn(SessionManager, "listAll").mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("detaches the active session before selector deletion removes it", async () => {
const activeSession = makeSessionInfo("/tmp/project/sessions/active.jsonl");
const { ctx, calls } = createContext(activeSession.path);
vi.spyOn(SessionManager, "list").mockResolvedValue([activeSession]);
const deleteSessionWithArtifacts = vi
.spyOn(FileSessionStorage.prototype, "deleteSessionWithArtifacts")
.mockImplementation(async sessionPath => {
calls.push(`delete:${sessionPath}`);
});
const controller = new SelectorController(ctx);
await controller.showSessionSelector();
const selector = ctx.editorContainer.children[0];
if (!(selector instanceof SessionSelectorComponent)) {
throw new Error("Expected session selector component");
}
const sessionList = selector.getSessionList() as unknown as {
onDeleteRequest?: (session: SessionInfo) => void;
};
sessionList.onDeleteRequest?.(activeSession);
selector.handleInput("\n");
await Bun.sleep(0);
expect(deleteSessionWithArtifacts).toHaveBeenCalledWith(activeSession.path);
expect(calls).toEqual([
"editorContainer.clear",
"editorContainer.addChild",
"ui.requestRender",
"session.newSession",
"loadingAnimation.stop",
"statusContainer.clear",
"pendingMessagesContainer.clear",
"pendingTools.clear",
"statusLine.invalidate",
"statusLine.setSessionStartTime",
"updateEditorTopBorder",
"updateEditorBorderColor",
"renderInitialMessages",
"reloadTodos",
"ui.requestRender",
`delete:${activeSession.path}`,
"ui.requestRender",
]);
expect(ctx.sessionManager.getSessionFile()).toBe("/tmp/project/sessions/detached.jsonl");
});
it("shows inline selector errors when session deletion fails after detach", async () => {
const activeSession = makeSessionInfo("/tmp/project/sessions/active.jsonl");
const { ctx, newSession } = createContext(activeSession.path);
vi.spyOn(SessionManager, "list").mockResolvedValue([activeSession]);
const deleteSessionWithArtifacts = vi
.spyOn(FileSessionStorage.prototype, "deleteSessionWithArtifacts")
.mockRejectedValue(new Error("disk failed"));
const controller = new SelectorController(ctx);
await controller.showSessionSelector();
const selector = ctx.editorContainer.children[0];
if (!(selector instanceof SessionSelectorComponent)) {
throw new Error("Expected session selector component");
}
const sessionList = selector.getSessionList() as unknown as {
onDeleteRequest?: (session: SessionInfo) => void;
};
sessionList.onDeleteRequest?.(activeSession);
selector.handleInput("\n");
await Bun.sleep(0);
expect(newSession).toHaveBeenCalledTimes(1);
expect(deleteSessionWithArtifacts).toHaveBeenCalledWith(activeSession.path);
expect(ctx.showError).not.toHaveBeenCalled();
expect(ctx.sessionManager.getSessionFile()).toBe("/tmp/project/sessions/detached.jsonl");
expect(renderText(selector)).toContain("Error: Failed to delete session: disk failed");
});
it("creates a fresh session before deleting via slash command and then shows the selector", async () => {
const activeSessionPath = "/tmp/project/sessions/active.jsonl";
const { ctx, calls, showHookConfirm, newSession } = createContext(activeSessionPath);
const deleteSessionWithArtifacts = vi
.spyOn(FileSessionStorage.prototype, "deleteSessionWithArtifacts")
.mockImplementation(async sessionPath => {
calls.push(`delete:${sessionPath}`);
});
const exists = vi.spyOn(FileSessionStorage.prototype, "exists").mockResolvedValue(true);
const controller = new SelectorController(ctx);
await controller.handleSessionDeleteCommand();
expect(exists).toHaveBeenCalledWith(activeSessionPath);
expect(showHookConfirm).toHaveBeenCalledWith(
"Delete Session",
"This will permanently delete the current session.\nYou will be returned to the session selector.",
);
expect(newSession).toHaveBeenCalledTimes(1);
expect(deleteSessionWithArtifacts).toHaveBeenCalledWith(activeSessionPath);
expect(calls).toEqual([
"session.newSession",
"loadingAnimation.stop",
"statusContainer.clear",
"pendingMessagesContainer.clear",
"pendingTools.clear",
"statusLine.invalidate",
"statusLine.setSessionStartTime",
"updateEditorTopBorder",
"updateEditorBorderColor",
"renderInitialMessages",
"reloadTodos",
"ui.requestRender",
`delete:${activeSessionPath}`,
"showStatus:Session deleted",
"editorContainer.clear",
"editorContainer.addChild",
"ui.requestRender",
]);
});
});
@@ -76,18 +76,25 @@ describe("extractLastCommand", () => {
expect(extractLastCommand(messages)).toEqual({ kind: "bash", code: "echo b", language: "bash" });
});
it("joins eval cell code and reports the cell language", () => {
it("extracts eval code from flat args and reports the language", () => {
const py = [
assistantCalls([{ name: "eval", arguments: { language: "py", code: "print(1)" } }]),
] as unknown as AgentMessage[];
expect(extractLastCommand(py)).toEqual({ kind: "eval", code: "print(1)", language: "python" });
const js = [
assistantCalls([{ name: "eval", arguments: { language: "js", code: "log(1)" } }]),
] as unknown as AgentMessage[];
expect(extractLastCommand(js)?.language).toBe("javascript");
});
it("still joins legacy multi-cell eval args from older transcripts", () => {
const py = [
assistantCalls([
{ name: "eval", arguments: { cells: [{ language: "py", code: "print(1)" }, { code: "print(2)" }] } },
]),
] as unknown as AgentMessage[];
expect(extractLastCommand(py)).toEqual({ kind: "eval", code: "print(1)\n\nprint(2)", language: "python" });
const js = [
assistantCalls([{ name: "eval", arguments: { cells: [{ language: "js", code: "log(1)" }] } }]),
] as unknown as AgentMessage[];
expect(extractLastCommand(js)?.language).toBe("javascript");
});
});
@@ -242,7 +242,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => {
await Settings.init({ inMemory: true, overrides: { "terminal.showImages": true } });
setTerminalImageProtocol(ImageProtocol.Sixel);
const transcript = transcriptWith([
assistantToolCall("eval-image", "eval", { cells: [{ language: "py", code: "display(image)" }] }),
assistantToolCall("eval-image", "eval", { language: "py", code: "display(image)" }),
{
role: "toolResult",
toolCallId: "eval-image",
@@ -279,9 +279,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => {
isError: false,
timestamp: 2,
});
session.appendMessage(
assistantToolCall("eval-reopened", "eval", { cells: [{ language: "py", code: "display(image)" }] }),
);
session.appendMessage(assistantToolCall("eval-reopened", "eval", { language: "py", code: "display(image)" }));
session.appendMessage({
role: "toolResult",
toolCallId: "eval-reopened",
+64
View File
@@ -347,6 +347,70 @@ describe("AgentSession shake", () => {
expect(fullStart).toBeDefined();
});
it("counts pre-shake prune savings when deciding whether to fall back to context-full", async () => {
session.settings.set("compaction.strategy", "shake");
session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.dropUseless", true);
session.settings.set("contextPromotion.enabled", false);
const now = Date.now();
sessionManager.appendMessage({
role: "user",
content: "Investigate every module of the project.",
timestamp: now - 200,
});
const bigCallId = "call-big-useless-for-shake";
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: bigCallId, name: "search", arguments: { pattern: "TODO" } }],
...apiInfo,
stopReason: "toolUse",
usage,
timestamp: now - 180,
});
sessionManager.appendMessage({
role: "toolResult",
toolCallId: bigCallId,
toolName: "search",
content: [{ type: "text", text: "match line\n".repeat(20000) }],
isError: false,
useless: true,
timestamp: now - 170,
});
session.agent.replaceMessages(session.buildDisplaySessionContext().messages);
const shakeSpy = vi
.spyOn(session, "shake")
.mockResolvedValue({ mode: "elide", toolResultsDropped: 1, blocksDropped: 0, tokensFreed: 100 });
const assistantMessage: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "trigger" }],
...apiInfo,
stopReason: "stop",
usage: {
input: 5000,
output: 1000,
cacheRead: 85000,
cacheWrite: 0,
totalTokens: 91000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now,
};
session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] });
await Bun.sleep(50);
expect(shakeSpy).toHaveBeenCalledTimes(1);
const fullStart = events.find(
event => event.type === "auto_compaction_start" && (event as { action?: string }).action === "context-full",
);
expect(fullStart).toBeUndefined();
});
it("falls back after pre-prompt shake when the floored stored conversation remains over threshold", async () => {
session.settings.set("compaction.strategy", "shake");
session.settings.set("compaction.thresholdTokens", 8_000);
@@ -435,7 +435,9 @@ describe("streaming tool call preview height (bounded across renderers)", () =>
const hidden = total - window;
const longLines = Array.from({ length: total }, (_, i) => `line-${i}`);
const { lines, text } = renderPending("eval", {
cells: [{ language: "js", title: "big", code: longLines.map(line => `const ${line} = 1;`).join("\n") }],
language: "js",
title: "big",
code: longLines.map(line => `const ${line} = 1;`).join("\n"),
});
expect(lines.length, "eval code preview should stay bounded").toBeLessThan(window + 10);
@@ -74,6 +74,21 @@ describe("built-in tool loadMode annotations", () => {
}
expect(missing).toEqual([]);
});
it("marks eval essential so it survives tools.discoveryMode 'all'", async () => {
const metadata = await getToolMetadata();
expect(metadata.get("eval")?.loadMode).toBe("essential");
// Essential loadMode keeps eval active under discovery-all even when it is
// absent from the essential-names set — not relying on the names list.
const kept = filterInitialToolsForDiscoveryAll(["eval"], {
loadModeOf: name => metadata.get(name)?.loadMode as BuiltinToolLoadMode | undefined,
essentialNames: new Set<string>(),
explicitlyRequested: new Set<string>(),
restored: new Set<string>(),
forceActive: new Set<string>(),
});
expect(kept).toEqual(["eval"]);
});
});
describe("computeEssentialBuiltinNames", () => {
+6 -1
View File
@@ -803,7 +803,12 @@ describe("Coding Agent Tools", () => {
fs.writeFileSync(testFile, pngBuffer);
const legacyReadTool = wrapToolWithMetaNotice(
new ReadTool(createTestToolSession(testDir, Settings.isolated({ "inspect_image.enabled": false }))),
new ReadTool(
createTestToolSession(
testDir,
Settings.isolated({ "inspect_image.enabled": false, "images.autoResize": false }),
),
),
);
const result = await legacyReadTool.execute("test-call-img-1", { path: testFile });
+29 -13
View File
@@ -496,7 +496,7 @@ describe("AskTool option descriptions", () => {
step += 1;
return selectItemLabel(options.find(o => selectItemLabel(o)?.endsWith("beta")));
}
return "Other (type your own)";
return selectItemLabel(options.find(o => selectItemLabel(o)?.includes("Done selecting")));
},
editor,
});
@@ -564,10 +564,11 @@ describe("AskTool custom input", () => {
expect(abort).not.toHaveBeenCalled();
});
it("aborts when editor is cancelled in single-question flow", async () => {
it("returns to the option selector when custom input is dismissed in single-question flow", async () => {
const tool = new AskTool(createSession());
const abort = vi.fn();
const editor = vi.fn(async () => undefined);
let selectCalls = 0;
const questions = [
{
id: "details",
@@ -576,22 +577,27 @@ describe("AskTool custom input", () => {
},
];
const context = createContext({
select: async () => "Other (type your own)",
select: async () => {
selectCalls += 1;
return selectCalls === 1 ? "Other (type your own)" : "yes";
},
editor,
abort,
});
await expect(
tool.execute("call-editor-cancel", { questions }, undefined, undefined, context),
).rejects.toBeInstanceOf(ToolAbortError);
const result = await tool.execute("call-editor-cancel", { questions }, undefined, undefined, context);
expect(result.details?.selectedOptions).toEqual(["yes"]);
expect(result.details?.customInput).toBeUndefined();
expect(selectCalls).toBe(2);
expect(editor).toHaveBeenCalledTimes(1);
expect(abort).toHaveBeenCalledTimes(1);
expect(abort).not.toHaveBeenCalled();
});
it("continues multi-question flow when editor is dismissed on a fresh question", async () => {
it("returns to the option selector when custom input is dismissed in multi-question flow", async () => {
const tool = new AskTool(createSession());
const abort = vi.fn();
const editor = vi.fn(async () => undefined);
let detailsVisits = 0;
const questions = [
{
id: "first",
@@ -607,7 +613,10 @@ describe("AskTool custom input", () => {
const context = createContext({
select: async prompt => {
if (prompt.includes("First?")) return "one";
if (prompt.includes("Details?")) return "Other (type your own)";
if (prompt.includes("Details?")) {
detailsVisits += 1;
return detailsVisits === 1 ? "Other (type your own)" : "short";
}
return undefined;
},
editor,
@@ -616,10 +625,10 @@ describe("AskTool custom input", () => {
const result = await tool.execute("call-editor-multi-dismiss", { questions }, undefined, undefined, context);
// Editor dismissed on "Details?" — flow continues with empty answer, not abort
expect(result.details?.results?.[0]?.selectedOptions).toEqual(["one"]);
expect(result.details?.results?.[1]?.selectedOptions).toEqual([]);
expect(result.details?.results?.[1]?.selectedOptions).toEqual(["short"]);
expect(result.details?.results?.[1]?.customInput).toBeUndefined();
expect(detailsVisits).toBe(2);
expect(editor).toHaveBeenCalledTimes(1);
expect(abort).not.toHaveBeenCalled();
});
@@ -745,7 +754,7 @@ describe("AskTool custom input", () => {
expect(renderedText).toContain("custom detail");
});
it("preserves prior multi-select answers when custom editor is dismissed", async () => {
it("returns to the option selector when multi-select custom input is dismissed", async () => {
const tool = new AskTool(createSession());
let step = 0;
const editor = vi.fn(async () => undefined);
@@ -757,7 +766,13 @@ describe("AskTool custom input", () => {
if (!alphaOption) throw new Error("Missing alpha option");
return selectItemLabel(alphaOption);
}
return "Other (type your own)";
if (step === 1) {
step += 1;
return "Other (type your own)";
}
const doneOption = options.find(option => selectItemLabel(option)?.includes("Done selecting"));
if (!doneOption) throw new Error("Missing done option");
return selectItemLabel(doneOption);
},
editor,
});
@@ -786,6 +801,7 @@ describe("AskTool custom input", () => {
throw new Error("Expected text result");
}
expect(result.content[0].text).toContain("User selected: alpha");
expect(step).toBe(2);
expect(editor).toHaveBeenCalledTimes(1);
});
});
@@ -61,7 +61,7 @@ describe("eval renderer: viewport tail window for cell code", () => {
it("bounds the pending preview to the same live tail window", () => {
const component = evalToolRenderer.renderCall(
{ cells: [{ language: "py", code }] },
{ language: "py", code },
{ expanded: false, isPartial: true },
theme,
);
@@ -17,7 +17,7 @@ function makeSession(opts: { spawns?: string | null; backends?: Record<string, b
} as unknown as ToolSession;
}
/** Pull the model-facing cell-schema fields (sorted `language` enum + descriptions) from the wire schema. */
/** Pull the model-facing cell-schema fields (sorted `language` enum + descriptions) from the flat wire schema. */
function wireCellFields(tool: EvalTool): {
languages: string[];
languageDescription?: string;
@@ -25,17 +25,11 @@ function wireCellFields(tool: EvalTool): {
} {
const wire = toolWireSchema(tool as unknown as AiTool) as {
properties?: {
cells?: {
items?: {
properties?: {
language?: { enum?: string[]; const?: string; description?: string };
code?: { description?: string };
};
};
};
language?: { enum?: string[]; const?: string; description?: string };
code?: { description?: string };
};
};
const props = wire.properties?.cells?.items?.properties;
const props = wire.properties;
const language = props?.language;
const languages = Array.isArray(language?.enum)
? [...language.enum].sort()
@@ -86,15 +80,19 @@ describe("eval tool dynamic schema", () => {
}
});
it("hides rb/jl from the wire schema, summary, and description by default", () => {
const fields = wireCellFields(new EvalTool(makeSession({})));
it("hides rb/jl from the wire schema, summary, description, and examples by default", () => {
const tool = new EvalTool(makeSession({}));
const fields = wireCellFields(tool);
// Default config: rb/jl off → the wire schema is byte-identical to the pre-feature py/js one.
expect(fields.languages).toEqual(["js", "py"]);
expect(fields.languageDescription).toBe('runtime: "py" for the IPython kernel, "js" for the persistent JS VM');
expect(fields.codeDescription).toBe("cell body, verbatim. Use top-level await freely.");
const tool = new EvalTool(makeSession({}));
expect(fields.codeDescription).toBe("code to run in this eval call, verbatim. Use top-level await freely.");
expect(tool.summary).toBe("Execute Python or JavaScript code in an in-process eval backend");
expect(tool.description).not.toMatch(/ruby|julia/i);
// Examples must not advertise a disabled backend.
const exampleLangs = tool.examples.map(ex => ("call" in ex ? ex.call.language : null));
expect(exampleLangs).toEqual(["py", "py", "py"]);
expect(tool.examples.some(ex => "call" in ex && ex.call.language === "rb")).toBe(false);
});
it("advertises rb/jl across enum, descriptions, summary, and prelude once enabled", () => {
@@ -104,12 +102,15 @@ describe("eval tool dynamic schema", () => {
expect(fields.languageDescription).toBe(
'runtime: "py" for the IPython kernel, "js" for the persistent JS VM, "rb" for the persistent Ruby kernel, "jl" for the persistent Julia kernel',
);
expect(fields.codeDescription).toBe(
"cell body, verbatim. Top-level `await` is available in py/js; rb/jl auto-display the last expression like a REPL.",
expect(fields.codeDescription).toContain(
"code to run in this eval call, verbatim. Top-level `await` is available in py/js; rb/jl auto-display the last expression like a REPL.",
);
expect(tool.summary).toBe("Execute Python, JavaScript, Ruby, or Julia code in a persistent eval backend");
expect(tool.description).toMatch(/ruby/i);
expect(tool.description).toMatch(/julia/i);
// Ruby examples appear once rb is enabled.
const rbExampleLangs = tool.examples.filter(ex => "call" in ex && ex.call.language === "rb");
expect(rbExampleLangs.length).toBe(2);
});
it("advertises only the enabled subset of optional backends", () => {
@@ -55,7 +55,8 @@ describe("EvalTool display() text surfacing", () => {
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-display-json", {
cells: [{ language: "js", code: "```js\ndisplay({ stdout: 'hi', exit_code: 0 });\n```\n" }],
language: "js",
code: "```js\ndisplay({ stdout: 'hi', exit_code: 0 });\n```\n",
});
const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n");
@@ -76,7 +77,8 @@ describe("EvalTool display() text surfacing", () => {
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-mixed", {
cells: [{ language: "js", code: "```js\nprint('before'); display([1,2,3]);\n```\n" }],
language: "js",
code: "```js\nprint('before'); display([1,2,3]);\n```\n",
});
const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n");
@@ -96,9 +98,8 @@ describe("EvalTool display() text surfacing", () => {
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-image", {
cells: [
{ language: "js", code: "```js\ndisplay({ type: 'image', data: '...', mimeType: 'image/png' });\n```\n" },
],
language: "js",
code: "```js\ndisplay({ type: 'image', data: '...', mimeType: 'image/png' });\n```\n",
});
const imageBlocks = result.content.filter(c => c.type === "image");
@@ -125,12 +126,8 @@ describe("EvalTool display() text surfacing", () => {
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-large-image", {
cells: [
{
language: "js",
code: "```js\ndisplay({ type: 'image', data: largePng, mimeType: 'image/png' });\n```\n",
},
],
language: "js",
code: "```js\ndisplay({ type: 'image', data: largePng, mimeType: 'image/png' });\n```\n",
});
const image = result.content.find(c => c.type === "image");
@@ -154,7 +151,8 @@ describe("EvalTool display() text surfacing", () => {
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-empty", {
cells: [{ language: "js", code: "```js\nconst x = 1;\n```\n" }],
language: "js",
code: "```js\nconst x = 1;\n```\n",
});
const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n");
@@ -172,7 +170,8 @@ describe("EvalTool display() text surfacing", () => {
const tool = new EvalTool(makeSession());
const result = await tool.execute("call-huge", {
cells: [{ language: "js", code: "```js\ndisplay({ payload: 'x'.repeat(20000) });\n```\n" }],
language: "js",
code: "```js\ndisplay({ payload: 'x'.repeat(20000) });\n```\n",
});
const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n");
@@ -67,7 +67,8 @@ describe("EvalTool language dispatch", () => {
const tool = new EvalTool(makeSession());
await tool.execute("call-js", {
cells: [{ language: "js", code: "const x = 1;" }],
language: "js",
code: "const x = 1;",
});
expect(jsExecuteSpy).toHaveBeenCalledTimes(1);
@@ -82,26 +83,23 @@ describe("EvalTool language dispatch", () => {
const tool = new EvalTool(makeSession());
await tool.execute("call-py", {
cells: [{ language: "py", code: "print('hi')" }],
language: "py",
code: "print('hi')",
});
expect(pythonExecuteSpy).toHaveBeenCalledTimes(1);
expect(jsExecuteSpy).not.toHaveBeenCalled();
});
it("interleaves backends across cells in a single call", async () => {
it("dispatches each call to the backend named by its language", async () => {
vi.spyOn(pyKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
vi.spyOn(evalIndex.pythonBackend, "isAvailable").mockResolvedValue(true);
const pythonExecuteSpy = vi.spyOn(evalIndex.pythonBackend, "execute").mockResolvedValue(mockResult);
const jsExecuteSpy = vi.spyOn(evalIndex.jsBackend, "execute").mockResolvedValue(mockResult);
const tool = new EvalTool(makeSession());
await tool.execute("call-mixed", {
cells: [
{ language: "py", code: "x = 1" },
{ language: "js", code: "const y = 2;" },
],
});
await tool.execute("call-py", { language: "py", code: "x = 1" });
await tool.execute("call-js", { language: "js", code: "const y = 2;" });
expect(pythonExecuteSpy).toHaveBeenCalledTimes(1);
expect(jsExecuteSpy).toHaveBeenCalledTimes(1);
@@ -113,7 +111,8 @@ describe("EvalTool language dispatch", () => {
const tool = new EvalTool(makeSession(settings));
await expect(
tool.execute("call-py-disabled", {
cells: [{ language: "py", code: "print('hi')" }],
language: "py",
code: "print('hi')",
}),
).rejects.toThrow(/eval\.py = false/);
});
@@ -124,7 +123,8 @@ describe("EvalTool language dispatch", () => {
const tool = new EvalTool(makeSession(settings));
await expect(
tool.execute("call-js-disabled", {
cells: [{ language: "js", code: "const x = 1;" }],
language: "js",
code: "const x = 1;",
}),
).rejects.toThrow(/eval\.js = false/);
});
@@ -151,7 +151,8 @@ describe("EvalTool language dispatch", () => {
await expect(
tool.execute("call-js-env-disabled", {
cells: [{ language: "js", code: "const x = 1;" }],
language: "js",
code: "const x = 1;",
}),
).rejects.toThrow(/PI_JS=0/);
});
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as evalIndex from "@oh-my-pi/pi-coding-agent/eval";
import type { EvalToolDetails } from "@oh-my-pi/pi-coding-agent/eval/types";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { EvalTool } from "@oh-my-pi/pi-coding-agent/tools/eval";
function makeSession(): ToolSession {
return {
cwd: "/tmp/eval-test",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => null,
settings: Settings.isolated(),
};
}
function baseResult(overrides: Record<string, unknown> = {}) {
return {
output: "",
exitCode: 0,
cancelled: false,
truncated: false,
artifactId: undefined,
totalLines: 0,
totalBytes: 0,
outputLines: 0,
outputBytes: 0,
displayOutputs: [] as unknown[],
...overrides,
};
}
/**
* Defends the contract that stdout streamed by a still-running cell lands in the
* running cell's rendered `output` *before* `backend.execute()` returns — so a
* long-running cell (e.g. a `time.sleep()` monitor loop) shows progress live in
* the eval card instead of dumping everything at once on completion/interrupt.
*
* The eval card renderer draws cell output from `details.cells[i].output`; if
* that field is only filled after the backend resolves, the card stays blank for
* the whole run. This pins the live bridge that keeps it populated mid-flight.
*/
describe("EvalTool live stdout streaming", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("populates the running cell's output with streamed chunks before the cell returns", async () => {
const updates: EvalToolDetails[] = [];
vi.spyOn(evalIndex.jsBackend, "execute").mockImplementation((async (
_code: string,
options: { onChunk?: (chunk: string) => void },
) => {
// Emit a chunk while the cell is still running, mirroring a print()
// before a long sleep. The host's onChunk path runs synchronously.
options.onChunk?.("tick 1\n");
return baseResult({ output: "tick 1\ntick 2\n" });
}) as never);
const tool = new EvalTool(makeSession());
const result = await tool.execute(
"call-stream",
{ language: "js", code: "for (let i = 0; i < 2; i++) print('tick ' + i)" },
undefined,
update => {
if (update.details) updates.push(update.details as EvalToolDetails);
},
);
// A snapshot taken while the cell was still running carried the streamed
// chunk — proving the output bridge fires mid-execution, not just at the end.
const liveRunning = updates.find(
d => d.cells?.[0]?.status === "running" && (d.cells?.[0]?.output ?? "").includes("tick 1"),
);
expect(liveRunning).toBeDefined();
// The live snapshot shows only what streamed so far, not the post-return total.
expect(liveRunning?.cells?.[0]?.output).not.toContain("tick 2");
// Completion still overwrites with the authoritative full output.
const text = result.content.map(c => (c.type === "text" ? c.text : "")).join("\n");
expect(text).toContain("tick 1");
expect(text).toContain("tick 2");
expect(result.details?.cells?.[0]?.status).toBe("complete");
expect(result.details?.cells?.[0]?.output).toContain("tick 2");
});
});
@@ -31,7 +31,9 @@ describe("EvalTool timeout semantics", () => {
// 1s budget; the cell idles for 5s and emits no status, so nothing extends
// the budget — it must be cut off at the wall-clock limit.
const result = await tool.execute("call-compute-timeout", {
cells: [{ language: "js", code: "await Bun.sleep(2000); return 'never';", timeout: 1 }],
language: "js",
code: "await Bun.sleep(2000); return 'never';",
timeout: 1,
});
const text = result.content
@@ -161,7 +161,7 @@ describe("InspectImageTool", () => {
const stub = createCompleteSimpleSuccessStub("Attached image inspected");
const missingCwd = path.join(testDir, "missing-cwd");
const tool = new InspectImageTool(
createSession(missingCwd, visionModel, "test-key", Settings.isolated(), {
createSession(missingCwd, visionModel, "test-key", Settings.isolated({ "images.autoResize": false }), {
imageAttachments: [{ label: "Image #1", uri: "attachment://1", image }],
}),
stub.fn,
@@ -0,0 +1,106 @@
/**
* `local://` is routed through the internal-URL handler, whose resource
* contract is text-only (`content: string`). Before the image fast path, a
* `local://photo.png` read UTF-8-decoded the PNG bytes into mojibake. These
* lock the fix: genuine image files under the session local root decode into an
* inline image block, text files still read as text, and a file symlinked
* outside the local root is rejected by the same realpath guard the router uses
* (the fast path must not become a containment bypass).
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { InternalUrlRouter, LocalProtocolHandler } from "@oh-my-pi/pi-coding-agent/internal-urls";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
// 1x1 transparent PNG — small enough to pass through image loading untouched.
const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
function makeSession(testDir: string): ToolSession {
const sessionFile = path.join(testDir, "session.jsonl");
const artifactsDir = sessionFile.slice(0, -6);
return {
cwd: testDir,
hasUI: false,
getSessionFile: () => sessionFile,
getArtifactsDir: () => artifactsDir,
getSessionSpawns: () => null,
settings: Settings.isolated({ "images.autoResize": false }),
} as unknown as ToolSession;
}
function joinText(content: Array<{ type: string; text?: string }>): string {
return content
.filter(c => c.type === "text")
.map(c => c.text ?? "")
.join("\n");
}
describe("read local:// images", () => {
let testDir: string;
let localRoot: string;
beforeEach(async () => {
LocalProtocolHandler.resetOverrideForTests();
InternalUrlRouter.resetForTests();
testDir = await fs.mkdtemp(path.join(os.tmpdir(), "read-local-image-"));
const artifactsDir = path.join(testDir, "artifacts");
localRoot = path.join(artifactsDir, "local");
await fs.mkdir(localRoot, { recursive: true });
LocalProtocolHandler.setOverride({
getArtifactsDir: () => artifactsDir,
getSessionId: () => "session-local-image",
});
});
afterEach(async () => {
LocalProtocolHandler.resetOverrideForTests();
InternalUrlRouter.resetForTests();
await fs.rm(testDir, { recursive: true, force: true });
});
it("decodes a local:// PNG into an inline image block", async () => {
await Bun.write(path.join(localRoot, "clifford.png"), TINY_PNG);
const tool = new ReadTool(makeSession(testDir));
const result = await tool.execute("call", { path: "local://clifford.png" });
const image = result.content.find(c => c.type === "image");
expect(image).toBeDefined();
expect(image && "mimeType" in image ? image.mimeType : undefined).toBe("image/png");
// The pre-fix bug surfaced the PNG signature byte (0x89) UTF-8-decoded to
// the replacement char; the fixed path must never emit it as text.
expect(joinText(result.content)).not.toContain("\uFFFDPNG");
});
it("still reads a local:// text file as text (fast path falls through)", async () => {
await Bun.write(path.join(localRoot, "notes.txt"), "hello world");
const tool = new ReadTool(makeSession(testDir));
const result = await tool.execute("call", { path: "local://notes.txt" });
expect(result.content.some(c => c.type === "image")).toBe(false);
expect(joinText(result.content)).toContain("hello world");
});
it("does not read an image symlinked outside the local root", async () => {
if (process.platform === "win32") return;
const outsideDir = path.join(testDir, "outside");
await fs.mkdir(outsideDir, { recursive: true });
await Bun.write(path.join(outsideDir, "secret.png"), TINY_PNG);
await fs.symlink(outsideDir, path.join(localRoot, "linked"));
const tool = new ReadTool(makeSession(testDir));
// The realpath/containment guard the router applies must still reject the
// escape; the image fast path must not silently read it.
await expect(tool.execute("call", { path: "local://linked/secret.png" })).rejects.toThrow(
"local:// URL escapes local root",
);
});
});
+78 -115
View File
@@ -59,12 +59,8 @@ describe("TodoTool auto-start behavior", () => {
it("auto-starts the first task after init", async () => {
const tool = new TodoTool(createSession());
const result = await tool.execute("call-1", {
ops: [
{
op: "init",
list: [{ phase: "Execution", items: ["status", "diagnostics"] }],
},
],
op: "init",
list: [{ phase: "Execution", items: ["status", "diagnostics"] }],
});
const tasks = result.details?.phases[0]?.tasks ?? [];
@@ -79,15 +75,11 @@ describe("TodoTool auto-start behavior", () => {
it("auto-promotes the next pending task when current task is completed", async () => {
const tool = new TodoTool(createSession());
await tool.execute("call-1", {
ops: [
{
op: "init",
list: [{ phase: "Execution", items: ["status", "diagnostics"] }],
},
],
op: "init",
list: [{ phase: "Execution", items: ["status", "diagnostics"] }],
});
const result = await tool.execute("call-2", { ops: [{ op: "done", task: "status" }] });
const result = await tool.execute("call-2", { op: "done", task: "status" });
const tasks = result.details?.phases[0]?.tasks ?? [];
expect(tasks.map(task => task.status)).toEqual(["completed", "in_progress"]);
@@ -96,7 +88,7 @@ describe("TodoTool auto-start behavior", () => {
if (summary?.type !== "text") throw new Error("Expected text summary from todo");
expect(summary.text).toContain("Remaining items (1):");
expect(summary.text).toContain("diagnostics [in_progress] (Execution)");
const completedResult = await tool.execute("call-3", { ops: [{ op: "done", task: "diagnostics" }] });
const completedResult = await tool.execute("call-3", { op: "done", task: "diagnostics" });
const completedSummary = completedResult.content.find(part => part.type === "text");
if (completedSummary?.type !== "text") {
throw new Error("Expected text summary from todo");
@@ -107,10 +99,8 @@ describe("TodoTool auto-start behavior", () => {
it("renders completed tasks as checked before revealing strikethrough", async () => {
const tool = new TodoTool(createSession());
await tool.execute("call-1", {
ops: [{ op: "init", list: [{ phase: "Execution", items: ["finish"] }] }],
});
const result = await tool.execute("call-2", { ops: [{ op: "done", task: "finish" }] });
await tool.execute("call-1", { op: "init", list: [{ phase: "Execution", items: ["finish"] }] });
const result = await tool.execute("call-2", { op: "done", task: "finish" });
const options = { expanded: true, isPartial: false, spinnerFrame: 0 };
const component = todoToolRenderer.renderResult(result, options, theme);
@@ -124,19 +114,15 @@ it("renders completed tasks as checked before revealing strikethrough", async ()
expect(revealFrame).toContain("\x1b[9m");
});
describe("TodoTool ops operations", () => {
describe("TodoTool operations", () => {
it("jumps to a specific task out of order", async () => {
const tool = new TodoTool(createSession());
await tool.execute("call-1", {
ops: [
{
op: "init",
list: [{ phase: "Phase A", items: ["first", "second", "third"] }],
},
],
op: "init",
list: [{ phase: "Phase A", items: ["first", "second", "third"] }],
});
const result = await tool.execute("call-2", { ops: [{ op: "start", task: "third" }] });
const result = await tool.execute("call-2", { op: "start", task: "third" });
const tasks = result.details?.phases[0]?.tasks ?? [];
expect(tasks.map(task => task.status)).toEqual(["pending", "pending", "in_progress"]);
@@ -145,18 +131,14 @@ describe("TodoTool ops operations", () => {
it("demotes the current in_progress task when starting another", async () => {
const tool = new TodoTool(createSession());
await tool.execute("call-1", {
ops: [
{
op: "init",
list: [
{ phase: "A", items: ["a1", "a2"] },
{ phase: "B", items: ["b1"] },
],
},
op: "init",
list: [
{ phase: "A", items: ["a1", "a2"] },
{ phase: "B", items: ["b1"] },
],
});
const result = await tool.execute("call-2", { ops: [{ op: "start", task: "b1" }] });
const result = await tool.execute("call-2", { op: "start", task: "b1" });
const allTasks = result.details?.phases.flatMap(phase => phase.tasks) ?? [];
expect(allTasks.map(task => task.status)).toEqual(["pending", "pending", "in_progress"]);
@@ -164,18 +146,12 @@ describe("TodoTool ops operations", () => {
it("appends items to an existing phase", async () => {
const tool = new TodoTool(createSession());
await tool.execute("call-1", {
ops: [{ op: "init", list: [{ phase: "Work", items: ["First"] }] }],
});
await tool.execute("call-1", { op: "init", list: [{ phase: "Work", items: ["First"] }] });
const result = await tool.execute("call-2", {
ops: [
{
op: "append",
phase: "Work",
items: ["Second"],
},
],
op: "append",
phase: "Work",
items: ["Second"],
});
const tasks = result.details?.phases[0]?.tasks ?? [];
@@ -187,18 +163,12 @@ describe("TodoTool ops operations", () => {
it("creates a phase when append targets a missing phase", async () => {
const tool = new TodoTool(createSession());
await tool.execute("call-1", {
ops: [{ op: "init", list: [{ phase: "Work", items: ["First"] }] }],
});
await tool.execute("call-1", { op: "init", list: [{ phase: "Work", items: ["First"] }] });
const result = await tool.execute("call-2", {
ops: [
{
op: "append",
phase: "Cleanup",
items: ["Remove dead code"],
},
],
op: "append",
phase: "Cleanup",
items: ["Remove dead code"],
});
expect(result.details?.phases.map(phase => phase.name)).toEqual(["Work", "Cleanup"]);
@@ -208,18 +178,14 @@ describe("TodoTool ops operations", () => {
it("marks all tasks in a phase done", async () => {
const tool = new TodoTool(createSession());
await tool.execute("call-1", {
ops: [
{
op: "init",
list: [
{ phase: "Work", items: ["First", "Second"] },
{ phase: "Later", items: ["Third"] },
],
},
op: "init",
list: [
{ phase: "Work", items: ["First", "Second"] },
{ phase: "Later", items: ["Third"] },
],
});
const result = await tool.execute("call-2", { ops: [{ op: "done", phase: "Work" }] });
const result = await tool.execute("call-2", { op: "done", phase: "Work" });
const allTasks = result.details?.phases.flatMap(phase => phase.tasks) ?? [];
expect(allTasks.map(task => task.status)).toEqual(["completed", "completed", "in_progress"]);
});
@@ -227,15 +193,11 @@ describe("TodoTool ops operations", () => {
it("removes all tasks when rm omits task and phase", async () => {
const tool = new TodoTool(createSession());
await tool.execute("call-1", {
ops: [
{
op: "init",
list: [{ phase: "Work", items: ["First", "Second"] }],
},
],
op: "init",
list: [{ phase: "Work", items: ["First", "Second"] }],
});
const result = await tool.execute("call-2", { ops: [{ op: "rm" }] });
const result = await tool.execute("call-2", { op: "rm" });
expect(result.details?.phases[0]?.tasks).toEqual([]);
const summary = result.content.find(part => part.type === "text");
if (summary?.type !== "text") throw new Error("Expected text summary");
@@ -245,15 +207,11 @@ describe("TodoTool ops operations", () => {
it("drops all tasks in a phase", async () => {
const tool = new TodoTool(createSession());
await tool.execute("call-1", {
ops: [
{
op: "init",
list: [{ phase: "Work", items: ["First", "Second"] }],
},
],
op: "init",
list: [{ phase: "Work", items: ["First", "Second"] }],
});
const result = await tool.execute("call-2", { ops: [{ op: "drop", phase: "Work" }] });
const result = await tool.execute("call-2", { op: "drop", phase: "Work" });
const tasks = result.details?.phases[0]?.tasks ?? [];
expect(tasks.map(task => task.status)).toEqual(["abandoned", "abandoned"]);
});
@@ -270,7 +228,7 @@ describe("TodoTool ops operations", () => {
]);
const tool = new TodoTool(session);
const result = await tool.execute("call-1", { ops: [{ op: "view" }] });
const result = await tool.execute("call-1", { op: "view" });
const tasks = result.details?.phases[0]?.tasks ?? [];
expect(tasks.map(task => task.status)).toEqual(["pending", "pending"]);
@@ -284,7 +242,7 @@ describe("TodoTool ops operations", () => {
it("view on an empty list reports empty, not cleared", async () => {
const tool = new TodoTool(createSession());
const result = await tool.execute("call-1", { ops: [{ op: "view" }] });
const result = await tool.execute("call-1", { op: "view" });
const summary = result.content.find(part => part.type === "text");
if (summary?.type !== "text") throw new Error("Expected text summary");
expect(summary.text).toContain("Todo list is empty.");
@@ -295,9 +253,7 @@ describe("TodoTool ops operations", () => {
describe("TodoTool lenient init shapes", () => {
it("accepts a flattened init with bare items and no phase", async () => {
const tool = new TodoTool(createSession());
const result = await tool.execute("call-1", {
ops: [{ op: "init", items: ["First", "Second"] }],
});
const result = await tool.execute("call-1", { op: "init", items: ["First", "Second"] });
expect(result.isError).toBeUndefined();
expect(result.details?.phases.map(phase => phase.name)).toEqual(["Tasks"]);
@@ -310,9 +266,7 @@ describe("TodoTool lenient init shapes", () => {
it("honors a bare phase on a flattened init", async () => {
const tool = new TodoTool(createSession());
const result = await tool.execute("call-1", {
ops: [{ op: "init", phase: "Cleanup", items: ["Remove dead code"] }],
});
const result = await tool.execute("call-1", { op: "init", phase: "Cleanup", items: ["Remove dead code"] });
expect(result.isError).toBeUndefined();
expect(result.details?.phases.map(phase => phase.name)).toEqual(["Cleanup"]);
@@ -321,7 +275,7 @@ describe("TodoTool lenient init shapes", () => {
it("still errors when init has neither list nor items", async () => {
const tool = new TodoTool(createSession());
const result = await tool.execute("call-1", { ops: [{ op: "init" }] });
const result = await tool.execute("call-1", { op: "init" });
expect(result.isError).toBe(true);
const summary = result.content.find(part => part.type === "text");
@@ -443,20 +397,16 @@ describe("todoToolRenderer.renderResult phase collapsing", () => {
async function buildThreePhaseAfterDone() {
const tool = new TodoTool(createSession());
await tool.execute("init", {
ops: [
{
op: "init",
list: [
{ phase: "Alpha", items: ["a1", "a2"] },
{ phase: "Beta", items: ["b1", "b2"] },
{ phase: "Gamma", items: ["c1", "c2"] },
],
},
op: "init",
list: [
{ phase: "Alpha", items: ["a1", "a2"] },
{ phase: "Beta", items: ["b1", "b2"] },
{ phase: "Gamma", items: ["c1", "c2"] },
],
});
// `done a1` keeps the active task inside Alpha (auto-promotes a2), leaving
// Beta and Gamma untouched by this update.
return tool.execute("done", { ops: [{ op: "done", task: "a1" }] });
return tool.execute("done", { op: "done", task: "a1" });
}
function innerLines(component: Component): string[] {
const lines = Bun.stripANSI(component.render(100).join("\n")).split("\n");
@@ -465,7 +415,8 @@ describe("todoToolRenderer.renderResult phase collapsing", () => {
it("collapses untouched phases to a one-line summary while expanding the active phase", async () => {
const result = await buildThreePhaseAfterDone();
const component = todoToolRenderer.renderResult(result, { expanded: false, isPartial: false }, theme, {
ops: [{ op: "done", task: "a1" }],
op: "done",
task: "a1",
});
const rendered = Bun.stripANSI(component.render(100).join("\n"));
// Active phase renders its full task list.
@@ -493,7 +444,8 @@ describe("todoToolRenderer.renderResult phase collapsing", () => {
it("shows every phase fully when manually expanded", async () => {
const result = await buildThreePhaseAfterDone();
const component = todoToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme, {
ops: [{ op: "done", task: "a1" }],
op: "done",
task: "a1",
});
const rendered = Bun.stripANSI(component.render(100).join("\n"));
expect(rendered).toContain("b1");
@@ -504,7 +456,8 @@ describe("todoToolRenderer.renderResult phase collapsing", () => {
it("drops blank separator lines between phases", async () => {
const result = await buildThreePhaseAfterDone();
const component = todoToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme, {
ops: [{ op: "done", task: "a1" }],
op: "done",
task: "a1",
});
// No empty body line survives between phases.
expect(innerLines(component).every(line => line.length > 0)).toBe(true);
@@ -520,27 +473,39 @@ describe("todoToolRenderer.renderCall malformed-args regression (#2005)", () =>
// retry cascade.
const renderOptions = { expanded: false, isPartial: true } as const;
it("does not throw when ops is a streaming-truncated string", () => {
// Mid-stream `partialJson === '{"ops":"[{'` parses into `{ops: "[{"}`.
const args = { ops: '[{"op":"init"' } as unknown as Parameters<typeof todoToolRenderer.renderCall>[0];
it("does not throw when op is a streaming-truncated number", () => {
// Mid-stream the new flat shape can surface `{ op: 1 }` before the
// discriminator string lands.
const args = { op: 1 } as unknown as Parameters<typeof todoToolRenderer.renderCall>[0];
expect(() => todoToolRenderer.renderCall(args, renderOptions, theme)).not.toThrow();
});
it("does not throw when ops entries are null", () => {
// `partialParse` of `'{"ops":[null'` can hand back `{ops: [null]}` in
// intermediate states before the entry object opens.
const args = { ops: [null] } as unknown as Parameters<typeof todoToolRenderer.renderCall>[0];
expect(() => todoToolRenderer.renderCall(args, renderOptions, theme)).not.toThrow();
});
it("does not throw when an entry's items field is a non-array", () => {
it("does not throw when a flat op's items field is a non-array", () => {
const args = {
ops: [{ op: "append", phase: "Work", items: "Second" as unknown as string[] }],
op: "append",
phase: "Work",
items: "Second" as unknown as string[],
} as unknown as Parameters<typeof todoToolRenderer.renderCall>[0];
expect(() => todoToolRenderer.renderCall(args, renderOptions, theme)).not.toThrow();
});
it("still renders ops summary metadata for well-formed args", () => {
it("does not throw on the legacy streaming-truncated `ops` string", () => {
// Old transcripts/collab-web still carry `{ ops: "[{" }` mid-stream;
// `normalizeTodoArg` must keep tolerating the legacy batch shape.
const args = { ops: '[{"op":"init"' } as unknown as Parameters<typeof todoToolRenderer.renderCall>[0];
expect(() => todoToolRenderer.renderCall(args, renderOptions, theme)).not.toThrow();
});
it("renders op summary metadata for a well-formed flat call", () => {
const args = { op: "init", items: ["a", "b", "c"] };
const component = todoToolRenderer.renderCall(args, renderOptions, theme);
// `Text(text, 0, 0)` from `@oh-my-pi/pi-tui` exposes the content via .render().
const rendered = Bun.stripANSI(component.render(120).join("\n"));
expect(rendered).toContain("init");
expect(rendered).toContain("3 items");
});
it("still renders legacy multi-op `ops` arrays from old transcripts", () => {
const args = {
ops: [
{ op: "init", items: ["a", "b", "c"] },
@@ -549,12 +514,10 @@ describe("todoToolRenderer.renderCall malformed-args regression (#2005)", () =>
],
};
const component = todoToolRenderer.renderCall(args, renderOptions, theme);
// `Text(text, 0, 0)` from `@oh-my-pi/pi-tui` exposes the content via .render().
const rendered = Bun.stripANSI(component.render(120).join("\n"));
expect(rendered).toContain("init");
expect(rendered).toContain("3 items");
expect(rendered).toContain("done");
expect(rendered).toContain("a");
expect(rendered).toContain("append");
expect(rendered).toContain("Cleanup");
expect(rendered).toContain("1 item");
@@ -3,11 +3,13 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import {
buildExaRequestBody,
ExaProvider,
normalizeSearchType,
resetExaSearchThrottleForTest,
searchExa,
synthesizeAnswer,
} from "@oh-my-pi/pi-coding-agent/web/search/providers/exa";
@@ -214,13 +216,18 @@ function makeMockExaResponse(overrides: Record<string, unknown> = {}) {
describe("searchExa", () => {
let capturedRequestBody: Record<string, unknown> | null = null;
beforeEach(() => {
beforeEach(async () => {
resetSettingsForTest();
resetExaSearchThrottleForTest();
await Settings.init({ inMemory: true, overrides: { "exa.searchDelayMs": 0 } });
capturedRequestBody = null;
process.env.EXA_API_KEY = "test-key-123";
});
afterEach(() => {
vi.restoreAllMocks();
resetExaSearchThrottleForTest();
resetSettingsForTest();
delete process.env.EXA_API_KEY;
});
@@ -290,6 +297,90 @@ describe("searchExa", () => {
});
});
it("paces consecutive Exa API requests by the configured delay", async () => {
resetSettingsForTest();
resetExaSearchThrottleForTest();
await Settings.init({ inMemory: true, overrides: { "exa.searchDelayMs": 25 } });
const requestTimes: number[] = [];
const fetchMock: FetchImpl = (_url, init) => {
requestTimes.push(Date.now());
if (init?.body) {
capturedRequestBody = JSON.parse(init.body as string);
}
return Promise.resolve(
new Response(JSON.stringify(makeMockExaResponse()), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
};
await searchExa({ query: "first paced request", fetch: fetchMock });
await searchExa({ query: "second paced request", fetch: fetchMock });
expect(requestTimes).toHaveLength(2);
expect(requestTimes[1] - requestTimes[0]).toBeGreaterThanOrEqual(20);
});
it("aborts while waiting for the configured Exa request delay", async () => {
resetSettingsForTest();
resetExaSearchThrottleForTest();
await Settings.init({ inMemory: true, overrides: { "exa.searchDelayMs": 1_000 } });
let fetchCount = 0;
const fetchMock: FetchImpl = () => {
fetchCount += 1;
return Promise.resolve(
new Response(JSON.stringify(makeMockExaResponse()), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
};
await searchExa({ query: "first request", fetch: fetchMock });
const controller = new AbortController();
const startedAt = Date.now();
const pending = searchExa({ query: "cancelled request", fetch: fetchMock, signal: controller.signal });
await Bun.sleep(0);
controller.abort(new Error("cancelled Exa throttle wait"));
await expect(pending).rejects.toThrow("cancelled Exa throttle wait");
expect(Date.now() - startedAt).toBeLessThan(250);
expect(fetchCount).toBe(1);
});
it("aborts while queued behind another Exa throttle wait", async () => {
resetSettingsForTest();
resetExaSearchThrottleForTest();
await Settings.init({ inMemory: true, overrides: { "exa.searchDelayMs": 1_000 } });
let fetchCount = 0;
const fetchMock: FetchImpl = () => {
fetchCount += 1;
return Promise.resolve(
new Response(JSON.stringify(makeMockExaResponse()), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
};
await searchExa({ query: "first request", fetch: fetchMock });
const secondController = new AbortController();
const thirdController = new AbortController();
const second = searchExa({ query: "second request", fetch: fetchMock, signal: secondController.signal });
const startedAt = Date.now();
const third = searchExa({ query: "third request", fetch: fetchMock, signal: thirdController.signal });
await Bun.sleep(0);
thirdController.abort(new Error("cancelled queued Exa throttle wait"));
await expect(third).rejects.toThrow("cancelled queued Exa throttle wait");
expect(Date.now() - startedAt).toBeLessThan(250);
expect(fetchCount).toBe(1);
secondController.abort(new Error("cleanup second Exa throttle wait"));
await expect(second).rejects.toThrow("cleanup second Exa throttle wait");
});
it("prefers summary over text for snippet field", async () => {
const result = await searchExa({
query: "snippet test",
@@ -25,8 +25,8 @@ async function makeRedWebP(width: number, height: number): Promise<string> {
// its input, so a single decodable source per shape serves every test. Real image
// encode/decode is the only cost here, so each source is the smallest solid-red
// image that still crosses the threshold under test:
// - oversizedPng: a thin strip whose long edge exceeds the 1568 default cap, so
// re-encodes touch ~1568×98 px instead of 1568×1568. A uniform red keeps format
// - oversizedPng: a strip whose long edge exceeds the 1568 default cap, so
// re-encodes touch ~1568×392 px instead of 1568×1568. A uniform red keeps format
// selection deterministic (WebP is always smallest; PNG always beats JPEG), so
// the strip exercises the same format/budget logic as a large square.
// - smallPng / smallWebp: 200×200, comfortably inside every default cap (fast path).
@@ -36,7 +36,7 @@ let smallWebp: string;
beforeAll(async () => {
[oversizedPng, smallPng, smallWebp] = await Promise.all([
makeRedPng(1600, 100),
makeRedPng(1600, 400),
makeRedPng(200, 200),
makeRedWebP(200, 200),
]);
@@ -50,8 +50,8 @@ describe("resizeImage defaults", () => {
expect(result.wasResized).toBe(true);
expect(result.width).toBeLessThanOrEqual(1568);
expect(result.height).toBeLessThanOrEqual(1568);
// Aspect ratio of the 1600x100 source preserved (with rounding tolerance).
expect(Math.abs(result.width / result.height - 1600 / 100)).toBeLessThan(0.01);
// Aspect ratio of the 1600x400 source preserved (with rounding tolerance).
expect(Math.abs(result.width / result.height - 1600 / 400)).toBeLessThan(0.01);
});
it("preserves inputs already within budget and dimensions (fast path)", async () => {
@@ -128,6 +128,50 @@ describe("resizeImage defaults", () => {
});
});
describe("resizeImage minimum dimension", () => {
it("upscales a degenerate 1x1 image up to the 200px floor", async () => {
// A 1x1 PNG (e.g. an empty chart render) would sail through the fast path
// untouched and trip a provider 400 "Could not process image".
const result = await resizeImage({ type: "image", data: RED_1X1_PNG_BASE64, mimeType: "image/png" });
expect(result.wasResized).toBe(true);
// Square source stays square at the floor.
expect(result.width).toBe(200);
expect(result.height).toBe(200);
// The encoded bytes actually carry those dimensions.
const meta = await new Bun.Image(Buffer.from(result.data, "base64")).metadata();
expect(meta.width).toBeGreaterThanOrEqual(200);
expect(meta.height).toBeGreaterThanOrEqual(200);
});
it("honors a custom minDimension override", async () => {
const result = await resizeImage(
{ type: "image", data: RED_1X1_PNG_BASE64, mimeType: "image/png" },
{ minDimension: 64 },
);
expect(result.width).toBe(64);
expect(result.height).toBe(64);
});
it("stretches a degenerate aspect ratio so both edges clear the floor and stay within the cap", async () => {
// 1x1600 strip: the cap pulls the long edge to 1568 while the short edge
// stays at 1px, so a uniform scale can't satisfy both bounds — the floor
// must be reached by fill-stretching the short edge.
const strip = await makeRedPng(1, 1600);
const result = await resizeImage({ type: "image", data: strip, mimeType: "image/png" });
expect(result.wasResized).toBe(true);
expect(result.width).toBeGreaterThanOrEqual(200);
expect(result.height).toBeGreaterThanOrEqual(200);
expect(result.width).toBeLessThanOrEqual(1568);
expect(result.height).toBeLessThanOrEqual(1568);
const meta = await new Bun.Image(Buffer.from(result.data, "base64")).metadata();
expect(meta.width).toBeGreaterThanOrEqual(200);
expect(meta.height).toBeGreaterThanOrEqual(200);
});
});
describe("resizeImage env wiring", () => {
const prior = Bun.env.OMP_NO_WEBP;