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() {