mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(team): plan-execute leads orchestrate through the task board — hand-off bridge, resume gate and step dependencies
This commit is contained in:
parent
c15e51b34b
commit
2cbe00a1e7
@ -61,6 +61,7 @@ import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.team.service.TeamContextBuilder;
|
||||
import vip.mate.team.service.TeamPlanBridge;
|
||||
import vip.mate.wiki.service.WikiContextService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
@ -103,6 +104,7 @@ public class AgentGraphBuilder {
|
||||
private boolean markdownNormalizeEnabled;
|
||||
private final ConversationService conversationService;
|
||||
private final TeamContextBuilder teamContextBuilder;
|
||||
private final TeamPlanBridge teamPlanBridge;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ModelContextWindowResolver contextWindowResolver;
|
||||
@ -643,6 +645,9 @@ public class AgentGraphBuilder {
|
||||
executor.setAuditEventService(auditEventService);
|
||||
}
|
||||
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService);
|
||||
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
|
||||
// the team task board instead of the serial delegation pipeline.
|
||||
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
|
||||
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
|
||||
// Per-step delegation: route a step assigned to a specialist agent
|
||||
// through DelegateAgentTool (null when delegation deps aren't wired).
|
||||
@ -787,7 +792,10 @@ public class AgentGraphBuilder {
|
||||
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
|
||||
Map.of(
|
||||
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
|
||||
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE))
|
||||
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE,
|
||||
// Board-delegated plan settled: step results were
|
||||
// rebuilt from team tasks — summarize directly.
|
||||
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE))
|
||||
.addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE,
|
||||
AsyncEdgeAction.edge_async(new StepProgressDispatcher()),
|
||||
Map.of(
|
||||
|
||||
@ -3,6 +3,7 @@ package vip.mate.agent.graph.plan.edge;
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import com.alibaba.cloud.ai.graph.action.EdgeAction;
|
||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
|
||||
/**
|
||||
* Routes the graph after the triage node.
|
||||
@ -22,6 +23,12 @@ public class PlanGenerationDispatcher implements EdgeAction {
|
||||
|
||||
@Override
|
||||
public String apply(OverAllState state) {
|
||||
// A board-delegated plan whose tasks all settled resumes straight into
|
||||
// the summary — its step results were rebuilt from the team task board
|
||||
// by the resume gate; the step loop has nothing left to execute.
|
||||
if ("plan_delegated_settled".equals(state.value(MateClawStateKeys.CURRENT_PHASE, ""))) {
|
||||
return PlanStateKeys.PLAN_SUMMARY_NODE;
|
||||
}
|
||||
boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, false);
|
||||
if (!needsPlanning) {
|
||||
return PlanStateKeys.DIRECT_ANSWER_NODE;
|
||||
|
||||
@ -28,6 +28,8 @@ import vip.mate.goal.model.GoalCriterion;
|
||||
import vip.mate.goal.model.GoalEntity;
|
||||
import vip.mate.goal.service.GoalService;
|
||||
import vip.mate.planning.service.PlanningService;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.service.TeamPlanBridge;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
@ -70,6 +72,17 @@ public class PlanGenerationNode implements NodeAction {
|
||||
* resolve per-step assignments. Null disables per-step delegation (legacy/test). */
|
||||
private final AgentService agentService;
|
||||
|
||||
/**
|
||||
* Optional — hands a team lead's plan off to the team task board and
|
||||
* resumes parked plans on later messages. Null keeps the legacy serial
|
||||
* pipeline (non-team deployments / tests).
|
||||
*/
|
||||
private TeamPlanBridge teamPlanBridge;
|
||||
|
||||
public void setTeamPlanBridge(TeamPlanBridge teamPlanBridge) {
|
||||
this.teamPlanBridge = teamPlanBridge;
|
||||
}
|
||||
|
||||
/** Plan steps below this size are trivial tool tasks, not goal-worthy. */
|
||||
private static final int MIN_STEPS_FOR_AUTO_GOAL = 2;
|
||||
/** Cap the auto-derived goal title; the full request rides in the description. */
|
||||
@ -88,7 +101,13 @@ public class PlanGenerationNode implements NodeAction {
|
||||
// order). An empty string / missing entry means "run with the parent
|
||||
// agent". Only populated when delegatable specialist agents are
|
||||
// advertised to the planner; absent for backward compatibility.
|
||||
@JsonProperty("step_agents") List<String> stepAgents
|
||||
@JsonProperty("step_agents") List<String> stepAgents,
|
||||
// Optional per-step prerequisites, parallel to steps: each entry is
|
||||
// a comma-separated list of earlier 1-based step numbers ("" = no
|
||||
// prerequisite, may start immediately). Only requested when steps
|
||||
// hand off to a team board, where independent steps run in
|
||||
// parallel; anything invalid falls back to a sequential chain.
|
||||
@JsonProperty("step_deps") List<String> stepDeps
|
||||
) {}
|
||||
|
||||
private static final String PLANNING_PROMPT = """
|
||||
@ -331,6 +350,60 @@ public class PlanGenerationNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the planner's step_deps (1-based step numbers, comma separated)
|
||||
* into 0-based prerequisite indices. Any irregularity — missing field,
|
||||
* length mismatch, unparseable entry, self/forward reference — falls back
|
||||
* to a sequential chain (step i depends on step i-1), which is exactly
|
||||
* the serial semantics the plan pipeline has today: dependency info is a
|
||||
* parallelism bonus, never a correctness requirement. Package-private for
|
||||
* direct unit testing.
|
||||
*/
|
||||
static List<List<Integer>> parseStepDeps(List<String> stepDeps, int stepCount) {
|
||||
if (stepDeps == null || stepDeps.size() != stepCount) {
|
||||
return sequentialChain(stepCount);
|
||||
}
|
||||
List<List<Integer>> parsed = new ArrayList<>();
|
||||
for (int i = 0; i < stepCount; i++) {
|
||||
List<Integer> deps = new ArrayList<>();
|
||||
String raw = stepDeps.get(i);
|
||||
if (raw != null && !raw.isBlank()) {
|
||||
for (String part : raw.split("[,,]")) {
|
||||
if (part.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
int depIndex = Integer.parseInt(part.trim()) - 1;
|
||||
if (depIndex < 0 || depIndex >= i) {
|
||||
return sequentialChain(stepCount);
|
||||
}
|
||||
deps.add(depIndex);
|
||||
} catch (NumberFormatException e) {
|
||||
return sequentialChain(stepCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
parsed.add(deps);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static List<List<Integer>> sequentialChain(int stepCount) {
|
||||
List<List<Integer>> chain = new ArrayList<>();
|
||||
for (int i = 0; i < stepCount; i++) {
|
||||
chain.add(i == 0 ? List.of() : List.of(i - 1));
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static Long parseNumericAgentId(String agentId) {
|
||||
try {
|
||||
return Long.valueOf(agentId);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled agents in the given workspace, excluding the parent (plan) agent
|
||||
* itself — these are the agents a step can be delegated to. Empty when
|
||||
@ -422,6 +495,45 @@ public class PlanGenerationNode implements NodeAction {
|
||||
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
||||
events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal)));
|
||||
|
||||
// Delegated-plan resume gate: a plan parked on the team board resumes
|
||||
// here on ANY inbound message (settle announcement or a user asking
|
||||
// for status) — deterministic routing that never depends on how the
|
||||
// triage LLM classifies the wake-up text. Mirrors the approval-replay
|
||||
// pattern: park in the DB, resume from the DB.
|
||||
if (teamPlanBridge != null) {
|
||||
TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId);
|
||||
if (parked instanceof TeamPlanBridge.Settled settled) {
|
||||
log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary",
|
||||
settled.planId(), settled.completedResults().size());
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(true)
|
||||
.planId(settled.planId())
|
||||
.planSteps(settled.steps())
|
||||
.planValid(true)
|
||||
.currentStepIndex(settled.steps().size())
|
||||
// Summarize against the original plan goal, not the
|
||||
// wake-up message that happened to trigger the resume.
|
||||
.goal(settled.goal())
|
||||
.put(PlanStateKeys.COMPLETED_RESULTS, settled.completedResults())
|
||||
.currentPhase("plan_delegated_settled")
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
if (parked instanceof TeamPlanBridge.InFlight inFlight) {
|
||||
log.info("[PlanGeneration] Delegated plan still in flight — answering with progress");
|
||||
if (streamingHelper != null) {
|
||||
streamingHelper.broadcastContent(conversationId, inFlight.progressText());
|
||||
}
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(false)
|
||||
.directAnswer(inFlight.progressText())
|
||||
.currentPhase("direct_answer")
|
||||
.contentStreamed(true)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
|
||||
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
|
||||
if (existingPlanId != null) {
|
||||
@ -467,22 +579,42 @@ public class PlanGenerationNode implements NodeAction {
|
||||
+ "\n单次工具调用应归为单步(B),不要拆成多步。"));
|
||||
}
|
||||
|
||||
// Advertise delegatable specialist agents so the planner can assign a
|
||||
// multi-step plan's step to a dedicated agent (e.g. a test step to a
|
||||
// QA agent, a UI step to a frontend agent). Only fills the step's
|
||||
// step_agents slot; unassigned steps stay with the parent agent.
|
||||
// Skipped entirely when no peer agents exist in the workspace.
|
||||
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
||||
if (!delegatable.isEmpty()) {
|
||||
String agentLines = delegatable.stream()
|
||||
// Advertise delegatable agents. A team lead advertises its member
|
||||
// roster with mandatory assignment (steps hand off to the team
|
||||
// board and run in parallel there); everyone else advertises the
|
||||
// workspace-wide specialist list with optional assignment.
|
||||
AgentTeamEntity leadTeam = null;
|
||||
if (teamPlanBridge != null) {
|
||||
Long numericAgentId = parseNumericAgentId(agentId);
|
||||
leadTeam = numericAgentId == null ? null
|
||||
: teamPlanBridge.leadTeam(numericAgentId).orElse(null);
|
||||
}
|
||||
if (leadTeam != null) {
|
||||
String memberLines = teamPlanBridge.roster(leadTeam).stream()
|
||||
.map(a -> "- " + a.getName()
|
||||
+ (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : ""))
|
||||
.collect(Collectors.joining("\n"));
|
||||
promptMessages.add(new UserMessage(
|
||||
"可委派的专职 Agent(仅当某步骤明显属于其专长时才指派,否则该步骤留空、由你自己执行):\n"
|
||||
+ agentLines
|
||||
+ "\n若要委派,在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);"
|
||||
+ "不委派的步骤填空字符串。多数步骤通常不需要委派。"));
|
||||
"你是团队「" + leadTeam.getName() + "」的 lead。多步任务的每个步骤都将分派到团队任务板,"
|
||||
+ "由成员并行执行。团队成员:\n" + memberLines
|
||||
+ "\n要求:\n"
|
||||
+ "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n"
|
||||
+ "2. 在 step_deps 数组标注每个步骤的前置步骤序号(1 起始,逗号分隔;无前置填空字符串)。"
|
||||
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
|
||||
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。"));
|
||||
} else {
|
||||
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
||||
if (!delegatable.isEmpty()) {
|
||||
String agentLines = delegatable.stream()
|
||||
.map(a -> "- " + a.getName()
|
||||
+ (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : ""))
|
||||
.collect(Collectors.joining("\n"));
|
||||
promptMessages.add(new UserMessage(
|
||||
"可委派的专职 Agent(仅当某步骤明显属于其专长时才指派,否则该步骤留空、由你自己执行):\n"
|
||||
+ agentLines
|
||||
+ "\n若要委派,在 step_agents 数组对应位置填写 Agent 名称(与 steps 同序、等长);"
|
||||
+ "不委派的步骤填空字符串。多数步骤通常不需要委派。"));
|
||||
}
|
||||
}
|
||||
|
||||
// Inject working context (rolling conversation summary) so triage respects
|
||||
@ -599,6 +731,39 @@ public class PlanGenerationNode implements NodeAction {
|
||||
steps = List.of(persistGoal);
|
||||
}
|
||||
|
||||
// Team hand-off: a lead whose every step resolved to a member
|
||||
// parks the plan on the task board and ends the turn — execution
|
||||
// continues through the board's dispatch/announce machinery, and
|
||||
// any later message resumes via the delegated-plan gate above.
|
||||
if (leadTeam != null) {
|
||||
List<Long> memberIds = teamPlanBridge.resolveMembers(leadTeam, steps,
|
||||
triage != null ? triage.stepAgents() : null);
|
||||
if (memberIds != null) {
|
||||
List<List<Integer>> stepDeps = parseStepDeps(
|
||||
triage != null ? triage.stepDeps() : null, steps.size());
|
||||
var delegatedPlan = planningService.createPlan(
|
||||
agentId, conversationId, persistGoal, steps, memberIds);
|
||||
events.add(GraphEventPublisher.planCreated(delegatedPlan.getId(), steps));
|
||||
String announcement = teamPlanBridge.delegatePlan(leadTeam,
|
||||
delegatedPlan.getId(), persistGoal, steps, stepDeps,
|
||||
memberIds, conversationId);
|
||||
streamingHelper.broadcastContent(conversationId, announcement);
|
||||
log.info("[PlanGeneration] Plan {} handed off to team {} board ({} steps)",
|
||||
delegatedPlan.getId(), leadTeam.getId(), steps.size());
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(false)
|
||||
.directAnswer(announcement)
|
||||
.currentPhase("direct_answer")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.mergeUsage(state, result)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
log.info("[PlanGeneration] Lead plan not fully assigned to members; "
|
||||
+ "falling back to the serial pipeline");
|
||||
}
|
||||
|
||||
// Resolve any per-step agent delegation the planner asked for. Null
|
||||
// when nothing is delegated, keeping createPlan on the legacy path.
|
||||
List<Long> stepAgentIds = resolveStepAgents(steps,
|
||||
|
||||
@ -74,7 +74,9 @@ public class PlanSummaryNode implements NodeAction {
|
||||
Prompt prompt = new Prompt(List.of(
|
||||
new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。"
|
||||
+ "直接回答用户的原始问题,不要罗列步骤。"
|
||||
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"),
|
||||
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"
|
||||
+ "若执行结果中包含交付物下载链接,请在回答中原样列出这些链接。"
|
||||
+ "若某些步骤未完成,如实说明未完成的部分及原因。"),
|
||||
new UserMessage(userContent.toString())
|
||||
));
|
||||
|
||||
|
||||
@ -127,6 +127,35 @@ public class PlanningService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a plan whose steps were handed off to a team task board. The plan
|
||||
* stays in this status while board tasks execute; any later inbound
|
||||
* message resumes it through the delegated-plan gate.
|
||||
*/
|
||||
public void markPlanDelegated(Long planId) {
|
||||
PlanEntity plan = planMapper.selectById(planId);
|
||||
if (plan != null) {
|
||||
plan.setStatus("delegated");
|
||||
planMapper.updateById(plan);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest board-delegated plan parked on this conversation, or null. The
|
||||
* counterpart of {@link #findAwaitingApprovalContext} for the team
|
||||
* hand-off flow: park in the DB, resume from the DB.
|
||||
*/
|
||||
public PlanEntity findDelegatedPlan(String conversationId) {
|
||||
if (conversationId == null || conversationId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return planMapper.selectOne(new LambdaQueryWrapper<PlanEntity>()
|
||||
.eq(PlanEntity::getConversationId, conversationId)
|
||||
.eq(PlanEntity::getStatus, "delegated")
|
||||
.orderByDesc(PlanEntity::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成计划
|
||||
*/
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
package vip.mate.team.event;
|
||||
|
||||
/**
|
||||
* Published after a plan's steps were handed off to a team's task board, so
|
||||
* the dispatch layer sweeps immediately instead of waiting for the scheduled
|
||||
* pass. An event (rather than a direct call) keeps the hand-off bridge free
|
||||
* of the dispatch service — a direct dependency would close a bean cycle
|
||||
* through the agent graph builder.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public record TeamTasksDelegatedEvent(Long teamId) {
|
||||
}
|
||||
@ -3,8 +3,10 @@ package vip.mate.team.service;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.team.event.TeamTasksDelegatedEvent;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
@ -77,6 +79,16 @@ public class TeamDispatchService {
|
||||
/** Members with a run currently in flight in this JVM (belt-and-braces on top of hasActiveTask). */
|
||||
private final Set<Long> runningMembers = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/**
|
||||
* Plan hand-off notification: sweep the board as soon as a delegated
|
||||
* plan's tasks land. Event-driven because the hand-off bridge cannot
|
||||
* depend on this service directly (bean cycle through the graph builder).
|
||||
*/
|
||||
@EventListener
|
||||
public void onTeamTasksDelegated(TeamTasksDelegatedEvent event) {
|
||||
requestDispatch(event.teamId());
|
||||
}
|
||||
|
||||
/** Asynchronously sweep the team's board and dispatch whatever is eligible. */
|
||||
public void requestDispatch(Long teamId) {
|
||||
DISPATCH_EXECUTOR.submit(() -> {
|
||||
|
||||
@ -0,0 +1,304 @@
|
||||
package vip.mate.team.service;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.planning.model.PlanEntity;
|
||||
import vip.mate.planning.service.PlanningService;
|
||||
import vip.mate.team.event.TeamTasksDelegatedEvent;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.AgentTeamMemberEntity;
|
||||
import vip.mate.team.model.TeamRole;
|
||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Bridges the Plan-Execute graph onto the team task board. When a plan's
|
||||
* lead-of-team owner assigns every step to a team member, the steps become
|
||||
* board tasks (dependencies mapped to blockedBy), the plan parks in the
|
||||
* "delegated" status and the lead's turn ends — execution then runs through
|
||||
* the board's dispatch/announce machinery instead of the serial per-step
|
||||
* delegation pipeline. Any later inbound message resumes through
|
||||
* {@link #checkParkedPlan}: settled boards feed the plan summary, in-flight
|
||||
* boards produce a progress answer.
|
||||
*
|
||||
* Deliberately does NOT depend on the dispatch service (bean cycle through
|
||||
* the agent graph builder) — a {@link TeamTasksDelegatedEvent} triggers the
|
||||
* immediate sweep instead.
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TeamPlanBridge {
|
||||
|
||||
/** Task subject cap; the full step text rides in the description. */
|
||||
static final int SUBJECT_MAX_CHARS = 120;
|
||||
|
||||
private final TeamService teamService;
|
||||
private final TeamTaskService taskService;
|
||||
private final PlanningService planningService;
|
||||
private final AgentMapper agentMapper;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
// ==================== triage support ====================
|
||||
|
||||
/** The team this agent leads, if any. */
|
||||
public Optional<AgentTeamEntity> leadTeam(Long agentId) {
|
||||
if (agentId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return teamService.getTeamForAgent(agentId)
|
||||
.filter(team -> teamService.isLead(team, agentId));
|
||||
}
|
||||
|
||||
/** Assignable members (lead excluded), for the planner's roster message. */
|
||||
public List<AgentEntity> roster(AgentTeamEntity team) {
|
||||
List<AgentEntity> members = new ArrayList<>();
|
||||
for (AgentTeamMemberEntity member : teamService.listMembers(team.getId())) {
|
||||
if (TeamRole.LEAD.equals(member.getRole())) {
|
||||
continue;
|
||||
}
|
||||
AgentEntity agent = agentMapper.selectById(member.getAgentId());
|
||||
if (agent != null) {
|
||||
members.add(agent);
|
||||
}
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the planner's step_agents names onto team member ids. Returns null
|
||||
* unless EVERY step resolves to a member — the hand-off is all-or-nothing
|
||||
* (mixed local/board plans are out of scope), and a null keeps the plan
|
||||
* on the legacy serial pipeline.
|
||||
*/
|
||||
public List<Long> resolveMembers(AgentTeamEntity team, List<String> steps,
|
||||
List<String> stepAgents) {
|
||||
if (steps == null || steps.isEmpty() || stepAgents == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Long> byName = new HashMap<>();
|
||||
for (AgentEntity member : roster(team)) {
|
||||
if (member.getName() != null) {
|
||||
byName.put(member.getName().trim().toLowerCase(), member.getId());
|
||||
}
|
||||
}
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
String name = i < stepAgents.size() ? stepAgents.get(i) : null;
|
||||
Long id = (name == null || name.isBlank()) ? null
|
||||
: byName.get(name.trim().toLowerCase());
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ==================== hand-off ====================
|
||||
|
||||
/**
|
||||
* Create one board task per step (dependencies → blockedBy), park the plan
|
||||
* as "delegated" and nudge the dispatcher. Returns the announcement text
|
||||
* the lead streams to the user before ending its turn.
|
||||
*
|
||||
* @param stepDeps per-step prerequisite step indices (0-based, each
|
||||
* referencing an earlier step); the caller guarantees
|
||||
* validity via its sequential-chain fallback
|
||||
*/
|
||||
public String delegatePlan(AgentTeamEntity team, Long planId, String goal,
|
||||
List<String> steps, List<List<Integer>> stepDeps,
|
||||
List<Long> memberIds, String leadConversationId) {
|
||||
List<TeamTaskEntity> created = new ArrayList<>();
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
String step = steps.get(i);
|
||||
List<Long> blockedBy = new ArrayList<>();
|
||||
for (Integer depIndex : stepDeps.get(i)) {
|
||||
blockedBy.add(created.get(depIndex).getId());
|
||||
}
|
||||
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
|
||||
.teamId(team.getId())
|
||||
.subject(subjectOf(step))
|
||||
.description(step + "\n\n[Plan context]\nOverall request: " + goal)
|
||||
.assigneeAgentId(memberIds.get(i))
|
||||
.createdByAgentId(team.getLeadAgentId())
|
||||
.blockedBy(blockedBy.isEmpty() ? null : blockedBy)
|
||||
.leadConversationId(leadConversationId)
|
||||
.channel("plan")
|
||||
.metadata(new JSONObject()
|
||||
.set("planId", String.valueOf(planId))
|
||||
.set("stepIndex", i)
|
||||
.toString())
|
||||
.build());
|
||||
created.add(task);
|
||||
}
|
||||
planningService.markPlanDelegated(planId);
|
||||
eventPublisher.publishEvent(new TeamTasksDelegatedEvent(team.getId()));
|
||||
log.info("Plan {} delegated to team {} board as {} task(s)", planId, team.getId(),
|
||||
created.size());
|
||||
return buildAnnouncement(created, stepDeps);
|
||||
}
|
||||
|
||||
// ==================== resume gate ====================
|
||||
|
||||
/** Outcome of the parked-plan check on an inbound message. */
|
||||
public sealed interface ParkedPlanState permits None, Settled, InFlight {
|
||||
}
|
||||
|
||||
public record None() implements ParkedPlanState {
|
||||
}
|
||||
|
||||
/** All board tasks terminal — resume into the plan summary. */
|
||||
public record Settled(Long planId, String goal, List<String> steps,
|
||||
List<String> completedResults) implements ParkedPlanState {
|
||||
}
|
||||
|
||||
/** Board still working — answer with live progress, stay parked. */
|
||||
public record InFlight(String progressText) implements ParkedPlanState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect the conversation's parked plan, if any. Settled boards sync the
|
||||
* sub-plan mirror and return the step results (with deliverable links) in
|
||||
* the summary node's expected format; in-flight boards return a rendered
|
||||
* progress snapshot.
|
||||
*/
|
||||
public ParkedPlanState checkParkedPlan(String conversationId) {
|
||||
PlanEntity plan = planningService.findDelegatedPlan(conversationId);
|
||||
if (plan == null) {
|
||||
return new None();
|
||||
}
|
||||
Optional<AgentTeamEntity> teamOpt = leadTeam(parseAgentId(plan.getAgentId()));
|
||||
if (teamOpt.isEmpty()) {
|
||||
// Team dissolved or lead reassigned while parked — nothing to wait
|
||||
// for; fail the plan so the conversation is not wedged forever.
|
||||
planningService.markPlanFailed(plan.getId(), "team no longer available");
|
||||
return new None();
|
||||
}
|
||||
List<TeamTaskEntity> tasks = taskService.listTasksByPlan(teamOpt.get().getId(), plan.getId());
|
||||
if (tasks.isEmpty()) {
|
||||
planningService.markPlanFailed(plan.getId(), "board tasks vanished");
|
||||
return new None();
|
||||
}
|
||||
boolean allTerminal = tasks.stream()
|
||||
.allMatch(task -> TeamTaskStatus.isTerminal(task.getStatus()));
|
||||
List<String> steps = planningService.getSubPlans(plan.getId()).stream()
|
||||
.map(sub -> sub.getDescription())
|
||||
.toList();
|
||||
if (!allTerminal) {
|
||||
return new InFlight(buildProgressText(tasks));
|
||||
}
|
||||
return new Settled(plan.getId(), plan.getGoal(), steps, settle(plan.getId(), tasks));
|
||||
}
|
||||
|
||||
/** Sync the sub-plan mirror from terminal tasks and render step results. */
|
||||
private List<String> settle(Long planId, List<TeamTaskEntity> tasks) {
|
||||
List<String> results = new ArrayList<>();
|
||||
for (TeamTaskEntity task : tasks) {
|
||||
int stepIndex = stepIndexOf(task);
|
||||
StringBuilder line = new StringBuilder();
|
||||
if (TeamTaskStatus.COMPLETED.equals(task.getStatus())) {
|
||||
planningService.updateSubPlanResult(planId, stepIndex,
|
||||
task.getResult() == null ? "" : task.getResult());
|
||||
line.append(String.format("步骤%d结果:%s", stepIndex + 1,
|
||||
task.getResult() == null ? "(无输出)" : task.getResult()));
|
||||
} else {
|
||||
String reason = task.getReason() == null ? task.getStatus() : task.getReason();
|
||||
planningService.updateSubPlanFailure(planId, stepIndex, reason);
|
||||
line.append(String.format("步骤%d未完成(%s):%s", stepIndex + 1,
|
||||
task.getStatus(), reason));
|
||||
}
|
||||
for (TeamTaskService.Deliverable file : taskService.listDeliverables(task)) {
|
||||
line.append("\n交付物:").append(file.name()).append(" → ").append(file.url());
|
||||
}
|
||||
results.add(line.toString());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ==================== rendering ====================
|
||||
|
||||
private String buildAnnouncement(List<TeamTaskEntity> tasks, List<List<Integer>> stepDeps) {
|
||||
StringBuilder sb = new StringBuilder("已将计划分派到团队任务板并行执行:\n");
|
||||
for (int i = 0; i < tasks.size(); i++) {
|
||||
TeamTaskEntity task = tasks.get(i);
|
||||
sb.append("- #").append(task.getTaskNumber()).append(' ')
|
||||
.append(task.getSubject())
|
||||
.append("(").append(agentName(task.getAssigneeAgentId())).append(")");
|
||||
if (!stepDeps.get(i).isEmpty()) {
|
||||
sb.append(" — 前置:");
|
||||
for (Integer depIndex : stepDeps.get(i)) {
|
||||
sb.append('#').append(tasks.get(depIndex).getTaskNumber()).append(' ');
|
||||
}
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
sb.append("成员完成后我会汇总结果给你。");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String buildProgressText(List<TeamTaskEntity> tasks) {
|
||||
StringBuilder sb = new StringBuilder("计划仍在团队任务板上执行中:\n");
|
||||
for (TeamTaskEntity task : tasks) {
|
||||
sb.append("- #").append(task.getTaskNumber()).append(' ')
|
||||
.append(task.getSubject())
|
||||
.append(":").append(task.getStatus());
|
||||
if (task.getProgressPercent() != null
|
||||
&& TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) {
|
||||
sb.append("(").append(task.getProgressPercent()).append('%');
|
||||
if (task.getProgressStep() != null) {
|
||||
sb.append(" — ").append(task.getProgressStep());
|
||||
}
|
||||
sb.append(')');
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
sb.append("全部完成后我会汇总;如需调整可在团队任务板上操作。");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ==================== helpers ====================
|
||||
|
||||
private static String subjectOf(String step) {
|
||||
String firstLine = step.strip().lines().findFirst().orElse(step.strip());
|
||||
return firstLine.length() <= SUBJECT_MAX_CHARS ? firstLine
|
||||
: firstLine.substring(0, SUBJECT_MAX_CHARS);
|
||||
}
|
||||
|
||||
private static int stepIndexOf(TeamTaskEntity task) {
|
||||
try {
|
||||
return JSONUtil.parseObj(task.getMetadata()).getInt("stepIndex", 0);
|
||||
} catch (Exception e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static Long parseAgentId(String agentId) {
|
||||
try {
|
||||
return Long.valueOf(agentId);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String agentName(Long agentId) {
|
||||
AgentEntity agent = agentId == null ? null : agentMapper.selectById(agentId);
|
||||
return agent != null && agent.getName() != null ? agent.getName()
|
||||
: String.valueOf(agentId);
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,6 @@ public class TeamService {
|
||||
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
|
||||
List<Long> memberAgentIds, String createdBy) {
|
||||
requireAgentExists(leadAgentId, "lead");
|
||||
requireReactLead(leadAgentId);
|
||||
requireNotInAnyTeam(leadAgentId);
|
||||
if (memberAgentIds != null) {
|
||||
for (Long memberId : memberAgentIds) {
|
||||
@ -225,21 +224,6 @@ public class TeamService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead must run the ReAct graph. A plan-execute lead orchestrates
|
||||
* through its own serial per-step delegation pipeline, which bypasses the
|
||||
* team board entirely — tasks are never created, members never run in
|
||||
* parallel, and the collaboration silently degrades to solo delegation.
|
||||
*/
|
||||
private void requireReactLead(Long agentId) {
|
||||
AgentEntity agent = agentMapper.selectById(agentId);
|
||||
if (agent != null && "plan_execute".equals(agent.getAgentType())) {
|
||||
throw new IllegalArgumentException(
|
||||
"lead agent must be a ReAct agent: a plan-execute lead plans serial steps "
|
||||
+ "through its own delegation pipeline and never uses the team board. "
|
||||
+ "Switch the agent's type to ReAct, or pick a different lead.");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireNotInAnyTeam(Long agentId) {
|
||||
// Membership check ignores team status on purpose: an agent parked in a
|
||||
|
||||
@ -563,6 +563,19 @@ public class TeamTaskService {
|
||||
return taskMapper.selectById(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks created from a delegated plan's steps, ordered by creation. The
|
||||
* plan linkage lives in the task metadata JSON ({@code "planId"} written
|
||||
* as a string), matched with a LIKE — team boards are small and the
|
||||
* pattern includes the quoted key, so false positives are not a concern.
|
||||
*/
|
||||
public List<TeamTaskEntity> listTasksByPlan(Long teamId, Long planId) {
|
||||
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
|
||||
.eq(TeamTaskEntity::getTeamId, teamId)
|
||||
.like(TeamTaskEntity::getMetadata, "\"planId\":\"" + planId + "\"")
|
||||
.orderByAsc(TeamTaskEntity::getCreateTime));
|
||||
}
|
||||
|
||||
public List<TeamTaskEntity> listTasks(Long teamId, List<String> statuses) {
|
||||
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
|
||||
.eq(TeamTaskEntity::getTeamId, teamId)
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
package vip.mate.agent.graph.plan.node;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pins the step_deps parsing contract: valid annotations become 0-based
|
||||
* prerequisite indices enabling parallel board dispatch; ANY irregularity
|
||||
* falls back to the sequential chain that matches today's serial semantics.
|
||||
*/
|
||||
class PlanGenerationStepDepsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("valid deps parse to 0-based indices; empty entries mean no prerequisite")
|
||||
void validDepsParse() {
|
||||
List<List<Integer>> deps = PlanGenerationNode.parseStepDeps(
|
||||
List.of("", "", "1,2"), 3);
|
||||
assertEquals(List.of(), deps.get(0));
|
||||
assertEquals(List.of(), deps.get(1));
|
||||
assertEquals(List.of(0, 1), deps.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("missing field or length mismatch falls back to the sequential chain")
|
||||
void missingFallsBackToChain() {
|
||||
List<List<Integer>> absent = PlanGenerationNode.parseStepDeps(null, 3);
|
||||
assertEquals(List.of(), absent.get(0));
|
||||
assertEquals(List.of(0), absent.get(1));
|
||||
assertEquals(List.of(1), absent.get(2));
|
||||
|
||||
List<List<Integer>> mismatch = PlanGenerationNode.parseStepDeps(List.of(""), 3);
|
||||
assertEquals(List.of(1), mismatch.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("self references, forward references and junk all fall back to the chain")
|
||||
void invalidFallsBackToChain() {
|
||||
// Self reference (step 2 depends on step 2).
|
||||
assertEquals(List.of(0),
|
||||
PlanGenerationNode.parseStepDeps(List.of("", "2"), 2).get(1));
|
||||
// Forward reference (step 1 depends on step 2).
|
||||
assertEquals(List.of(),
|
||||
PlanGenerationNode.parseStepDeps(List.of("2", ""), 2).get(0));
|
||||
// Unparseable entry.
|
||||
assertEquals(List.of(0),
|
||||
PlanGenerationNode.parseStepDeps(List.of("", "abc"), 2).get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("full-width comma separators are accepted")
|
||||
void fullWidthCommaAccepted() {
|
||||
List<List<Integer>> deps = PlanGenerationNode.parseStepDeps(
|
||||
List.of("", "", "1,2"), 3);
|
||||
assertEquals(List.of(0, 1), deps.get(2));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,228 @@
|
||||
package vip.mate.team.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.planning.model.PlanEntity;
|
||||
import vip.mate.planning.model.SubPlanEntity;
|
||||
import vip.mate.planning.service.PlanningService;
|
||||
import vip.mate.team.event.TeamTasksDelegatedEvent;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.AgentTeamMemberEntity;
|
||||
import vip.mate.team.model.TeamRole;
|
||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Pins the plan→board hand-off contract: all-or-nothing member resolution,
|
||||
* dependency→blockedBy mapping with plan linkage in task metadata, parking
|
||||
* via the delegated status + dispatch event, and the three-way resume gate.
|
||||
*/
|
||||
class TeamPlanBridgeTest {
|
||||
|
||||
private static final Long TEAM_ID = 10L;
|
||||
private static final Long LEAD_ID = 1L;
|
||||
private static final Long WRITER_ID = 2L;
|
||||
private static final Long ANALYST_ID = 3L;
|
||||
private static final Long PLAN_ID = 77L;
|
||||
private static final String CONV = "lead-conv";
|
||||
|
||||
private TeamService teamService;
|
||||
private TeamTaskService taskService;
|
||||
private PlanningService planningService;
|
||||
private AgentMapper agentMapper;
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
private TeamPlanBridge bridge;
|
||||
private AgentTeamEntity team;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
teamService = mock(TeamService.class);
|
||||
taskService = mock(TeamTaskService.class);
|
||||
planningService = mock(PlanningService.class);
|
||||
agentMapper = mock(AgentMapper.class);
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
bridge = new TeamPlanBridge(teamService, taskService, planningService,
|
||||
agentMapper, eventPublisher);
|
||||
|
||||
team = new AgentTeamEntity();
|
||||
team.setId(TEAM_ID);
|
||||
team.setName("编队");
|
||||
team.setLeadAgentId(LEAD_ID);
|
||||
|
||||
when(teamService.listMembers(TEAM_ID)).thenReturn(List.of(
|
||||
member(LEAD_ID, TeamRole.LEAD),
|
||||
member(WRITER_ID, TeamRole.MEMBER),
|
||||
member(ANALYST_ID, TeamRole.MEMBER)));
|
||||
when(agentMapper.selectById(WRITER_ID)).thenReturn(agent(WRITER_ID, "写手"));
|
||||
when(agentMapper.selectById(ANALYST_ID)).thenReturn(agent(ANALYST_ID, "分析师"));
|
||||
}
|
||||
|
||||
private static AgentTeamMemberEntity member(Long agentId, String role) {
|
||||
AgentTeamMemberEntity m = new AgentTeamMemberEntity();
|
||||
m.setTeamId(TEAM_ID);
|
||||
m.setAgentId(agentId);
|
||||
m.setRole(role);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static AgentEntity agent(Long id, String name) {
|
||||
AgentEntity a = new AgentEntity();
|
||||
a.setId(id);
|
||||
a.setName(name);
|
||||
return a;
|
||||
}
|
||||
|
||||
private static TeamTaskEntity task(Long id, int number, int stepIndex, 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(WRITER_ID);
|
||||
t.setMetadata("{\"planId\":\"" + PLAN_ID + "\",\"stepIndex\":" + stepIndex + "}");
|
||||
return t;
|
||||
}
|
||||
|
||||
// ==================== member resolution ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("resolution is all-or-nothing: one unknown name keeps the legacy pipeline")
|
||||
void resolutionAllOrNothing() {
|
||||
assertEquals(List.of(WRITER_ID, ANALYST_ID),
|
||||
bridge.resolveMembers(team, List.of("s1", "s2"), List.of("写手", "分析师")));
|
||||
assertNull(bridge.resolveMembers(team, List.of("s1", "s2"), List.of("写手", "路人")));
|
||||
assertNull(bridge.resolveMembers(team, List.of("s1", "s2"), List.of("写手", "")));
|
||||
assertNull(bridge.resolveMembers(team, List.of("s1", "s2"), null));
|
||||
}
|
||||
|
||||
// ==================== hand-off ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("delegatePlan maps deps to blockedBy, stamps plan linkage, parks and nudges dispatch")
|
||||
void delegatePlanCreatesLinkedTasks() {
|
||||
when(taskService.createTask(any())).thenAnswer(inv -> {
|
||||
TeamTaskCreateCommand cmd = inv.getArgument(0);
|
||||
TeamTaskEntity created = new TeamTaskEntity();
|
||||
created.setId((long) (100 + cmd.getMetadata().hashCode() % 1000));
|
||||
created.setId(cmd.getMetadata().contains("\"stepIndex\":0") ? 101L : 102L);
|
||||
created.setTaskNumber(created.getId().intValue() - 100);
|
||||
created.setSubject(cmd.getSubject());
|
||||
created.setAssigneeAgentId(cmd.getAssigneeAgentId());
|
||||
return created;
|
||||
});
|
||||
|
||||
String announcement = bridge.delegatePlan(team, PLAN_ID, "整体请求",
|
||||
List.of("第一步", "第二步"), List.of(List.of(), List.of(0)),
|
||||
List.of(WRITER_ID, ANALYST_ID), CONV);
|
||||
|
||||
ArgumentCaptor<TeamTaskCreateCommand> captor =
|
||||
ArgumentCaptor.forClass(TeamTaskCreateCommand.class);
|
||||
verify(taskService, times(2)).createTask(captor.capture());
|
||||
TeamTaskCreateCommand first = captor.getAllValues().get(0);
|
||||
TeamTaskCreateCommand second = captor.getAllValues().get(1);
|
||||
|
||||
assertNull(first.getBlockedBy());
|
||||
assertEquals(List.of(101L), second.getBlockedBy());
|
||||
assertTrue(first.getMetadata().contains("\"planId\":\"" + PLAN_ID + "\""));
|
||||
assertTrue(second.getMetadata().contains("\"stepIndex\":1"));
|
||||
assertEquals(CONV, first.getLeadConversationId());
|
||||
assertEquals(LEAD_ID, first.getCreatedByAgentId());
|
||||
assertTrue(first.getDescription().contains("整体请求"));
|
||||
|
||||
verify(planningService).markPlanDelegated(PLAN_ID);
|
||||
verify(eventPublisher).publishEvent(new TeamTasksDelegatedEvent(TEAM_ID));
|
||||
assertTrue(announcement.contains("并行"));
|
||||
assertTrue(announcement.contains("前置"));
|
||||
}
|
||||
|
||||
// ==================== resume gate ====================
|
||||
|
||||
private void parkedPlan() {
|
||||
PlanEntity plan = new PlanEntity();
|
||||
plan.setId(PLAN_ID);
|
||||
plan.setAgentId(String.valueOf(LEAD_ID));
|
||||
plan.setConversationId(CONV);
|
||||
plan.setStatus("delegated");
|
||||
plan.setGoal("整体请求");
|
||||
when(planningService.findDelegatedPlan(CONV)).thenReturn(plan);
|
||||
when(teamService.getTeamForAgent(LEAD_ID)).thenReturn(Optional.of(team));
|
||||
when(teamService.isLead(team, LEAD_ID)).thenReturn(true);
|
||||
SubPlanEntity sub0 = new SubPlanEntity();
|
||||
sub0.setStepIndex(0);
|
||||
sub0.setDescription("第一步");
|
||||
SubPlanEntity sub1 = new SubPlanEntity();
|
||||
sub1.setStepIndex(1);
|
||||
sub1.setDescription("第二步");
|
||||
when(planningService.getSubPlans(PLAN_ID)).thenReturn(List.of(sub0, sub1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no parked plan yields None; in-flight boards yield a progress answer")
|
||||
void gateNoneAndInFlight() {
|
||||
when(planningService.findDelegatedPlan(CONV)).thenReturn(null);
|
||||
assertInstanceOf(TeamPlanBridge.None.class, bridge.checkParkedPlan(CONV));
|
||||
|
||||
parkedPlan();
|
||||
when(taskService.listTasksByPlan(TEAM_ID, PLAN_ID)).thenReturn(List.of(
|
||||
task(101L, 1, 0, TeamTaskStatus.COMPLETED),
|
||||
task(102L, 2, 1, TeamTaskStatus.IN_PROGRESS)));
|
||||
|
||||
TeamPlanBridge.ParkedPlanState state = bridge.checkParkedPlan(CONV);
|
||||
TeamPlanBridge.InFlight inFlight = assertInstanceOf(TeamPlanBridge.InFlight.class, state);
|
||||
assertTrue(inFlight.progressText().contains("in_progress"));
|
||||
verify(planningService, never()).updateSubPlanResult(any(), anyInt(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a settled board syncs the sub-plan mirror and returns summary-ready results")
|
||||
void gateSettled() {
|
||||
parkedPlan();
|
||||
TeamTaskEntity done = task(101L, 1, 0, TeamTaskStatus.COMPLETED);
|
||||
done.setResult("卖点已产出");
|
||||
done.setMetadata("{\"planId\":\"" + PLAN_ID + "\",\"stepIndex\":0,"
|
||||
+ "\"deliverables\":[{\"name\":\"a.docx\",\"url\":\"/api/v1/files/generated/x\"}]}");
|
||||
TeamTaskEntity failed = task(102L, 2, 1, TeamTaskStatus.FAILED);
|
||||
failed.setReason("blocked: 缺输入");
|
||||
when(taskService.listTasksByPlan(TEAM_ID, PLAN_ID)).thenReturn(List.of(done, failed));
|
||||
when(taskService.listDeliverables(done)).thenReturn(List.of(
|
||||
new TeamTaskService.Deliverable("a.docx", "/api/v1/files/generated/x", null)));
|
||||
|
||||
TeamPlanBridge.ParkedPlanState state = bridge.checkParkedPlan(CONV);
|
||||
TeamPlanBridge.Settled settled = assertInstanceOf(TeamPlanBridge.Settled.class, state);
|
||||
|
||||
assertEquals(PLAN_ID, settled.planId());
|
||||
assertEquals("整体请求", settled.goal());
|
||||
assertEquals(List.of("第一步", "第二步"), settled.steps());
|
||||
assertTrue(settled.completedResults().get(0).contains("步骤1结果:卖点已产出"));
|
||||
assertTrue(settled.completedResults().get(0).contains("a.docx → /api/v1/files/generated/x"));
|
||||
assertTrue(settled.completedResults().get(1).contains("步骤2未完成"));
|
||||
verify(planningService).updateSubPlanResult(PLAN_ID, 0, "卖点已产出");
|
||||
verify(planningService).updateSubPlanFailure(eq(PLAN_ID), eq(1), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a vanished board fails the plan instead of wedging the conversation")
|
||||
void gateVanishedBoardFailsPlan() {
|
||||
parkedPlan();
|
||||
when(taskService.listTasksByPlan(TEAM_ID, PLAN_ID)).thenReturn(List.of());
|
||||
|
||||
assertInstanceOf(TeamPlanBridge.None.class, bridge.checkParkedPlan(CONV));
|
||||
verify(planningService).markPlanFailed(eq(PLAN_ID), anyString());
|
||||
}
|
||||
}
|
||||
@ -21,9 +21,9 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Pins team creation guards — most importantly that a plan-execute agent can
|
||||
* never become a lead: its own serial delegation pipeline bypasses the team
|
||||
* board, so the collaboration would silently degrade to solo delegation.
|
||||
* Pins team creation guards. Any agent type may lead: a plan-execute lead's
|
||||
* multi-step plans hand off to the team board through the plan bridge, so the
|
||||
* former ReAct-only restriction no longer applies.
|
||||
*/
|
||||
class TeamServiceTest {
|
||||
|
||||
@ -62,22 +62,10 @@ class TeamServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a plan-execute agent is rejected as lead with an actionable message")
|
||||
void planExecuteLeadRejected() {
|
||||
@DisplayName("a plan-execute agent may lead — its plans hand off to the board via the bridge")
|
||||
void planExecuteLeadAllowed() {
|
||||
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "plan_execute"));
|
||||
|
||||
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||
() -> service.createTeam("组", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
|
||||
|
||||
assertTrue(e.getMessage().contains("ReAct"));
|
||||
verify(teamMapper, never()).insert(any(AgentTeamEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("members may be plan-execute — only the lead role is restricted")
|
||||
void planExecuteMemberAllowedByTypeGuard() {
|
||||
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "react"));
|
||||
when(agentMapper.selectById(MEMBER_ID)).thenReturn(agent(MEMBER_ID, "plan_execute"));
|
||||
when(agentMapper.selectById(MEMBER_ID)).thenReturn(agent(MEMBER_ID, "react"));
|
||||
when(memberMapper.selectCount(any())).thenReturn(0L);
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
|
||||
@ -52,8 +52,6 @@ export default {
|
||||
deliverables: 'Deliverables',
|
||||
viewRun: 'View execution',
|
||||
timeline: 'Timeline',
|
||||
leadReactHint: 'The lead must be a ReAct agent: plan-execute agents orchestrate through their own serial delegation pipeline and never use the team board',
|
||||
leadTypeWarning: 'Lead is plan-execute — team collaboration will not engage',
|
||||
eventType: {
|
||||
created: 'Created',
|
||||
dispatched: 'Dispatched',
|
||||
|
||||
@ -52,8 +52,6 @@ export default {
|
||||
deliverables: '交付物',
|
||||
viewRun: '查看执行过程',
|
||||
timeline: '时间线',
|
||||
leadReactHint: 'Lead 需为 ReAct 型员工:计划执行型员工走自身的串行委派管线,不会使用团队任务板',
|
||||
leadTypeWarning: 'Lead 是计划执行型,团队协同不会生效',
|
||||
eventType: {
|
||||
created: '创建',
|
||||
dispatched: '派发',
|
||||
|
||||
@ -78,9 +78,6 @@
|
||||
</span>
|
||||
{{ store.currentTeam.leadName }}
|
||||
</span>
|
||||
<span v-if="leadIsPlanExecute" class="lead-warning" :title="t('teams.leadReactHint')">
|
||||
⚠ {{ t('teams.leadTypeWarning') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-header__right">
|
||||
<div class="view-switch">
|
||||
@ -205,8 +202,6 @@
|
||||
:key="String(agent.id)"
|
||||
class="agent-pill"
|
||||
:class="{ 'is-selected is-lead': createForm.leadAgentId === String(agent.id) }"
|
||||
:disabled="agent.agentType === 'plan_execute'"
|
||||
:title="agent.agentType === 'plan_execute' ? t('teams.leadReactHint') : undefined"
|
||||
@click="selectLead(String(agent.id))"
|
||||
>
|
||||
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
|
||||
@ -215,7 +210,6 @@
|
||||
{{ agent.name }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-hint">{{ t('teams.leadReactHint') }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('teams.membersField') }} <i>*</i></label>
|
||||
@ -685,17 +679,6 @@ const memberCandidates = computed(() =>
|
||||
agentStore.agents.filter((a) => String(a.id) !== createForm.leadAgentId),
|
||||
)
|
||||
|
||||
/**
|
||||
* A plan-execute lead orchestrates through its own serial delegation pipeline
|
||||
* and never touches the team board — surface a standing warning so a lead
|
||||
* whose type was switched after team creation doesn't fail silently.
|
||||
*/
|
||||
const leadIsPlanExecute = computed(() => {
|
||||
const leadId = store.currentTeam?.team.leadAgentId
|
||||
if (!leadId) return false
|
||||
const agent = agentStore.agents.find((a) => String(a.id) === String(leadId))
|
||||
return agent?.agentType === 'plan_execute'
|
||||
})
|
||||
|
||||
function openCreateDialog() {
|
||||
createForm.name = ''
|
||||
@ -1617,18 +1600,6 @@ async function cancelTask() {
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.agent-pill:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.lead-warning {
|
||||
font-size: 12px;
|
||||
color: var(--mc-danger, #d9534f);
|
||||
background: rgba(217, 83, 79, 0.08);
|
||||
border: 1px solid rgba(217, 83, 79, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.activity-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user