diff --git a/CHANGELOG.md b/CHANGELOG.md index f7594c3..60ecde1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [6.8.0] - 2026-08-28 + +### Changed + +- **Ported the 6.8.2 coding-harness update from the Orbital extension.** + - `search_files` is now one-shot: ripgrep-first with FFF fallback, results bounded to the first 100 matches (default `max_results` 100), and cursor pagination removed from the model-facing schema and output. Capped results tell the model to refine the query instead of paginating. + - Independent read-only tool calls (`read_file`, `search_files`, `list_files`, `list_code_definition_names`, `codebase_search`, `lsp`) at the start of an assistant response now execute concurrently (max 4) with results committed in model order; mutating and interactive tools stay serialized. + - Malformed tool-call JSON now returns a corrective tool result that includes the raw arguments, so the model can re-issue the call with valid JSON instead of dead-ending. + - Native tool schemas tightened for strict mode: optional parameters are now required with nullable types (`replace_all`, `recursive`, `follow_up`, `offset`/`limit`, `cwd`/`message`/`isDangerous`, and the inactive-in-CLI tool schemas), and `execute_command` guidance asks for an explicit safety classification. + - System-prompt `search_files` guidance updated to the bounded one-shot behavior. ### Added diff --git a/package.json b/package.json index 1ae4b5f..7dce595 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.7.9", + "version": "6.8.0", "description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI", "type": "module", "bin": { diff --git a/src/core/agent.ts b/src/core/agent.ts index 625d1c8..dcf0cec 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -50,6 +50,20 @@ import { const MAX_STEPS_PER_TURN = 50 const RESULT_PREVIEW_LINES = 6 +/** Maximum number of independent read-only tools started at once. */ +const MAX_PARALLEL_READ_ONLY_TOOLS = 4 + +/** These tools only observe repository state, so a leading run of them in one + * assistant response can execute concurrently. Mutating, interactive, and + * external tools stay serialized. */ +const PARALLEL_READ_ONLY_TOOLS = new Set([ + "read_file", + "search_files", + "list_files", + "list_code_definition_names", + "codebase_search", + "lsp", +]) /** How many times to automatically re-establish a model request that fails * before producing any output (transient/connection errors). */ const MAX_STREAM_RETRIES = 3 @@ -1147,13 +1161,46 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` } let completed = false - for (const toolCall of toolCalls) { + const runToolCall = async (toolCall: PendingToolCall): Promise => { const resultText = await this.handleToolCall(toolCall) this.messages.push({ role: "tool", tool_call_id: toolCall.id, content: resultText }) if (toolCall.name === "attempt_completion") { completed = true } } + + // Independent read-only calls (the leading run of the response) execute + // concurrently, at most MAX_PARALLEL_READ_ONLY_TOOLS at a time. Results are + // committed in model order so tool_call/tool_result pairing stays intact; + // mutating and interactive calls remain on the serialized path. + let batchEnd = 0 + while (batchEnd < toolCalls.length && PARALLEL_READ_ONLY_TOOLS.has(toolCalls[batchEnd].name)) { + batchEnd++ + } + + if (batchEnd > 1) { + const batch = toolCalls.slice(0, batchEnd) + const results = new Array(batch.length) + let nextIndex = 0 + await Promise.all( + Array.from({ length: Math.min(MAX_PARALLEL_READ_ONLY_TOOLS, batch.length) }, async () => { + while (nextIndex < batch.length) { + const index = nextIndex++ + results[index] = await this.handleToolCall(batch[index]) + } + }), + ) + for (const [index, toolCall] of batch.entries()) { + this.messages.push({ role: "tool", tool_call_id: toolCall.id, content: results[index] }) + } + } else { + for (let index = 0; index < batchEnd; index++) { + await runToolCall(toolCalls[index]) + } + } + for (let index = batchEnd; index < toolCalls.length; index++) { + await runToolCall(toolCalls[index]) + } return completed } @@ -1164,7 +1211,11 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` try { args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {} } catch (error) { - const message = `Invalid JSON arguments for ${toolCall.name}: ${(error as Error).message}` + // Recover instead of dead-ending: the error result carries the raw + // arguments so the model can re-issue the call with valid, complete JSON. + const rawArgs = toolCall.arguments.trim() + const preview = rawArgs.length > 500 ? `${rawArgs.slice(0, 500)}...(truncated)` : rawArgs + const message = `Malformed tool call JSON for ${toolCall.name}: ${(error as Error).message}. The raw arguments were:\n\n${preview}\n\nPlease re-issue the tool call with valid, complete JSON arguments.` onEvent({ type: "tool-end", id: toolCall.id, diff --git a/src/prompts/system.ts b/src/prompts/system.ts index e5e23f7..ec27bfd 100644 --- a/src/prompts/system.ts +++ b/src/prompts/system.ts @@ -185,19 +185,17 @@ Command validity rules: a command is never empty, never just \`:\`, never a bare ## search_files -Search file contents using a Rust-compatible regex. Results are compact, limited to three matches per file, and paginated. +Search file contents using a Rust-compatible regex. Results are compact and bounded to the first 100 matches; refine the query instead of paginating. ### Parameters 1. **path** (string, required): Directory to search recursively, relative to workspace 2. **regex** (string, required): Rust-compatible regular expression pattern 3. **file_pattern** (string or null, required): Glob pattern to filter files OR null -4. **cursor** (string or null, required): Copy the opaque cursor from the same search exactly, or pass JSON null without quotes for the first page -5. **max_results** (integer or null, required): Target 1-100 results; null defaults to 50 -6. **context_lines** (integer or null, required): 0-2 surrounding lines; null defaults to 0 +4. **max_results** (integer or null, required): Target 1-100 results; null defaults to 100 +5. **context_lines** (integer or null, required): 0-2 surrounding lines; null defaults to 0 -Use zero context for discovery, then read the relevant file region. Reuse a cursor only with the same path, regex, and file pattern; never invent or edit one. -If \`Next cursor\` is \`none\`, the search is complete: stop and never pass the word \`none\`. If a result says \`Restarted: yes\`, the FFF continuation failed and ripgrep restarted at page one, so account for repeated matches and continue only with the new cursor. +Use zero context for discovery, then read the relevant file region. If results are capped, refine the path, regex, or file pattern. ### Search Hygiene diff --git a/src/tools/executors/searchFiles.ts b/src/tools/executors/searchFiles.ts index b4a0435..d8352b2 100644 --- a/src/tools/executors/searchFiles.ts +++ b/src/tools/executors/searchFiles.ts @@ -63,33 +63,31 @@ export async function searchFiles(args: Record, context: ToolCo const options = parseSearchOptions(args, fingerprint) let page: SearchPage - if (options.cursor?.engine === "ripgrep") { - page = await searchFilesWithRipgrep(context.cwd, directoryPath, regex, filePattern, options) + if (options.cursor?.engine === "fff") { + page = await searchFilesWithFff(context.cwd, directoryPath, regex, filePattern, options) } else { try { - page = await searchFilesWithFff(context.cwd, directoryPath, regex, filePattern, options) + // Ripgrep is the fast, deterministic default used by coding agents. FFF + // remains available as a fallback for installations where the bundled + // ripgrep binary is unavailable or cannot execute the requested pattern. + page = await searchFilesWithRipgrep(context.cwd, directoryPath, regex, filePattern, options) } catch (error) { const message = error instanceof Error ? error.message : String(error) - const restarted = options.cursor?.engine === "fff" - page = await searchFilesWithRipgrep(context.cwd, directoryPath, regex, filePattern, { + page = await searchFilesWithFff(context.cwd, directoryPath, regex, filePattern, { ...options, cursor: null, }) - page.warning = restarted - ? `FFF continuation failed; ripgrep fallback restarted from the first page and may repeat earlier results (${message})` - : `FFF failed; used ripgrep fallback (${message})` - page.restarted = restarted + page.warning = `ripgrep failed; used FFF fallback (${message})` } } const output = formatSearchPage(page) - // forked_change: append guidance when a search returns no matches, - // steering the model toward tightening/loosening the regex or scoping - // the path instead of blindly retrying with a slightly different pattern. + // Append guidance when a search returns no matches so the model changes + // the query instead of repeating the same search unchanged. if (page.matches.length === 0) { return { - text: output + "\n\nNo matches found. Before retrying:\n- Tighten or simplify the regex (e.g. use a shorter, more specific pattern).\n- Widen the path scope (e.g. search from the repo root instead of a subdirectory).\n- Try a different file_pattern glob.\n- If you have already searched 2+ times with no results, stop searching and reason from what you already know.", + text: output + "\n\nNo matches found. Change the regex, path, or file_pattern before retrying; do not repeat this unchanged search.", } } diff --git a/src/tools/executors/searchFiles/format.ts b/src/tools/executors/searchFiles/format.ts index 9702bd0..7572a7a 100644 --- a/src/tools/executors/searchFiles/format.ts +++ b/src/tools/executors/searchFiles/format.ts @@ -25,7 +25,7 @@ export function stripSearchPageMetadataForDisplay(text: string): string { let firstVisibleLine = 0 while ( firstVisibleLine < lines.length && - /^(?:Engine|Matches|Next cursor|Restarted|Warning):/.test(lines[firstVisibleLine]) + /^(?:Engine|Matches|Next cursor|Restarted|Warning):|^Additional matches omitted/.test(lines[firstVisibleLine]) ) { firstVisibleLine++ } @@ -35,9 +35,10 @@ export function stripSearchPageMetadataForDisplay(text: string): string { export function formatSearchPage(page: SearchPage): string { const cursor = serializeSearchCursor(page.nextCursor) - const nextCursor = cursor ?? "none (search complete; do not continue)" - const header = [`Engine: ${page.engine}`, `Matches: ${page.matches.length}`, `Next cursor: ${nextCursor}`] - if (page.restarted) header.push("Restarted: yes") + const header = [`Engine: ${page.engine}`, `Matches: ${page.matches.length}`] + if (cursor) { + header.push("Additional matches omitted; refine the search pattern or path instead of paginating.") + } if (page.warning) header.push(`Warning: ${page.warning}`) if (page.matches.length === 0) return header.join("\n") diff --git a/src/tools/executors/searchFiles/types.ts b/src/tools/executors/searchFiles/types.ts index 2eda538..87d1077 100644 --- a/src/tools/executors/searchFiles/types.ts +++ b/src/tools/executors/searchFiles/types.ts @@ -1,7 +1,9 @@ import { createHash } from "node:crypto" import * as path from "node:path" -export const DEFAULT_SEARCH_RESULTS = 50 +// Keep the model-facing search operation one-shot. A larger first page is +// cheaper than forcing the model through cursor continuation turns. +export const DEFAULT_SEARCH_RESULTS = 100 export const MAX_SEARCH_RESULTS = 100 export const MAX_MATCHES_PER_FILE = 3 export const MAX_SEARCH_CONTEXT_LINES = 2 @@ -48,7 +50,6 @@ export interface SearchPage { matches: SearchMatch[] nextCursor: SearchCursor | null warning?: string - restarted?: boolean } export interface SearchOptions { diff --git a/src/tools/schemas/ask_followup_question.ts b/src/tools/schemas/ask_followup_question.ts index 89ce524..d2ef7df 100644 --- a/src/tools/schemas/ask_followup_question.ts +++ b/src/tools/schemas/ask_followup_question.ts @@ -38,7 +38,7 @@ export default { maxItems: 4, }, }, - required: ["question"], + required: ["question", "follow_up"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/browser_action.ts b/src/tools/schemas/browser_action.ts index 5e3378a..6f5df50 100644 --- a/src/tools/schemas/browser_action.ts +++ b/src/tools/schemas/browser_action.ts @@ -57,7 +57,7 @@ export default { description: "Text to type when performing the type action", }, }, - required: ["action"], + required: ["action", "url", "coordinate", "size", "text"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/check_past_chat_memories.ts b/src/tools/schemas/check_past_chat_memories.ts index 6b17d8a..a41297f 100644 --- a/src/tools/schemas/check_past_chat_memories.ts +++ b/src/tools/schemas/check_past_chat_memories.ts @@ -15,11 +15,11 @@ export default { description: "Regular expression pattern to search memory contents", }, workspace: { - type: "string", + type: ["string", "null"], description: "Filter by workspace directory (optional, defaults to current workspace)", }, }, - required: ["regex"], + required: ["regex", "workspace"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/codebase_search.ts b/src/tools/schemas/codebase_search.ts index 2764fe2..6ca1692 100644 --- a/src/tools/schemas/codebase_search.ts +++ b/src/tools/schemas/codebase_search.ts @@ -19,7 +19,7 @@ export default { description: "Optional subdirectory (relative to the workspace) to limit the search scope", }, }, - required: ["query"], + required: ["query", "path"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/execute_command.ts b/src/tools/schemas/execute_command.ts index e6210e8..a6cd0b5 100644 --- a/src/tools/schemas/execute_command.ts +++ b/src/tools/schemas/execute_command.ts @@ -5,7 +5,7 @@ export default { function: { name: "execute_command", description: - "Run a CLI command on the user's system. Tailor the command to the environment, explain what it does, and prefer relative paths or shell-appropriate chaining. Use the cwd parameter only when directed to run in a different directory.", + "Run one CLI command. Provide a short user-facing message and explicitly classify whether it may modify or delete data. Prefer commands scoped to the workspace.", strict: true, parameters: { type: "object", @@ -14,22 +14,21 @@ export default { type: "string", description: "Shell command to execute", }, - cwd: { - type: ["string", "null"], - description: "Optional working directory for the command, relative or absolute", - }, - message: { - type: "string", - description: - "A clear, concise one-line description of what the command does, shown to the user for approval (e.g. 'Install project dependencies with npm')", - }, + cwd: { + type: ["string", "null"], + description: "Working directory, or null for the workspace directory", + }, + message: { + type: "string", + description: "Clear one-line description shown to the user for approval", + }, isDangerous: { type: "boolean", description: "Set true when the command is potentially destructive or irreversible — e.g. deletes/overwrites files (rm, mv over existing paths), force-pushes or resets git history, drops/migrates databases, changes system/network/permission state, installs globally, or sends data to external services. Set false for safe read-only or routine commands (ls, cat, build, test, install local deps). The user's selected approval mode may auto-approve only commands marked false.", }, }, - required: ["command"], + required: ["command", "cwd", "message", "isDangerous"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/file_edit.ts b/src/tools/schemas/file_edit.ts index 3e01651..2b7504e 100644 --- a/src/tools/schemas/file_edit.ts +++ b/src/tools/schemas/file_edit.ts @@ -24,13 +24,13 @@ export default { description: "Replacement text. This will be inserted in place of the matched section. Can be an empty string to delete the match.", }, - replace_all: { - type: "boolean", - description: - "Set to true to replace every occurrence of the matched text. Defaults to false (replace a single uniquely identified occurrence).", - }, + replace_all: { + type: ["boolean", "null"], + description: + "Pass false (or null) unless the requested change intentionally applies to every occurrence. Never use it to bypass an ambiguity error.", }, - required: ["file_path", "old_string", "new_string"], + }, + required: ["file_path", "old_string", "new_string", "replace_all"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/generate_image.ts b/src/tools/schemas/generate_image.ts index 5a437c5..fb5fef8 100644 --- a/src/tools/schemas/generate_image.ts +++ b/src/tools/schemas/generate_image.ts @@ -25,7 +25,7 @@ export default { "Optional path (relative to the workspace) to an existing image to edit; supports PNG, JPG, JPEG, GIF, and WEBP", }, }, - required: ["prompt", "path"], + required: ["prompt", "path", "image"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/list_files.ts b/src/tools/schemas/list_files.ts index 163981b..5a2f9a8 100644 --- a/src/tools/schemas/list_files.ts +++ b/src/tools/schemas/list_files.ts @@ -19,7 +19,7 @@ export default { description: "Set true to list contents recursively; omit or false to show only the top level", }, }, - required: ["path"], + required: ["path", "recursive"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/lsp.ts b/src/tools/schemas/lsp.ts index 94e34c8..707cfd5 100644 --- a/src/tools/schemas/lsp.ts +++ b/src/tools/schemas/lsp.ts @@ -4,7 +4,7 @@ export default { type: "function", function: { name: "lsp", - description: `Interact with Language Server Protocol (LSP) servers to get code intelligence features like go-to-definition, find-references, hover information, and symbol search. + description: `Use language-server code intelligence when textual search is ambiguous. Supported operations are go_to_definition, find_references, hover, document_symbol, and workspace_symbol. Supported operations: - go_to_definition: Find where a symbol is defined @@ -13,10 +13,7 @@ Supported operations: - document_symbol: Get all symbols (functions, classes, variables) in a document - workspace_symbol: Search for symbols across the entire workspace -All operations require: -- file_path: The absolute path to the file to operate on -- line: The line number (1-based, as shown in editors) -- character: The character offset (1-based, as shown in editors) +Position-based operations require file_path, line, and character. document_symbol and workspace_symbol also accept a position to identify the document or symbol query. Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.`, strict: true, diff --git a/src/tools/schemas/multi_file_edit.ts b/src/tools/schemas/multi_file_edit.ts index c60cd90..7cb4d7d 100644 --- a/src/tools/schemas/multi_file_edit.ts +++ b/src/tools/schemas/multi_file_edit.ts @@ -32,13 +32,13 @@ export default { description: "Replacement text. This will be inserted in place of the matched section. Can be an empty string to delete the match.", }, - replace_all: { - type: "boolean", - description: - "Set to true to replace every occurrence of the matched text. Defaults to false (replace a single uniquely identified occurrence).", - }, + replace_all: { + type: ["boolean", "null"], + description: + "Pass false (or null) unless the requested change intentionally applies to every occurrence. Never use it to bypass an ambiguity error.", }, - required: ["file_path", "old_string", "new_string"], + }, + required: ["file_path", "old_string", "new_string", "replace_all"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/new_task.ts b/src/tools/schemas/new_task.ts index 3f48e51..7bcb386 100644 --- a/src/tools/schemas/new_task.ts +++ b/src/tools/schemas/new_task.ts @@ -24,7 +24,7 @@ export default { "Optional initial todo list written as a markdown checklist; required when the workspace mandates todos", }, }, - required: ["mode", "message"], + required: ["mode", "message", "todos"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/read_file.ts b/src/tools/schemas/read_file.ts index 2afb1e3..e1fca26 100644 --- a/src/tools/schemas/read_file.ts +++ b/src/tools/schemas/read_file.ts @@ -37,7 +37,7 @@ export const read_file = { "Lines to read from offset. Prefer 500-1000. Use null to read from offset up to the 1000-line cap.", }, }, - required: ["file_path"], + required: ["file_path", "offset", "limit"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/run_slash_command.ts b/src/tools/schemas/run_slash_command.ts index 68ba2d4..7a3e78e 100644 --- a/src/tools/schemas/run_slash_command.ts +++ b/src/tools/schemas/run_slash_command.ts @@ -19,7 +19,7 @@ export default { description: "Optional additional context or arguments for the command", }, }, - required: ["command"], + required: ["command", "args"], additionalProperties: false, }, }, diff --git a/src/tools/schemas/search_files.ts b/src/tools/schemas/search_files.ts index 7af4569..da612fb 100644 --- a/src/tools/schemas/search_files.ts +++ b/src/tools/schemas/search_files.ts @@ -1,48 +1,52 @@ -import type OpenAI from "openai" +import type OpenAI from "openai"; export default { - type: "function", - function: { - name: "search_files", - description: - "Search file contents recursively under a directory using a Rust-compatible regex and optional file glob. Returns a compact, paginated page with at most three matches per file; use context_lines 0 for discovery, then read the relevant file. Continue only with the opaque cursor returned by the same path, regex, and file_pattern; pass JSON null without quotes for the first page. If Next cursor is none, the search is complete: stop and never pass the word none. A rare FFF continuation failure may restart with ripgrep and is marked Restarted: yes. Scope path to the narrowest plausible directory instead of searching from the repository root. If a search returns 0 matches, tighten or simplify the regex rather than retrying with a slightly different pattern. After 2+ searches with no results, stop and reason from what you already know.", - strict: true, - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Directory to search recursively, relative to the workspace", - }, - regex: { - type: "string", - minLength: 1, - description: "Rust-compatible regular expression pattern to match", - }, - file_pattern: { - type: ["string", "null"], - description: "Glob limiting searched files (e.g. '*.ts'), or null for all files", - }, - cursor: { - type: ["string", "null"], - description: - "Opaque continuation cursor copied exactly from a previous identical search, or JSON null for the first page", - }, - max_results: { - type: ["integer", "null"], - minimum: 1, - maximum: 100, - description: "Target results for this page; null uses 50", - }, - context_lines: { - type: ["integer", "null"], - minimum: 0, - maximum: 2, - description: "Context lines before and after each match; null uses 0", - }, - }, - required: ["path", "regex", "file_pattern", "cursor", "max_results", "context_lines"], - additionalProperties: false, - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool + type: "function", + function: { + name: "search_files", + description: + "Search file contents recursively with a Rust-compatible regex. Returns up to 100 matching lines with file and line numbers; additional matches are omitted, so refine the pattern or path instead of repeating the same search. Use the narrowest plausible path and an optional file glob. Use read_file for surrounding context.", + strict: true, + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "Directory to search recursively, relative to the workspace", + }, + regex: { + type: "string", + minLength: 1, + description: "Rust-compatible regular expression pattern to match", + }, + file_pattern: { + type: ["string", "null"], + description: + "Glob limiting searched files (e.g. '*.ts'), or null for all files", + }, + max_results: { + type: ["integer", "null"], + minimum: 1, + maximum: 100, + description: + "Target result count; null uses 100. Results are bounded, so refine the query if the target is too broad.", + }, + context_lines: { + type: ["integer", "null"], + minimum: 0, + maximum: 2, + description: "Context lines before and after each match; null uses 0", + }, + }, + required: [ + "path", + "regex", + "file_pattern", + "max_results", + "context_lines", + ], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool; diff --git a/src/tools/schemas/switch_mode.ts b/src/tools/schemas/switch_mode.ts index 8c8fca0..0a9ab18 100644 --- a/src/tools/schemas/switch_mode.ts +++ b/src/tools/schemas/switch_mode.ts @@ -19,7 +19,7 @@ export default { description: "Optional explanation for why the mode switch is needed", }, }, - required: ["mode_slug"], + required: ["mode_slug", "reason"], additionalProperties: false, }, }, diff --git a/test/search-files.test.ts b/test/search-files.test.ts index d412427..7826876 100644 --- a/test/search-files.test.ts +++ b/test/search-files.test.ts @@ -31,7 +31,7 @@ after(async () => { await Promise.all(roots.map((root) => fs.rm(root, { recursive: true, force: true }))) }) -test("uses FFF by default with compact file filtering", async () => { +test("uses ripgrep by default with compact file filtering", async () => { const cwd = await fixture() const result = await searchFiles( { path: "src", regex: "needle", file_pattern: "*.ts", cursor: "null", max_results: 50, context_lines: 0 }, @@ -39,33 +39,27 @@ test("uses FFF by default with compact file filtering", async () => { ) assert.equal(result.isError, undefined) - assert.match(result.text, /^Engine: fff/m) + assert.match(result.text, /^Engine: ripgrep/m) assert.match(result.text, /# src\/alpha\.ts/) assert.doesNotMatch(result.text, /notes\.md/) assert.doesNotMatch(result.text, /outside\.ts/) }) -test("continues native FFF pagination without dropping matches", async () => { +test("returns one-shot bounded results without model-facing cursors", async () => { const cwd = await fixture() await fs.writeFile(path.join(cwd, "src", "beta.ts"), "const needle = 2\n") const context = { cwd, token: "", getTodos: () => "", setTodos: () => {} } - const args = { - path: "src", - regex: "needle", - file_pattern: "*.ts", - cursor: null as string | null, - max_results: 1, - context_lines: 0, - } - const first = await searchFiles(args, context) - assert.match(first.text, /^Engine: fff/m) - const cursor = /^Next cursor: (fff:\S+)$/m.exec(first.text)?.[1] - assert.ok(cursor) + const result = await searchFiles( + { path: "src", regex: "needle", file_pattern: "*.ts", cursor: null, max_results: 1, context_lines: 0 }, + context, + ) - const second = await searchFiles({ ...args, cursor }, context) - assert.match(second.text, /^Engine: fff/m) - assert.match(`${first.text}\n${second.text}`, /src\/alpha\.ts/) - assert.match(`${first.text}\n${second.text}`, /src\/beta\.ts/) + assert.equal(result.isError, undefined) + assert.match(result.text, /^Engine: ripgrep/m) + assert.match(result.text, /^Matches: 1$/m) + assert.equal((result.text.match(/# src\//g) ?? []).length, 1) + assert.match(result.text, /Additional matches omitted; refine the search pattern or path instead of paginating\./) + assert.doesNotMatch(result.text, /Next cursor:/) }) test("treats route-directory metacharacters literally and anchors nested globs to path", async () => { @@ -104,7 +98,7 @@ test("treats route-directory metacharacters literally and anchors nested globs t ) assert.equal(result.isError, undefined) - assert.match(result.text, /^Engine: fff/m) + assert.match(result.text, /^Engine: ripgrep/m) assert.match(result.text, /components\/view\.ts/) assert.doesNotMatch(result.text, /other\.ts/) }) @@ -206,8 +200,16 @@ test("keeps generated-directory exclusions and negative globs consistent across await fs.writeFile(path.join(cwd, "src", "negative.md"), "negativeNeedle markdown\n") const context = { cwd, token: "", getTodos: () => "", setTodos: () => {} } + const fffFingerprint = createSearchFingerprint(cwd, "excludedNeedle", "*.ts") const fff = await searchFiles( - { path: ".", regex: "excludedNeedle", file_pattern: "*.ts", cursor: null, max_results: 50, context_lines: 0 }, + { + path: ".", + regex: "excludedNeedle", + file_pattern: "*.ts", + cursor: `fff:0:${fffFingerprint}`, + max_results: 50, + context_lines: 0, + }, context, ) assert.match(fff.text, /^Engine: fff/m) @@ -233,7 +235,7 @@ test("keeps generated-directory exclusions and negative globs consistent across { path: "dist", regex: "excludedNeedle", file_pattern: "*.ts", cursor: null, max_results: 50, context_lines: 0 }, context, ) - assert.match(explicitDist.text, /^Engine: fff/m) + assert.match(explicitDist.text, /^Engine: ripgrep/m) assert.match(explicitDist.text, /dist\/generated\.ts/) const explicitDistFingerprint = createSearchFingerprint(path.join(cwd, "dist"), "excludedNeedle", "*.ts") const explicitDistRg = await searchFiles( @@ -249,12 +251,13 @@ test("keeps generated-directory exclusions and negative globs consistent across ) assert.match(explicitDistRg.text, /dist\/generated\.ts/) + const negationPathFingerprint = createSearchFingerprint(path.join(cwd, "src"), "negativeNeedle", "!components/**") const pathNegation = await searchFiles( { path: "src", regex: "negativeNeedle", file_pattern: "!components/**", - cursor: null, + cursor: `fff:0:${negationPathFingerprint}`, max_results: 50, context_lines: 0, }, @@ -301,25 +304,27 @@ test("ripgrep pagination preserves an adjacent match on the next page", async () assert.equal(first.isError, undefined) assert.match(first.text, /^Engine: ripgrep/m) assert.match(first.text, /> 1:1 /) - const cursor = /^Next cursor: (\S+)$/m.exec(first.text)?.[1] - assert.ok(cursor) - const second = await searchFiles({ ...args, cursor }, context) + // Cursors are no longer model-facing; construct the continuation cursor for + // the same search to verify the adjacent match survives pagination. + const second = await searchFiles({ ...args, cursor: `ripgrep:1:${fingerprint}` }, context) assert.equal(second.isError, undefined) assert.match(second.text, /> 2:1 /) }) test("makes completed searches and tool summaries unambiguous", () => { const completed = formatSearchPage({ engine: "fff", matches: [], nextCursor: null }) - assert.match(completed, /Next cursor: none \(search complete; do not continue\)/) + assert.doesNotMatch(completed, /Next cursor:|Additional matches omitted/) assert.equal(stripSearchPageMetadataForDisplay(completed), "") const page = formatSearchPage({ engine: "fff", matches: [{ file: "src/a.ts", line: 4, column: 2, text: "needle" }], nextCursor: { engine: "fff", offset: 2, fingerprint: "0123456789abcdef" }, }) + assert.doesNotMatch(page, /Next cursor:/) + assert.match(page, /Additional matches omitted; refine the search pattern or path instead of paginating\./) const visiblePage = stripSearchPageMetadataForDisplay(page) - assert.doesNotMatch(visiblePage, /Engine:|Matches:|Next cursor:/) + assert.doesNotMatch(visiblePage, /Engine:|Matches:|Additional matches omitted/) assert.match(visiblePage, /# src\/a\.ts\n> 4:2 \| needle/) assert.equal( describeToolCall("search_files", { path: "src", regex: "eido", file_pattern: "*.ts" }), @@ -328,38 +333,28 @@ test("makes completed searches and tool summaries unambiguous", () => { assert.equal(describeToolCall("search_files", { path: "src", regex: "eido", file_pattern: "null" }), "/eido/ in src") }) -test("marks and safely drains an FFF continuation fallback during cleanup", async () => { +test("settles a search that overlaps session cleanup", async () => { const cwd = await fixture() await fs.writeFile(path.join(cwd, "space file.ts"), "needle spaced\n") - const filePattern = "space *.ts" - const fingerprint = createSearchFingerprint(cwd, "needle", filePattern) const pending = searchFiles( - { - path: ".", - regex: "needle", - file_pattern: filePattern, - cursor: `fff:1:${fingerprint}`, - max_results: 50, - context_lines: 0, - }, + { path: ".", regex: "needle", file_pattern: null, cursor: null, max_results: 50, context_lines: 0 }, { cwd, token: "", getTodos: () => "", setTodos: () => {} }, ) const disposing = disposeSearchFiles() const result = await pending await disposing - assert.equal(result.isError, undefined) - assert.match(result.text, /^Engine: ripgrep/m) - assert.match(result.text, /^Restarted: yes$/m) - assert.match(result.text, /space file\.ts/) + // The search either completes after cleanup or is cancelled by it; either + // way it must settle with a result instead of hanging. + assert.ok(result.text.length > 0) }) -test("can initialize FFF again after session cleanup", async () => { +test("can initialize search engines again after session cleanup", async () => { const cwd = await fixture() const args = { path: "src", regex: "needle", file_pattern: "*.ts", cursor: null, max_results: 50, context_lines: 0 } const context = { cwd, token: "", getTodos: () => "", setTodos: () => {} } const first = await searchFiles(args, context) - assert.match(first.text, /^Engine: fff/m) + assert.match(first.text, /^Engine: ripgrep/m) await disposeSearchFiles() const second = await searchFiles(args, context) - assert.match(second.text, /^Engine: fff/m) + assert.match(second.text, /^Engine: ripgrep/m) })