From 251a3288dd58fb86dc833800ee61f57277239fbe Mon Sep 17 00:00:00 2001 From: mateaix <57164338+mateaix@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:32:49 +0800 Subject: [PATCH] feat(team): execution hardening, task deliverables and run transcript visibility --- .../vip/mate/agent/AgentGraphBuilder.java | 3 + .../mate/agent/graph/node/ReasoningNode.java | 27 ++ .../mate/team/controller/TeamController.java | 4 +- .../team/service/TeamAnnounceService.java | 24 +- .../mate/team/service/TeamContextBuilder.java | 80 +++++- .../team/service/TeamDispatchService.java | 83 +++++- .../mate/team/service/TeamTaskService.java | 109 ++++++++ .../vip/mate/team/tool/TeamTasksTool.java | 38 ++- .../team/service/TeamAnnounceServiceTest.java | 15 +- .../team/service/TeamContextBuilderTest.java | 79 +++++- .../team/service/TeamDispatchServiceTest.java | 79 ++++++ .../team/service/TeamTaskServiceTest.java | 75 ++++++ .../vip/mate/team/tool/TeamTasksToolTest.java | 65 ++++- mateclaw-ui/src/api/index.ts | 7 + mateclaw-ui/src/i18n/locales/en-US.ts | 9 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 9 + mateclaw-ui/src/views/Teams.vue | 238 +++++++++++++++++- 17 files changed, 912 insertions(+), 32 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 88ecc762..bdc83fd9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -962,6 +962,9 @@ public class AgentGraphBuilder { // C4: wire the environment-notification registry so ReasoningNode // can drain pending MCP/skill events and inject them as a SystemMessage. reasoningNode.setRunningConversationRegistry(runningConversationRegistry); + // Live team-board snapshot for leads, injected per turn as a meta + // user message; no-op for agents outside any team. + reasoningNode.setTeamContextBuilder(teamContextBuilder); ActionNode actionNode = new ActionNode(executor, streamTracker); // B2/B5: wire optional collaborators so ActionNode can pin skill // constraints and auto-record tool completions into ProgressLedger. 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 381a84da..b4006776 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 @@ -32,6 +32,7 @@ import vip.mate.agent.graph.state.MateClawStateKeys; import vip.mate.agent.graph.state.SourceEvidenceLedger; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.service.TeamContextBuilder; import java.util.*; import java.util.concurrent.CancellationException; @@ -390,6 +391,18 @@ public class ReasoningNode implements NodeAction { this.runningConversationRegistry = runningConversationRegistry; } + /** + * Live team-board snapshot source for agents leading a team. When non-null, + * each turn's prompt prefix carries the board's in-flight tasks as a meta + * user message so the lead never duplicates or prematurely closes work. + * Null in tests / legacy paths — injection is simply skipped. + */ + private TeamContextBuilder teamContextBuilder; + + public void setTeamContextBuilder(TeamContextBuilder teamContextBuilder) { + this.teamContextBuilder = teamContextBuilder; + } + /** Floor for the window-aware output clamp — an answer needs at least this much room. */ private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512; @@ -1318,6 +1331,20 @@ public class ReasoningNode implements NodeAction { // agentId not numeric — skip wiki injection (matches prior behavior). } } + // Live team-board snapshot for leads: a UserMessage (never SystemMessage, + // per the runtime-context cache discipline) listing in-flight tasks, so a + // lead mid-conversation neither duplicates nor prematurely closes work. + // buildBoardSnapshot returns null for non-leads and idle boards. + if (teamContextBuilder != null && agentIdStr != null && !agentIdStr.isEmpty()) { + try { + String boardSnapshot = teamContextBuilder.buildBoardSnapshot(Long.parseLong(agentIdStr)); + if (boardSnapshot != null && !boardSnapshot.isBlank()) { + prefix.add(new UserMessage(boardSnapshot)); + } + } catch (NumberFormatException ignored) { + // agentId not numeric — skip board injection. + } + } return prefix; } diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java index dcbf5d7a..2d568b47 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java @@ -189,8 +189,10 @@ public class TeamController { @PostMapping("/{id}/tasks/{taskId}/cancel") public R cancel(@PathVariable Long id, @PathVariable Long taskId, @RequestBody(required = false) ReasonRequest req) { - requireTask(id, taskId); + TeamTaskEntity task = requireTask(id, taskId); List released = taskService.cancelTask(taskId, req == null ? null : req.getReason()); + // Stop the member run mid-flight instead of letting it burn to the end. + dispatchService.interruptRun(task); if (!released.isEmpty()) { dispatchService.requestDispatch(id); } diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java index 33f8c340..8da88b08 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java @@ -11,6 +11,7 @@ import vip.mate.channel.web.ChatStreamTracker; import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskStatus; +import vip.mate.workspace.conversation.ConversationService; import java.util.ArrayList; import java.util.List; @@ -65,10 +66,12 @@ public class TeamAnnounceService { Executors.newVirtualThreadPerTaskExecutor(); private final TeamService teamService; + private final TeamTaskService taskService; private final AgentService agentService; private final AgentMapper agentMapper; private final RunningConversationRegistry runningConversations; private final ChatStreamTracker streamTracker; + private final ConversationService conversationService; /** Pending items per lead conversation; the first item arms the drain timer. */ private final Map> pending = new ConcurrentHashMap<>(); @@ -88,10 +91,18 @@ public class TeamAnnounceService { String detail = TeamTaskStatus.COMPLETED.equals(task.getStatus()) || TeamTaskStatus.IN_REVIEW.equals(task.getStatus()) ? task.getResult() : task.getReason(); + StringBuilder detailWithFiles = new StringBuilder(detail == null ? "" : detail); + List deliverables = taskService.listDeliverables(task); + if (!deliverables.isEmpty()) { + detailWithFiles.append("\nDeliverables (share these download links with the user):"); + for (TeamTaskService.Deliverable file : deliverables) { + detailWithFiles.append("\n- ").append(file.name()).append(" → ").append(file.url()); + } + } AnnounceItem item = new AnnounceItem(task.getTeamId(), task.getTaskNumber(), task.getSubject(), task.getStatus(), agentName(task.getAssigneeAgentId()), - detail == null ? "" : detail); + detailWithFiles.toString()); String key = task.getLeadConversationId(); List drainNow = null; @@ -158,12 +169,19 @@ public class TeamAnnounceService { try { streamTracker.broadcastObject(leadConversationId, "team_announce_start", Map.of("teamId", String.valueOf(team.getId()), "tasks", taskCount)); + // Persist the announce turn: message persistence is the caller's + // contract, and without it the lead's synthesized reply would + // vanish from the conversation history on the next reload. + conversationService.saveMessage(leadConversationId, "user", message); AgentService.ChatResult result = agentService.chatWithUsage( team.getLeadAgentId(), message, leadConversationId); + String reply = result == null ? null : result.content(); + if (reply != null && !reply.isBlank()) { + conversationService.saveMessage(leadConversationId, "assistant", reply); + } streamTracker.broadcastObject(leadConversationId, "team_announce_reply", Map.of("teamId", String.valueOf(team.getId()), - "content", result == null || result.content() == null - ? "" : result.content())); + "content", reply == null ? "" : reply)); log.info("Team {} lead woken with {} task result(s)", team.getId(), taskCount); } catch (Exception e) { log.warn("Team {} lead wake-up failed: {}", team.getId(), e.getMessage()); diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java index 3f73e3c9..b019d698 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamContextBuilder.java @@ -6,8 +6,12 @@ import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamMemberEntity; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -31,7 +35,11 @@ public class TeamContextBuilder { You are not part of any agent team. Do NOT call the team_tasks tool. """; + /** Snapshot line cap; larger boards are folded behind a "…and N more" line. */ + static final int SNAPSHOT_MAX_LINES = 15; + private final TeamService teamService; + private final TeamTaskService taskService; private final AgentMapper agentMapper; /** Build the team context block for the given agent; never returns null. */ @@ -76,12 +84,81 @@ public class TeamContextBuilder { return sb.toString(); } + /** + * Render a live snapshot of the team's non-terminal tasks for the lead's + * per-turn runtime context, so the lead never duplicates in-flight work or + * declares it finished. Returns null for non-leads, agents outside any + * team, and boards with no active tasks — callers inject nothing in those + * cases. Injected as a meta user message, never the system prompt, so the + * per-turn variation cannot break the system prompt cache. + */ + public String buildBoardSnapshot(Long agentId) { + Optional teamOpt = teamService.getTeamForAgent(agentId); + if (teamOpt.isEmpty() || !teamService.isLead(teamOpt.get(), agentId)) { + return null; + } + AgentTeamEntity team = teamOpt.get(); + List all = taskService.listTasks(team.getId(), null); + List active = all.stream() + .filter(t -> !TeamTaskStatus.isTerminal(t.getStatus())) + .toList(); + if (active.isEmpty()) { + return null; + } + Map numberById = new HashMap<>(); + for (TeamTaskEntity task : all) { + numberById.put(task.getId(), task.getTaskNumber()); + } + + StringBuilder sb = new StringBuilder(512); + sb.append("[team-board] Live board of team \"").append(team.getName()) + .append("\" — tasks currently in flight:\n"); + for (TeamTaskEntity task : active.subList(0, Math.min(active.size(), SNAPSHOT_MAX_LINES))) { + sb.append("- #").append(task.getTaskNumber()) + .append(" [").append(task.getStatus()).append("] ") + .append(task.getSubject()) + .append(" (assignee: ").append(agentName(task.getAssigneeAgentId())); + if (task.getProgressPercent() != null + && TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) { + sb.append(", ").append(task.getProgressPercent()).append('%'); + } + if (TeamTaskStatus.BLOCKED.equals(task.getStatus())) { + List blockers = TeamTaskService.parseIdArray(task.getBlockedBy()); + if (!blockers.isEmpty()) { + sb.append(", waits on"); + for (Long blockerId : blockers) { + Integer number = numberById.get(blockerId); + sb.append(" #").append(number != null ? number : blockerId); + } + } + } + sb.append(")\n"); + } + if (active.size() > SNAPSHOT_MAX_LINES) { + sb.append("- …and ").append(active.size() - SNAPSHOT_MAX_LINES) + .append(" more (call team_tasks list for the full board)\n"); + } + sb.append("Do NOT create a task duplicating any of the above, " + + "and do NOT claim in-flight work is finished."); + return sb.toString(); + } + + private String agentName(Long agentId) { + if (agentId == null) { + return "-"; + } + AgentEntity agent = agentMapper.selectById(agentId); + return agent != null && agent.getName() != null ? agent.getName() + : String.valueOf(agentId); + } + private static String leadPlaybook() { return """ ### Delegation workflow (mandatory) - Delegate work by creating tasks on the team board: `team_tasks(action="create", subject=..., description=..., assigneeAgentId=...)`. Every delegation MUST go through the board — never pretend a teammate did something without a task backing it. - - Check the board FIRST: call `team_tasks(action="list")` before creating tasks so you never create duplicates. + - Check the board FIRST: a live board snapshot is injected into your context whenever tasks are in flight; consult it (or call `team_tasks(action="list")`) before creating tasks so you never create duplicates. + - When a task's outcome needs a human decision before it counts as done (publishing something, destructive changes), create it with `requireApproval=true`; it will park in review for sign-off instead of completing automatically. - Create ALL tasks for the request up front in one batch. Order dependent work with `blockedBy` (ids of prerequisite tasks). Then announce the assignments to the user and STOP — do not keep reasoning while members work. - Delegation is NOT completion. After creating tasks, never say the work is "done" or "finished"; say it has been assigned and results will follow. - Never assign a task to yourself — the lead orchestrates, members execute. @@ -102,6 +179,7 @@ public class TeamContextBuilder { ### Working on assigned tasks - When a task is dispatched to you, focus entirely on executing it. Your final reply becomes the task result and is reported back to the lead automatically. - Report meaningful milestones with `team_tasks(action="progress", taskId=..., percent=..., step=...)`. The taskId is included in the dispatch message. + - When the output is a document, spreadsheet or presentation, produce a real file (renderDocx / renderXlsx / renderPptx, or the docx/pptx/xlsx skills), then register it with `team_tasks(action="attach", taskId=..., name="report.docx", url=)`. Keep your final reply a summary — never paste the file's full content as the result. - Leave findings other teammates may need as comments: `team_tasks(action="comment", taskId=..., text=...)`. - If you cannot proceed (missing input, unclear scope, failed dependency), report it with `team_tasks(action="comment", taskId=..., type="blocker", text="what you need")`. This fails the task and notifies the lead — do NOT silently improvise around a blocker. - You may inspect the board with `action="list"` or `action="get"` for context, but do not create or cancel tasks. diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index 93dfaf41..0e369429 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -20,6 +20,9 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; /** * Dispatches board tasks to their assigned member agents and closes the @@ -47,6 +50,21 @@ public class TeamDispatchService { private static final ExecutorService DISPATCH_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); + /** + * Lease-renewal cadence while a member run is in flight: a third of the + * lease keeps two renewal chances in hand even if one write is lost, so a + * long-running member is never reclaimed as stale while still working. + */ + private static final long HEARTBEAT_MINUTES = TeamTaskService.LOCK_MINUTES / 3; + + /** Single daemon thread firing lease-renewal heartbeats for all running tasks. */ + private static final ScheduledExecutorService HEARTBEAT_SCHEDULER = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "team-task-heartbeat"); + t.setDaemon(true); + return t; + }); + private final TeamService teamService; private final TeamTaskService taskService; private final AgentService agentService; @@ -120,7 +138,7 @@ public class TeamDispatchService { } /** Execute one dispatched task on its member agent, then settle the outcome. */ - private void runTask(Long teamId, TeamTaskEntity task) { + void runTask(Long teamId, TeamTaskEntity task) { Long memberId = task.getAssigneeAgentId(); if (!runningMembers.add(memberId)) { // Same member picked up concurrently in this JVM; put the task back. @@ -128,31 +146,77 @@ public class TeamDispatchService { return; } String childConvId = "team-task-" + IdUtil.fastSimpleUUID(); + ScheduledFuture heartbeat = null; try { conversationService.createChildConversation(childConvId, memberId, "system", null, task.getLeadConversationId()); taskService.attachConversation(task.getId(), childConvId); + // Track the child run so graph nodes honor requestStop() — without a + // registered RunState, cancelling the task could never interrupt the + // member mid-run. + streamTracker.register(childConvId); + streamTracker.incrementFlux(childConvId); + // Renew the execution lease while the member works; the conditional + // UPDATE inside renewLock makes this a no-op once the task settles. + heartbeat = HEARTBEAT_SCHEDULER.scheduleAtFixedRate( + () -> taskService.renewLock(task.getId()), + HEARTBEAT_MINUTES, HEARTBEAT_MINUTES, TimeUnit.MINUTES); broadcast(task, "team_task_dispatched", Map.of()); log.info("Team {} task #{} dispatched to agent {} (conv {})", teamId, task.getTaskNumber(), memberId, childConvId); + // Message persistence is the caller's contract (the graph expects the + // current user message to already be the conversation's last row), + // and the persisted pair is what makes the run's transcript + // reviewable from the task card. + String dispatchContent = buildDispatchContent(task); + conversationService.saveMessage(childConvId, "user", dispatchContent); AgentService.ChatResult result = agentService.chatWithUsage( - memberId, buildDispatchContent(task), childConvId); + memberId, dispatchContent, childConvId); + String reply = result == null ? null : result.content(); + if (reply != null && !reply.isBlank()) { + conversationService.saveMessage(childConvId, "assistant", reply); + } - settleOutcome(task, result == null ? null : result.content()); + settleOutcome(task, reply); } catch (Exception e) { - log.warn("Team {} task #{} member run failed: {}", teamId, task.getTaskNumber(), - e.getMessage()); - taskService.failTask(task.getId(), truncate("member run error: " + e.getMessage(), 1000)); - broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(e.getMessage()))); - announceService.announceTaskSettled(taskService.getTask(task.getId())); + log.warn("Team {} task #{} member run ended exceptionally: {}", teamId, + task.getTaskNumber(), e.getMessage()); + // Only report a failure the guarded transition actually applied — an + // interrupted run whose task is already cancelled must not produce a + // misleading failed event on top of the terminal state. + boolean failed = taskService.failTask(task.getId(), + truncate("member run error: " + e.getMessage(), 1000)); + if (failed) { + broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(e.getMessage()))); + announceService.announceTaskSettled(taskService.getTask(task.getId())); + } } finally { + if (heartbeat != null) { + heartbeat.cancel(false); + } + streamTracker.complete(childConvId); runningMembers.remove(memberId); // Chain: dispatch released dependents and the member's next task. requestDispatch(teamId); } } + /** + * Ask the member conversation executing this task to stop at the next graph + * node boundary (cancel path). No-op when the task never dispatched or the + * run already ended. + */ + public void interruptRun(TeamTaskEntity task) { + if (task == null || task.getConversationId() == null) { + return; + } + if (streamTracker.requestStop(task.getConversationId())) { + log.info("Team task #{} member run interrupted (conv {})", + task.getTaskNumber(), task.getConversationId()); + } + } + /** * Settle a finished member run. If the member already moved the task * (explicit complete, blocker fail) the run outcome is not applied on top; @@ -202,8 +266,9 @@ public class TeamDispatchService { [Instructions] - Execute this task now. Your final reply becomes the task result reported to the team lead, so end with a complete, self-contained summary of what you produced. - Report milestones with team_tasks(action="progress", taskId=%s, percent=..., step=...). + - If the output is a document, spreadsheet or presentation, generate a real file (renderDocx / renderXlsx / renderPptx or the docx/pptx/xlsx skills) and register it with team_tasks(action="attach", taskId=%s, name="", url=). Keep the result a summary — do not paste file contents. - If you are missing an input you cannot obtain yourself, call team_tasks(action="comment", taskId=%s, type="blocker", text="what you need") and stop. - """.formatted(task.getId(), task.getId())); + """.formatted(task.getId(), task.getId(), task.getId())); return sb.toString(); } diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index 950b3638..d47f3fa3 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -1,5 +1,7 @@ package vip.mate.team.service; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; @@ -14,6 +16,7 @@ import vip.mate.team.model.TeamTaskStatus; import vip.mate.team.repository.TeamTaskCommentMapper; import vip.mate.team.repository.TeamTaskMapper; +import java.net.URI; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; @@ -74,6 +77,10 @@ public class TeamTaskService { throw new IllegalArgumentException("assignee " + assignee + " is not a member of this team"); } + // Dependency edges can only reference pre-existing tasks and blockedBy is + // immutable after creation, so the dependency graph is acyclic by + // construction — adding an edit path for blockedBy would break this + // invariant and require real cycle detection. List blockers = cmd.getBlockedBy() == null ? List.of() : cmd.getBlockedBy(); for (Long blockerId : blockers) { TeamTaskEntity blocker = taskMapper.selectById(blockerId); @@ -309,6 +316,108 @@ public class TeamTaskService { .orderByAsc(TeamTaskCommentEntity::getCreateTime)); } + // ==================== deliverables ==================== + + /** Maximum deliverables per task; the board is a summary surface, not a file store. */ + static final int MAX_DELIVERABLES = 10; + + /** Download-path prefix of the generated-file cache — the only accepted deliverable URL form. */ + static final String GENERATED_FILE_PATH = "/api/v1/files/generated/"; + + /** A produced-file reference surfaced on the task card. */ + public record Deliverable(String name, String url, String time) { + } + + /** + * Attach a produced-file reference to the task, stored under the + * "deliverables" key of the task's metadata JSON. + * + * Single-writer assumption: only the task owner's run thread calls this + * (tool-side gating) and no other code path writes metadata, so a plain + * read-modify-write is safe. If a second metadata writer ever appears, + * switch to a SQL-level JSON merge or optimistic locking. + */ + @Transactional + public void addDeliverable(Long taskId, Long agentId, String name, String url) { + TeamTaskEntity task = requireTask(taskId); + if (TeamTaskStatus.isTerminal(task.getStatus())) { + throw new IllegalStateException("task #" + task.getTaskNumber() + " is " + + task.getStatus() + "; deliverables can only be attached while it is active"); + } + if (agentId != null && task.getOwnerAgentId() != null + && !agentId.equals(task.getOwnerAgentId())) { + throw new IllegalStateException("task #" + task.getTaskNumber() + + " is owned by another agent; only the owner can attach deliverables"); + } + if (name == null || name.isBlank() || url == null || url.isBlank()) { + throw new IllegalArgumentException("both name and url are required for a deliverable"); + } + String trimmedUrl = url.trim(); + if (!isGeneratedFileUrl(trimmedUrl)) { + throw new IllegalArgumentException("url must be a " + GENERATED_FILE_PATH + + " download link produced by a render tool; external links are not accepted"); + } + + JSONObject metadata = task.getMetadata() == null || task.getMetadata().isBlank() + ? new JSONObject() + : JSONUtil.parseObj(task.getMetadata()); + JSONArray deliverables = metadata.getJSONArray("deliverables"); + if (deliverables == null) { + deliverables = new JSONArray(); + } + if (deliverables.size() >= MAX_DELIVERABLES) { + throw new IllegalStateException("task #" + task.getTaskNumber() + " already has " + + MAX_DELIVERABLES + " deliverables; consolidate outputs instead of adding more"); + } + deliverables.add(new JSONObject() + .set("name", name.trim()) + .set("url", trimmedUrl) + .set("time", LocalDateTime.now().toString())); + metadata.set("deliverables", deliverables); + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TeamTaskEntity::getId, taskId) + .set(TeamTaskEntity::getMetadata, metadata.toString())); + log.info("Team task {} deliverable attached: {}", taskId, name.trim()); + } + + /** Parse the task's deliverable list; empty on missing/malformed metadata. */ + public List listDeliverables(TeamTaskEntity task) { + if (task == null || task.getMetadata() == null || task.getMetadata().isBlank()) { + return List.of(); + } + try { + JSONArray arr = JSONUtil.parseObj(task.getMetadata()) + .getJSONArray("deliverables"); + if (arr == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (Object entry : arr) { + JSONObject obj = (JSONObject) entry; + result.add(new Deliverable(obj.getStr("name"), obj.getStr("url"), obj.getStr("time"))); + } + return result; + } catch (Exception e) { + return List.of(); + } + } + + /** Accept the cache's relative download path, or an absolute URL whose path is one. */ + private static boolean isGeneratedFileUrl(String url) { + if (url.startsWith(GENERATED_FILE_PATH)) { + return true; + } + if (url.startsWith("http://") || url.startsWith("https://")) { + try { + String path = URI.create(url).getPath(); + return path != null && path.startsWith(GENERATED_FILE_PATH); + } catch (Exception e) { + return false; + } + } + return false; + } + // ==================== dispatch support ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java index 8e827f6e..55768ac9 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java +++ b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java @@ -50,11 +50,14 @@ public class TeamTasksTool { @Tool(description = "Operate your team's shared task board. Actions: " + "'list' all tasks; 'get' one task with comments (taskId); " + "'create' a task (lead only; subject, description, assigneeAgentId required, " - + "optional blockedBy comma-separated prerequisite task ids, priority, higher first); " + + "optional blockedBy comma-separated prerequisite task ids, priority, higher first, " + + "requireApproval=true to park the finished task for human sign-off); " + "'complete' a task with its result summary (taskId, result); " + "'progress' to report execution progress (taskId, percent 0-100, step); " + "'comment' to leave a note, or type='blocker' when you are stuck and need the lead " - + "(taskId, text); 'cancel' (lead only; taskId, text as reason); " + + "(taskId, text); 'attach' to register a produced file on the task " + + "(taskId, name, url — the download link returned by a render tool); " + + "'cancel' (lead only; taskId, text as reason); " + "'retry' a failed/stale task back to pending (lead only; taskId). " + "Only usable when you belong to an agent team.") public String team_tasks( @@ -72,6 +75,8 @@ public class TeamTasksTool { String blockedBy, @ToolParam(description = "create: priority, higher dispatches first (default 0)", required = false) Integer priority, + @ToolParam(description = "create: true to require human approval before the finished task counts as done", required = false) + Boolean requireApproval, @ToolParam(description = "complete: result summary reported back to the lead", required = false) String result, @ToolParam(description = "progress: completion percent 0-100", required = false) @@ -82,6 +87,10 @@ public class TeamTasksTool { String text, @ToolParam(description = "comment: 'note' (default) or 'blocker' to escalate to the lead", required = false) String type, + @ToolParam(description = "attach: display file name of the deliverable, e.g. report.docx", required = false) + String name, + @ToolParam(description = "attach: the /api/v1/files/generated/... download link returned by the render tool", required = false) + String url, @Nullable ToolContext ctx) { String conversationId = ToolExecutionContext.conversationId(ctx); @@ -105,14 +114,15 @@ public class TeamTasksTool { case "list" -> renderBoard(team); case "get" -> renderDetail(team, parseId(taskId, "taskId")); case "create" -> createTask(team, agentId, isLead, subject, description, - assigneeAgentId, blockedBy, priority, conversationId); + assigneeAgentId, blockedBy, priority, requireApproval, conversationId); case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result); case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step); case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text); + case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url); case "cancel" -> cancel(team, isLead, parseId(taskId, "taskId"), text); case "retry" -> retry(team, isLead, parseId(taskId, "taskId")); default -> "Error: unknown action '" + action - + "'. Use one of: list, get, create, complete, progress, comment, cancel, retry."; + + "'. Use one of: list, get, create, complete, progress, comment, attach, cancel, retry."; }; } catch (IllegalArgumentException | IllegalStateException e) { return "Error: " + e.getMessage(); @@ -127,7 +137,8 @@ public class TeamTasksTool { private String createTask(AgentTeamEntity team, Long agentId, boolean isLead, String subject, String description, String assigneeAgentId, - String blockedBy, Integer priority, String conversationId) { + String blockedBy, Integer priority, Boolean requireApproval, + String conversationId) { if (!isLead) { return "Error: only the team lead can create tasks. Report blockers or ask the " + "lead via a comment on your current task instead."; @@ -140,6 +151,7 @@ public class TeamTasksTool { .createdByAgentId(agentId) .priority(priority) .blockedBy(parseIdList(blockedBy)) + .requireApproval(Boolean.TRUE.equals(requireApproval)) .leadConversationId(conversationId) .build()); if (TeamTaskStatus.PENDING.equals(task.getStatus())) { @@ -198,12 +210,24 @@ public class TeamTasksTool { : "✓ Comment added."; } + private String attach(AgentTeamEntity team, Long agentId, Long taskId, String name, String url) { + requireTaskInTeam(team, taskId); + taskService.addDeliverable(taskId, agentId, name, url); + return "✓ Deliverable attached: " + name.trim() + + ". It now shows on the task card; keep your result a summary instead of pasting file contents."; + } + private String cancel(AgentTeamEntity team, boolean isLead, Long taskId, String reason) { if (!isLead) { return "Error: only the team lead can cancel tasks."; } - requireTaskInTeam(team, taskId); - taskService.cancelTask(taskId, reason); + TeamTaskEntity task = requireTaskInTeam(team, taskId); + List released = taskService.cancelTask(taskId, reason); + // Stop the member run mid-flight instead of letting it burn to the end. + dispatchService.interruptRun(task); + if (!released.isEmpty()) { + dispatchService.requestDispatch(team.getId()); + } return "✓ Task cancelled."; } diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java index b4d1be82..a4d2bb29 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java @@ -12,6 +12,7 @@ import vip.mate.channel.web.ChatStreamTracker; import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskStatus; +import vip.mate.workspace.conversation.ConversationService; import java.util.List; @@ -34,21 +35,25 @@ class TeamAnnounceServiceTest { private static final String LEAD_CONV = "lead-conv"; private TeamService teamService; + private TeamTaskService taskService; private AgentService agentService; private AgentMapper agentMapper; private RunningConversationRegistry runningConversations; private ChatStreamTracker streamTracker; + private ConversationService conversationService; private TeamAnnounceService service; @BeforeEach void setUp() { teamService = mock(TeamService.class); + taskService = mock(TeamTaskService.class); agentService = mock(AgentService.class); agentMapper = mock(AgentMapper.class); runningConversations = mock(RunningConversationRegistry.class); streamTracker = mock(ChatStreamTracker.class); - service = new TeamAnnounceService(teamService, agentService, agentMapper, - runningConversations, streamTracker); + conversationService = mock(ConversationService.class); + service = new TeamAnnounceService(teamService, taskService, agentService, agentMapper, + runningConversations, streamTracker, conversationService); AgentTeamEntity team = new AgentTeamEntity(); team.setId(TEAM_ID); @@ -136,6 +141,12 @@ class TeamAnnounceServiceTest { .broadcastObject(eq(LEAD_CONV), eq("team_announce_start"), any()); verify(streamTracker, timeout(3000)) .broadcastObject(eq(LEAD_CONV), eq("team_announce_reply"), any()); + // The announce turn persists, so the lead's reply survives a reload and + // stays in the lead's conversation window for later turns. + verify(conversationService, timeout(3000)) + .saveMessage(eq(LEAD_CONV), eq("user"), anyString()); + verify(conversationService, timeout(3000)) + .saveMessage(eq(LEAD_CONV), eq("assistant"), eq("综合汇报")); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java index ec9819af..2cddecea 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamContextBuilderTest.java @@ -8,6 +8,8 @@ import vip.mate.agent.repository.AgentMapper; import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamMemberEntity; import vip.mate.team.model.TeamRole; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; import java.util.List; import java.util.Optional; @@ -27,6 +29,7 @@ class TeamContextBuilderTest { private static final Long MEMBER_ID = 2L; private TeamService teamService; + private TeamTaskService taskService; private AgentMapper agentMapper; private TeamContextBuilder builder; private AgentTeamEntity team; @@ -34,8 +37,9 @@ class TeamContextBuilderTest { @BeforeEach void setUp() { teamService = mock(TeamService.class); + taskService = mock(TeamTaskService.class); agentMapper = mock(AgentMapper.class); - builder = new TeamContextBuilder(teamService, agentMapper); + builder = new TeamContextBuilder(teamService, taskService, agentMapper); team = new AgentTeamEntity(); team.setId(TEAM_ID); @@ -110,4 +114,77 @@ class TeamContextBuilderTest { // Self is marked in the roster instead of repeating its description. assertTrue(ctx.contains("— you")); } + + // ==================== live board snapshot ==================== + + private TeamTaskEntity boardTask(Long id, int number, String status) { + TeamTaskEntity t = new TeamTaskEntity(); + t.setId(id); + t.setTeamId(TEAM_ID); + t.setTaskNumber(number); + t.setSubject("task " + number); + t.setStatus(status); + t.setAssigneeAgentId(MEMBER_ID); + return t; + } + + @Test + @DisplayName("the lead's snapshot lists in-flight tasks with progress and blockers") + void snapshotForLeadShowsActiveTasks() { + when(teamService.getTeamForAgent(LEAD_ID)).thenReturn(Optional.of(team)); + when(teamService.isLead(team, LEAD_ID)).thenReturn(true); + TeamTaskEntity running = boardTask(11L, 1, TeamTaskStatus.IN_PROGRESS); + running.setProgressPercent(40); + TeamTaskEntity waiting = boardTask(12L, 2, TeamTaskStatus.BLOCKED); + waiting.setBlockedBy("[\"11\"]"); + TeamTaskEntity done = boardTask(13L, 3, TeamTaskStatus.COMPLETED); + when(taskService.listTasks(TEAM_ID, null)).thenReturn(List.of(running, waiting, done)); + + String snapshot = builder.buildBoardSnapshot(LEAD_ID); + + assertNotNull(snapshot); + assertTrue(snapshot.contains("#1 [in_progress] task 1")); + assertTrue(snapshot.contains("40%")); + assertTrue(snapshot.contains("#2 [blocked] task 2")); + assertTrue(snapshot.contains("waits on #1")); + // Terminal tasks stay off the snapshot — it is about in-flight work. + assertFalse(snapshot.contains("#3")); + assertTrue(snapshot.contains("Do NOT create a task duplicating")); + } + + @Test + @DisplayName("members and idle boards produce no snapshot") + void snapshotNullForMemberAndIdleBoard() { + when(teamService.getTeamForAgent(MEMBER_ID)).thenReturn(Optional.of(team)); + when(teamService.isLead(team, MEMBER_ID)).thenReturn(false); + assertNull(builder.buildBoardSnapshot(MEMBER_ID)); + + when(teamService.getTeamForAgent(LEAD_ID)).thenReturn(Optional.of(team)); + when(teamService.isLead(team, LEAD_ID)).thenReturn(true); + when(taskService.listTasks(TEAM_ID, null)) + .thenReturn(List.of(boardTask(13L, 3, TeamTaskStatus.COMPLETED))); + assertNull(builder.buildBoardSnapshot(LEAD_ID)); + + when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty()); + assertNull(builder.buildBoardSnapshot(99L)); + } + + @Test + @DisplayName("boards larger than the line cap fold into an overflow line") + void snapshotFoldsLargeBoards() { + when(teamService.getTeamForAgent(LEAD_ID)).thenReturn(Optional.of(team)); + when(teamService.isLead(team, LEAD_ID)).thenReturn(true); + List tasks = new java.util.ArrayList<>(); + for (int i = 1; i <= TeamContextBuilder.SNAPSHOT_MAX_LINES + 3; i++) { + tasks.add(boardTask(100L + i, i, TeamTaskStatus.PENDING)); + } + when(taskService.listTasks(TEAM_ID, null)).thenReturn(tasks); + + String snapshot = builder.buildBoardSnapshot(LEAD_ID); + + assertNotNull(snapshot); + assertTrue(snapshot.contains("#" + TeamContextBuilder.SNAPSHOT_MAX_LINES + " [pending]")); + assertFalse(snapshot.contains("#" + (TeamContextBuilder.SNAPSHOT_MAX_LINES + 1) + " [pending]")); + assertTrue(snapshot.contains("…and 3 more")); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java index 4651d0b4..0563aa34 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java @@ -16,6 +16,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.*; /** @@ -162,6 +163,84 @@ class TeamDispatchServiceTest { verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_failed"), any()); } + // ==================== run tracking & interrupt ==================== + + @Test + @DisplayName("a member run is registered with the stream tracker and completed afterwards") + void runTaskTracksChildConversation() { + TeamTaskEntity assigned = task(1L, MEMBER_A); + assigned.setStatus(TeamTaskStatus.IN_PROGRESS); + TeamTaskEntity done = task(1L, MEMBER_A); + done.setStatus(TeamTaskStatus.COMPLETED); + when(taskService.getTask(1L)).thenReturn(assigned, done, done); + when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); + when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) + .thenReturn(AgentService.ChatResult.contentOnly("all done")); + + service.runTask(TEAM_ID, assigned); + + // The child run must be trackable so requestStop() can interrupt it. + verify(streamTracker).register(startsWith("team-task-")); + verify(streamTracker).incrementFlux(startsWith("team-task-")); + verify(streamTracker).complete(startsWith("team-task-")); + // Both sides of the run persist, so the task card's transcript view has content. + verify(conversationService).saveMessage(startsWith("team-task-"), eq("user"), anyString()); + verify(conversationService).saveMessage(startsWith("team-task-"), eq("assistant"), eq("all done")); + } + + @Test + @DisplayName("an interrupted run whose task was cancelled produces no failed event") + void interruptedCancelledRunStaysSilent() { + TeamTaskEntity assigned = task(1L, MEMBER_A); + assigned.setStatus(TeamTaskStatus.IN_PROGRESS); + when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) + .thenThrow(new RuntimeException("run interrupted")); + // The guarded transition refuses: the task is already terminal (cancelled). + when(taskService.failTask(eq(1L), anyString())).thenReturn(false); + + service.runTask(TEAM_ID, assigned); + + verify(streamTracker, never()) + .broadcastObject(anyString(), eq("team_task_failed"), any()); + verify(announceService, never()).announceTaskSettled(any()); + // Tracking still ends cleanly. + verify(streamTracker).complete(startsWith("team-task-")); + } + + @Test + @DisplayName("a genuine member run error still fails the task and notifies the lead") + void genuineRunErrorStillAnnounced() { + TeamTaskEntity assigned = task(1L, MEMBER_A); + assigned.setStatus(TeamTaskStatus.IN_PROGRESS); + when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) + .thenThrow(new RuntimeException("model unavailable")); + when(taskService.failTask(eq(1L), anyString())).thenReturn(true); + TeamTaskEntity failed = task(1L, MEMBER_A); + failed.setStatus(TeamTaskStatus.FAILED); + when(taskService.getTask(1L)).thenReturn(failed); + + service.runTask(TEAM_ID, assigned); + + verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_failed"), any()); + verify(announceService).announceTaskSettled(failed); + } + + @Test + @DisplayName("interruptRun stops the attached member conversation and tolerates idle tasks") + void interruptRunStopsAttachedConversation() { + TeamTaskEntity running = task(1L, MEMBER_A); + running.setConversationId("team-task-abc"); + when(streamTracker.requestStop("team-task-abc")).thenReturn(true); + + service.interruptRun(running); + verify(streamTracker).requestStop("team-task-abc"); + + // Never-dispatched task and null task are silent no-ops. + service.interruptRun(task(2L, MEMBER_A)); + service.interruptRun(null); + verifyNoMoreInteractions(streamTracker); + } + @Test @DisplayName("an oversized member reply is truncated before persisting") void settleTruncatesLongReply() { diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java index 4c6bf4bc..b0523268 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java @@ -1,5 +1,6 @@ package vip.mate.team.service; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; @@ -262,4 +263,78 @@ class TeamTaskServiceTest { assertTrue(TeamTaskService.parseIdArray("not-json").isEmpty()); assertEquals(List.of(99L), TeamTaskService.parseIdArray("[\"99\"]")); } + + // ==================== deliverables ==================== + + @Test + @DisplayName("addDeliverable appends into metadata JSON and round-trips through listDeliverables") + void addDeliverableRoundTrip() { + TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS); + running.setOwnerAgentId(MEMBER_ID); + when(taskMapper.selectById(5L)).thenReturn(running); + + service.addDeliverable(5L, MEMBER_ID, "report.docx", "/api/v1/files/generated/abc"); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(taskMapper).update(isNull(), captor.capture()); + String metadataJson = String.valueOf(captor.getValue().getParamNameValuePairs().values().stream() + .filter(v -> String.valueOf(v).contains("deliverables")).findFirst().orElse("")); + assertTrue(metadataJson.contains("report.docx")); + + TeamTaskEntity stored = task(5L, TeamTaskStatus.IN_PROGRESS); + stored.setMetadata(metadataJson); + List files = service.listDeliverables(stored); + assertEquals(1, files.size()); + assertEquals("report.docx", files.get(0).name()); + assertEquals("/api/v1/files/generated/abc", files.get(0).url()); + } + + @Test + @DisplayName("deliverable guards: external URL, non-owner, terminal task and overflow are rejected") + void addDeliverableGuards() { + TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS); + running.setOwnerAgentId(MEMBER_ID); + when(taskMapper.selectById(5L)).thenReturn(running); + + assertThrows(IllegalArgumentException.class, + () -> service.addDeliverable(5L, MEMBER_ID, "x", "https://evil.example.com/f.docx")); + assertThrows(IllegalStateException.class, + () -> service.addDeliverable(5L, 999L, "x", "/api/v1/files/generated/abc")); + + when(taskMapper.selectById(6L)).thenReturn(task(6L, TeamTaskStatus.COMPLETED)); + assertThrows(IllegalStateException.class, + () -> service.addDeliverable(6L, MEMBER_ID, "x", "/api/v1/files/generated/abc")); + + TeamTaskEntity full = task(7L, TeamTaskStatus.IN_PROGRESS); + full.setOwnerAgentId(MEMBER_ID); + StringBuilder many = new StringBuilder("{\"deliverables\":["); + for (int i = 0; i < 10; i++) { + many.append(i > 0 ? "," : "") + .append("{\"name\":\"f").append(i).append("\",\"url\":\"/api/v1/files/generated/x\"}"); + } + full.setMetadata(many.append("]}").toString()); + when(taskMapper.selectById(7L)).thenReturn(full); + assertThrows(IllegalStateException.class, + () -> service.addDeliverable(7L, MEMBER_ID, "x", "/api/v1/files/generated/abc")); + + verify(taskMapper, never()).update(isNull(), any()); + } + + @Test + @DisplayName("absolute generated-file URLs pass validation; listDeliverables tolerates junk metadata") + void deliverableUrlAndParsingTolerance() { + TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS); + running.setOwnerAgentId(MEMBER_ID); + when(taskMapper.selectById(5L)).thenReturn(running); + + service.addDeliverable(5L, MEMBER_ID, "a.xlsx", + "https://claw.example.com/api/v1/files/generated/xyz"); + verify(taskMapper).update(isNull(), any()); + + TeamTaskEntity junk = task(8L, TeamTaskStatus.IN_PROGRESS); + junk.setMetadata("not-json"); + assertTrue(service.listDeliverables(junk).isEmpty()); + assertTrue(service.listDeliverables(null).isEmpty()); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java index b17550d6..1960715e 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java @@ -92,7 +92,7 @@ class TeamTasksToolTest { private String invoke(String action, String taskId) { return tool.team_tasks(action, taskId, null, null, null, null, null, - null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null); } // ==================== context & membership gating ==================== @@ -130,7 +130,8 @@ class TeamTasksToolTest { void memberCannotCreate() { callerIs(MEMBER_ID); String out = tool.team_tasks("create", null, "subj", "desc", - String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null); + String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null, + null, null, null); assertTrue(out.contains("only the team lead can create")); verify(taskService, never()).createTask(any()); } @@ -159,7 +160,8 @@ class TeamTasksToolTest { when(agentMapper.selectById(MEMBER_ID)).thenReturn(member); String out = tool.team_tasks("create", null, "collect data", "step details", - String.valueOf(MEMBER_ID), "11,12", 5, null, null, null, null, null, null); + String.valueOf(MEMBER_ID), "11,12", 5, null, null, null, null, null, null, + null, null, null); assertTrue(out.startsWith("✓ Created task #3")); assertTrue(out.contains("写手")); @@ -182,11 +184,44 @@ class TeamTasksToolTest { when(taskService.createTask(any())).thenReturn(blocked); tool.team_tasks("create", null, "later step", null, - String.valueOf(MEMBER_ID), "50", null, null, null, null, null, null, null); + String.valueOf(MEMBER_ID), "50", null, null, null, null, null, null, null, + null, null, null); verify(dispatchService, never()).requestDispatch(any()); } + @Test + @DisplayName("lead create passes requireApproval through to the command") + void createPassesRequireApproval() { + callerIs(LEAD_ID); + when(taskService.createTask(any())).thenReturn(task(52L, TeamTaskStatus.PENDING)); + + tool.team_tasks("create", null, "publish notes", null, + String.valueOf(MEMBER_ID), null, null, true, null, null, null, null, null, + null, null, null); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(TeamTaskCreateCommand.class); + verify(taskService).createTask(captor.capture()); + assertTrue(captor.getValue().isRequireApproval()); + } + + @Test + @DisplayName("lead cancel interrupts the running member conversation") + void cancelInterruptsRun() { + callerIs(LEAD_ID); + TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS); + running.setConversationId("team-task-abc"); + when(taskService.getTask(5L)).thenReturn(running); + when(taskService.cancelTask(eq(5L), any())).thenReturn(List.of(6L)); + + String out = invoke("cancel", "5"); + + assertTrue(out.startsWith("✓ Task cancelled")); + verify(dispatchService).interruptRun(running); + verify(dispatchService).requestDispatch(TEAM_ID); + } + @Test @DisplayName("service validation errors surface as Error: strings, not exceptions") void serviceErrorsBecomeStrings() { @@ -194,7 +229,8 @@ class TeamTasksToolTest { when(taskService.createTask(any())) .thenThrow(new IllegalArgumentException("assignee is required")); String out = tool.team_tasks("create", null, "s", null, - String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null); + String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null, + null, null, null); assertEquals("Error: assignee is required", out); } @@ -211,7 +247,7 @@ class TeamTasksToolTest { assertTrue(invoke("complete", "5").startsWith("Error: result is required")); String ok = tool.team_tasks("complete", "5", null, null, null, null, null, - "done, see report", null, null, null, null, null); + null, "done, see report", null, null, null, null, null, null, null); assertTrue(ok.contains("Released 1 dependent task(s)")); } @@ -224,10 +260,25 @@ class TeamTasksToolTest { anyString(), eq("blocker"), anyString())).thenReturn(true); String out = tool.team_tasks("comment", "5", null, null, null, null, null, - null, null, null, "missing credentials", "blocker", null); + null, null, null, null, "missing credentials", "blocker", null, null, null); assertTrue(out.contains("stop working")); } + @Test + @DisplayName("attach registers a deliverable and reminds the member to keep the result a summary") + void attachRegistersDeliverable() { + callerIs(MEMBER_ID); + when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS)); + + String out = tool.team_tasks("attach", "5", null, null, null, null, null, + null, null, null, null, null, null, + "report.docx", "/api/v1/files/generated/abc", null); + + assertTrue(out.startsWith("✓ Deliverable attached: report.docx")); + verify(taskService).addDeliverable(5L, MEMBER_ID, "report.docx", + "/api/v1/files/generated/abc"); + } + @Test @DisplayName("a task from another team is invisible") void foreignTaskRejected() { diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index ddb84063..61439ee2 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -853,10 +853,17 @@ export interface TeamTask { dispatchCount: number conversationId: string | null leadConversationId: string | null + metadata: string | null createTime?: string updateTime?: string } +export interface TeamTaskDeliverable { + name: string + url: string + time?: string +} + export interface TeamTaskVO { task: TeamTask assigneeName: string | null diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index a8189131..72e1df9b 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -42,6 +42,15 @@ export default { reject: 'Reject', rejectReason: 'Rejection reason (the lead will be notified)', retry: 'Retry', + createTask: 'New Task', + taskSubject: 'Subject', + priority: 'Priority', + requireApprovalField: 'Require human approval on completion', + blockedBy: 'Prerequisite tasks', + blockedByHint: 'Optional; this task dispatches only after all selected tasks finish', + taskCreateIncomplete: 'Subject and assignee are required', + deliverables: 'Deliverables', + viewRun: 'View execution', column: { todo: 'To Do', in_progress: 'In Progress', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b3f511c4..c24ccc4d 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -42,6 +42,15 @@ export default { reject: '驳回', rejectReason: '驳回原因(将通知 Lead)', retry: '重试', + createTask: '新建任务', + taskSubject: '任务标题', + priority: '优先级', + requireApprovalField: '完成后需人工审批', + blockedBy: '前置任务', + blockedByHint: '可选;所选任务全部完成后本任务才开始派发', + taskCreateIncomplete: '请填写任务标题并选择执行成员', + deliverables: '交付物', + viewRun: '查看执行过程', column: { todo: '待处理', in_progress: '进行中', diff --git a/mateclaw-ui/src/views/Teams.vue b/mateclaw-ui/src/views/Teams.vue index 7c2c21a6..95953ca5 100644 --- a/mateclaw-ui/src/views/Teams.vue +++ b/mateclaw-ui/src/views/Teams.vue @@ -92,6 +92,11 @@ @click="activeTab = 'members'" >{{ t('teams.members') }} + @@ -278,6 +283,82 @@ + + + + +