fix(runtime): harden long-task recovery and checkpoint writes

This commit is contained in:
matevip 2026-08-30 23:13:42 -04:00
parent 97125b991e
commit dd4db18ed8
27 changed files with 387 additions and 19 deletions

View File

@ -769,6 +769,7 @@ public class AgentBindingService implements AgentBindingResolver {
"read_file", "read_file",
"send_file", "send_file",
"write_file", "write_file",
"append_file",
"edit_file", "edit_file",
"execute_shell_command", "execute_shell_command",
// Inline code execution an agent-wide capability alongside shell. // Inline code execution an agent-wide capability alongside shell.

View File

@ -2509,11 +2509,41 @@ public class NodeStreamingChatHelper {
acc.id, acc.id,
acc.type != null ? acc.type : "function", acc.type != null ? acc.type : "function",
acc.name, acc.name,
sanitizeToolCallArguments(acc.name, acc.arguments.toString()))); toolCallArgumentsForExecution(acc.name, acc.arguments.toString())));
} }
return result; 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. * Ensure {@code function.arguments} is always a well-formed JSON string.
* <p> * <p>

View File

@ -85,7 +85,7 @@ public class ToolExecutionExecutor {
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16; static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of( 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) { } catch (Exception jsonEx) {
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}", log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
toolName, arguments.length(), jsonEx.getMessage()); toolName, arguments.length(), jsonEx.getMessage());
String truncationError = normalizeToolExecutionError(jsonEx); String truncationError = incompleteToolArgumentsError(toolName);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false)); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
allResponses.add(new ToolResponseMessage.ToolResponse( allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, truncationError)); toolCall.id(), responseName, truncationError));
@ -730,6 +730,17 @@ public class ToolExecutionExecutor {
rawEvidenceRef.get()); 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) { private static String requestedSkillName(String arguments) {
if (arguments == null || arguments.isBlank()) { if (arguments == null || arguments.isBlank()) {
return null; return null;

View File

@ -54,7 +54,7 @@ public class ObservationNode implements NodeAction {
* determined statically, and a false reminder is worse than none. * determined statically, and a false reminder is worse than none.
*/ */
private static final java.util.Set<String> FILE_MUTATION_TOOLS = 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 = private static final String VERIFICATION_REMINDER =
"\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" + "\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +

View File

@ -148,7 +148,7 @@ public class ReasoningNode implements NodeAction {
"(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)"); "(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)");
private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of( private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of(
"renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile", "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. */ /** Continuation nudge appended to the prompt when the model returns an empty turn. */
private static final String EMPTY_COMPLETION_NUDGE = private static final String EMPTY_COMPLETION_NUDGE =

View File

@ -3,6 +3,7 @@ package vip.mate.common.result;
import lombok.Data; import lombok.Data;
import java.io.Serializable; import java.io.Serializable;
import java.util.concurrent.atomic.AtomicReference;
/** /**
* 统一响应结果封装 * 统一响应结果封装
@ -24,12 +25,18 @@ public class R<T> implements Serializable {
private T data; private T data;
/** i18n holder — set once at startup by I18nAutoConfig, used by ok()/fail() */ /** 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) { 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() { public static <T> R<T> ok() {

View File

@ -34,6 +34,7 @@ public class ToolTimeoutProperties {
"web_fetch", "web", "web_fetch", "web",
"url_fetch", "web", "url_fetch", "web",
"write_file", "file", "write_file", "file",
"append_file", "file",
"edit_file", "file", "edit_file", "file",
"read_file", "file" "read_file", "file"
); );

View File

@ -35,6 +35,9 @@ public interface GoalService {
/** Active goal for the conversation, or null. Used by buildInitialState. */ /** Active goal for the conversation, or null. Used by buildInitialState. */
GoalEntity findActiveByConversation(String conversationId); 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. */ /** Paged list filtered by status / owner. */
List<GoalEntity> list(String status, String username, int limit); List<GoalEntity> list(String status, String username, int limit);

View File

@ -180,6 +180,18 @@ public class GoalServiceImpl implements GoalService {
.last("LIMIT 1")); .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 @Override
public List<GoalEntity> list(String status, String username, int limit) { public List<GoalEntity> list(String status, String username, int limit) {
LambdaQueryWrapper<GoalEntity> w = new LambdaQueryWrapper<GoalEntity>() LambdaQueryWrapper<GoalEntity> w = new LambdaQueryWrapper<GoalEntity>()

View File

@ -1,6 +1,7 @@
package vip.mate.i18n; package vip.mate.i18n;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import vip.mate.common.result.R; import vip.mate.common.result.R;
@ -21,4 +22,9 @@ public class I18nAutoConfig {
public void init() { public void init() {
R.setI18n(i18nService); R.setI18n(i18nService);
} }
@PreDestroy
public void destroy() {
R.clearI18n(i18nService);
}
} }

View File

@ -75,7 +75,7 @@ public class ToolConcurrencyRegistry {
// Keep the legacy hardcoded names so existing deployments without // Keep the legacy hardcoded names so existing deployments without
// annotations still see the same behavior. New code should rely on // annotations still see the same behavior. New code should rely on
// the @ConcurrencyUnsafe annotation rather than this list. // 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); this.unsafeNames = Collections.unmodifiableSet(discovered);
log.info("[ToolConcurrencyRegistry] Concurrency-unsafe tools ({}): {}", log.info("[ToolConcurrencyRegistry] Concurrency-unsafe tools ({}): {}",
unsafeNames.size(), unsafeNames); unsafeNames.size(), unsafeNames);

View File

@ -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);
}
}

View File

@ -192,7 +192,24 @@ public class GoalManagementTool {
if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled");
GoalEntity goal = resolveActive(ctx); GoalEntity goal = resolveActive(ctx);
if (goal == null) { 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<>(); Map<String, Object> out = new LinkedHashMap<>();
out.put("active", true); out.put("active", true);

View File

@ -34,6 +34,7 @@ public class DefaultToolGuard implements ToolGuard {
/** 文件写入类工具 —— 默认需要用户审批 */ /** 文件写入类工具 —— 默认需要用户审批 */
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of( private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
"write_file", "write_file",
"append_file",
"edit_file" "edit_file"
); );

View File

@ -54,6 +54,7 @@ public class FilePathGuardian implements ToolGuardGuardian {
private static final Map<String, String> TOOL_FILE_PARAMS = Map.of( private static final Map<String, String> TOOL_FILE_PARAMS = Map.of(
"read_file", "filePath", "read_file", "filePath",
"write_file", "filePath", "write_file", "filePath",
"append_file", "filePath",
"edit_file", "filePath", "edit_file", "filePath",
"file_read", "file_path", "file_read", "file_path",
"file_write", "file_path" "file_write", "file_path"

View File

@ -23,7 +23,7 @@ import java.util.Set;
public class FileWriteGuardian implements ToolGuardGuardian { public class FileWriteGuardian implements ToolGuardGuardian {
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of( private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
"write_file", "edit_file" "write_file", "append_file", "edit_file"
); );
@Override @Override

View File

@ -65,6 +65,7 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
private static final Map<String, String> FILE_PATH_PARAMS = Map.of( private static final Map<String, String> FILE_PATH_PARAMS = Map.of(
"read_file", "filePath", "read_file", "filePath",
"write_file", "filePath", "write_file", "filePath",
"append_file", "filePath",
"edit_file", "filePath" "edit_file", "filePath"
); );

View File

@ -44,6 +44,7 @@ public class ToolGuardRuleSeedService implements ApplicationRunner {
private static final Map<String, String> TOOL_NAME_RENAMES = Map.of( private static final Map<String, String> TOOL_NAME_RENAMES = Map.of(
"ShellExecuteTool", "execute_shell_command", "ShellExecuteTool", "execute_shell_command",
"WriteFileTool", "write_file", "WriteFileTool", "write_file",
"AppendFileTool", "append_file",
"EditFileTool", "edit_file" "EditFileTool", "edit_file"
); );

View File

@ -78,8 +78,8 @@ class NodeStreamingChatHelperToolCallArgsTest {
} }
@Test @Test
@DisplayName("Truncated/invalid JSON arguments normalized to '{}'") @DisplayName("Truncated/invalid JSON arguments preserved for executor rejection")
void truncatedJsonArguments_replacedWithEmptyJsonObject() { void truncatedJsonArguments_preservedForExecutor() {
// Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped. // Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped.
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
"id-truncated", "function", "search", "{\"q\":\"hel"); "id-truncated", "function", "search", "{\"q\":\"hel");
@ -94,9 +94,9 @@ class NodeStreamingChatHelperToolCallArgsTest {
assertTrue(result.hasToolCalls(), "tool call must survive"); assertTrue(result.hasToolCalls(), "tool call must survive");
assertEquals(1, result.toolCalls().size()); assertEquals(1, result.toolCalls().size());
assertEquals("{}", result.toolCalls().get(0).arguments(), assertEquals("{\"q\":\"hel", result.toolCalls().get(0).arguments(),
"invalid JSON arguments must be replaced with '{}' so the follow-up " "invalid streamed arguments must reach the executor so it can reject "
+ "request stays well-formed"); + "the call without invoking the tool");
} }
@Test @Test
@ -147,6 +147,24 @@ class NodeStreamingChatHelperToolCallArgsTest {
"tool call id must be preserved so the tool_call pairing holds"); "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 @Test
@DisplayName("Prompt with only valid tool-call arguments returned unchanged") @DisplayName("Prompt with only valid tool-call arguments returned unchanged")
void promptHistory_validArguments_returnsSameInstance() { void promptHistory_validArguments_returnsSameInstance() {

View File

@ -145,6 +145,24 @@ class ToolExecutionExecutorNameNormalizationTest {
assertEquals("ok:read_file", result.responses().get(0).responseData()); 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 @Test
@DisplayName("tool_call unwraps and executes the real tool in the same action round") @DisplayName("tool_call unwraps and executes the real tool in the same action round")
void progressiveBridge_executesTargetSameRound() { void progressiveBridge_executesTargetSameRound() {

View File

@ -661,6 +661,13 @@ class GoalServiceTest {
verify(goalMapper, never()).selectOne(any()); verify(goalMapper, never()).selectOne(any());
} }
@Test
void findLatestByConversation_returnsNull_forBlankInput() {
assertNull(service.findLatestByConversation(""));
assertNull(service.findLatestByConversation(null));
verify(goalMapper, never()).selectOne(any());
}
@Test @Test
void getById_throws404_whenMissing() { void getById_throws404_whenMissing() {
when(goalMapper.selectById(1L)).thenReturn(null); when(goalMapper.selectById(1L)).thenReturn(null);

View File

@ -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;
}
}

View File

@ -214,7 +214,7 @@ class TeamRunProjectorTest {
} }
@Test @Test
void fallbackAndStopReasonProduceAttentionWithHumanActionFirst() { void fallbackDoesNotInflateAttentionAndStopReasonKeepsHumanActionFirst() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
TeamRunEntity run = run(TeamRunStatus.CANCELLED, "{\"summaryQuality\":\"fallback\"}"); TeamRunEntity run = run(TeamRunStatus.CANCELLED, "{\"summaryQuality\":\"fallback\"}");
run.setFinalSummary("raw results"); run.setFinalSummary("raw results");
@ -233,7 +233,7 @@ class TeamRunProjectorTest {
TeamRunView view = projector.project(RUN_ID); TeamRunView view = projector.project(RUN_ID);
assertEquals("review", view.attentionItems().getFirst().type()); 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()))); assertTrue(view.attentionItems().stream().anyMatch(item -> "stopped".equals(item.type())));
} }

View File

@ -68,6 +68,41 @@ class FileMutationToolContextTest {
assertThat(defaultRoot.resolve("deck.md")).doesNotExist(); 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() { private static I18nService i18n() {
return mock(I18nService.class, inv -> return mock(I18nService.class, inv ->
"msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null); "msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null);

View File

@ -165,6 +165,33 @@ class GoalManagementToolTest {
assertTrue(result.contains("\"active\":false")); 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 @Test
void getGoalStatus_active_carriesProgressSummary() { void getGoalStatus_active_carriesProgressSummary() {
GoalEntity g = goal(GoalStatus.ACTIVE); GoalEntity g = goal(GoalStatus.ACTIVE);

View File

@ -168,6 +168,15 @@ class DefaultToolGuardTest {
assertFalse(result.isBlocked()); 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 @Test
@DisplayName("允许带 WHERE 的 DELETE") @DisplayName("允许带 WHERE 的 DELETE")
void shouldAllowFilteredDelete() { void shouldAllowFilteredDelete() {

View File

@ -48,8 +48,12 @@ class WorkspaceBoundaryGuardianTest {
} }
private ToolInvocationContext write(String path, String basePath) { 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\"}"; String args = "{\"filePath\":\"" + path + "\",\"content\":\"x\"}";
return ToolInvocationContext.of("write_file", args, "conv", "agent") return ToolInvocationContext.of(toolName, args, "conv", "agent")
.withWorkspaceBasePath(basePath); .withWorkspaceBasePath(basePath);
} }
@ -156,6 +160,14 @@ class WorkspaceBoundaryGuardianTest {
assertTrue(guardian.evaluate(write(WORKSPACE + "/notes.txt", WORKSPACE)).isEmpty()); 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 @Test
@DisplayName("write_file with a relative path resolves against the workspace, not the process CWD (issue #494)") @DisplayName("write_file with a relative path resolves against the workspace, not the process CWD (issue #494)")
void writeRelativePath_pass() { void writeRelativePath_pass() {