diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 2596faec..d0e15ef2 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -769,6 +769,7 @@ public class AgentBindingService implements AgentBindingResolver { "read_file", "send_file", "write_file", + "append_file", "edit_file", "execute_shell_command", // Inline code execution — an agent-wide capability alongside shell. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index ada8e499..24255cd7 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -2509,11 +2509,41 @@ public class NodeStreamingChatHelper { acc.id, acc.type != null ? acc.type : "function", acc.name, - sanitizeToolCallArguments(acc.name, acc.arguments.toString()))); + toolCallArgumentsForExecution(acc.name, acc.arguments.toString()))); } return result; } + /** + * Finalize a streamed tool call for local execution. + * + *

Blank arguments are a common zero-argument representation and remain + * normalized to an empty object. Invalid non-blank JSON, however, must be + * preserved until {@code ToolExecutionExecutor} sees it; replacing it with + * {@code {}} loses the distinction between a truncated stream and a real + * empty call and can execute the wrong operation. The outgoing-history + * normalization path still calls {@link #sanitizeToolCallArguments} before + * a later provider request.

+ */ + private static String toolCallArgumentsForExecution(String toolName, String arguments) { + if (arguments == null || arguments.isBlank()) { + return "{}"; + } + try { + TOOL_ARG_JSON_MAPPER.readTree(arguments); + return arguments; + } catch (Exception e) { + log.warn("Tool '{}' arguments are not valid JSON after stream aggregation " + + "(len={}, head={}); preserving the payload for safe executor rejection. " + + "Parse error: {}", + toolName, + arguments.length(), + arguments.substring(0, Math.min(80, arguments.length())), + e.getMessage()); + return arguments; + } + } + /** * Ensure {@code function.arguments} is always a well-formed JSON string. *

diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index 9e0f0663..f68279fc 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -85,7 +85,7 @@ public class ToolExecutionExecutor { static final int MAX_TOOL_CALLS_PER_RESPONSE = 16; private static final Set DEFAULT_UNSAFE_TOOLS = Set.of( - "browser_use", "BrowserUseTool", "write_file", "edit_file" + "browser_use", "BrowserUseTool", "write_file", "append_file", "edit_file" ); /** @@ -614,7 +614,7 @@ public class ToolExecutionExecutor { } catch (Exception jsonEx) { log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}", toolName, arguments.length(), jsonEx.getMessage()); - String truncationError = normalizeToolExecutionError(jsonEx); + String truncationError = incompleteToolArgumentsError(toolName); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false)); allResponses.add(new ToolResponseMessage.ToolResponse( toolCall.id(), responseName, truncationError)); @@ -730,6 +730,17 @@ public class ToolExecutionExecutor { rawEvidenceRef.get()); } + private static String incompleteToolArgumentsError(String toolName) { + var error = OBJECT_MAPPER.createObjectNode(); + error.put("error", true); + error.put("code", "TOOL_ARGUMENTS_INCOMPLETE"); + error.put("recoverable", true); + error.put("toolName", toolName == null ? "" : toolName); + error.put("message", "Tool arguments were incomplete or invalid JSON; the tool was not executed."); + error.put("hint", "Retry with a smaller payload. For file updates, prefer edit_file or append_file instead of rewriting the whole file."); + return error.toString(); + } + private static String requestedSkillName(String arguments) { if (arguments == null || arguments.isBlank()) { return null; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java index 6f4d5981..c8d3ef88 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java @@ -54,7 +54,7 @@ public class ObservationNode implements NodeAction { * determined statically, and a false reminder is worse than none. */ private static final java.util.Set FILE_MUTATION_TOOLS = - java.util.Set.of("write_file", "edit_file"); + java.util.Set.of("write_file", "append_file", "edit_file"); private static final String VERIFICATION_REMINDER = "\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" + diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 24147384..316b3d2d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -148,7 +148,7 @@ public class ReasoningNode implements NodeAction { "(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)"); private static final List ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of( "renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile", - "write_file", "local_write_file", "edit_file", "local_edit_file"); + "write_file", "append_file", "local_write_file", "edit_file", "local_edit_file"); /** Continuation nudge appended to the prompt when the model returns an empty turn. */ private static final String EMPTY_COMPLETION_NUDGE = diff --git a/mateclaw-server/src/main/java/vip/mate/common/result/R.java b/mateclaw-server/src/main/java/vip/mate/common/result/R.java index 17be7aaf..55558ff0 100644 --- a/mateclaw-server/src/main/java/vip/mate/common/result/R.java +++ b/mateclaw-server/src/main/java/vip/mate/common/result/R.java @@ -3,6 +3,7 @@ package vip.mate.common.result; import lombok.Data; import java.io.Serializable; +import java.util.concurrent.atomic.AtomicReference; /** * 统一响应结果封装 @@ -24,12 +25,18 @@ public class R implements Serializable { private T data; /** i18n holder — set once at startup by I18nAutoConfig, used by ok()/fail() */ - private static volatile vip.mate.i18n.I18nService i18n; + private static final AtomicReference I18N = new AtomicReference<>(); - public static void setI18n(vip.mate.i18n.I18nService service) { i18n = service; } + public static void setI18n(vip.mate.i18n.I18nService service) { I18N.set(service); } + + /** Clear a closing context's service without clobbering a newer context. */ + public static void clearI18n(vip.mate.i18n.I18nService service) { + I18N.compareAndSet(service, null); + } private static String resolveMsg(ResultCode rc) { - return i18n != null ? rc.getMsg(i18n) : rc.getMsg(); + vip.mate.i18n.I18nService service = I18N.get(); + return service != null ? rc.getMsg(service) : rc.getMsg(); } public static R ok() { diff --git a/mateclaw-server/src/main/java/vip/mate/config/ToolTimeoutProperties.java b/mateclaw-server/src/main/java/vip/mate/config/ToolTimeoutProperties.java index 775febed..14ea6001 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/ToolTimeoutProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/config/ToolTimeoutProperties.java @@ -34,6 +34,7 @@ public class ToolTimeoutProperties { "web_fetch", "web", "url_fetch", "web", "write_file", "file", + "append_file", "file", "edit_file", "file", "read_file", "file" ); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java index 6c7e46bb..d4fc2086 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java @@ -35,6 +35,9 @@ public interface GoalService { /** Active goal for the conversation, or null. Used by buildInitialState. */ GoalEntity findActiveByConversation(String conversationId); + /** Most recently created goal for the conversation, regardless of status, or null. */ + GoalEntity findLatestByConversation(String conversationId); + /** Paged list filtered by status / owner. */ List list(String status, String username, int limit); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index e9b570ce..b6b76bb2 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -180,6 +180,18 @@ public class GoalServiceImpl implements GoalService { .last("LIMIT 1")); } + @Override + public GoalEntity findLatestByConversation(String conversationId) { + if (conversationId == null || conversationId.isBlank()) { + return null; + } + return goalMapper.selectOne(new LambdaQueryWrapper() + .eq(GoalEntity::getConversationId, conversationId) + .orderByDesc(GoalEntity::getCreateTime) + .orderByDesc(GoalEntity::getId) + .last("LIMIT 1")); + } + @Override public List list(String status, String username, int limit) { LambdaQueryWrapper w = new LambdaQueryWrapper() diff --git a/mateclaw-server/src/main/java/vip/mate/i18n/I18nAutoConfig.java b/mateclaw-server/src/main/java/vip/mate/i18n/I18nAutoConfig.java index 8b33f03e..7ba368f5 100644 --- a/mateclaw-server/src/main/java/vip/mate/i18n/I18nAutoConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/i18n/I18nAutoConfig.java @@ -1,6 +1,7 @@ package vip.mate.i18n; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import vip.mate.common.result.R; @@ -21,4 +22,9 @@ public class I18nAutoConfig { public void init() { R.setI18n(i18nService); } + + @PreDestroy + public void destroy() { + R.clearI18n(i18nService); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java index c6624c69..8e42e72f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolConcurrencyRegistry.java @@ -75,7 +75,7 @@ public class ToolConcurrencyRegistry { // Keep the legacy hardcoded names so existing deployments without // annotations still see the same behavior. New code should rely on // the @ConcurrencyUnsafe annotation rather than this list. - discovered.addAll(Arrays.asList("browser_use", "BrowserUseTool", "write_file", "edit_file")); + discovered.addAll(Arrays.asList("browser_use", "BrowserUseTool", "write_file", "append_file", "edit_file")); this.unsafeNames = Collections.unmodifiableSet(discovered); log.info("[ToolConcurrencyRegistry] Concurrency-unsafe tools ({}): {}", unsafeNames.size(), unsafeNames); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/AppendFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/AppendFileTool.java new file mode 100644 index 00000000..33c96b80 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/AppendFileTool.java @@ -0,0 +1,104 @@ +package vip.mate.tool.builtin; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.i18n.I18nService; +import vip.mate.tool.ConcurrencyUnsafe; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +/** Append-only file mutation with retry idempotency and an optional tail precondition. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AppendFileTool { + + private final I18nService i18n; + + @ConcurrencyUnsafe("append file — must serialize with reads/writes on overlapping paths") + @Tool(description = "Append a small content block to a file without rewriting existing content. " + + "Creates the file and parent directories when absent. If the file already ends with the exact " + + "content, the retry succeeds without writing it again. expectedTail can prevent appending to a " + + "file that changed since it was read. Returns structured JSON.") + public String append_file( + @ToolParam(description = "Absolute or relative file path") String filePath, + @ToolParam(description = "Content block to append; send only the new content") String content, + @ToolParam(description = "Optional exact suffix that must currently end the file", required = false) + String expectedTail, + @Nullable ToolContext ctx) { + + if (filePath == null || filePath.isBlank()) { + return error(filePath, "INVALID_ARGUMENT", i18n.msg("tool.write_file.error.path_empty")); + } + if (content == null || content.isEmpty()) { + return error(filePath, "INVALID_ARGUMENT", "content must not be empty"); + } + + try { + Path path = WorkspacePathGuard.validatePath(filePath, ctx); + if (Files.isDirectory(path)) { + return error(filePath, "IS_DIRECTORY", i18n.msg("tool.write_file.error.is_directory", path)); + } + + Path parent = path.getParent(); + if (parent != null) Files.createDirectories(parent); + + boolean existed = Files.exists(path); + String current = existed ? Files.readString(path, StandardCharsets.UTF_8) : ""; + if (current.endsWith(content)) { + JSONObject result = baseResult(filePath); + result.set("bytesWritten", 0); + result.set("created", false); + result.set("alreadyApplied", true); + result.set("message", "Content already present at file tail; no write needed"); + return JSONUtil.toJsonPrettyStr(result); + } + if (expectedTail != null && !current.endsWith(expectedTail)) { + return error(filePath, "PRECONDITION_FAILED", + "File tail changed; re-read the file before appending"); + } + + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + Files.write(path, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + + JSONObject result = baseResult(filePath); + result.set("bytesWritten", bytes.length); + result.set("created", !existed); + result.set("alreadyApplied", false); + result.set("message", "Appended: " + path + " (" + bytes.length + " bytes)"); + log.info("[AppendFile] Appended {} bytes to {}", bytes.length, path); + return JSONUtil.toJsonPrettyStr(result); + } catch (IllegalArgumentException e) { + return error(filePath, "PATH_REJECTED", e.getMessage()); + } catch (Exception e) { + log.error("[AppendFile] Failed to append file: {}", e.getMessage(), e); + return error(filePath, "APPEND_FAILED", + i18n.msg("tool.write_file.error.write_exception", e.getMessage())); + } + } + + private JSONObject baseResult(String filePath) { + JSONObject result = new JSONObject(); + result.set("filePath", filePath); + return result; + } + + private String error(String filePath, String code, String message) { + JSONObject result = baseResult(filePath); + result.set("error", true); + result.set("code", code); + result.set("message", message); + return JSONUtil.toJsonPrettyStr(result); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java index 63f1784a..e9e93e40 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java @@ -192,7 +192,24 @@ public class GoalManagementTool { if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); GoalEntity goal = resolveActive(ctx); if (goal == null) { - return successJson(Map.of("active", false)); + ChatOrigin origin = ChatOrigin.from(ctx); + GoalEntity latest = origin != null && origin.conversationId() != null + ? goalService.findLatestByConversation(origin.conversationId()) : null; + if (latest == null) { + return successJson(Map.of( + "active", false, + "recoverable", false, + "reason", "no_goal_on_conversation")); + } + boolean recoverable = latest.getStatus() == GoalStatus.PAUSED; + Map out = new LinkedHashMap<>(); + out.put("active", false); + out.put("goalId", String.valueOf(latest.getId())); + out.put("title", latest.getTitle()); + out.put("status", latest.getStatus().getValue()); + out.put("recoverable", recoverable); + out.put("reason", "latest_goal_" + latest.getStatus().getValue()); + return successJson(out); } Map out = new LinkedHashMap<>(); out.put("active", true); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java index 85c30b7a..eb1f9dd0 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java @@ -34,6 +34,7 @@ public class DefaultToolGuard implements ToolGuard { /** 文件写入类工具 —— 默认需要用户审批 */ private static final Set FILE_WRITE_TOOL_NAMES = Set.of( "write_file", + "append_file", "edit_file" ); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FilePathGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FilePathGuardian.java index ca704abd..9e5e957e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FilePathGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FilePathGuardian.java @@ -54,6 +54,7 @@ public class FilePathGuardian implements ToolGuardGuardian { private static final Map TOOL_FILE_PARAMS = Map.of( "read_file", "filePath", "write_file", "filePath", + "append_file", "filePath", "edit_file", "filePath", "file_read", "file_path", "file_write", "file_path" diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java index 38b5290d..20421c49 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/FileWriteGuardian.java @@ -23,7 +23,7 @@ import java.util.Set; public class FileWriteGuardian implements ToolGuardGuardian { private static final Set FILE_WRITE_TOOL_NAMES = Set.of( - "write_file", "edit_file" + "write_file", "append_file", "edit_file" ); @Override diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java index 5ee19d99..1224765a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java @@ -65,6 +65,7 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian { private static final Map FILE_PATH_PARAMS = Map.of( "read_file", "filePath", "write_file", "filePath", + "append_file", "filePath", "edit_file", "filePath" ); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java index ca44d1d0..0026f366 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java @@ -44,6 +44,7 @@ public class ToolGuardRuleSeedService implements ApplicationRunner { private static final Map TOOL_NAME_RENAMES = Map.of( "ShellExecuteTool", "execute_shell_command", "WriteFileTool", "write_file", + "AppendFileTool", "append_file", "EditFileTool", "edit_file" ); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java index 8b10e1ac..c4d6ad92 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java @@ -78,8 +78,8 @@ class NodeStreamingChatHelperToolCallArgsTest { } @Test - @DisplayName("Truncated/invalid JSON arguments normalized to '{}'") - void truncatedJsonArguments_replacedWithEmptyJsonObject() { + @DisplayName("Truncated/invalid JSON arguments preserved for executor rejection") + void truncatedJsonArguments_preservedForExecutor() { // Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped. AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( "id-truncated", "function", "search", "{\"q\":\"hel"); @@ -94,9 +94,9 @@ class NodeStreamingChatHelperToolCallArgsTest { assertTrue(result.hasToolCalls(), "tool call must survive"); assertEquals(1, result.toolCalls().size()); - assertEquals("{}", result.toolCalls().get(0).arguments(), - "invalid JSON arguments must be replaced with '{}' so the follow-up " - + "request stays well-formed"); + assertEquals("{\"q\":\"hel", result.toolCalls().get(0).arguments(), + "invalid streamed arguments must reach the executor so it can reject " + + "the call without invoking the tool"); } @Test @@ -147,6 +147,24 @@ class NodeStreamingChatHelperToolCallArgsTest { "tool call id must be preserved so the tool_call pairing holds"); } + @Test + @DisplayName("Prompt-history invalid arguments are normalized before provider replay") + void promptHistory_invalidArguments_normalizedBeforeSend() { + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "id-hist-invalid", "function", "write_file", "{\"filePath\":\"x"); + AssistantMessage historyMsg = AssistantMessage.builder() + .content("") + .toolCalls(List.of(tc)) + .build(); + + Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments( + new Prompt(List.of(new UserMessage("hi"), historyMsg))); + + AssistantMessage out = (AssistantMessage) normalized.getInstructions().get(1); + assertEquals("{}", out.getToolCalls().get(0).arguments(), + "strict providers must never receive invalid JSON in replayed history"); + } + @Test @DisplayName("Prompt with only valid tool-call arguments returned unchanged") void promptHistory_validArguments_returnsSameInstance() { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java index 86030a38..3f641916 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java @@ -145,6 +145,24 @@ class ToolExecutionExecutorNameNormalizationTest { assertEquals("ok:read_file", result.responses().get(0).responseData()); } + @Test + @DisplayName("invalid streamed arguments return a stable rejection without executing the tool") + void invalidArgumentsRejectedWithoutExecution() { + ToolCallback callback = callbackNamed("write_file"); + ToolExecutionExecutor executor = newExecutor(callback); + + var result = executor.execute( + List.of(new AssistantMessage.ToolCall( + "call_invalid", "function", "write_file", "{\"filePath\":\"notes.md\"")), + "conv", "agent", false, "user", null); + + assertEquals(1, result.responses().size()); + assertTrue(result.responses().get(0).responseData().contains("TOOL_ARGUMENTS_INCOMPLETE")); + assertTrue(result.responses().get(0).responseData().contains("append_file")); + verify(callback, never()).call(anyString(), any()); + verify(callback, never()).call(anyString()); + } + @Test @DisplayName("tool_call unwraps and executes the real tool in the same action round") void progressiveBridge_executesTargetSameRound() { diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java index e7797bc5..2f38f134 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -661,6 +661,13 @@ class GoalServiceTest { verify(goalMapper, never()).selectOne(any()); } + @Test + void findLatestByConversation_returnsNull_forBlankInput() { + assertNull(service.findLatestByConversation("")); + assertNull(service.findLatestByConversation(null)); + verify(goalMapper, never()).selectOne(any()); + } + @Test void getById_throws404_whenMissing() { when(goalMapper.selectById(1L)).thenReturn(null); diff --git a/mateclaw-server/src/test/java/vip/mate/i18n/I18nAutoConfigTest.java b/mateclaw-server/src/test/java/vip/mate/i18n/I18nAutoConfigTest.java new file mode 100644 index 00000000..9593b469 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/i18n/I18nAutoConfigTest.java @@ -0,0 +1,46 @@ +package vip.mate.i18n; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import vip.mate.common.result.R; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class I18nAutoConfigTest { + + @AfterEach + void clearHolder() { + R.setI18n(null); + } + + @Test + void closingContextClearsItsService() { + I18nService service = localized("localized-success"); + I18nAutoConfig config = new I18nAutoConfig(service); + config.init(); + + config.destroy(); + + assertEquals("result.success", R.ok().getMsg()); + } + + @Test + void closingOlderContextDoesNotClearNewerService() { + I18nAutoConfig older = new I18nAutoConfig(localized("older")); + I18nAutoConfig newer = new I18nAutoConfig(localized("newer")); + older.init(); + newer.init(); + + older.destroy(); + + assertEquals("newer", R.ok().getMsg()); + } + + private static I18nService localized(String message) { + I18nService service = mock(I18nService.class); + when(service.msg("result.success")).thenReturn(message); + return service; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java index 7f763a91..fe1d8f12 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java @@ -214,7 +214,7 @@ class TeamRunProjectorTest { } @Test - void fallbackAndStopReasonProduceAttentionWithHumanActionFirst() { + void fallbackDoesNotInflateAttentionAndStopReasonKeepsHumanActionFirst() { LocalDateTime now = LocalDateTime.now(); TeamRunEntity run = run(TeamRunStatus.CANCELLED, "{\"summaryQuality\":\"fallback\"}"); run.setFinalSummary("raw results"); @@ -233,7 +233,7 @@ class TeamRunProjectorTest { TeamRunView view = projector.project(RUN_ID); assertEquals("review", view.attentionItems().getFirst().type()); - assertTrue(view.attentionItems().stream().anyMatch(item -> "synthesis".equals(item.type()))); + assertTrue(view.attentionItems().stream().noneMatch(item -> "synthesis".equals(item.type()))); assertTrue(view.attentionItems().stream().anyMatch(item -> "stopped".equals(item.type()))); } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/FileMutationToolContextTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/FileMutationToolContextTest.java index e52bb2a4..8718e0ff 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/FileMutationToolContextTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/FileMutationToolContextTest.java @@ -68,6 +68,41 @@ class FileMutationToolContextTest { assertThat(defaultRoot.resolve("deck.md")).doesNotExist(); } + @Test + @DisplayName("append_file is workspace-scoped and duplicate retries are idempotent") + void appendFileUsesWorkspaceAndDeduplicatesRetry(@TempDir Path tempDir) throws Exception { + Path defaultRoot = Files.createDirectory(tempDir.resolve("default-root")); + Path contextRoot = Files.createDirectory(tempDir.resolve("context-root")); + WorkspacePathGuard.setDefaultRoot(defaultRoot.toString()); + Files.writeString(contextRoot.resolve("checkpoints.md"), "# Checkpoints\n", StandardCharsets.UTF_8); + AppendFileTool tool = new AppendFileTool(i18n()); + var ctx = ChatOrigin.web("conv-617", "alice", 1L, contextRoot.toString()).toToolContext(); + + String first = tool.append_file("checkpoints.md", "\n## CHK-020\nDone\n", "# Checkpoints\n", ctx); + String retry = tool.append_file("checkpoints.md", "\n## CHK-020\nDone\n", null, ctx); + + assertThat(JSONUtil.parseObj(first).getBool("error", false)).isFalse(); + assertThat(JSONUtil.parseObj(retry).getBool("alreadyApplied", false)).isTrue(); + assertThat(contextRoot.resolve("checkpoints.md")) + .hasContent("# Checkpoints\n\n## CHK-020\nDone\n"); + assertThat(defaultRoot.resolve("checkpoints.md")).doesNotExist(); + } + + @Test + @DisplayName("append_file rejects a stale expected tail without changing the file") + void appendFileRejectsExpectedTailMismatch(@TempDir Path tempDir) throws Exception { + Path contextRoot = Files.createDirectory(tempDir.resolve("context-root")); + Path file = contextRoot.resolve("checkpoints.md"); + Files.writeString(file, "current tail", StandardCharsets.UTF_8); + AppendFileTool tool = new AppendFileTool(i18n()); + + String result = tool.append_file("checkpoints.md", "new section", "different tail", + ChatOrigin.web("conv-617", "alice", 1L, contextRoot.toString()).toToolContext()); + + assertThat(JSONUtil.parseObj(result).getStr("code")).isEqualTo("PRECONDITION_FAILED"); + assertThat(file).hasContent("current tail"); + } + private static I18nService i18n() { return mock(I18nService.class, inv -> "msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java index 0e9dcf20..95aa98a6 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java @@ -165,6 +165,33 @@ class GoalManagementToolTest { assertTrue(result.contains("\"active\":false")); } + @Test + void getGoalStatus_completedLatestExplainsTerminalState() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + GoalEntity completed = goal(GoalStatus.COMPLETED); + when(goalService.findLatestByConversation("conv-1")).thenReturn(completed); + + String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice")); + + assertTrue(result.contains("\"active\":false")); + assertTrue(result.contains("\"status\":\"completed\"")); + assertTrue(result.contains("\"recoverable\":false")); + assertTrue(result.contains("latest_goal_completed")); + } + + @Test + void getGoalStatus_pausedLatestIsResumable() { + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + GoalEntity paused = goal(GoalStatus.PAUSED); + when(goalService.findLatestByConversation("conv-1")).thenReturn(paused); + + String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice")); + + assertTrue(result.contains("\"status\":\"paused\"")); + assertTrue(result.contains("\"recoverable\":true")); + assertTrue(result.contains("latest_goal_paused")); + } + @Test void getGoalStatus_active_carriesProgressSummary() { GoalEntity g = goal(GoalStatus.ACTIVE); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java index b444ff02..3e76cc32 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java @@ -168,6 +168,15 @@ class DefaultToolGuardTest { assertFalse(result.isBlocked()); } + @Test + @DisplayName("append_file follows the file-write approval policy") + void shouldRequireApprovalForAppendFile() { + ToolGuardResult result = toolGuard.check("append_file", + "{\"filePath\":\"notes.md\",\"content\":\"new\"}"); + assertFalse(result.isBlocked()); + assertTrue(result.needsApproval()); + } + @Test @DisplayName("允许带 WHERE 的 DELETE") void shouldAllowFilteredDelete() { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java index f86334dc..48d8caa6 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java @@ -48,8 +48,12 @@ class WorkspaceBoundaryGuardianTest { } private ToolInvocationContext write(String path, String basePath) { + return fileMutation("write_file", path, basePath); + } + + private ToolInvocationContext fileMutation(String toolName, String path, String basePath) { String args = "{\"filePath\":\"" + path + "\",\"content\":\"x\"}"; - return ToolInvocationContext.of("write_file", args, "conv", "agent") + return ToolInvocationContext.of(toolName, args, "conv", "agent") .withWorkspaceBasePath(basePath); } @@ -156,6 +160,14 @@ class WorkspaceBoundaryGuardianTest { assertTrue(guardian.evaluate(write(WORKSPACE + "/notes.txt", WORKSPACE)).isEmpty()); } + @Test + @DisplayName("append_file uses the same workspace boundary as write_file") + void appendFileBoundaryEnforced() { + assertBlocked(guardian.evaluate(fileMutation("append_file", "/etc/evil.conf", WORKSPACE))); + assertTrue(guardian.evaluate(fileMutation( + "append_file", WORKSPACE + "/checkpoints.md", WORKSPACE)).isEmpty()); + } + @Test @DisplayName("write_file with a relative path resolves against the workspace, not the process CWD (issue #494)") void writeRelativePath_pass() {