mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(runtime): harden long-task recovery and checkpoint writes
This commit is contained in:
parent
97125b991e
commit
dd4db18ed8
@ -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.
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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.
|
||||
* <p>
|
||||
|
||||
@ -85,7 +85,7 @@ public class ToolExecutionExecutor {
|
||||
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
|
||||
|
||||
private static final Set<String> 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;
|
||||
|
||||
@ -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<String> 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[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +
|
||||
|
||||
@ -148,7 +148,7 @@ public class ReasoningNode implements NodeAction {
|
||||
"(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)");
|
||||
private static final List<String> 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 =
|
||||
|
||||
@ -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<T> 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<vip.mate.i18n.I18nService> 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 <T> R<T> ok() {
|
||||
|
||||
@ -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"
|
||||
);
|
||||
|
||||
@ -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<GoalEntity> list(String status, String username, int limit);
|
||||
|
||||
|
||||
@ -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<GoalEntity>()
|
||||
.eq(GoalEntity::getConversationId, conversationId)
|
||||
.orderByDesc(GoalEntity::getCreateTime)
|
||||
.orderByDesc(GoalEntity::getId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GoalEntity> list(String status, String username, int limit) {
|
||||
LambdaQueryWrapper<GoalEntity> w = new LambdaQueryWrapper<GoalEntity>()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("active", true);
|
||||
|
||||
@ -34,6 +34,7 @@ public class DefaultToolGuard implements ToolGuard {
|
||||
/** 文件写入类工具 —— 默认需要用户审批 */
|
||||
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
|
||||
"write_file",
|
||||
"append_file",
|
||||
"edit_file"
|
||||
);
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ public class FilePathGuardian implements ToolGuardGuardian {
|
||||
private static final Map<String, String> 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"
|
||||
|
||||
@ -23,7 +23,7 @@ import java.util.Set;
|
||||
public class FileWriteGuardian implements ToolGuardGuardian {
|
||||
|
||||
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
|
||||
"write_file", "edit_file"
|
||||
"write_file", "append_file", "edit_file"
|
||||
);
|
||||
|
||||
@Override
|
||||
|
||||
@ -65,6 +65,7 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
|
||||
private static final Map<String, String> FILE_PATH_PARAMS = Map.of(
|
||||
"read_file", "filePath",
|
||||
"write_file", "filePath",
|
||||
"append_file", "filePath",
|
||||
"edit_file", "filePath"
|
||||
);
|
||||
|
||||
|
||||
@ -44,6 +44,7 @@ public class ToolGuardRuleSeedService implements ApplicationRunner {
|
||||
private static final Map<String, String> TOOL_NAME_RENAMES = Map.of(
|
||||
"ShellExecuteTool", "execute_shell_command",
|
||||
"WriteFileTool", "write_file",
|
||||
"AppendFileTool", "append_file",
|
||||
"EditFileTool", "edit_file"
|
||||
);
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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())));
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user