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 // C4: wire the environment-notification registry so ReasoningNode
// can drain pending MCP/skill events and inject them as a SystemMessage. // can drain pending MCP/skill events and inject them as a SystemMessage.
reasoningNode.setRunningConversationRegistry(runningConversationRegistry); 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); ActionNode actionNode = new ActionNode(executor, streamTracker);
// B2/B5: wire optional collaborators so ActionNode can pin skill // B2/B5: wire optional collaborators so ActionNode can pin skill
// constraints and auto-record tool completions into ProgressLedger. // 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.agent.graph.state.SourceEvidenceLedger;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.service.TeamContextBuilder;
import java.util.*; import java.util.*;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
@ -390,6 +391,18 @@ public class ReasoningNode implements NodeAction {
this.runningConversationRegistry = runningConversationRegistry; 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. */ /** Floor for the window-aware output clamp — an answer needs at least this much room. */
private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512; 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). // 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; return prefix;
} }

View File

@ -189,8 +189,10 @@ public class TeamController {
@PostMapping("/{id}/tasks/{taskId}/cancel") @PostMapping("/{id}/tasks/{taskId}/cancel")
public R<TaskVO> cancel(@PathVariable Long id, @PathVariable Long taskId, public R<TaskVO> cancel(@PathVariable Long id, @PathVariable Long taskId,
@RequestBody(required = false) ReasonRequest req) { @RequestBody(required = false) ReasonRequest req) {
requireTask(id, taskId); TeamTaskEntity task = requireTask(id, taskId);
List<Long> released = taskService.cancelTask(taskId, req == null ? null : req.getReason()); 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()) { if (!released.isEmpty()) {
dispatchService.requestDispatch(id); 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.AgentTeamEntity;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus; import vip.mate.team.model.TeamTaskStatus;
import vip.mate.workspace.conversation.ConversationService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -65,10 +66,12 @@ public class TeamAnnounceService {
Executors.newVirtualThreadPerTaskExecutor(); Executors.newVirtualThreadPerTaskExecutor();
private final TeamService teamService; private final TeamService teamService;
private final TeamTaskService taskService;
private final AgentService agentService; private final AgentService agentService;
private final AgentMapper agentMapper; private final AgentMapper agentMapper;
private final RunningConversationRegistry runningConversations; private final RunningConversationRegistry runningConversations;
private final ChatStreamTracker streamTracker; private final ChatStreamTracker streamTracker;
private final ConversationService conversationService;
/** Pending items per lead conversation; the first item arms the drain timer. */ /** Pending items per lead conversation; the first item arms the drain timer. */
private final Map<String, List<AnnounceItem>> pending = new ConcurrentHashMap<>(); private final Map<String, List<AnnounceItem>> pending = new ConcurrentHashMap<>();
@ -88,10 +91,18 @@ public class TeamAnnounceService {
String detail = TeamTaskStatus.COMPLETED.equals(task.getStatus()) String detail = TeamTaskStatus.COMPLETED.equals(task.getStatus())
|| TeamTaskStatus.IN_REVIEW.equals(task.getStatus()) || TeamTaskStatus.IN_REVIEW.equals(task.getStatus())
? task.getResult() : task.getReason(); ? 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(), AnnounceItem item = new AnnounceItem(task.getTeamId(), task.getTaskNumber(),
task.getSubject(), task.getStatus(), task.getSubject(), task.getStatus(),
agentName(task.getAssigneeAgentId()), agentName(task.getAssigneeAgentId()),
detail == null ? "" : detail); detailWithFiles.toString());
String key = task.getLeadConversationId(); String key = task.getLeadConversationId();
List<AnnounceItem> drainNow = null; List<AnnounceItem> drainNow = null;
@ -158,12 +169,19 @@ public class TeamAnnounceService {
try { try {
streamTracker.broadcastObject(leadConversationId, "team_announce_start", streamTracker.broadcastObject(leadConversationId, "team_announce_start",
Map.of("teamId", String.valueOf(team.getId()), "tasks", taskCount)); 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( AgentService.ChatResult result = agentService.chatWithUsage(
team.getLeadAgentId(), message, leadConversationId); 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", streamTracker.broadcastObject(leadConversationId, "team_announce_reply",
Map.of("teamId", String.valueOf(team.getId()), Map.of("teamId", String.valueOf(team.getId()),
"content", result == null || result.content() == null "content", reply == null ? "" : reply));
? "" : result.content()));
log.info("Team {} lead woken with {} task result(s)", team.getId(), taskCount); log.info("Team {} lead woken with {} task result(s)", team.getId(), taskCount);
} catch (Exception e) { } catch (Exception e) {
log.warn("Team {} lead wake-up failed: {}", team.getId(), e.getMessage()); 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.agent.repository.AgentMapper;
import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity; 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.List;
import java.util.Map;
import java.util.Optional; 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. 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 TeamService teamService;
private final TeamTaskService taskService;
private final AgentMapper agentMapper; private final AgentMapper agentMapper;
/** Build the team context block for the given agent; never returns null. */ /** Build the team context block for the given agent; never returns null. */
@ -76,12 +84,81 @@ public class TeamContextBuilder {
return sb.toString(); 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() { private static String leadPlaybook() {
return """ return """
### Delegation workflow (mandatory) ### 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. - 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. - 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. - 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. - Never assign a task to yourself the lead orchestrates, members execute.
@ -102,6 +179,7 @@ public class TeamContextBuilder {
### Working on assigned tasks ### 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. - 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. - 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=...)`. - 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. - 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. - 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.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; 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 * Dispatches board tasks to their assigned member agents and closes the
@ -47,6 +50,21 @@ public class TeamDispatchService {
private static final ExecutorService DISPATCH_EXECUTOR = private static final ExecutorService DISPATCH_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor(); 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 TeamService teamService;
private final TeamTaskService taskService; private final TeamTaskService taskService;
private final AgentService agentService; private final AgentService agentService;
@ -120,7 +138,7 @@ public class TeamDispatchService {
} }
/** Execute one dispatched task on its member agent, then settle the outcome. */ /** 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(); Long memberId = task.getAssigneeAgentId();
if (!runningMembers.add(memberId)) { if (!runningMembers.add(memberId)) {
// Same member picked up concurrently in this JVM; put the task back. // Same member picked up concurrently in this JVM; put the task back.
@ -128,31 +146,77 @@ public class TeamDispatchService {
return; return;
} }
String childConvId = "team-task-" + IdUtil.fastSimpleUUID(); String childConvId = "team-task-" + IdUtil.fastSimpleUUID();
ScheduledFuture<?> heartbeat = null;
try { try {
conversationService.createChildConversation(childConvId, memberId, "system", conversationService.createChildConversation(childConvId, memberId, "system",
null, task.getLeadConversationId()); null, task.getLeadConversationId());
taskService.attachConversation(task.getId(), childConvId); 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()); broadcast(task, "team_task_dispatched", Map.of());
log.info("Team {} task #{} dispatched to agent {} (conv {})", log.info("Team {} task #{} dispatched to agent {} (conv {})",
teamId, task.getTaskNumber(), memberId, childConvId); 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( 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) { } catch (Exception e) {
log.warn("Team {} task #{} member run failed: {}", teamId, task.getTaskNumber(), log.warn("Team {} task #{} member run ended exceptionally: {}", teamId,
e.getMessage()); task.getTaskNumber(), e.getMessage());
taskService.failTask(task.getId(), truncate("member run error: " + e.getMessage(), 1000)); // Only report a failure the guarded transition actually applied an
broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(e.getMessage()))); // interrupted run whose task is already cancelled must not produce a
announceService.announceTaskSettled(taskService.getTask(task.getId())); // 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 { } finally {
if (heartbeat != null) {
heartbeat.cancel(false);
}
streamTracker.complete(childConvId);
runningMembers.remove(memberId); runningMembers.remove(memberId);
// Chain: dispatch released dependents and the member's next task. // Chain: dispatch released dependents and the member's next task.
requestDispatch(teamId); 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 * Settle a finished member run. If the member already moved the task
* (explicit complete, blocker fail) the run outcome is not applied on top; * (explicit complete, blocker fail) the run outcome is not applied on top;
@ -202,8 +266,9 @@ public class TeamDispatchService {
[Instructions] [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. - 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=...). - 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. - 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(); return sb.toString();
} }

View File

@ -1,5 +1,7 @@
package vip.mate.team.service; package vip.mate.team.service;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -14,6 +16,7 @@ import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.repository.TeamTaskCommentMapper; import vip.mate.team.repository.TeamTaskCommentMapper;
import vip.mate.team.repository.TeamTaskMapper; import vip.mate.team.repository.TeamTaskMapper;
import java.net.URI;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -74,6 +77,10 @@ public class TeamTaskService {
throw new IllegalArgumentException("assignee " + assignee + " is not a member of this team"); 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(); List<Long> blockers = cmd.getBlockedBy() == null ? List.of() : cmd.getBlockedBy();
for (Long blockerId : blockers) { for (Long blockerId : blockers) {
TeamTaskEntity blocker = taskMapper.selectById(blockerId); TeamTaskEntity blocker = taskMapper.selectById(blockerId);
@ -309,6 +316,108 @@ public class TeamTaskService {
.orderByAsc(TeamTaskCommentEntity::getCreateTime)); .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 ==================== // ==================== dispatch support ====================
/** /**

View File

@ -50,11 +50,14 @@ public class TeamTasksTool {
@Tool(description = "Operate your team's shared task board. Actions: " @Tool(description = "Operate your team's shared task board. Actions: "
+ "'list' all tasks; 'get' one task with comments (taskId); " + "'list' all tasks; 'get' one task with comments (taskId); "
+ "'create' a task (lead only; subject, description, assigneeAgentId required, " + "'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); " + "'complete' a task with its result summary (taskId, result); "
+ "'progress' to report execution progress (taskId, percent 0-100, step); " + "'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 " + "'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). " + "'retry' a failed/stale task back to pending (lead only; taskId). "
+ "Only usable when you belong to an agent team.") + "Only usable when you belong to an agent team.")
public String team_tasks( public String team_tasks(
@ -72,6 +75,8 @@ public class TeamTasksTool {
String blockedBy, String blockedBy,
@ToolParam(description = "create: priority, higher dispatches first (default 0)", required = false) @ToolParam(description = "create: priority, higher dispatches first (default 0)", required = false)
Integer priority, 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) @ToolParam(description = "complete: result summary reported back to the lead", required = false)
String result, String result,
@ToolParam(description = "progress: completion percent 0-100", required = false) @ToolParam(description = "progress: completion percent 0-100", required = false)
@ -82,6 +87,10 @@ public class TeamTasksTool {
String text, String text,
@ToolParam(description = "comment: 'note' (default) or 'blocker' to escalate to the lead", required = false) @ToolParam(description = "comment: 'note' (default) or 'blocker' to escalate to the lead", required = false)
String type, 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) { @Nullable ToolContext ctx) {
String conversationId = ToolExecutionContext.conversationId(ctx); String conversationId = ToolExecutionContext.conversationId(ctx);
@ -105,14 +114,15 @@ public class TeamTasksTool {
case "list" -> renderBoard(team); case "list" -> renderBoard(team);
case "get" -> renderDetail(team, parseId(taskId, "taskId")); case "get" -> renderDetail(team, parseId(taskId, "taskId"));
case "create" -> createTask(team, agentId, isLead, subject, description, 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 "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result);
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step); case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text); 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 "cancel" -> cancel(team, isLead, parseId(taskId, "taskId"), text);
case "retry" -> retry(team, isLead, parseId(taskId, "taskId")); case "retry" -> retry(team, isLead, parseId(taskId, "taskId"));
default -> "Error: unknown action '" + action 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) { } catch (IllegalArgumentException | IllegalStateException e) {
return "Error: " + e.getMessage(); return "Error: " + e.getMessage();
@ -127,7 +137,8 @@ public class TeamTasksTool {
private String createTask(AgentTeamEntity team, Long agentId, boolean isLead, private String createTask(AgentTeamEntity team, Long agentId, boolean isLead,
String subject, String description, String assigneeAgentId, String subject, String description, String assigneeAgentId,
String blockedBy, Integer priority, String conversationId) { String blockedBy, Integer priority, Boolean requireApproval,
String conversationId) {
if (!isLead) { if (!isLead) {
return "Error: only the team lead can create tasks. Report blockers or ask the " return "Error: only the team lead can create tasks. Report blockers or ask the "
+ "lead via a comment on your current task instead."; + "lead via a comment on your current task instead.";
@ -140,6 +151,7 @@ public class TeamTasksTool {
.createdByAgentId(agentId) .createdByAgentId(agentId)
.priority(priority) .priority(priority)
.blockedBy(parseIdList(blockedBy)) .blockedBy(parseIdList(blockedBy))
.requireApproval(Boolean.TRUE.equals(requireApproval))
.leadConversationId(conversationId) .leadConversationId(conversationId)
.build()); .build());
if (TeamTaskStatus.PENDING.equals(task.getStatus())) { if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
@ -198,12 +210,24 @@ public class TeamTasksTool {
: "✓ Comment added."; : "✓ 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) { private String cancel(AgentTeamEntity team, boolean isLead, Long taskId, String reason) {
if (!isLead) { if (!isLead) {
return "Error: only the team lead can cancel tasks."; return "Error: only the team lead can cancel tasks.";
} }
requireTaskInTeam(team, taskId); TeamTaskEntity task = requireTaskInTeam(team, taskId);
taskService.cancelTask(taskId, reason); 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."; 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.AgentTeamEntity;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus; import vip.mate.team.model.TeamTaskStatus;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List; import java.util.List;
@ -34,21 +35,25 @@ class TeamAnnounceServiceTest {
private static final String LEAD_CONV = "lead-conv"; private static final String LEAD_CONV = "lead-conv";
private TeamService teamService; private TeamService teamService;
private TeamTaskService taskService;
private AgentService agentService; private AgentService agentService;
private AgentMapper agentMapper; private AgentMapper agentMapper;
private RunningConversationRegistry runningConversations; private RunningConversationRegistry runningConversations;
private ChatStreamTracker streamTracker; private ChatStreamTracker streamTracker;
private ConversationService conversationService;
private TeamAnnounceService service; private TeamAnnounceService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
teamService = mock(TeamService.class); teamService = mock(TeamService.class);
taskService = mock(TeamTaskService.class);
agentService = mock(AgentService.class); agentService = mock(AgentService.class);
agentMapper = mock(AgentMapper.class); agentMapper = mock(AgentMapper.class);
runningConversations = mock(RunningConversationRegistry.class); runningConversations = mock(RunningConversationRegistry.class);
streamTracker = mock(ChatStreamTracker.class); streamTracker = mock(ChatStreamTracker.class);
service = new TeamAnnounceService(teamService, agentService, agentMapper, conversationService = mock(ConversationService.class);
runningConversations, streamTracker); service = new TeamAnnounceService(teamService, taskService, agentService, agentMapper,
runningConversations, streamTracker, conversationService);
AgentTeamEntity team = new AgentTeamEntity(); AgentTeamEntity team = new AgentTeamEntity();
team.setId(TEAM_ID); team.setId(TEAM_ID);
@ -136,6 +141,12 @@ class TeamAnnounceServiceTest {
.broadcastObject(eq(LEAD_CONV), eq("team_announce_start"), any()); .broadcastObject(eq(LEAD_CONV), eq("team_announce_start"), any());
verify(streamTracker, timeout(3000)) verify(streamTracker, timeout(3000))
.broadcastObject(eq(LEAD_CONV), eq("team_announce_reply"), any()); .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 @Test

View File

@ -8,6 +8,8 @@ import vip.mate.agent.repository.AgentMapper;
import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity; import vip.mate.team.model.AgentTeamMemberEntity;
import vip.mate.team.model.TeamRole; 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.List;
import java.util.Optional; import java.util.Optional;
@ -27,6 +29,7 @@ class TeamContextBuilderTest {
private static final Long MEMBER_ID = 2L; private static final Long MEMBER_ID = 2L;
private TeamService teamService; private TeamService teamService;
private TeamTaskService taskService;
private AgentMapper agentMapper; private AgentMapper agentMapper;
private TeamContextBuilder builder; private TeamContextBuilder builder;
private AgentTeamEntity team; private AgentTeamEntity team;
@ -34,8 +37,9 @@ class TeamContextBuilderTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
teamService = mock(TeamService.class); teamService = mock(TeamService.class);
taskService = mock(TeamTaskService.class);
agentMapper = mock(AgentMapper.class); agentMapper = mock(AgentMapper.class);
builder = new TeamContextBuilder(teamService, agentMapper); builder = new TeamContextBuilder(teamService, taskService, agentMapper);
team = new AgentTeamEntity(); team = new AgentTeamEntity();
team.setId(TEAM_ID); team.setId(TEAM_ID);
@ -110,4 +114,77 @@ class TeamContextBuilderTest {
// Self is marked in the roster instead of repeating its description. // Self is marked in the roster instead of repeating its description.
assertTrue(ctx.contains("— you")); 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.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
@ -162,6 +163,84 @@ class TeamDispatchServiceTest {
verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_failed"), any()); 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 @Test
@DisplayName("an oversized member reply is truncated before persisting") @DisplayName("an oversized member reply is truncated before persisting")
void settleTruncatesLongReply() { void settleTruncatesLongReply() {

View File

@ -1,5 +1,6 @@
package vip.mate.team.service; package vip.mate.team.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration; import org.apache.ibatis.session.Configuration;
@ -262,4 +263,78 @@ class TeamTaskServiceTest {
assertTrue(TeamTaskService.parseIdArray("not-json").isEmpty()); assertTrue(TeamTaskService.parseIdArray("not-json").isEmpty());
assertEquals(List.of(99L), TeamTaskService.parseIdArray("[\"99\"]")); 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) { private String invoke(String action, String taskId) {
return tool.team_tasks(action, taskId, null, null, null, null, null, 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 ==================== // ==================== context & membership gating ====================
@ -130,7 +130,8 @@ class TeamTasksToolTest {
void memberCannotCreate() { void memberCannotCreate() {
callerIs(MEMBER_ID); callerIs(MEMBER_ID);
String out = tool.team_tasks("create", null, "subj", "desc", 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")); assertTrue(out.contains("only the team lead can create"));
verify(taskService, never()).createTask(any()); verify(taskService, never()).createTask(any());
} }
@ -159,7 +160,8 @@ class TeamTasksToolTest {
when(agentMapper.selectById(MEMBER_ID)).thenReturn(member); when(agentMapper.selectById(MEMBER_ID)).thenReturn(member);
String out = tool.team_tasks("create", null, "collect data", "step details", 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.startsWith("✓ Created task #3"));
assertTrue(out.contains("写手")); assertTrue(out.contains("写手"));
@ -182,11 +184,44 @@ class TeamTasksToolTest {
when(taskService.createTask(any())).thenReturn(blocked); when(taskService.createTask(any())).thenReturn(blocked);
tool.team_tasks("create", null, "later step", null, 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()); 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 @Test
@DisplayName("service validation errors surface as Error: strings, not exceptions") @DisplayName("service validation errors surface as Error: strings, not exceptions")
void serviceErrorsBecomeStrings() { void serviceErrorsBecomeStrings() {
@ -194,7 +229,8 @@ class TeamTasksToolTest {
when(taskService.createTask(any())) when(taskService.createTask(any()))
.thenThrow(new IllegalArgumentException("assignee is required")); .thenThrow(new IllegalArgumentException("assignee is required"));
String out = tool.team_tasks("create", null, "s", null, 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); assertEquals("Error: assignee is required", out);
} }
@ -211,7 +247,7 @@ class TeamTasksToolTest {
assertTrue(invoke("complete", "5").startsWith("Error: result is required")); assertTrue(invoke("complete", "5").startsWith("Error: result is required"));
String ok = tool.team_tasks("complete", "5", null, null, null, null, null, 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)")); assertTrue(ok.contains("Released 1 dependent task(s)"));
} }
@ -224,10 +260,25 @@ class TeamTasksToolTest {
anyString(), eq("blocker"), anyString())).thenReturn(true); anyString(), eq("blocker"), anyString())).thenReturn(true);
String out = tool.team_tasks("comment", "5", null, null, null, null, null, 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")); 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 @Test
@DisplayName("a task from another team is invisible") @DisplayName("a task from another team is invisible")
void foreignTaskRejected() { void foreignTaskRejected() {

View File

@ -853,10 +853,17 @@ export interface TeamTask {
dispatchCount: number dispatchCount: number
conversationId: string | null conversationId: string | null
leadConversationId: string | null leadConversationId: string | null
metadata: string | null
createTime?: string createTime?: string
updateTime?: string updateTime?: string
} }
export interface TeamTaskDeliverable {
name: string
url: string
time?: string
}
export interface TeamTaskVO { export interface TeamTaskVO {
task: TeamTask task: TeamTask
assigneeName: string | null assigneeName: string | null

View File

@ -42,6 +42,15 @@ export default {
reject: 'Reject', reject: 'Reject',
rejectReason: 'Rejection reason (the lead will be notified)', rejectReason: 'Rejection reason (the lead will be notified)',
retry: 'Retry', 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: { column: {
todo: 'To Do', todo: 'To Do',
in_progress: 'In Progress', in_progress: 'In Progress',

View File

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

View File

@ -92,6 +92,11 @@
@click="activeTab = 'members'" @click="activeTab = 'members'"
>{{ t('teams.members') }}</button> >{{ t('teams.members') }}</button>
</div> </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-secondary" @click="refreshBoard">{{ t('common.refresh') }}</button>
<button class="btn-danger" @click="removeTeam">{{ t('common.delete') }}</button> <button class="btn-danger" @click="removeTeam">{{ t('common.delete') }}</button>
</div> </div>
@ -278,6 +283,82 @@
</div> </div>
</Teleport> </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 ==================== --> <!-- ==================== Task detail dialog ==================== -->
<Teleport to="body"> <Teleport to="body">
<div v-if="taskDialogVisible && currentTask" class="modal-overlay" @click.self="taskDialogVisible = false"> <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"> <div v-if="currentTask.task.reason" class="task-detail__reason">
{{ currentTask.task.reason }} {{ currentTask.task.reason }}
</div> </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__block">
<div class="task-detail__label">{{ t('teams.comments') }}</div> <div class="task-detail__label">{{ t('teams.comments') }}</div>
<div v-if="comments.length === 0" class="task-detail__muted"></div> <div v-if="comments.length === 0" class="task-detail__muted"></div>
@ -335,6 +432,11 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button
v-if="currentTask.task.conversationId"
class="btn-secondary modal-footer__left"
@click="openTaskRun"
>{{ t('teams.viewRun') }}</button>
<button <button
v-if="currentTask.task.status === 'in_review'" v-if="currentTask.task.status === 'in_review'"
class="btn-success" class="btn-success"
@ -365,15 +467,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { teamApi } from '@/api/index' 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 SkillIcon from '@/components/common/SkillIcon.vue'
import { agentIconColor } from '@/utils/agentIconColor' import { agentIconColor } from '@/utils/agentIconColor'
import { useAgentStore } from '@/stores/useAgentStore' import { useAgentStore } from '@/stores/useAgentStore'
import { useTeamStore } from '@/stores/useTeamStore' import { useTeamStore } from '@/stores/useTeamStore'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter()
const store = useTeamStore() const store = useTeamStore()
const agentStore = useAgentStore() 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 ==================== // ==================== members ====================
const memberDialogVisible = ref(false) const memberDialogVisible = ref(false)
@ -571,6 +742,28 @@ const currentTask = ref<TeamTaskVO | null>(null)
const comments = ref<TeamTaskComment[]>([]) const comments = ref<TeamTaskComment[]>([])
const newComment = ref('') 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) { async function openTask(vo: TeamTaskVO) {
if (!store.currentTeam) return if (!store.currentTeam) return
try { try {
@ -1314,6 +1507,49 @@ async function cancelTask() {
font-size: 12px; font-size: 12px;
color: var(--mc-text-tertiary); 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 ==================== */ /* ==================== agent chip picker ==================== */