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:
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+166
@@ -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);
|
||||
});
|
||||
});
|
||||
-284
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user