From 2cbe00a1e7c4c59e6f82afd222acc7f5b5c2e07b Mon Sep 17 00:00:00 2001 From: mateaix <57164338+mateaix@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:22:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(team):=20plan-execute=20leads=20orchestrat?= =?UTF-8?q?e=20through=20the=20task=20board=20=E2=80=94=20hand-off=20bridg?= =?UTF-8?q?e,=20resume=20gate=20and=20step=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vip/mate/agent/AgentGraphBuilder.java | 10 +- .../plan/edge/PlanGenerationDispatcher.java | 7 + .../graph/plan/node/PlanGenerationNode.java | 191 ++++++++++- .../graph/plan/node/PlanSummaryNode.java | 4 +- .../planning/service/PlanningService.java | 29 ++ .../team/event/TeamTasksDelegatedEvent.java | 13 + .../team/service/TeamDispatchService.java | 12 + .../vip/mate/team/service/TeamPlanBridge.java | 304 ++++++++++++++++++ .../vip/mate/team/service/TeamService.java | 16 - .../mate/team/service/TeamTaskService.java | 13 + .../plan/node/PlanGenerationStepDepsTest.java | 60 ++++ .../mate/team/service/TeamPlanBridgeTest.java | 228 +++++++++++++ .../mate/team/service/TeamServiceTest.java | 24 +- mateclaw-ui/src/i18n/locales/en-US.ts | 2 - mateclaw-ui/src/i18n/locales/zh-CN.ts | 2 - mateclaw-ui/src/views/Teams.vue | 29 -- 16 files changed, 862 insertions(+), 82 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/team/event/TeamTasksDelegatedEvent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationStepDepsTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index bdc83fd9..aacdc66a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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( diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/PlanGenerationDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/PlanGenerationDispatcher.java index 4ef99922..6af9f361 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/PlanGenerationDispatcher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/edge/PlanGenerationDispatcher.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index fac3be5a..571e60e1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -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 stepAgents + @JsonProperty("step_agents") List 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 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> parseStepDeps(List stepDeps, int stepCount) { + if (stepDeps == null || stepDeps.size() != stepCount) { + return sequentialChain(stepCount); + } + List> parsed = new ArrayList<>(); + for (int i = 0; i < stepCount; i++) { + List 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> sequentialChain(int stepCount) { + List> 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 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.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 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 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 memberIds = teamPlanBridge.resolveMembers(leadTeam, steps, + triage != null ? triage.stepAgents() : null); + if (memberIds != null) { + List> 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 stepAgentIds = resolveStepAgents(steps, diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java index d2a1a0a5..19faa464 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java @@ -74,7 +74,9 @@ public class PlanSummaryNode implements NodeAction { Prompt prompt = new Prompt(List.of( new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。" + "直接回答用户的原始问题,不要罗列步骤。" - + "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"), + + "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。" + + "若执行结果中包含交付物下载链接,请在回答中原样列出这些链接。" + + "若某些步骤未完成,如实说明未完成的部分及原因。"), new UserMessage(userContent.toString()) )); diff --git a/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java b/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java index fac3ac82..ab550b1c 100644 --- a/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java +++ b/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java @@ -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() + .eq(PlanEntity::getConversationId, conversationId) + .eq(PlanEntity::getStatus, "delegated") + .orderByDesc(PlanEntity::getCreateTime) + .last("LIMIT 1")); + } + /** * 完成计划 */ diff --git a/mateclaw-server/src/main/java/vip/mate/team/event/TeamTasksDelegatedEvent.java b/mateclaw-server/src/main/java/vip/mate/team/event/TeamTasksDelegatedEvent.java new file mode 100644 index 00000000..50c805c3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/event/TeamTasksDelegatedEvent.java @@ -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) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index b89211ef..984a82a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -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 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(() -> { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java new file mode 100644 index 00000000..2b824ddf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java @@ -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 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 roster(AgentTeamEntity team) { + List 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 resolveMembers(AgentTeamEntity team, List steps, + List stepAgents) { + if (steps == null || steps.isEmpty() || stepAgents == null) { + return null; + } + Map byName = new HashMap<>(); + for (AgentEntity member : roster(team)) { + if (member.getName() != null) { + byName.put(member.getName().trim().toLowerCase(), member.getId()); + } + } + List 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 steps, List> stepDeps, + List memberIds, String leadConversationId) { + List created = new ArrayList<>(); + for (int i = 0; i < steps.size(); i++) { + String step = steps.get(i); + List 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 steps, + List 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 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 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 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 settle(Long planId, List tasks) { + List 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 tasks, List> 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 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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java index a8861d06..539938a4 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java @@ -46,7 +46,6 @@ public class TeamService { public AgentTeamEntity createTeam(String name, String description, Long leadAgentId, List 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 diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index 51097c48..cb186560 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -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 listTasksByPlan(Long teamId, Long planId) { + return taskMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamTaskEntity::getTeamId, teamId) + .like(TeamTaskEntity::getMetadata, "\"planId\":\"" + planId + "\"") + .orderByAsc(TeamTaskEntity::getCreateTime)); + } + public List listTasks(Long teamId, List statuses) { return taskMapper.selectList(Wrappers.lambdaQuery() .eq(TeamTaskEntity::getTeamId, teamId) diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationStepDepsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationStepDepsTest.java new file mode 100644 index 00000000..c5358f55 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanGenerationStepDepsTest.java @@ -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> 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> 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> 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> deps = PlanGenerationNode.parseStepDeps( + List.of("", "", "1,2"), 3); + assertEquals(List.of(0, 1), deps.get(2)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java new file mode 100644 index 00000000..3e71b4dd --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java @@ -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 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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java index b34af7ec..567d3fe5 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java @@ -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(() -> diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index b74f499c..88d426b2 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 4fbcc0af..76ce7dab 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -52,8 +52,6 @@ export default { deliverables: '交付物', viewRun: '查看执行过程', timeline: '时间线', - leadReactHint: 'Lead 需为 ReAct 型员工:计划执行型员工走自身的串行委派管线,不会使用团队任务板', - leadTypeWarning: 'Lead 是计划执行型,团队协同不会生效', eventType: { created: '创建', dispatched: '派发', diff --git a/mateclaw-ui/src/views/Teams.vue b/mateclaw-ui/src/views/Teams.vue index c8804ac5..6854222e 100644 --- a/mateclaw-ui/src/views/Teams.vue +++ b/mateclaw-ui/src/views/Teams.vue @@ -78,9 +78,6 @@ {{ store.currentTeam.leadName }} - - ⚠ {{ t('teams.leadTypeWarning') }} -
@@ -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))" > @@ -215,7 +210,6 @@ {{ agent.name }}
-

{{ t('teams.leadReactHint') }}

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