diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java index 30211dd3..28a49d57 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java @@ -78,9 +78,24 @@ public record ChatOrigin( */ @Nullable Long requesterUserId, @Nullable Long originMessageId, - @Nullable ExecutionAttribution executionAttribution + @Nullable ExecutionAttribution executionAttribution, + /** Goal selected when approval was created: null=legacy unknown, 0=observed unselected. */ + @Nullable Long selectedGoalId ) { + public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId, + @Nullable String requesterId, @Nullable Long workspaceId, + @Nullable String workspaceBasePath, @Nullable Long channelId, + @Nullable ChannelTarget channelTarget, boolean cronOrigin, + @Nullable String senderName, @Nullable String channelType, + @Nullable String chatId, @Nullable String baseUrl, + @Nullable Long requesterUserId, @Nullable Long originMessageId, + @Nullable ExecutionAttribution executionAttribution) { + this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, + channelId, channelTarget, cronOrigin, senderName, channelType, + chatId, baseUrl, requesterUserId, originMessageId, executionAttribution, null); + } + public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId, @Nullable String requesterId, @Nullable Long workspaceId, @Nullable String workspaceBasePath, @Nullable Long channelId, @@ -163,27 +178,32 @@ public record ChatOrigin( public ChatOrigin withAgent(@Nullable Long newAgentId) { return new ChatOrigin(newAgentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution); + senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, + executionAttribution, selectedGoalId); } public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, @Nullable String newWorkspaceBasePath) { return new ChatOrigin(agentId, conversationId, requesterId, newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, executionAttribution); + senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, + executionAttribution, selectedGoalId); } public ChatOrigin withConversationId(@Nullable String newConversationId) { return new ChatOrigin(agentId, newConversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, Objects.equals(conversationId, newConversationId) ? executionAttribution : null); + senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId, + Objects.equals(conversationId, newConversationId) ? executionAttribution : null, + selectedGoalId); } /** Carry a request-derived public base URL (see {@link #baseUrl()}). */ public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId, executionAttribution); + senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId, + executionAttribution, selectedGoalId); } /** @@ -197,13 +217,15 @@ public record ChatOrigin( @Nullable String newChatId) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId, executionAttribution); + newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId, + executionAttribution, selectedGoalId); } public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, - senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId, executionAttribution); + senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId, + executionAttribution, selectedGoalId); } public ChatOrigin withApprovalId(String pendingId) { @@ -216,7 +238,13 @@ public record ChatOrigin( public ChatOrigin withExecutionAttribution(ExecutionAttribution attribution) { return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, senderName, channelType, chatId, baseUrl, - requesterUserId, originMessageId, attribution); + requesterUserId, originMessageId, attribution, selectedGoalId); + } + + public ChatOrigin withSelectedGoalId(@Nullable Long goalId) { + return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, + channelId, channelTarget, cronOrigin, senderName, channelType, chatId, baseUrl, + requesterUserId, originMessageId, executionAttribution, goalId); } // ---------------- Spring AI ToolContext interop ---------------- diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java index e9f475bb..35e4d318 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java @@ -20,6 +20,7 @@ import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.goal.service.GoalApprovalRunService; import vip.mate.approval.event.ApprovalResolutionEvent; import vip.mate.approval.event.WorkflowApprovalResolvedEvent; import vip.mate.approval.model.ToolApprovalEntity; @@ -62,6 +63,8 @@ public class ApprovalWorkflowService implements ApplicationRunner { * publish is a no-op. */ @Autowired(required = false) private ApplicationEventPublisher events; + @Autowired(required = false) + private GoalApprovalRunService goalApprovalRuns; /** * GC scheduler — owns the 5-minute clock for the entire approval state machine @@ -230,19 +233,18 @@ public class ApprovalWorkflowService implements ApplicationRunner { String toolName, String toolArguments, String reason, String toolCallPayload, String siblingToolCalls, String agentId, GuardEvaluation evaluation) { + // Capture the graph-bound origin and selected Goal before creating + // the pending row. Its Goal may change while the approval waits, but + // the persisted snapshot retains the identity it had at creation. + ChatOrigin origin = ChatOriginHolder.get(); + if (goalApprovalRuns != null) origin = goalApprovalRuns.captureSelectedGoal(origin); + String chatOriginJson = serializeChatOrigin(origin); + // 1. 内存层 String pendingId = approvalService.createPending( conversationId, userId, toolName, toolArguments, reason, toolCallPayload, siblingToolCalls, agentId); - // RFC-063r §2.12: capture the originating ChatOrigin from the holder. - // The holder was set by AgentService.{chat,chatStream,...} for the - // duration of the agent invocation that produced this approval — so - // it is non-null for IM / web triggered tool calls. Snapshot is - // serialized once here and persisted on the DB row so cross-restart - // replays keep the channel binding. - String chatOriginJson = serializeChatOrigin(ChatOriginHolder.get()); - // 2. 增强内存记录 approvalService.getPending(pendingId).ifPresent(pending -> { if (evaluation != null) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 24c83824..d62224e9 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1367,15 +1367,25 @@ public class ChatController { String username, boolean approve) { if (goalApprovalRuns != null && jsonAcceptance != null) { var origin = approvalService.restoreChatOrigin(pending.getChatOrigin()); - if (origin != null && goalApprovalRuns.requiresCurrentApprover(origin.withApprovalId(pending.getPendingId()))) { + if (approve && (origin == null || origin.conversationId() == null + || origin.requesterUserId() == null && (origin.executionAttribution() == null + || origin.executionAttribution().goalId() == null)) + && goalApprovalRuns.hasManagedGoalHistory(pending.getConversationId(), pending.getAgentId())) { + throw new vip.mate.exception.MateClawException(409, + "Managed Goal approval origin is unavailable; start a new request"); + } + if (origin != null) origin = origin.withApprovalId(pending.getPendingId()); + if (origin != null && goalApprovalRuns.requiresCurrentApprover(origin)) { + var capturedOrigin = origin; Long currentUserId = requesterUserIdOf(auth); return jsonAcceptance.withAuthenticatedUser(currentUserId, username, current -> { - var attribution = origin.executionAttribution(); - if (attribution == null || attribution.goalId() == null) { - if (!java.util.Objects.equals(currentUserId, origin.requesterUserId())) { + var attribution = capturedOrigin.executionAttribution(); + if (attribution == null || attribution.goalAttemptId() == null) { + if (!java.util.Objects.equals(currentUserId, capturedOrigin.requesterUserId())) { throw new vip.mate.exception.MateClawException(403, "Approval belongs to another account"); } } + goalApprovalRuns.validateCapturedForApproval(capturedOrigin, current, approve); return approve ? approvalService.resolveAndConsume(pending.getPendingId(), current) : approvalService.resolve(pending.getPendingId(), current, "denied"); }); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java index 475317e4..20260346 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java @@ -34,6 +34,25 @@ public class GoalApprovalRunService { public record ReplayRun(GoalRunCoordinator.ClaimedRun run, ChatOrigin origin) { } + /** Persist the selected interactive Goal on the approval's origin snapshot. */ + public ChatOrigin captureSelectedGoal(ChatOrigin origin) { + if (origin == null || origin.cronOrigin() || origin.requesterUserId() == null + || origin.conversationId() == null || origin.agentId() == null || origin.workspaceId() == null + || origin.selectedGoalId() != null + || (origin.executionAttribution() != null + && origin.executionAttribution().goalAttemptId() != null)) return origin; + var selected = jdbc.queryForList(""" + SELECT id FROM mate_agent_goal + WHERE conversation_id=? AND agent_id=? AND workspace_id=? + AND json_acceptance_required=TRUE AND status IN ('active','paused') AND deleted=0 + """, Long.class, origin.conversationId(), origin.agentId(), origin.workspaceId()); + if (selected.size() > 1) throw rejected(); + // Zero records an explicit non-managed snapshot. Null is reserved for + // approvals persisted by older binaries that had no capture field. + if (selected.isEmpty()) return origin.withSelectedGoalId(0L); + return origin.withSelectedGoalId(selected.getFirst()); + } + public boolean requiresHandoff(ChatOrigin origin) { var link = origin == null ? null : origin.executionAttribution(); if (link == null || link.goalId() == null || link.approvalId() == null) return false; @@ -45,7 +64,9 @@ public class GoalApprovalRunService { /** Selected interactive Goals must retain their original authenticated requester at approval time. */ public boolean requiresCurrentApprover(ChatOrigin origin) { if (requiresHandoff(origin)) return true; + if (origin != null && origin.selectedGoalId() != null && origin.selectedGoalId() > 0) return true; if (origin == null || origin.conversationId() == null || origin.agentId() == null) return false; + if (legacyTerminalSelection(origin)) return true; Integer selected = jdbc.queryForObject(""" SELECT COUNT(*) FROM mate_agent_goal WHERE conversation_id=? AND agent_id=? AND json_acceptance_required=TRUE @@ -54,6 +75,60 @@ public class GoalApprovalRunService { return selected != null && selected > 0; } + /** Revalidate the captured Goal before an approval can be consumed. */ + public void validateCapturedForApproval(ChatOrigin origin, String username, boolean approve) { + if (approve && legacyTerminalSelection(origin)) throw rejected(); + var link = origin == null ? null : origin.executionAttribution(); + Long goalId = origin == null ? null : origin.selectedGoalId(); + if (link != null && link.goalId() != null && link.approvalId() != null + && (link.goalAttemptId() == null || link.ownerFence() == null)) throw rejected(); + boolean scheduled = link != null && link.goalAttemptId() != null; + if (goalId != null && goalId == 0L) goalId = null; + if (goalId == null && link != null) goalId = link.goalId(); + if (goalId == null) return; + var scope = acceptance.authorizedGoal(goalId, username, true); + if (!scope.required() || (approve && !(scheduled ? "active".equals(scope.status()) + : java.util.List.of("active", "paused").contains(scope.status()))) + || !Objects.equals(scope.conversationId(), origin.conversationId()) + || !Objects.equals(scope.workspaceId(), origin.workspaceId()) + || !Objects.equals(scope.agentId(), origin.agentId())) throw rejected(); + } + + /** Without a persisted origin, a managed approval cannot safely execute after an upgrade. */ + public boolean hasManagedGoalHistory(String conversationId, String agentId) { + if (conversationId == null) return false; + Long agent = null; + try { if (agentId != null) agent = Long.parseLong(agentId); } + catch (NumberFormatException invalid) { /* unknown agent: check the whole conversation */ } + Integer count = agent == null + ? jdbc.queryForObject(""" + SELECT COUNT(*) FROM mate_agent_goal + WHERE conversation_id=? AND json_acceptance_required=TRUE + """, Integer.class, conversationId) + : jdbc.queryForObject(""" + SELECT COUNT(*) FROM mate_agent_goal + WHERE conversation_id=? AND agent_id=? AND json_acceptance_required=TRUE + """, Integer.class, conversationId, agent); + return count != null && count > 0; + } + + /** Old pending rows lacked a selection snapshot; ambiguity near a terminal Goal fails closed. */ + private boolean legacyTerminalSelection(ChatOrigin origin) { + if (origin == null || origin.selectedGoalId() != null || origin.requesterUserId() == null + || origin.conversationId() == null || origin.agentId() == null || origin.workspaceId() == null) + return false; + var link = origin.executionAttribution(); + if (link == null || link.approvalId() == null) return false; + Integer count = jdbc.queryForObject(""" + SELECT COUNT(*) FROM mate_agent_goal g + JOIN mate_tool_approval p ON p.pending_id=? AND p.deleted=0 + WHERE g.conversation_id=? AND g.agent_id=? AND g.workspace_id=? + AND g.json_acceptance_required=TRUE + AND (g.status IN ('completed','abandoned','exhausted') OR g.deleted<>0) + """, Integer.class, link.approvalId(), origin.conversationId(), origin.agentId(), origin.workspaceId()); + return count != null && count > 0; + } + @Transactional public ReplayRun claim(ChatOrigin requested, String toolCallPayload) { ExecutionAttribution link = requested == null ? null : requested.executionAttribution(); diff --git a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md index fdc9f3fd..071b22a3 100644 --- a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md @@ -53,7 +53,7 @@ The host clock, database credentials and service host remain trusted. Run arbitr V198 also stores absolute scheduler lease deadlines. Existing leases expire during upgrade and are recovered from their persisted checkpoints: safe work may receive a new attempt; uncertain side effects remain blocked for review. Expired owners cannot renew, checkpoint, settle or use managed JSON tools. Renewal checks both current lease records after acquiring the goal lock, so a delayed scheduler tick cannot reuse an old timestamp to revive its owner. New valid owners can continue under the existing requirements. Recovery skips a scanned attempt while its continuation lease is still live or its owner has changed, so other eligible recoveries can proceed. A passing JSON binding does not override a pause caused by an uncertain tool outcome. -Validation snapshot (2026-09-14): the full default backend test run passed 5,249 executed tests with 33 conditional skips; the frontend passed 386 tests. The managed contract also has real compiled ReAct/Plan graph tests for account and scheduled-owner execution. Their model choices and semantic verdicts are controlled fixtures, not online-model benchmarks. Specialized integration profiles and the proprietary Kingbase engine are outside that full-default-suite claim. +Validation snapshot (2026-09-15): the full default backend test run passed 5,359 executed tests with 44 conditional skips; the frontend passed 391 tests. The managed contract also has real compiled ReAct/Plan graph tests for account and scheduled-owner execution. Their model choices and semantic verdicts are controlled fixtures, not online-model benchmarks. Specialized integration profiles and the proprietary Kingbase engine are outside that full-default-suite claim. Built-in shell/code execution is not OS-isolated from the service host. Selecting JSON acceptance does not sandbox those tools, and the protocol cannot defend against host code that can access database credentials or files. Environment-name filtering and workspace path checks do not replace that isolation. Lease deadlines are calculated from absolute instants, including daylight-saving clock rollback; scheduling display fields remain local timestamps. @@ -76,3 +76,5 @@ Approval snapshots use the identity bound to tool execution in graph state, rath For a Goal with managed JSON requirements, Web approval consumption also locks and rechecks the requesting account ID through the approval transaction. If an account is retired and replaced under the same username after JWT validation, the old in-flight request neither consumes the pending approval nor starts replay; a newly signed-in account can still approve. Denial uses the same current-account check. An interactive managed Goal approval also retains the original requester account ID. A newly signed-in account with the same username cannot consume the old account’s pending approval; the user must make a new request under the new account. A persistent Goal attempt approval checks the current approver account ID and can be approved after the new account signs in. + +An interactive approval now persists which Goal was selected when the approval was created. If that Goal becomes terminal or changes scope while the approval waits, approval cannot consume the pending record or replay its tool. The original enabled account can deny the pending approval to clear it. An approval saved before this snapshot existed is treated conservatively when its conversation has a terminal managed Goal; if its origin is missing, approval is refused when that conversation has managed Goal history. Such a pending request should be denied or allowed to expire, then issued again. New approvals created while no managed Goal is selected explicitly record that fact and retain the existing unselected path. The chat UI shows an approval as allowed only after the server confirms it; a rejected SSE request keeps the pending card visible. diff --git a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md index 27863a2e..efe262eb 100644 --- a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md @@ -53,7 +53,7 @@ V197 使用 epoch 秒作为有效期依据,不受 JVM/JDBC 时区变化影响 V198 同样以绝对时间保存调度租约截止。升级时旧租约失效,按已有检查点恢复:安全工作可建立新 attempt,不确定副作用仍阻断并要求核实。过期 owner 不能续租、提交检查点、结算或使用受管 JSON 工具。续租在获得 Goal 锁后检查两侧当前租约,迟到调度 tick 不能利用旧时间戳复活 owner;新的有效 owner 可按现有要求继续。恢复扫描遇到 continuation 租约仍有效或 owner 已变化的记录时会跳过,继续处理其他可恢复目标。JSON 绑定通过也不能越过不确定工具结果导致的暂停。 -验证快照(2026-09-14):默认后端完整测试集实际通过 5,249 项、条件跳过 33 项,前端通过 386 项。受管契约还覆盖真实编译的 ReAct/Plan 图及账户/调度 owner 四种组合;模型选择和语义评价是受控夹具,不是在线模型基准。专门集成 profile 与 Kingbase 专有引擎不包含在“默认完整测试集”结论中。 +验证快照(2026-09-15):默认后端完整测试集实际通过 5,359 项、条件跳过 44 项,前端通过 391 项。受管契约还覆盖真实编译的 ReAct/Plan 图及账户/调度 owner 四种组合;模型选择和语义评价是受控夹具,不是在线模型基准。专门集成 profile 与 Kingbase 专有引擎不包含在“默认完整测试集”结论中。 内置 shell/code 执行没有与服务宿主做操作系统隔离。选择 JSON 验收不会把这些工具变成沙箱;此协议不能抵抗能访问数据库凭据或文件的宿主代码,环境变量名称过滤和工作区路径检查也不能替代隔离。租约截止从绝对时刻计算,覆盖夏令时回拨;调度显示字段仍使用本地时间戳。 @@ -78,3 +78,5 @@ V200 保存待审批结算对应的准确 attempt,并对审批生成的新 att 选定JSON要求的Goal在Web审批消费时,也按当前请求的认证账户ID持锁复查,直到审批写入事务结束。若请求通过JWT校验后旧账户被停用并由同名新账户取代,旧请求不会消费待审批记录或触发重放;新账户重新登录后仍可批准。拒绝审批同样按当前账户复查。 交互式选定Goal的旧审批还绑定最初请求者的账户ID。同名新账户重新登录也不能消费旧账户留下的待审批记录;用户需要在新账户下发起新的请求。后台Goal的持久attempt审批按当前批准者账户ID复查,允许新账户在重新登录后批准。 + +交互式审批会持久记录创建审批时选定的Goal。若等待期间该Goal进入终态或归属范围改变,“批准”不能消费pending或重放工具;原启用账户仍可“拒绝”清理。升级前没有此快照的审批,若同一会话有已终结的受管Goal,将保守拒绝批准;origin缺失且会话存在受管Goal历史时也拒绝批准。用户可拒绝或等待其过期,再重新发起请求。未选定受管Goal时新建的审批会明确记录这一状态,沿用原有路径。聊天页面仅在服务端确认后显示“已允许”;SSE拒绝后保留待审批卡片。 diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java index 23584da7..e293efce 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java @@ -14,6 +14,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.TestPropertySource; import reactor.core.publisher.Flux; import vip.mate.MateClawApplication; +import vip.mate.agent.context.ChatOrigin; import vip.mate.goal.model.*; import vip.mate.goal.service.*; import vip.mate.memory.spi.MemoryManager; @@ -53,6 +54,7 @@ class GoalJsonHttpRuntimeIntegrationTest { @Autowired private GoalRecoveryService recovery; @Autowired private GoalSegmentRunner runner; @Autowired private GoalAttemptStore attempts; + @Autowired private GoalApprovalRunService approvalRuns; @Autowired private vip.mate.approval.ApprovalWorkflowService approvals; @Autowired private vip.mate.tool.guard.repository.ToolGuardRuleMapper guardRules; @Autowired private vip.mate.tool.guard.engine.ToolGuardRuleRegistry guardRegistry; @@ -87,11 +89,15 @@ class GoalJsonHttpRuntimeIntegrationTest { "false,foreign-approval,true", "true,foreign-approval,true", "false,scheduled-foreign-approval,true", "true,scheduled-foreign-approval,true", "false,scheduled-reassigned-approval,true", "true,scheduled-reassigned-approval,true", - "false,reassigned-approval,true", "true,reassigned-approval,true"}) + "false,reassigned-approval,true", "true,reassigned-approval,true", + "false,terminal-approval,true", "true,terminal-approval,true", + "false,legacy-terminal-approval,true", "true,legacy-terminal-approval,true", + "false,originless-terminal-approval,true", "true,originless-terminal-approval,true"}) void authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(boolean plan, String entry, boolean accepted) throws Exception { boolean approval = entry.endsWith("approval"); boolean doubleApproval = entry.equals("scheduled-double-approval"); boolean reassigned = entry.contains("reassigned"); + boolean terminal = entry.contains("terminal-"); boolean detached = entry.contains("detached"); boolean foreign = entry.contains("foreign"); boolean supervised = entry.startsWith("supervised"); @@ -333,8 +339,51 @@ class GoalJsonHttpRuntimeIntegrationTest { assertNotNull(approvals.restoreChatOrigin(persistedOrigin).executionAttribution(), persistedOrigin); assertEquals(run.attempt().id(), approvals.restoreChatOrigin(persistedOrigin).executionAttribution().goalAttemptId()); } - else assertEquals(userId, approvals.restoreChatOrigin(persistedOrigin).requesterUserId()); + else { + assertEquals(userId, approvals.restoreChatOrigin(persistedOrigin).requesterUserId()); + assertEquals(goal.getId(), approvals.restoreChatOrigin(persistedOrigin).selectedGoalId()); + var approvalReplayOrigin = approvals.restoreChatOrigin(persistedOrigin) + .withSelectedGoalId(null).withApprovalId(pendingId); + assertEquals(goal.getId(), approvalRuns.captureSelectedGoal(approvalReplayOrigin).selectedGoalId(), + "A replayed interactive approval can create another selected approval"); + } Long approvedPlan = plan ? jdbc.queryForObject("SELECT id FROM mate_plan WHERE conversation_id=?", Long.class, conversation) : null; + if (terminal) { + if (entry.startsWith("legacy-")) { + var oldOrigin = (com.fasterxml.jackson.databind.node.ObjectNode) json.readTree(persistedOrigin); + oldOrigin.remove("selectedGoalId"); + String oldSnapshot = json.writeValueAsString(oldOrigin); + approvals.getPending(pendingId).orElseThrow().setChatOrigin(oldSnapshot); + jdbc.update("UPDATE mate_tool_approval SET chat_origin=? WHERE pending_id=?", oldSnapshot, pendingId); + } else if (entry.startsWith("originless-")) { + approvals.getPending(pendingId).orElseThrow().setChatOrigin(null); + jdbc.update("UPDATE mate_tool_approval SET chat_origin=NULL WHERE pending_id=?", pendingId); + } + goals.abandon(goal.getId(), username); + assertEquals(GoalStatus.ABANDONED, goals.getById(goal.getId()).getStatus()); + if (entry.startsWith("legacy-")) { + var oldApprovalOrigin = approvals.restoreChatOrigin( + approvals.getPending(pendingId).orElseThrow().getChatOrigin()).withApprovalId(pendingId); + assertNull(oldApprovalOrigin.selectedGoalId()); + assertTrue(approvalRuns.requiresCurrentApprover(oldApprovalOrigin), + "An old approval pending before Goal termination must remain managed"); + } + var laterUnselected = approvalRuns.captureSelectedGoal( + ChatOrigin.web(conversation, username, 1L, null, null, userId).withAgent(agentId)); + assertEquals(0L, laterUnselected.selectedGoalId()); + assertFalse(approvalRuns.requiresCurrentApprover(laterUnselected.withApprovalId(pendingId)), + "A newly unselected approval must retain the legacy route"); + String rejected = requestBody("POST", "/api/v1/chat/stream", token, + Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, + "message", "/approve", "pendingApprovalId", pendingId)); + assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM mate_tool_approval WHERE pending_id=?", String.class, pendingId), rejected); + assertEquals(GoalStatus.ABANDONED, goals.getById(goal.getId()).getStatus()); + requestBody("POST", "/api/v1/chat/stream", token, + Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, + "message", "/deny", "pendingApprovalId", pendingId)); + assertEquals("DENIED", jdbc.queryForObject("SELECT status FROM mate_tool_approval WHERE pending_id=?", String.class, pendingId)); + return; + } if (reassigned) { var replaceOnce = new java.util.concurrent.atomic.AtomicBoolean(true); doAnswer(invocation -> {