mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
feat(team): execution hardening, task deliverables and run transcript visibility
This commit is contained in:
parent
bc867f0cbb
commit
251a3288dd
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -20,6 +20,9 @@ import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Dispatches board tasks to their assigned member agents and closes the
|
||||
@ -47,6 +50,21 @@ public class TeamDispatchService {
|
||||
private static final ExecutorService DISPATCH_EXECUTOR =
|
||||
Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/**
|
||||
* Lease-renewal cadence while a member run is in flight: a third of the
|
||||
* lease keeps two renewal chances in hand even if one write is lost, so a
|
||||
* long-running member is never reclaimed as stale while still working.
|
||||
*/
|
||||
private static final long HEARTBEAT_MINUTES = TeamTaskService.LOCK_MINUTES / 3;
|
||||
|
||||
/** Single daemon thread firing lease-renewal heartbeats for all running tasks. */
|
||||
private static final ScheduledExecutorService HEARTBEAT_SCHEDULER =
|
||||
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "team-task-heartbeat");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
private final TeamService teamService;
|
||||
private final TeamTaskService taskService;
|
||||
private final AgentService agentService;
|
||||
@ -120,7 +138,7 @@ public class TeamDispatchService {
|
||||
}
|
||||
|
||||
/** Execute one dispatched task on its member agent, then settle the outcome. */
|
||||
private void runTask(Long teamId, TeamTaskEntity task) {
|
||||
void runTask(Long teamId, TeamTaskEntity task) {
|
||||
Long memberId = task.getAssigneeAgentId();
|
||||
if (!runningMembers.add(memberId)) {
|
||||
// Same member picked up concurrently in this JVM; put the task back.
|
||||
@ -128,31 +146,77 @@ public class TeamDispatchService {
|
||||
return;
|
||||
}
|
||||
String childConvId = "team-task-" + IdUtil.fastSimpleUUID();
|
||||
ScheduledFuture<?> heartbeat = null;
|
||||
try {
|
||||
conversationService.createChildConversation(childConvId, memberId, "system",
|
||||
null, task.getLeadConversationId());
|
||||
taskService.attachConversation(task.getId(), childConvId);
|
||||
// Track the child run so graph nodes honor requestStop() — without a
|
||||
// registered RunState, cancelling the task could never interrupt the
|
||||
// member mid-run.
|
||||
streamTracker.register(childConvId);
|
||||
streamTracker.incrementFlux(childConvId);
|
||||
// Renew the execution lease while the member works; the conditional
|
||||
// UPDATE inside renewLock makes this a no-op once the task settles.
|
||||
heartbeat = HEARTBEAT_SCHEDULER.scheduleAtFixedRate(
|
||||
() -> taskService.renewLock(task.getId()),
|
||||
HEARTBEAT_MINUTES, HEARTBEAT_MINUTES, TimeUnit.MINUTES);
|
||||
broadcast(task, "team_task_dispatched", Map.of());
|
||||
log.info("Team {} task #{} dispatched to agent {} (conv {})",
|
||||
teamId, task.getTaskNumber(), memberId, childConvId);
|
||||
|
||||
// Message persistence is the caller's contract (the graph expects the
|
||||
// current user message to already be the conversation's last row),
|
||||
// and the persisted pair is what makes the run's transcript
|
||||
// reviewable from the task card.
|
||||
String dispatchContent = buildDispatchContent(task);
|
||||
conversationService.saveMessage(childConvId, "user", dispatchContent);
|
||||
AgentService.ChatResult result = agentService.chatWithUsage(
|
||||
memberId, buildDispatchContent(task), childConvId);
|
||||
memberId, dispatchContent, childConvId);
|
||||
String reply = result == null ? null : result.content();
|
||||
if (reply != null && !reply.isBlank()) {
|
||||
conversationService.saveMessage(childConvId, "assistant", reply);
|
||||
}
|
||||
|
||||
settleOutcome(task, result == null ? null : result.content());
|
||||
settleOutcome(task, reply);
|
||||
} catch (Exception e) {
|
||||
log.warn("Team {} task #{} member run failed: {}", teamId, task.getTaskNumber(),
|
||||
e.getMessage());
|
||||
taskService.failTask(task.getId(), truncate("member run error: " + e.getMessage(), 1000));
|
||||
broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(e.getMessage())));
|
||||
announceService.announceTaskSettled(taskService.getTask(task.getId()));
|
||||
log.warn("Team {} task #{} member run ended exceptionally: {}", teamId,
|
||||
task.getTaskNumber(), e.getMessage());
|
||||
// Only report a failure the guarded transition actually applied — an
|
||||
// interrupted run whose task is already cancelled must not produce a
|
||||
// misleading failed event on top of the terminal state.
|
||||
boolean failed = taskService.failTask(task.getId(),
|
||||
truncate("member run error: " + e.getMessage(), 1000));
|
||||
if (failed) {
|
||||
broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(e.getMessage())));
|
||||
announceService.announceTaskSettled(taskService.getTask(task.getId()));
|
||||
}
|
||||
} finally {
|
||||
if (heartbeat != null) {
|
||||
heartbeat.cancel(false);
|
||||
}
|
||||
streamTracker.complete(childConvId);
|
||||
runningMembers.remove(memberId);
|
||||
// Chain: dispatch released dependents and the member's next task.
|
||||
requestDispatch(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the member conversation executing this task to stop at the next graph
|
||||
* node boundary (cancel path). No-op when the task never dispatched or the
|
||||
* run already ended.
|
||||
*/
|
||||
public void interruptRun(TeamTaskEntity task) {
|
||||
if (task == null || task.getConversationId() == null) {
|
||||
return;
|
||||
}
|
||||
if (streamTracker.requestStop(task.getConversationId())) {
|
||||
log.info("Team task #{} member run interrupted (conv {})",
|
||||
task.getTaskNumber(), task.getConversationId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a finished member run. If the member already moved the task
|
||||
* (explicit complete, blocker fail) the run outcome is not applied on top;
|
||||
@ -202,8 +266,9 @@ public class TeamDispatchService {
|
||||
[Instructions]
|
||||
- Execute this task now. Your final reply becomes the task result reported to the team lead, so end with a complete, self-contained summary of what you produced.
|
||||
- Report milestones with team_tasks(action="progress", taskId=%s, percent=..., step=...).
|
||||
- If the output is a document, spreadsheet or presentation, generate a real file (renderDocx / renderXlsx / renderPptx or the docx/pptx/xlsx skills) and register it with team_tasks(action="attach", taskId=%s, name="<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();
|
||||
}
|
||||
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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.";
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -42,6 +42,15 @@ export default {
|
||||
reject: '驳回',
|
||||
rejectReason: '驳回原因(将通知 Lead)',
|
||||
retry: '重试',
|
||||
createTask: '新建任务',
|
||||
taskSubject: '任务标题',
|
||||
priority: '优先级',
|
||||
requireApprovalField: '完成后需人工审批',
|
||||
blockedBy: '前置任务',
|
||||
blockedByHint: '可选;所选任务全部完成后本任务才开始派发',
|
||||
taskCreateIncomplete: '请填写任务标题并选择执行成员',
|
||||
deliverables: '交付物',
|
||||
viewRun: '查看执行过程',
|
||||
column: {
|
||||
todo: '待处理',
|
||||
in_progress: '进行中',
|
||||
|
||||
@ -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">×</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 ==================== */
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user