feat(team): execution hardening, task deliverables and run transcript visibility

This commit is contained in:
mateaix 2026-07-25 14:32:49 +08:00
parent bc867f0cbb
commit 251a3288dd
17 changed files with 912 additions and 32 deletions

View File

@ -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.

View File

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

View File

@ -189,8 +189,10 @@ public class TeamController {
@PostMapping("/{id}/tasks/{taskId}/cancel")
public R<TaskVO> cancel(@PathVariable Long id, @PathVariable Long taskId,
@RequestBody(required = false) ReasonRequest req) {
requireTask(id, taskId);
TeamTaskEntity task = requireTask(id, taskId);
List<Long> 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);
}

View File

@ -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<String, List<AnnounceItem>> 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<TeamTaskService.Deliverable> 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<AnnounceItem> 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());

View File

@ -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<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
if (teamOpt.isEmpty() || !teamService.isLead(teamOpt.get(), agentId)) {
return null;
}
AgentTeamEntity team = teamOpt.get();
List<TeamTaskEntity> all = taskService.listTasks(team.getId(), null);
List<TeamTaskEntity> active = all.stream()
.filter(t -> !TeamTaskStatus.isTerminal(t.getStatus()))
.toList();
if (active.isEmpty()) {
return null;
}
Map<Long, Integer> 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<Long> 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=<the download link the render tool returned>)`. 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.

View File

@ -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));
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="<file name>", url=<the download link the render tool returned>). 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();
}

View File

@ -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<Long> 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.<TeamTaskEntity>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<Deliverable> 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<Deliverable> 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 ====================
/**

View File

@ -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<Long> 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.";
}

View File

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

View File

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

View File

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

View File

@ -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<LambdaUpdateWrapper<TeamTaskEntity>> 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<TeamTaskService.Deliverable> 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());
}
}

View File

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

View File

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

View File

@ -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',

View File

@ -42,6 +42,15 @@ export default {
reject: '驳回',
rejectReason: '驳回原因(将通知 Lead',
retry: '重试',
createTask: '新建任务',
taskSubject: '任务标题',
priority: '优先级',
requireApprovalField: '完成后需人工审批',
blockedBy: '前置任务',
blockedByHint: '可选;所选任务全部完成后本任务才开始派发',
taskCreateIncomplete: '请填写任务标题并选择执行成员',
deliverables: '交付物',
viewRun: '查看执行过程',
column: {
todo: '待处理',
in_progress: '进行中',

View File

@ -92,6 +92,11 @@
@click="activeTab = 'members'"
>{{ t('teams.members') }}</button>
</div>
<button
v-if="activeTab === 'board'"
class="btn-primary"
@click="openTaskCreateDialog"
>+ {{ t('teams.createTask') }}</button>
<button class="btn-secondary" @click="refreshBoard">{{ t('common.refresh') }}</button>
<button class="btn-danger" @click="removeTeam">{{ t('common.delete') }}</button>
</div>
@ -278,6 +283,82 @@
</div>
</Teleport>
<!-- ==================== Create task dialog ==================== -->
<Teleport to="body">
<div v-if="taskCreateDialogVisible" class="modal-overlay" @click.self="taskCreateDialogVisible = false">
<div class="modal modal--wide">
<div class="modal-header">
<h3>{{ t('teams.createTask') }}</h3>
<button class="modal-close" @click="taskCreateDialogVisible = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ t('teams.taskSubject') }} <i>*</i></label>
<input v-model.trim="taskForm.subject" class="form-input" maxlength="256" />
</div>
<div class="form-group">
<label>{{ t('teams.taskDescription') }}</label>
<textarea v-model="taskForm.description" class="form-input form-textarea" rows="3"></textarea>
</div>
<div class="form-group">
<label>{{ t('teams.assignee') }} <i>*</i></label>
<div class="agent-picker">
<button
v-for="m in assigneeCandidates"
:key="m.agentId"
class="agent-pill"
:class="{ 'is-selected': taskForm.assigneeAgentId === String(m.agentId) }"
@click="taskForm.assigneeAgentId = String(m.agentId)"
>
<span v-if="taskForm.assigneeAgentId === String(m.agentId)" class="agent-pill__check"></span>
<span class="agent-pill__icon" :style="{ color: agentIconColor(agentIcon(m.agentId, m.icon)) }">
<SkillIcon :value="agentIcon(m.agentId, m.icon)" :size="14" />
</span>
{{ m.name }}
</button>
</div>
</div>
<div v-if="blockerCandidates.length > 0" class="form-group">
<label>{{ t('teams.blockedBy') }}</label>
<div class="agent-picker">
<button
v-for="vo in blockerCandidates"
:key="vo.task.id"
class="agent-pill"
:class="{ 'is-selected': taskForm.blockedBy.includes(String(vo.task.id)) }"
@click="toggleBlocker(String(vo.task.id))"
>
<span v-if="taskForm.blockedBy.includes(String(vo.task.id))" class="agent-pill__check"></span>
#{{ vo.task.taskNumber }} {{ vo.task.subject }}
</button>
</div>
<p class="form-hint">{{ t('teams.blockedByHint') }}</p>
</div>
<div class="form-group form-group--inline">
<label>{{ t('teams.priority') }}</label>
<input
v-model.number="taskForm.priority"
type="number"
class="form-input form-input--narrow"
min="0"
max="99"
/>
<label class="form-check">
<input v-model="taskForm.requireApproval" type="checkbox" />
{{ t('teams.requireApprovalField') }}
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="taskCreateDialogVisible = false">{{ t('common.cancel') }}</button>
<button class="btn-primary" :disabled="taskCreating" @click="submitTaskCreate">
{{ taskCreating ? t('common.processing') : t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ==================== Task detail dialog ==================== -->
<Teleport to="body">
<div v-if="taskDialogVisible && currentTask" class="modal-overlay" @click.self="taskDialogVisible = false">
@ -311,6 +392,22 @@
<div v-if="currentTask.task.reason" class="task-detail__reason">
{{ currentTask.task.reason }}
</div>
<div v-if="currentDeliverables.length > 0" class="task-detail__block">
<div class="task-detail__label">{{ t('teams.deliverables') }}</div>
<div class="deliverable-list">
<a
v-for="(file, idx) in currentDeliverables"
:key="idx"
class="deliverable-row"
:href="file.url"
target="_blank"
rel="noopener"
>
<span class="deliverable-row__icon">📄</span>
<span class="deliverable-row__name">{{ file.name }}</span>
</a>
</div>
</div>
<div class="task-detail__block">
<div class="task-detail__label">{{ t('teams.comments') }}</div>
<div v-if="comments.length === 0" class="task-detail__muted"></div>
@ -335,6 +432,11 @@
</div>
</div>
<div class="modal-footer">
<button
v-if="currentTask.task.conversationId"
class="btn-secondary modal-footer__left"
@click="openTaskRun"
>{{ t('teams.viewRun') }}</button>
<button
v-if="currentTask.task.status === 'in_review'"
class="btn-success"
@ -365,15 +467,17 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { teamApi } from '@/api/index'
import type { TeamMemberVO, TeamTaskComment, TeamTaskVO } from '@/api/index'
import type { TeamMemberVO, TeamTaskComment, TeamTaskDeliverable, TeamTaskVO } from '@/api/index'
import SkillIcon from '@/components/common/SkillIcon.vue'
import { agentIconColor } from '@/utils/agentIconColor'
import { useAgentStore } from '@/stores/useAgentStore'
import { useTeamStore } from '@/stores/useTeamStore'
const { t } = useI18n()
const router = useRouter()
const store = useTeamStore()
const agentStore = useAgentStore()
@ -532,6 +636,73 @@ async function submitCreate() {
}
}
// ==================== create task ====================
const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'cancelled']
const taskCreateDialogVisible = ref(false)
const taskCreating = ref(false)
const taskForm = reactive({
subject: '',
description: '',
assigneeAgentId: '',
priority: 0,
requireApproval: false,
blockedBy: [] as string[],
})
// The lead orchestrates and cannot execute tasks assignable members only.
const assigneeCandidates = computed(() => store.members.filter((m) => m.role !== 'lead'))
const blockerCandidates = computed(() =>
store.tasks.filter((vo) => !TERMINAL_TASK_STATUSES.includes(vo.task.status)),
)
function openTaskCreateDialog() {
taskForm.subject = ''
taskForm.description = ''
taskForm.assigneeAgentId = ''
taskForm.priority = 0
taskForm.requireApproval = false
taskForm.blockedBy = []
taskCreateDialogVisible.value = true
}
function toggleBlocker(taskId: string) {
const idx = taskForm.blockedBy.indexOf(taskId)
if (idx >= 0) {
taskForm.blockedBy.splice(idx, 1)
} else {
taskForm.blockedBy.push(taskId)
}
}
async function submitTaskCreate() {
if (!store.currentTeam) return
if (!taskForm.subject || !taskForm.assigneeAgentId) {
ElMessage.warning(t('teams.taskCreateIncomplete'))
return
}
taskCreating.value = true
try {
await teamApi.createTask(store.currentTeam.team.id, {
subject: taskForm.subject,
description: taskForm.description || undefined,
assigneeAgentId: taskForm.assigneeAgentId,
priority: taskForm.priority,
requireApproval: taskForm.requireApproval,
blockedBy: taskForm.blockedBy.length ? [...taskForm.blockedBy] : undefined,
})
taskCreateDialogVisible.value = false
ElMessage.success(t('common.success'))
refreshBoard()
} catch (e: any) {
ElMessage.error(e?.message || 'failed')
} finally {
taskCreating.value = false
}
}
// ==================== members ====================
const memberDialogVisible = ref(false)
@ -571,6 +742,28 @@ const currentTask = ref<TeamTaskVO | null>(null)
const comments = ref<TeamTaskComment[]>([])
const newComment = ref('')
/** Deliverables live under the "deliverables" key of the task's metadata JSON. */
const currentDeliverables = computed<TeamTaskDeliverable[]>(() => {
const raw = currentTask.value?.task.metadata
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed?.deliverables) ? parsed.deliverables : []
} catch {
return []
}
})
/** Open the member's execution transcript (its child conversation) in the chat console. */
function openTaskRun() {
const task = currentTask.value?.task
if (!task?.conversationId) return
router.push({
path: '/chat',
query: { agentId: task.assigneeAgentId ?? undefined, conversationId: task.conversationId },
})
}
async function openTask(vo: TeamTaskVO) {
if (!store.currentTeam) return
try {
@ -1314,6 +1507,49 @@ async function cancelTask() {
font-size: 12px;
color: var(--mc-text-tertiary);
}
.deliverable-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.deliverable-row {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--mc-border);
border-radius: 10px;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
font-size: 13px;
text-decoration: none;
transition: border-color 0.15s;
}
.deliverable-row:hover {
border-color: var(--mc-primary);
}
.deliverable-row__name {
font-weight: 600;
}
.modal-footer__left {
margin-right: auto;
}
.form-group--inline {
flex-direction: row;
align-items: center;
gap: 12px;
}
.form-input--narrow {
width: 90px;
}
.form-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--mc-text-secondary);
cursor: pointer;
}
/* ==================== agent chip picker ==================== */