From ba5645d2ab0cbf001394570b6f8f13205f5beb63 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Wed, 24 Jun 2026 17:48:49 +0800 Subject: [PATCH] fix(tools): fail Write early when full-file Read is required --- .../src-tauri/src/commands/workspace/fs.rs | 85 ++++++++++ crates/agent-gui/src-tauri/src/lib.rs | 1 + .../src/lib/chat/runner/agentRunner.ts | 149 +++++++++++++++++- .../src/lib/tools/builtinRegistry.ts | 18 +++ .../agent-gui/src/lib/tools/builtinTypes.ts | 13 +- crates/agent-gui/src/lib/tools/fsTools.ts | 137 +++++++++++++++- .../chat/turns/runAgentConversationTurn.ts | 2 + .../agent-gui/test/chat/agent-runner.test.mjs | 104 ++++++++++++ .../test/tools/path-and-system-tools.test.mjs | 65 ++++++++ 9 files changed, 566 insertions(+), 8 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index 7e8ee7af..bce237a6 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -2356,6 +2356,61 @@ pub async fn fs_read_editable_text( .await } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PathStatusResponse { + pub path: String, + pub exists: bool, + pub kind: Option, + pub size_bytes: Option, + pub mtime_ms: Option, +} + +pub(crate) fn fs_path_status_sync( + workdir: String, + path: String, +) -> Result { + let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; + let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; + let logical_path = logical_rel_path(&rel); + let target = wd.join(&rel); + + match fs::symlink_metadata(&target) { + Ok(meta) => { + let file_type = meta.file_type(); + let kind = if file_type.is_symlink() { + "symlink" + } else if meta.is_file() { + "file" + } else if meta.is_dir() { + "dir" + } else { + "other" + }; + Ok(PathStatusResponse { + path: logical_path, + exists: true, + kind: Some(kind.to_string()), + size_bytes: Some(meta.len()), + mtime_ms: Some(metadata_mtime_ms(&meta)), + }) + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(PathStatusResponse { + path: logical_path, + exists: false, + kind: None, + size_bytes: None, + mtime_ms: None, + }), + Err(err) => Err(FsError::Io(err).to_string()), + } +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn fs_path_status(workdir: String, path: String) -> Result { + run_blocking("fs_path_status", move || fs_path_status_sync(workdir, path)).await +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct WriteTextResponse { @@ -4229,6 +4284,36 @@ mod tests { let _ = fs::remove_dir_all(workdir); } + #[test] + fn path_status_reports_existing_file_directory_and_missing_path() { + let workdir = unique_test_workdir("path-status"); + fs::create_dir_all(workdir.join("src")).expect("create workdir"); + fs::write(workdir.join("src/main.rs"), "fn main() {}\n").expect("write file"); + + let file = fs_path_status_sync(workdir.display().to_string(), "src/main.rs".to_string()) + .expect("file status should succeed"); + assert_eq!(file.path, "src/main.rs"); + assert!(file.exists); + assert_eq!(file.kind.as_deref(), Some("file")); + assert_eq!(file.size_bytes, Some("fn main() {}\n".len() as u64)); + assert!(file.mtime_ms.unwrap_or_default() > 0); + + let dir = fs_path_status_sync(workdir.display().to_string(), "src".to_string()) + .expect("dir status should succeed"); + assert_eq!(dir.kind.as_deref(), Some("dir")); + assert!(dir.exists); + + let missing = fs_path_status_sync(workdir.display().to_string(), "new.html".to_string()) + .expect("missing status should succeed"); + assert_eq!(missing.path, "new.html"); + assert!(!missing.exists); + assert!(missing.kind.is_none()); + assert!(missing.size_bytes.is_none()); + assert!(missing.mtime_ms.is_none()); + + let _ = fs::remove_dir_all(workdir); + } + #[test] fn read_editable_text_rejects_invalid_targets_and_non_utf8() { let workdir = unique_test_workdir("read-editable-invalid"); diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 789f2e29..22daaa7e 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -61,6 +61,7 @@ macro_rules! app_invoke_handler { // File system commands::fs::fs_read_text, commands::fs::fs_read_editable_text, + commands::fs::fs_path_status, commands::fs::fs_read_image_source, commands::fs::fs_read_workspace_image, commands::fs::fs_write_text, diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts index 5d9c8b21..963e7861 100644 --- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts +++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts @@ -1,6 +1,7 @@ import { Agent, type AgentTool } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, + AssistantMessageEvent, Context, Message, ToolCall, @@ -640,6 +641,10 @@ export async function runAssistantWithTools(params: { signal?: AbortSignal, context?: ToolExecutionEventContext, ) => Promise; + preflightToolCall?: ( + toolCall: ToolCall, + signal?: AbortSignal, + ) => Promise<{ toolCall?: ToolCall; toolResult: ToolResultMessage } | null>; onTurnStart?: (round: number) => void; onTextDelta: (delta: string, round: number) => void; onThinkingDelta?: (delta: string, round: number) => void; @@ -712,6 +717,7 @@ export async function runAssistantWithTools(params: { const toolResultErrorFlags = new Map(); const toolCallsById = new Map(); + const streamPreflightToolResults = new Map(); const parallelBatchKeyByToolCallId = new Map(); const parallelToolBatches = new Map(); const llmTools = params.tools ?? []; @@ -970,7 +976,9 @@ export async function runAssistantWithTools(params: { } function replaceAgentStateMessage(target: Message, replacement: Message) { - const stateMessages = getAgentMessages(agent); + const currentAgent = agent; + if (!currentAgent) return false; + const stateMessages = getAgentMessages(currentAgent); let targetIndex = stateMessages.lastIndexOf(target); if (targetIndex < 0) { for (let index = stateMessages.length - 1; index >= 0; index -= 1) { @@ -989,7 +997,7 @@ export async function runAssistantWithTools(params: { } } if (targetIndex < 0) return false; - agent!.state.messages = [ + currentAgent.state.messages = [ ...stateMessages.slice(0, targetIndex), replacement, ...stateMessages.slice(targetIndex + 1), @@ -1079,6 +1087,16 @@ export async function runAssistantWithTools(params: { }); toolCallsById.set(toolCall.id, toolCall); + const preflightToolResult = streamPreflightToolResults.get(toolCall.id); + if (preflightToolResult) { + streamPreflightToolResults.delete(toolCall.id); + toolResultErrorFlags.set(toolCall.id, Boolean(preflightToolResult.isError)); + return { + content: preflightToolResult.content, + details: preflightToolResult.details ?? {}, + }; + } + if (tool.name === "Bash" || tool.name === "Agent") { const batchKey = parallelBatchKeyByToolCallId.get(toolCallId); if (batchKey) { @@ -1126,6 +1144,118 @@ export async function runAssistantWithTools(params: { agentTools = [...visibleAgentTools, ...hiddenProviderNativeWebSearchAgentTools]; let streamRound = 0; + function getToolCallFromStreamEvent(event: AssistantMessageEvent) { + if ( + event.type !== "toolcall_start" && + event.type !== "toolcall_delta" && + event.type !== "toolcall_end" + ) { + return null; + } + + const toolCall = + event.type === "toolcall_end" ? event.toolCall : event.partial.content[event.contentIndex]; + return toolCall?.type === "toolCall" + ? { + contentIndex: event.contentIndex, + toolCall, + partial: event.partial, + } + : null; + } + + function buildPreflightToolUseAssistant( + partial: AssistantMessage, + contentIndex: number, + toolCall: ToolCall, + ): AssistantMessage { + const content = partial.content.slice(); + content[contentIndex] = toolCall; + return { + ...partial, + content, + stopReason: "toolUse", + errorMessage: undefined, + }; + } + + function wrapStreamWithToolPreflight( + source: ReturnType, + signal: AbortSignal | undefined, + abortEarly: () => void, + cleanup: () => void, + ): ReturnType { + let preflightFinalMessage: AssistantMessage | null = null; + + return { + async *[Symbol.asyncIterator]() { + const iterator = source[Symbol.asyncIterator](); + try { + while (true) { + const next = await iterator.next(); + if (next.done) return; + + const event = next.value; + const candidate = getToolCallFromStreamEvent(event); + const effectiveToolCall = candidate + ? normalizeToolCallNameForExecution(candidate.toolCall) + : null; + if (!candidate || !effectiveToolCall || !params.preflightToolCall) { + yield event; + continue; + } + + const preflight = await params.preflightToolCall(effectiveToolCall, signal); + + if (!preflight) { + yield event; + continue; + } + + const completedToolCall = normalizeToolCallNameForExecution( + preflight.toolCall ?? effectiveToolCall, + ); + toolCallsById.set(completedToolCall.id, completedToolCall); + streamPreflightToolResults.set(completedToolCall.id, { + ...preflight.toolResult, + toolCallId: completedToolCall.id, + toolName: completedToolCall.name, + }); + preflightFinalMessage = buildPreflightToolUseAssistant( + candidate.partial, + candidate.contentIndex, + completedToolCall, + ); + + abortEarly(); + await iterator.return?.(); + + yield event; + if (event.type !== "toolcall_end") { + yield { + type: "toolcall_end", + contentIndex: candidate.contentIndex, + toolCall: completedToolCall, + partial: preflightFinalMessage, + }; + } + yield { + type: "done", + reason: "toolUse", + message: preflightFinalMessage, + }; + return; + } + } finally { + cleanup(); + } + }, + result() { + return preflightFinalMessage ? Promise.resolve(preflightFinalMessage) : source.result(); + }, + } as unknown as ReturnType; + } + const streamFn = (streamModel: typeof model, streamContext: Context, options?: any) => { const round = ++streamRound; const streamTools = @@ -1149,6 +1279,11 @@ export async function runAssistantWithTools(params: { const hostedSearchProbeId = shouldProbeHostedSearch ? createHostedSearchProbeId(params.providerId) : undefined; + const earlyPreflightAbortController = new AbortController(); + const streamAbortSignal = createLinkedAbortSignal([ + options?.signal, + earlyPreflightAbortController.signal, + ]); let streamOptions: StreamOptionsEx = { ...(options ?? {}), apiKey: options?.apiKey ?? params.runtime.apiKey, @@ -1159,7 +1294,7 @@ export async function runAssistantWithTools(params: { }, hostedSearchProbeId, ), - signal: options?.signal, + signal: streamAbortSignal.signal, sessionId: options?.sessionId ?? params.sessionId, cacheRetention: options?.cacheRetention ?? @@ -1220,7 +1355,13 @@ export async function runAssistantWithTools(params: { }), ); - return streamSimpleByApi(streamModel, effectiveContext, streamOptions); + const sourceStream = streamSimpleByApi(streamModel, effectiveContext, streamOptions); + return wrapStreamWithToolPreflight( + sourceStream, + options?.signal, + () => earlyPreflightAbortController.abort(), + streamAbortSignal.cleanup, + ); }; agent = new Agent({ diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 00bbb465..92f3a82d 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -21,6 +21,7 @@ import type { BuiltinToolBundle, BuiltinToolExecutionContext, BuiltinToolMetadata, + BuiltinToolPreflightResult, } from "./builtinTypes"; import { createCronTools } from "./cronTools"; import { createCustomSystemTools } from "./customSystemTools"; @@ -46,6 +47,10 @@ export type BuiltinToolRegistry = { signal?: AbortSignal, context?: BuiltinToolExecutionContext, ) => Promise; + preflightToolCall: ( + toolCall: ToolCall, + signal?: AbortSignal, + ) => Promise; metadataByName: Map; hasTool: (toolName: string) => boolean; }; @@ -105,6 +110,7 @@ function createBuiltinToolRegistry(bundles: BuiltinToolBundle[]): BuiltinToolReg const tools: BuiltinToolBundle["tools"] = []; const metadataByName = new Map(); const executorsByName = new Map(); + const preflightsByName = new Map>(); const canonicalToolNameByLookupKey = new Map(); const registerCanonicalToolName = (toolName: string) => { @@ -131,6 +137,9 @@ function createBuiltinToolRegistry(bundles: BuiltinToolBundle[]): BuiltinToolReg } tools.push(tool); executorsByName.set(tool.name, bundle.executeToolCall); + if (bundle.preflightToolCall) { + preflightsByName.set(tool.name, bundle.preflightToolCall); + } registerCanonicalToolName(tool.name); const metadata = bundle.metadataByName.get(tool.name); if (metadata) { @@ -172,6 +181,15 @@ function createBuiltinToolRegistry(bundles: BuiltinToolBundle[]): BuiltinToolReg resolvedToolName === toolCall.name ? toolCall : { ...toolCall, name: resolvedToolName }; return execute(effectiveToolCall, signal, context); }, + async preflightToolCall(toolCall, signal) { + const resolvedToolName = resolveToolName(toolCall.name); + if (!resolvedToolName) return null; + const preflight = preflightsByName.get(resolvedToolName); + if (!preflight) return null; + const effectiveToolCall = + resolvedToolName === toolCall.name ? toolCall : { ...toolCall, name: resolvedToolName }; + return preflight(effectiveToolCall, signal); + }, }; } diff --git a/crates/agent-gui/src/lib/tools/builtinTypes.ts b/crates/agent-gui/src/lib/tools/builtinTypes.ts index 5ba7587e..52f5fe98 100644 --- a/crates/agent-gui/src/lib/tools/builtinTypes.ts +++ b/crates/agent-gui/src/lib/tools/builtinTypes.ts @@ -41,10 +41,21 @@ export type BuiltinToolExecutor = ( context?: BuiltinToolExecutionContext, ) => Promise; -export type BuiltinToolBundle = TExtra & { +export type BuiltinToolPreflightResult = { + toolCall?: ToolCall; + toolResult: ToolResultMessage; +}; + +export type BuiltinToolPreflight = ( + toolCall: ToolCall, + signal?: AbortSignal, +) => Promise; + +export type BuiltinToolBundle = TExtra & { groupId: BuiltinToolGroupId; tools: Tool[]; executeToolCall: BuiltinToolExecutor; + preflightToolCall?: BuiltinToolPreflight; metadataByName: Map; }; diff --git a/crates/agent-gui/src/lib/tools/fsTools.ts b/crates/agent-gui/src/lib/tools/fsTools.ts index 0d762aa4..1714afaa 100644 --- a/crates/agent-gui/src/lib/tools/fsTools.ts +++ b/crates/agent-gui/src/lib/tools/fsTools.ts @@ -9,6 +9,7 @@ import { invoke } from "@tauri-apps/api/core"; import { Type } from "typebox"; import { type BuiltinToolBundle, + type BuiltinToolPreflightResult, type BuiltinToolResultDetails, createBuiltinMetadataMap, type DeleteResultDetails, @@ -107,6 +108,14 @@ type WriteCommandResponse = { totalLines: number; }; +type PathStatusCommandResponse = { + path: string; + exists: boolean; + kind?: "file" | "dir" | "symlink" | "other" | null; + sizeBytes?: number | null; + mtimeMs?: number | null; +}; + type EditCommandResponse = { path: string; replacements: number; @@ -243,6 +252,18 @@ async function invokePathFileCommand(params: { } } +function buildToolErrorResult(toolCall: ToolCall, text: string): ToolResultMessage { + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text }], + details: {}, + isError: true, + timestamp: Date.now(), + }; +} + export function createFsTools(params: { workdir: string; fileState: FileToolState; @@ -277,6 +298,37 @@ export function createFsTools(params: { skillAccessPolicy, resolveSkillsRootDir, }); + const writePreflightSafePathByToolCallId = new Map(); + + function buildWriteRequiresFullReadMessage(resolved: ResolvedPath) { + return `Write requires a full-file Read first for existing files: ${formatResolvedTarget(resolved)}. Retry with Read using the same path before rewriting. Do not use Bash for workspace or Skill file operations.`; + } + + async function readPathStatus(resolved: ResolvedPath, path: string) { + return invokePathFileCommand({ + toolName: "Write", + resolved, + command: "fs_path_status", + args: { + workdir: resolved.workdir, + path, + }, + }); + } + + function completeWriteToolCallForPreflight(toolCall: ToolCall): ToolCall { + const args = toolCall.arguments && typeof toolCall.arguments === "object" + ? toolCall.arguments + : {}; + if (typeof args.content === "string") return toolCall; + return { + ...toolCall, + arguments: { + ...args, + content: "", + }, + }; + } const toolRead: Tool = { name: "Read", @@ -1318,9 +1370,7 @@ export function createFsTools(params: { const latest = fileState.getLatest(statePathKey(resolved)); if (latest?.kind === "text" && latest.isPartialView) { - throw new Error( - `Write requires a full-file Read first for existing files: ${formatResolvedTarget(resolved)}. Retry with Read using the same path before rewriting. Do not use Bash for workspace or Skill file operations.`, - ); + throw new Error(buildWriteRequiresFullReadMessage(resolved)); } const fullSnapshot = fileState.getLatestFullText(statePathKey(resolved)); @@ -1371,6 +1421,67 @@ export function createFsTools(params: { }; } + async function preflightWrite( + toolCall: ToolCall, + signal?: AbortSignal, + ): Promise { + if (signal?.aborted) return null; + + const rawPath = typeof toolCall.arguments?.path === "string" ? toolCall.arguments.path : ""; + if (!rawPath.trim()) return null; + + const resolved = await pathResolver.resolvePath(rawPath, { + label: "Write.path", + intent: "write", + required: true, + }); + const path = backendPath(resolved); + if (!path) { + return { + toolCall: completeWriteToolCallForPreflight(toolCall), + toolResult: buildToolErrorResult(toolCall, "Write.path must identify a file"), + }; + } + + const preflightPathKey = `${resolved.workdir}\0${path}`; + if (writePreflightSafePathByToolCallId.get(toolCall.id) === preflightPathKey) { + return null; + } + + const key = statePathKey(resolved); + const latest = fileState.getLatest(key); + if (latest?.kind === "text" && latest.isPartialView) { + return { + toolCall: completeWriteToolCallForPreflight(toolCall), + toolResult: buildToolErrorResult(toolCall, buildWriteRequiresFullReadMessage(resolved)), + }; + } + + if (fileState.getLatestFullText(key)) { + writePreflightSafePathByToolCallId.set(toolCall.id, preflightPathKey); + return null; + } + + const status = await readPathStatus(resolved, path); + if (!status.exists) { + writePreflightSafePathByToolCallId.set(toolCall.id, preflightPathKey); + return null; + } + + const completedToolCall = completeWriteToolCallForPreflight(toolCall); + if (status.kind === "dir") { + return { + toolCall: completedToolCall, + toolResult: buildToolErrorResult(toolCall, "Cannot write to a directory path"), + }; + } + + return { + toolCall: completedToolCall, + toolResult: buildToolErrorResult(toolCall, buildWriteRequiresFullReadMessage(resolved)), + }; + } + async function execEdit(args: any, signal?: AbortSignal): Promise> { if (signal?.aborted) throw new Error("Cancelled"); @@ -1817,10 +1928,30 @@ export function createFsTools(params: { } } + async function preflightToolCall( + toolCall: ToolCall, + signal?: AbortSignal, + ): Promise { + try { + switch (toolCall.name) { + case "Write": + return await preflightWrite(toolCall, signal); + default: + return null; + } + } catch (err) { + return { + toolCall: toolCall.name === "Write" ? completeWriteToolCallForPreflight(toolCall) : toolCall, + toolResult: buildToolErrorResult(toolCall, asErrorMessage(err)), + }; + } + } + return { groupId: "fs", tools, executeToolCall, + preflightToolCall, metadataByName: createBuiltinMetadataMap([ [ "Read", diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 8f79546f..40c6162e 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -788,6 +788,8 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP tools: combinedTools, subagentScheduler, executeToolCall: combinedExecutor, + preflightToolCall: (toolCall, signal) => + builtinRegistry.preflightToolCall(toolCall, signal), onTurnStart: (round) => { activeAgentRound = round; streamedAgentText = ""; diff --git a/crates/agent-gui/test/chat/agent-runner.test.mjs b/crates/agent-gui/test/chat/agent-runner.test.mjs index 1f9d5394..2f1e266b 100644 --- a/crates/agent-gui/test/chat/agent-runner.test.mjs +++ b/crates/agent-gui/test/chat/agent-runner.test.mjs @@ -1130,6 +1130,110 @@ test("runAssistantWithTools emits streaming tool call argument deltas before fin ); }); +test("runAssistantWithTools stops streaming Write content when preflight returns an error", async () => { + const finalWriteCall = createToolCall("call_00_preflight_write", "Write", { + path: "test6/gobang.html", + content: "\nSHOULD_NOT_STREAM\n", + }); + resetFakeStreams( + createToolCallDeltaStream(finalWriteCall, [ + createToolCall(finalWriteCall.id, "Write", {}), + createToolCall(finalWriteCall.id, "Write", { path: "test6/gobang.html" }), + createToolCall(finalWriteCall.id, "Write", { + path: "test6/gobang.html", + content: "\nSHOULD_NOT_STREAM", + }), + ]), + createTextAssistant("after read-first reminder"), + ); + const writeTool = { + name: "Write", + description: "Write files", + parameters: { type: "object", properties: {} }, + }; + const deltas = []; + const toolResults = []; + const preflightCalls = []; + const { params, executedToolCalls } = createBaseParams({ + tools: [writeTool], + context: { + systemPrompt: "Base system prompt", + messages: [{ role: "user", content: "Start", timestamp: 1 }], + tools: [writeTool], + }, + async preflightToolCall(toolCall) { + preflightCalls.push({ + id: toolCall.id, + name: toolCall.name, + arguments: { ...(toolCall.arguments ?? {}) }, + }); + if (toolCall.name !== "Write" || typeof toolCall.arguments?.path !== "string") { + return null; + } + const completedToolCall = { + ...toolCall, + arguments: { + ...toolCall.arguments, + content: + typeof toolCall.arguments.content === "string" ? toolCall.arguments.content : "", + }, + }; + return { + toolCall: completedToolCall, + toolResult: { + role: "toolResult", + toolCallId: completedToolCall.id, + toolName: completedToolCall.name, + content: [ + { + type: "text", + text: "Write requires a full-file Read first for existing files: test6/gobang.html.", + }, + ], + details: {}, + isError: true, + timestamp: Date.now(), + }, + }; + }, + onToolCallDelta: (toolCall) => { + deltas.push({ + id: toolCall.id, + name: toolCall.name, + arguments: { ...(toolCall.arguments ?? {}) }, + }); + }, + onToolResult: (toolCall, toolResult) => { + toolResults.push({ toolCall, toolResult }); + }, + }); + + const result = await runAssistantWithTools(params); + + assert.deepEqual( + deltas.map((delta) => delta.arguments), + [{}, { path: "test6/gobang.html" }], + ); + assert.equal( + preflightCalls.some((call) => String(call.arguments.content ?? "").includes("SHOULD_NOT_STREAM")), + false, + ); + assert.equal(executedToolCalls.length, 0); + assert.equal(toolResults.length, 1); + assert.equal(toolResults[0].toolResult.isError, true); + assert.match(toolResults[0].toolResult.content[0].text, /full-file Read first/); + const earlyAssistantToolCall = result.emittedMessages[0].content.find( + (block) => block.type === "toolCall", + ); + assert.equal(earlyAssistantToolCall.arguments.path, "test6/gobang.html"); + assert.equal(earlyAssistantToolCall.arguments.content, ""); + assert.equal(JSON.stringify(result.emittedMessages[0]).includes("SHOULD_NOT_STREAM"), false); + assert.deepEqual( + result.emittedMessages.map((message) => message.role), + ["assistant", "toolResult", "assistant"], + ); +}); + test("runAssistantWithTools strips bare tool_name text without duplicate execution", async () => { const grepCall = createToolCall("call_00_native_route_grep", "Grep", { pattern: "express|route|api", diff --git a/crates/agent-gui/test/tools/path-and-system-tools.test.mjs b/crates/agent-gui/test/tools/path-and-system-tools.test.mjs index 3486bba8..056b33e0 100644 --- a/crates/agent-gui/test/tools/path-and-system-tools.test.mjs +++ b/crates/agent-gui/test/tools/path-and-system-tools.test.mjs @@ -500,6 +500,71 @@ test("file tools allow direct mutations inside enabled Skills when mutation is g ]); }); +test("Write preflight blocks existing files before content streaming but allows new files", async () => { + const invocations = []; + const fsLoader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + invocations.push({ command, args }); + assert.equal(command, "fs_path_status"); + return { + path: args.path, + exists: args.path === "existing.html", + kind: args.path === "existing.html" ? "file" : null, + sizeBytes: args.path === "existing.html" ? 128 : null, + mtimeMs: args.path === "existing.html" ? 44 : null, + }; + }, + }, + }, + }); + const fsTools = fsLoader.loadModule("src/lib/tools/fsTools.ts"); + const fileToolState = fsLoader.loadModule("src/lib/tools/fileToolState.ts"); + const bundle = fsTools.createFsTools({ + workdir: "/workspace", + fileState: fileToolState.createFileToolState(), + }); + + const blocked = await bundle.preflightToolCall({ + type: "toolCall", + id: "stream-write-existing", + name: "Write", + arguments: { + path: "existing.html", + }, + }); + const allowed = await bundle.preflightToolCall({ + type: "toolCall", + id: "stream-write-new", + name: "Write", + arguments: { + path: "new.html", + }, + }); + + assert.equal(blocked.toolResult.isError, true); + assert.match(blocked.toolResult.content[0].text, /full-file Read first/); + assert.equal(blocked.toolCall.arguments.content, ""); + assert.equal(allowed, null); + assert.deepEqual(invocations, [ + { + command: "fs_path_status", + args: { + workdir: "/workspace", + path: "existing.html", + }, + }, + { + command: "fs_path_status", + args: { + workdir: "/workspace", + path: "new.html", + }, + }, + ]); +}); + test("file tools block direct mutations inside built-in Skills", async () => { const invocations = []; const fsLoader = createTsModuleLoader({