diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index d9a6f0ba..e5559544 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -192,6 +192,14 @@ public class ChatController { } } + // Worker conversations are immutable evidence from the web UI. Keep this + // guard at the user entry point so internal dispatch can still persist its + // user/assistant execution transcript through ConversationService. + if (!conversationService.isUserMessageAllowed(conversationId)) { + sendErrorDoneAndComplete(emitter, "执行任务会话为只读,不能发送新消息"); + return emitter; + } + // ---- 审批命令拦截:/approve、/deny 走 SSE 流式 replay ---- String normalizedMsg = requestMessage.trim().toLowerCase(); boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg); diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java index 3db75426..826e7592 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java @@ -14,6 +14,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.DispatcherType; import vip.mate.kbopen.auth.KbOpenApiAuthFilter; /** @@ -79,6 +80,10 @@ public class SecurityConfig { ) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> { + // REQUEST dispatches are authenticated below. Async SSE error/completion + // redispatches can run after the response is committed and no longer carry + // the JWT; challenging them produces a second AccessDeniedException. + auth.dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll(); // GET /settings/language stays anonymous (first-paint i18n). PUT // requires login + admin (see @RequireGlobalAdmin on the controller). auth.requestMatchers(HttpMethod.GET, "/api/v1/settings/language").permitAll() diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java index b4ddae92..dbb74b68 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java @@ -27,8 +27,11 @@ import vip.mate.team.service.TeamTaskService; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.security.Principal; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import java.util.function.Supplier; /** @@ -142,8 +145,17 @@ public class TeamController { @RequestParam(required = false) Integer offset) { return guarded(() -> { requireTeam(id); - return R.ok(taskService.listTasks(id, status, limit, offset).stream() - .map(this::toTaskVO).toList()); + List tasks = taskService.listTasks(id, status, limit, offset); + Set agentIds = tasks.stream() + .flatMap(task -> java.util.stream.Stream.of( + task.getAssigneeAgentId(), task.getOwnerAgentId())) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map agents = agentIds.isEmpty() + ? Map.of() + : agentMapper.selectBatchIds(agentIds).stream() + .collect(Collectors.toMap(AgentEntity::getId, agent -> agent)); + return R.ok(tasks.stream().map(task -> toTaskVO(task, agents)).toList()); }); } @@ -402,6 +414,21 @@ public class TeamController { task.getRunId()); } + private TaskVO toTaskVO(TeamTaskEntity task, Map agents) { + return new TaskVO(task, + agentName(task.getAssigneeAgentId(), agents), + agentName(task.getOwnerAgentId(), agents), + task.getRunId()); + } + + private String agentName(Long agentId, Map agents) { + if (agentId == null) { + return null; + } + AgentEntity agent = agents.get(agentId); + return agent != null && agent.getName() != null ? agent.getName() : String.valueOf(agentId); + } + private String agentName(Long agentId) { if (agentId == null) { return null; diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java index 2689f399..6390ed54 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java @@ -49,6 +49,19 @@ public class TeamRunController { return guarded(() -> R.ok(runService.listTeamRuns(teamId, workspaceId(workspaceId), activeOnly))); } + @Operation(summary = "Page team runs") + @GetMapping("/teams/{teamId}/runs/page") + @RequireWorkspaceRole("viewer") + public R pageTeamRuns( + @PathVariable Long teamId, + @RequestParam(value = "activeOnly", defaultValue = "false") boolean activeOnly, + @RequestParam(value = "cursor", required = false) String cursor, + @RequestParam(value = "limit", defaultValue = "20") int limit, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return guarded(() -> R.ok(runService.pageTeamRuns( + teamId, workspaceId(workspaceId), activeOnly, cursor, limit))); + } + @Operation(summary = "List conversation team runs") @GetMapping("/conversations/{conversationId}/team-runs") @RequireWorkspaceRole("viewer") @@ -59,6 +72,18 @@ public class TeamRunController { conversationId, workspaceId(workspaceId)))); } + @Operation(summary = "Page conversation team runs") + @GetMapping("/conversations/{conversationId}/team-runs/page") + @RequireWorkspaceRole("viewer") + public R pageConversationRuns( + @PathVariable String conversationId, + @RequestParam(value = "cursor", required = false) String cursor, + @RequestParam(value = "limit", defaultValue = "20") int limit, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return guarded(() -> R.ok(runService.pageConversationRuns( + conversationId, workspaceId(workspaceId), cursor, limit))); + } + @Operation(summary = "Cancel team run") @PostMapping("/team-runs/{runId}/cancel") @RequireWorkspaceRole("admin") diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamWorkerConversationController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamWorkerConversationController.java new file mode 100644 index 00000000..7a9a2b57 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamWorkerConversationController.java @@ -0,0 +1,37 @@ +package vip.mate.team.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.team.service.TeamWorkerConversationContext; +import vip.mate.team.service.TeamWorkerConversationGovernanceService; +import vip.mate.workspace.conversation.ConversationService; + +@RestController +@RequestMapping("/api/v1/conversations") +@RequiredArgsConstructor +public class TeamWorkerConversationController { + + private final ConversationService conversationService; + private final TeamWorkerConversationGovernanceService governanceService; + + @GetMapping("/{conversationId}/team-worker-context") + public R context( + @PathVariable String conversationId, + @RequestParam(required = false) Long runId, + @RequestParam(required = false) Long taskId, + Authentication authentication) { + String username = authentication == null ? "anonymous" : authentication.getName(); + if (!conversationService.isConversationOwner(conversationId, username)) { + return R.fail(403, "无权访问该会话"); + } + return governanceService.resolve(conversationId, runId, taskId) + .map(R::ok) + .orElseGet(() -> R.ok(null)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunView.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunView.java index 6633ffcf..c9fcea5a 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunView.java +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamRunView.java @@ -21,13 +21,54 @@ public record TeamRunView( LocalDateTime completedAt, LocalDateTime createTime, LocalDateTime updateTime, + String projectionCompleteness, + String outcomeQuality, + List deliverables, + List contributions, + List attentionItems, + Liveness liveness, + Metrics metrics, Progress progress, List tasks ) { + /** Compatibility constructor retained while clients adopt the canonical projection fields. */ + public TeamRunView(Long id, Long teamId, Long workspaceId, Long leadAgentId, + String leadConversationId, Long originMessageId, String title, + String objective, String status, String finalSummary, String stopReason, + String metadata, LocalDateTime startedAt, LocalDateTime completedAt, + LocalDateTime createTime, LocalDateTime updateTime, Progress progress, + List tasks) { + this(id, teamId, workspaceId, leadAgentId, leadConversationId, originMessageId, title, + objective, status, finalSummary, stopReason, metadata, startedAt, completedAt, + createTime, updateTime, "full", null, List.of(), List.of(), List.of(), null, null, + progress, tasks); + } + public record Progress(int total, int done, int failed, int inReview, int percent) { } + public record Deliverable(String id, String name, String url, String type, + List sourceTaskIds, List sourceAgentIds, + LocalDateTime createdAt, String verificationStatus) { + } + + public record MemberContribution(Long taskId, Long agentId, String subject, String status, + Long durationSeconds, LocalDateTime lastActivityAt, + String resultSummary, String conversationId) { + } + + public record AttentionItem(String id, String type, String severity, int priority, Long taskId, + String message, LocalDateTime createdAt) { + } + + public record Liveness(String state, LocalDateTime lastActivityAt) { + } + + public record Metrics(Long durationSeconds, int totalTasks, int completedTasks, + int failedTasks, int deliverableCount) { + } + public record Task( Long id, Long teamId, @@ -60,5 +101,14 @@ public record TeamRunView( task.getProgressStep(), task.getResult(), task.getReason(), task.getConversationId(), task.getMetadata(), task.getCreateTime(), task.getUpdateTime()); } + + public static Task summaryFrom(TeamTaskEntity task) { + return new Task(task.getId(), task.getTeamId(), task.getRunId(), task.getTaskNumber(), + task.getSubject(), null, task.getStatus(), task.getPriority(), task.getTaskType(), + task.getAssigneeAgentId(), task.getOwnerAgentId(), task.getBlockedBy(), + task.getRequireApproval(), task.getProgressPercent(), task.getProgressStep(), null, + task.getReason(), task.getConversationId(), null, task.getCreateTime(), + task.getUpdateTime()); + } } } 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 3e1df3d4..3c1a8f87 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 @@ -167,7 +167,8 @@ public class TeamDispatchService { try { AgentTeamEntity team = teamService.getTeam(teamId); conversationService.createChildConversation(childConvId, memberId, "system", - team == null ? null : team.getWorkspaceId(), task.getLeadConversationId()); + team == null ? null : team.getWorkspaceId(), task.getLeadConversationId(), + "team_worker"); taskService.attachConversation(task.getId(), childConvId); // Track the child run so graph nodes honor requestStop() — without a // registered RunState, cancelling the task could never interrupt the diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjector.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjector.java index dac9b43a..e5d81120 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjector.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunProjector.java @@ -92,11 +92,7 @@ public class TeamRunProjector { private TeamRunView view(TeamRunEntity run, TeamRunStateMachine.Projection projection, List tasks) { - return new TeamRunView(run.getId(), run.getTeamId(), run.getWorkspaceId(), run.getLeadAgentId(), - run.getLeadConversationId(), run.getOriginMessageId(), run.getTitle(), run.getObjective(), - run.getStatus(), run.getFinalSummary(), run.getStopReason(), run.getMetadata(), - run.getStartedAt(), run.getCompletedAt(), run.getCreateTime(), run.getUpdateTime(), - projection.progress(), tasks.stream().map(TeamRunView.Task::from).toList()); + return TeamRunViewFactory.create(run, run.getStatus(), projection.progress(), tasks, true); } private JSONObject metadata(String value) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java index c6912471..de00227c 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java @@ -19,7 +19,12 @@ import vip.mate.team.repository.TeamRunMapper; import vip.mate.team.repository.TeamTaskMapper; import java.time.LocalDateTime; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Collection; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -29,6 +34,9 @@ import java.util.stream.Collectors; @Slf4j public class TeamRunService { + public record RunPage(List items, String nextCursor) { + } + public record SealResult(TeamRunEntity run, boolean transitioned) { } @@ -36,6 +44,7 @@ public class TeamRunService { } private static final int MAX_TITLE_LENGTH = 255; + private static final int TASK_SUMMARY_BATCH_SIZE = 500; private static final Set FINAL_OUTCOMES = Set.of( TeamRunStatus.COMPLETED, TeamRunStatus.PARTIAL, TeamRunStatus.FAILED); @@ -131,38 +140,137 @@ public class TeamRunService { private void finalizeWithoutSummary(TeamRunEntity run, String outcome, String summary) { LocalDateTime completedAt = LocalDateTime.now(); + String fallbackMetadata = metadata(run.getMetadata()).set("summaryQuality", "fallback").toString(); runMapper.update(null, Wrappers.lambdaUpdate() .eq(TeamRunEntity::getId, run.getId()) .eq(TeamRunEntity::getStatus, TeamRunStatus.FINALIZING) .set(TeamRunEntity::getStatus, outcome) .set(TeamRunEntity::getFinalSummary, summary) + .set(TeamRunEntity::getMetadata, fallbackMetadata) .set(TeamRunEntity::getCompletedAt, completedAt)); log.warn("Reconciled stranded team run {} from finalizing to {}", run.getId(), outcome); } - public List listTeamRuns(Long teamId, Long workspaceId) { - return listTeamRuns(teamId, workspaceId, false); + public RunPage pageTeamRuns(Long teamId, Long workspaceId, boolean activeOnly, + String cursor, int requestedLimit) { + return pageRuns(teamId, null, workspaceId, activeOnly, cursor, requestedLimit); } + /** Backward-compatible array response used until clients migrate to cursor pagination. */ public List listTeamRuns(Long teamId, Long workspaceId, boolean activeOnly) { + return listRuns(teamId, null, workspaceId, activeOnly); + } + + /** Backward-compatible array response used until clients migrate to cursor pagination. */ + public List listConversationRuns(String conversationId, Long workspaceId) { + return listRuns(null, conversationId, workspaceId, false); + } + + private List listRuns(Long teamId, String conversationId, Long workspaceId, + boolean activeOnly) { var query = Wrappers.lambdaQuery() - .eq(TeamRunEntity::getTeamId, teamId) - .eq(TeamRunEntity::getWorkspaceId, workspaceId) - .orderByDesc(TeamRunEntity::getCreateTime); + .eq(teamId != null, TeamRunEntity::getTeamId, teamId) + .eq(conversationId != null, TeamRunEntity::getLeadConversationId, conversationId) + .eq(TeamRunEntity::getWorkspaceId, workspaceId); if (activeOnly) { query.in(TeamRunEntity::getStatus, TeamRunStatus.PLANNING, TeamRunStatus.RUNNING, TeamRunStatus.AWAITING_REVIEW, TeamRunStatus.FINALIZING); } - return runMapper.selectList(query) - .stream().map(this::buildView).toList(); + List runs = runMapper.selectList(query + .orderByDesc(TeamRunEntity::getCreateTime).orderByDesc(TeamRunEntity::getId)); + return summaryViews(runs); } - public List listConversationRuns(String conversationId, Long workspaceId) { - return runMapper.selectList(Wrappers.lambdaQuery() - .eq(TeamRunEntity::getLeadConversationId, conversationId) - .eq(TeamRunEntity::getWorkspaceId, workspaceId) - .orderByDesc(TeamRunEntity::getCreateTime)) - .stream().map(this::buildView).toList(); + public RunPage pageConversationRuns(String conversationId, Long workspaceId, + String cursor, int requestedLimit) { + return pageRuns(null, conversationId, workspaceId, false, cursor, requestedLimit); + } + + private RunPage pageRuns(Long teamId, String conversationId, Long workspaceId, + boolean activeOnly, String cursor, int requestedLimit) { + int limit = Math.max(1, Math.min(requestedLimit <= 0 ? 20 : requestedLimit, 100)); + Cursor decoded = decodeCursor(cursor); + var query = Wrappers.lambdaQuery() + .eq(teamId != null, TeamRunEntity::getTeamId, teamId) + .eq(conversationId != null, TeamRunEntity::getLeadConversationId, conversationId) + .eq(TeamRunEntity::getWorkspaceId, workspaceId); + if (activeOnly) { + query.in(TeamRunEntity::getStatus, TeamRunStatus.PLANNING, TeamRunStatus.RUNNING, + TeamRunStatus.AWAITING_REVIEW, TeamRunStatus.FINALIZING); + } + if (decoded != null) { + query.and(nested -> nested.lt(TeamRunEntity::getCreateTime, decoded.createTime()) + .or(equal -> equal.eq(TeamRunEntity::getCreateTime, decoded.createTime()) + .lt(TeamRunEntity::getId, decoded.id()))); + } + List fetched = runMapper.selectList(query + .orderByDesc(TeamRunEntity::getCreateTime).orderByDesc(TeamRunEntity::getId) + .last("LIMIT " + (limit + 1))); + boolean hasMore = fetched.size() > limit; + List runs = hasMore ? fetched.subList(0, limit) : fetched; + List items = summaryViews(runs); + TeamRunEntity last = runs.isEmpty() ? null : runs.getLast(); + return new RunPage(items, hasMore && last != null ? encodeCursor(last) : null); + } + + private List summaryViews(List runs) { + Map> tasksByRun = summaryTasks(runs); + return runs.stream().map(run -> { + List tasks = tasksByRun.getOrDefault(run.getId(), List.of()); + var projection = stateMachine.project(run, tasks); + return TeamRunViewFactory.create(run, projection.status(), projection.progress(), tasks, false); + }).toList(); + } + + private Map> summaryTasks(List runs) { + if (runs.isEmpty()) { + return Map.of(); + } + Map> grouped = new LinkedHashMap<>(); + List runIds = runs.stream().map(TeamRunEntity::getId).toList(); + for (int start = 0; start < runIds.size(); start += TASK_SUMMARY_BATCH_SIZE) { + List batch = runIds.subList(start, Math.min(start + TASK_SUMMARY_BATCH_SIZE, runIds.size())); + List tasks = taskMapper.selectList(Wrappers.lambdaQuery() + .select(TeamTaskEntity::getId, TeamTaskEntity::getTeamId, TeamTaskEntity::getRunId, + TeamTaskEntity::getTaskNumber, TeamTaskEntity::getSubject, + TeamTaskEntity::getStatus, TeamTaskEntity::getPriority, + TeamTaskEntity::getTaskType, TeamTaskEntity::getAssigneeAgentId, + TeamTaskEntity::getOwnerAgentId, TeamTaskEntity::getBlockedBy, + TeamTaskEntity::getRequireApproval, TeamTaskEntity::getProgressPercent, + TeamTaskEntity::getProgressStep, TeamTaskEntity::getReason, + TeamTaskEntity::getConversationId, TeamTaskEntity::getMetadata, + TeamTaskEntity::getLockExpiresAt, TeamTaskEntity::getCreateTime, + TeamTaskEntity::getUpdateTime) + .in(TeamTaskEntity::getRunId, batch) + .orderByAsc(TeamTaskEntity::getTaskNumber)); + for (TeamTaskEntity task : tasks) { + grouped.computeIfAbsent(task.getRunId(), ignored -> new ArrayList<>()).add(task); + } + } + return grouped; + } + + private record Cursor(LocalDateTime createTime, Long id) { + } + + private String encodeCursor(TeamRunEntity run) { + String raw = run.getCreateTime() + "|" + run.getId(); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + private Cursor decodeCursor(String cursor) { + if (cursor == null || cursor.isBlank()) { + return null; + } + try { + String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + int separator = raw.lastIndexOf('|'); + return new Cursor(LocalDateTime.parse(raw.substring(0, separator)), + Long.valueOf(raw.substring(separator + 1))); + } catch (RuntimeException invalid) { + throw new IllegalArgumentException("invalid team run cursor"); + } } @Transactional @@ -264,11 +372,7 @@ public class TeamRunService { public TeamRunView buildView(TeamRunEntity run) { List tasks = tasksForRun(run.getId()); TeamRunStateMachine.Projection projection = stateMachine.project(run, tasks); - return new TeamRunView(run.getId(), run.getTeamId(), run.getWorkspaceId(), run.getLeadAgentId(), - run.getLeadConversationId(), run.getOriginMessageId(), run.getTitle(), run.getObjective(), - projection.status(), run.getFinalSummary(), run.getStopReason(), run.getMetadata(), - run.getStartedAt(), run.getCompletedAt(), run.getCreateTime(), run.getUpdateTime(), - projection.progress(), tasks.stream().map(TeamRunView.Task::from).toList()); + return TeamRunViewFactory.create(run, projection.status(), projection.progress(), tasks, true); } private List tasksForRun(Long runId) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunViewFactory.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunViewFactory.java new file mode 100644 index 00000000..f4a9fdae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunViewFactory.java @@ -0,0 +1,406 @@ +package vip.mate.team.service; + +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; + +import java.net.URI; +import java.math.BigDecimal; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.Set; + +/** Builds the canonical delivery projection from existing run and task records. */ +final class TeamRunViewFactory { + + private static final String GENERATED_FILE_PATH = "/api/v1/files/generated/"; + private static final Duration STALLED_WINDOW = Duration.ofMinutes(15); + private static final int SUMMARY_LIMIT = 500; + private static final Set OUTCOME_QUALITIES = Set.of("synthesized", "fallback", "partial", "pending"); + + private TeamRunViewFactory() { + } + + static TeamRunView create(TeamRunEntity run, String status, TeamRunView.Progress progress, + List tasks, boolean includeTasks) { + List deliverables = deliverables(run, tasks); + LocalDateTime lastActivity = lastActivity(run, tasks); + return new TeamRunView(run.getId(), run.getTeamId(), run.getWorkspaceId(), run.getLeadAgentId(), + run.getLeadConversationId(), run.getOriginMessageId(), run.getTitle(), run.getObjective(), + status, run.getFinalSummary(), run.getStopReason(), run.getMetadata(), run.getStartedAt(), + run.getCompletedAt(), run.getCreateTime(), run.getUpdateTime(), + includeTasks ? "full" : "summary", outcomeQuality(run, tasks), + deliverables, contributions(tasks), attentionItems(run, tasks), + liveness(status, lastActivity, tasks), + metrics(run, tasks, deliverables.size()), progress, + tasks.stream().map(includeTasks ? TeamRunView.Task::from : TeamRunView.Task::summaryFrom) + .toList()); + } + + private static String outcomeQuality(TeamRunEntity run, List tasks) { + if (run.getFinalSummary() != null && !run.getFinalSummary().isBlank()) { + String projected = metadata(run.getMetadata()).getStr("summaryQuality"); + return projected != null && OUTCOME_QUALITIES.contains(projected) ? projected : "synthesized"; + } + if (tasks.isEmpty() || tasks.stream().anyMatch(task -> !TeamTaskStatus.isTerminal(task.getStatus()))) { + return "pending"; + } + boolean allCompleted = tasks.stream() + .allMatch(task -> TeamTaskStatus.COMPLETED.equals(task.getStatus())); + return allCompleted ? "fallback" : "partial"; + } + + /** + * Aggregates run-level and task-level metadata in that order. Entries are + * de-duplicated by safe normalized URL. Display fields use the first + * non-empty value, missing timestamps are filled by later duplicates, and + * verification status may only move to a stronger (non-degraded) state. + * Explicit source arrays and task-implied sources are merged in order. + */ + private static List deliverables(TeamRunEntity run, + List tasks) { + Map unique = new LinkedHashMap<>(); + collectDeliverables(unique, run.getMetadata(), null, null); + for (TeamTaskEntity task : tasks) { + collectDeliverables(unique, task.getMetadata(), task.getId(), task.getAssigneeAgentId()); + } + return unique.values().stream().map(value -> new TeamRunView.Deliverable(value.id, + value.name == null ? value.url : value.name, value.url, + value.type == null ? fileType(value.url) : value.type, + List.copyOf(value.taskIds), List.copyOf(value.agentIds), value.createdAt, + value.verificationStatus == null ? "available" : value.verificationStatus)).toList(); + } + + private static void collectDeliverables(Map unique, String rawMetadata, + Long taskId, Long agentId) { + JSONArray values = metadata(rawMetadata).getJSONArray("deliverables"); + if (values == null) { + return; + } + for (Object value : values) { + if (!(value instanceof JSONObject item)) { + continue; + } + String name = text(item.getStr("name")); + SafeUrl url = normalizeDeliverableUrl(text(item.getStr("url"))); + if (url == null) { + continue; + } + MutableDeliverable delivery = unique.computeIfAbsent(url.identity(), ignored -> new MutableDeliverable( + stableId(url.identity()), name, url.href(), text(item.getStr("type")), + deliverableTime(item), verificationStatus(item))); + if (delivery.name == null) delivery.name = name; + if (delivery.type == null) delivery.type = text(item.getStr("type")); + if (delivery.createdAt == null) delivery.createdAt = deliverableTime(item); + String candidateStatus = verificationStatus(item); + if (verificationRank(candidateStatus) > verificationRank(delivery.verificationStatus)) { + delivery.verificationStatus = candidateStatus; + } + addIds(delivery.taskIds, item.getJSONArray("sourceTaskIds")); + addIds(delivery.agentIds, item.getJSONArray("sourceAgentIds")); + add(delivery.taskIds, taskId); + add(delivery.agentIds, agentId); + } + } + + private static LocalDateTime deliverableTime(JSONObject item) { + LocalDateTime createdAt = parseTime(item.getStr("createdAt")); + return createdAt != null ? createdAt : parseTime(item.getStr("time")); + } + + private static String verificationStatus(JSONObject item) { + String status = text(item.getStr("verificationStatus")); + if (status == null) { + return null; + } + String normalized = status.toLowerCase(Locale.ROOT); + return switch (normalized) { + case "verified", "available", "pending", "failed", "unavailable", "rejected" -> normalized; + default -> null; + }; + } + + private static int verificationRank(String status) { + if (status == null) return -1; + return switch (status) { + case "verified" -> 4; + case "available" -> 3; + case "pending" -> 2; + case "failed", "unavailable", "rejected" -> 1; + default -> 0; + }; + } + + private static void addIds(LinkedHashSet target, JSONArray values) { + if (values == null) { + return; + } + for (Object value : values) { + try { + Long id = null; + if (value instanceof Number number) { + id = new BigDecimal(number.toString()).longValueExact(); + } else if (value instanceof String string && !string.isBlank()) { + id = Long.parseLong(string); + } + add(target, id); + } catch (ArithmeticException | NumberFormatException ignored) { + // One malformed source id must not discard the deliverable. + } + } + } + + private static List contributions(List tasks) { + return tasks.stream().map(task -> new TeamRunView.MemberContribution(task.getId(), + task.getAssigneeAgentId(), task.getSubject(), task.getStatus(), + durationSeconds(task.getCreateTime(), task.getUpdateTime()), task.getUpdateTime(), + summarize(task.getResult()), task.getConversationId())).toList(); + } + + private static List attentionItems(TeamRunEntity run, + List tasks) { + List items = new ArrayList<>(); + for (TeamTaskEntity task : tasks) { + String type = switch (task.getStatus()) { + case TeamTaskStatus.IN_REVIEW -> "review"; + case TeamTaskStatus.FAILED -> "failure"; + case TeamTaskStatus.BLOCKED -> "blocked"; + case TeamTaskStatus.STALE -> "stale"; + default -> null; + }; + if (type != null) { + String message = text(task.getReason()); + int priority = TeamTaskStatus.IN_REVIEW.equals(task.getStatus()) ? 0 : 20; + items.add(new TeamRunView.AttentionItem("task:" + task.getId() + ":" + type, + type, priority == 0 ? "action" : "error", priority, + task.getId(), message == null ? task.getSubject() : message, task.getUpdateTime())); + } + } + String quality = outcomeQuality(run, tasks); + if ("fallback".equals(quality) || "partial".equals(quality)) { + items.add(new TeamRunView.AttentionItem("run:" + run.getId() + ":synthesis", "synthesis", + "warning", 10, null, "Final synthesis used a degraded outcome", run.getUpdateTime())); + } + if (text(run.getStopReason()) != null) { + items.add(new TeamRunView.AttentionItem("run:" + run.getId() + ":stopped", "stopped", + "warning", 10, null, run.getStopReason(), run.getUpdateTime())); + } + items.sort((left, right) -> { + int priority = Integer.compare(left.priority(), right.priority()); + return priority != 0 ? priority : compareNullableDesc(left.createdAt(), right.createdAt()); + }); + return List.copyOf(items); + } + + private static TeamRunView.Liveness liveness(String status, LocalDateTime lastActivity, + List tasks) { + if (TeamRunStatus.isTerminal(status)) { + return new TeamRunView.Liveness("terminal", lastActivity); + } + LocalDateTime now = LocalDateTime.now(); + boolean leased = tasks.stream().anyMatch(task -> TeamTaskStatus.IN_PROGRESS.equals(task.getStatus()) + && task.getLockExpiresAt() != null && task.getLockExpiresAt().isAfter(now)); + if (leased) { + return new TeamRunView.Liveness("live", lastActivity); + } + if (lastActivity == null) { + return new TeamRunView.Liveness("quiet", null); + } + Duration age = Duration.between(lastActivity, now); + String state = age.compareTo(STALLED_WINDOW) <= 0 ? "quiet" : "stalled"; + return new TeamRunView.Liveness(state, lastActivity); + } + + private static TeamRunView.Metrics metrics(TeamRunEntity run, List tasks, + int deliverableCount) { + int completed = (int) tasks.stream() + .filter(task -> TeamTaskStatus.COMPLETED.equals(task.getStatus())).count(); + int failed = (int) tasks.stream() + .filter(task -> TeamTaskStatus.FAILED.equals(task.getStatus())).count(); + LocalDateTime end = run.getCompletedAt() != null ? run.getCompletedAt() : lastActivity(run, tasks); + return new TeamRunView.Metrics(durationSeconds(run.getStartedAt(), end), tasks.size(), completed, + failed, deliverableCount); + } + + private static LocalDateTime lastActivity(TeamRunEntity run, List tasks) { + LocalDateTime latest = max(run.getUpdateTime(), run.getCompletedAt(), run.getStartedAt(), + run.getCreateTime()); + for (TeamTaskEntity task : tasks) { + latest = max(latest, task.getUpdateTime(), task.getCreateTime()); + } + return latest; + } + + private static LocalDateTime max(LocalDateTime... values) { + LocalDateTime latest = null; + for (LocalDateTime value : values) { + if (value != null && (latest == null || value.isAfter(latest))) { + latest = value; + } + } + return latest; + } + + private static Long durationSeconds(LocalDateTime start, LocalDateTime end) { + return start == null || end == null || end.isBefore(start) ? null : Duration.between(start, end).toSeconds(); + } + + private static SafeUrl normalizeDeliverableUrl(String url) { + if (url == null) { + return null; + } + try { + URI uri = URI.create(url); + if (uri.getScheme() != null || uri.getRawAuthority() != null) { + return null; + } + String rawPath = uri.getRawPath(); + if (rawPath == null || rawPath.indexOf('\\') >= 0) { + return null; + } + String path = fullyDecode(rawPath); + if (path == null || path.indexOf('\\') >= 0) { + return null; + } + Path parsed = Path.of(path); + for (Path segment : parsed) { + if ("..".equals(segment.toString())) { + return null; + } + } + String normalized = parsed.normalize().toString(); + return normalized.startsWith(GENERATED_FILE_PATH) ? new SafeUrl(normalized, rawPath) : null; + } catch (IllegalArgumentException invalid) { + return null; + } + } + + private static String fullyDecode(String path) { + String decoded = path; + for (int remaining = path.length() + 1; remaining > 0; remaining--) { + String next = decodePercentOnce(decoded); + if (next.equals(decoded)) { + return decoded; + } + decoded = next; + } + return null; + } + + private static String decodePercentOnce(String value) { + StringBuilder decoded = new StringBuilder(value.length()); + for (int index = 0; index < value.length();) { + if (value.charAt(index) != '%') { + decoded.append(value.charAt(index++)); + continue; + } + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + while (index < value.length() && value.charAt(index) == '%') { + if (index + 2 >= value.length()) { + throw new IllegalArgumentException("Incomplete percent escape"); + } + int high = Character.digit(value.charAt(index + 1), 16); + int low = Character.digit(value.charAt(index + 2), 16); + if (high < 0 || low < 0) { + throw new IllegalArgumentException("Invalid percent escape"); + } + bytes.write((high << 4) | low); + index += 3; + } + decoded.append(bytes.toString(StandardCharsets.UTF_8)); + } + return decoded.toString(); + } + + private static String stableId(String url) { + return UUID.nameUUIDFromBytes(url.getBytes(StandardCharsets.UTF_8)).toString(); + } + + private static String fileType(String url) { + String path; + try { + path = URI.create(url).getPath(); + } catch (IllegalArgumentException invalid) { + path = url; + } + int dot = path == null ? -1 : path.lastIndexOf('.'); + return dot < 0 ? "file" : path.substring(dot + 1).toLowerCase(Locale.ROOT); + } + + private static LocalDateTime parseTime(String value) { + try { + return value == null ? null : LocalDateTime.parse(value); + } catch (RuntimeException invalid) { + return null; + } + } + + private static JSONObject metadata(String value) { + try { + return value == null || value.isBlank() ? new JSONObject() : JSONUtil.parseObj(value); + } catch (RuntimeException invalid) { + return new JSONObject(); + } + } + + private static String summarize(String value) { + String normalized = text(value); + return normalized == null || normalized.length() <= SUMMARY_LIMIT + ? normalized : normalized.substring(0, SUMMARY_LIMIT); + } + + private static String text(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static void add(LinkedHashSet values, T value) { + if (value instanceof Long id ? id > 0 : value != null) { + values.add(value); + } + } + + private static int compareNullableDesc(LocalDateTime left, LocalDateTime right) { + if (left == null) return right == null ? 0 : 1; + if (right == null) return -1; + return right.compareTo(left); + } + + private static final class MutableDeliverable { + private final String id; + private String name; + private final String url; + private String type; + private LocalDateTime createdAt; + private String verificationStatus; + private final LinkedHashSet taskIds = new LinkedHashSet<>(); + private final LinkedHashSet agentIds = new LinkedHashSet<>(); + + private MutableDeliverable(String id, String name, String url, String type, + LocalDateTime createdAt, String verificationStatus) { + this.id = id; + this.name = name; + this.url = url; + this.type = type; + this.createdAt = createdAt; + this.verificationStatus = verificationStatus; + } + } + + private record SafeUrl(String identity, String href) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationContext.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationContext.java new file mode 100644 index 00000000..77ad4040 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationContext.java @@ -0,0 +1,13 @@ +package vip.mate.team.service; + +/** Server-proven linkage for a delegated team worker conversation. */ +public record TeamWorkerConversationContext( + boolean verified, + String conversationKind, + String conversationId, + Long runId, + Long taskId, + Long teamId, + String leadConversationId, + Long agentId) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java new file mode 100644 index 00000000..1e0c308a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java @@ -0,0 +1,61 @@ +package vip.mate.team.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.repository.TeamRunMapper; +import vip.mate.team.repository.TeamTaskMapper; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.Optional; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class TeamWorkerConversationGovernanceService { + + private final TeamTaskMapper taskMapper; + private final TeamRunMapper runMapper; + private final ConversationMapper conversationMapper; + + public Optional resolve( + String conversationId, Long requestedRunId, Long requestedTaskId) { + if (conversationId == null || conversationId.isBlank()) { + return Optional.empty(); + } + ConversationEntity conversation = conversationMapper.selectOne( + new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, conversationId) + .last("LIMIT 1")); + if (!ConversationService.isTeamWorkerConversation(conversation)) { + return Optional.empty(); + } + TeamTaskEntity task = taskMapper.selectOne(new LambdaQueryWrapper() + .eq(TeamTaskEntity::getConversationId, conversationId) + .last("LIMIT 1")); + if (task == null || task.getRunId() == null + || requestedRunId != null && !requestedRunId.equals(task.getRunId()) + || requestedTaskId != null && !requestedTaskId.equals(task.getId())) { + return Optional.empty(); + } + TeamRunEntity run = runMapper.selectById(task.getRunId()); + boolean legacyMissingParent = conversation.getParentConversationId() == null + && !"team_worker".equals(conversation.getConversationKind()) + && conversationId.startsWith("team-task-"); + if (run == null + || !Objects.equals(run.getTeamId(), task.getTeamId()) + || !Objects.equals(conversation.getWorkspaceId(), run.getWorkspaceId()) + || !Objects.equals(conversation.getAgentId(), task.getAssigneeAgentId()) + || !legacyMissingParent + && !Objects.equals(conversation.getParentConversationId(), run.getLeadConversationId())) { + return Optional.empty(); + } + return Optional.of(new TeamWorkerConversationContext( + true, "team_worker", conversationId, run.getId(), task.getId(), run.getTeamId(), + run.getLeadConversationId(), task.getAssigneeAgentId())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 7937b00e..0961110f 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -158,6 +158,7 @@ public class ConversationService { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .and(w -> applyOwnerScope(w, username, includeWebchat)) .and(this::applyMalformedIdGuard) + .and(this::applyOrdinaryConversationGuard) .isNull(ConversationEntity::getParentConversationId) .orderByDesc(ConversationEntity::getPinned) .orderByDesc(ConversationEntity::getLastActiveTime); @@ -231,6 +232,15 @@ public class ConversationService { w.notLikeLeft(ConversationEntity::getConversationId, ":"); } + /** Keep worker evidence sessions out of ordinary list/page SQL, including legacy rows. */ + private void applyOrdinaryConversationGuard(LambdaQueryWrapper w) { + w.and(kind -> kind + .isNull(ConversationEntity::getConversationKind) + .or() + .ne(ConversationEntity::getConversationKind, "team_worker")) + .notLikeRight(ConversationEntity::getConversationId, "team-task-"); + } + /** * Whether the user is a global admin (role=admin), resolved from the DB — * never from client-controlled data. Gates webchat row visibility in the @@ -268,6 +278,7 @@ public class ConversationService { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .and(w -> applyOwnerScope(w, username, isGlobalAdmin(username))) .and(this::applyMalformedIdGuard) + .and(this::applyOrdinaryConversationGuard) .isNull(ConversationEntity::getParentConversationId) .orderByDesc(ConversationEntity::getPinned) .orderByDesc(ConversationEntity::getLastActiveTime); @@ -443,8 +454,19 @@ public class ConversationService { public ConversationEntity createChildConversation(String childConversationId, Long agentId, String username, Long workspaceId, String parentConversationId) { + return createChildConversation(childConversationId, agentId, username, workspaceId, + parentConversationId, "primary"); + } + + @Transactional + public ConversationEntity createChildConversation(String childConversationId, Long agentId, + String username, Long workspaceId, + String parentConversationId, + String conversationKind) { ConversationEntity conv = getOrCreateConversation(childConversationId, agentId, username, workspaceId); conv.setParentConversationId(parentConversationId); + conv.setConversationKind(conversationKind == null || conversationKind.isBlank() + ? "primary" : conversationKind); conv.setTitle("子任务"); conversationMapper.updateById(conv); return conv; @@ -1800,6 +1822,22 @@ public class ConversationService { .eq(ConversationEntity::getConversationId, conversationId)); } + /** User-facing Chat endpoints may not append turns to worker evidence sessions. */ + public boolean isUserMessageAllowed(String conversationId) { + ConversationEntity conversation = findByConversationId(conversationId); + return !isTeamWorkerConversation(conversation); + } + + /** Canonical server-side worker classification, including bounded legacy fallback. */ + public static boolean isTeamWorkerConversation(ConversationEntity conversation) { + if (conversation == null) { + return false; + } + return "team_worker".equals(conversation.getConversationKind()) + || conversation.getConversationId() != null + && conversation.getConversationId().startsWith("team-task-"); + } + /** * Get the persisted stream status for a conversation. * diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java index 22f16873..a20d3bb7 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java @@ -48,6 +48,9 @@ public class ConversationEntity { /** 父会话 ID(委派场景下,子会话记录其父会话的 conversationId) */ private String parentConversationId; + /** Product-level conversation classification. Defaults to primary. */ + private String conversationKind; + /** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */ private Integer pinned; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java index 44be0d79..f933482d 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java @@ -45,6 +45,9 @@ public class ConversationVO extends ConversationEntity { */ private String source; + /** Stable product classification; independent from the display title. */ + private String conversationKind; + /** * 工厂方法:从实体构建 VO,补充 agentName/agentIcon/status * @@ -65,6 +68,8 @@ public class ConversationVO extends ConversationEntity { vo.setLastMessage(entity.getLastMessage()); vo.setLastActiveTime(entity.getLastActiveTime()); vo.setWorkspaceId(entity.getWorkspaceId()); + vo.setParentConversationId(entity.getParentConversationId()); + vo.setConversationKind(extractConversationKind(entity)); vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0); vo.setArchived(entity.getArchived() != null ? entity.getArchived() : 0); vo.setModelProvider(entity.getModelProvider()); @@ -90,6 +95,21 @@ public class ConversationVO extends ConversationEntity { return vo; } + private static String extractConversationKind(ConversationEntity entity) { + String id = entity.getConversationId(); + if ("team_worker".equals(entity.getConversationKind())) { + return "team_worker"; + } + if (id != null && id.startsWith("team-task-")) { + return "team_worker"; + } + if ("cron".equals(extractSource(id))) { + return "scheduled"; + } + return entity.getConversationKind() == null || entity.getConversationKind().isBlank() + ? "primary" : entity.getConversationKind(); + } + private static String extractSource(String conversationId) { if (conversationId == null) return "web"; // Underscore-prefixed cron buckets — use the cron icon for both. diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V182__team_run_stable_history_indexes.sql b/mateclaw-server/src/main/resources/db/migration/h2/V182__team_run_stable_history_indexes.sql new file mode 100644 index 00000000..6b22976c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V182__team_run_stable_history_indexes.sql @@ -0,0 +1,10 @@ +UPDATE mate_team_run +SET create_time = COALESCE(update_time, CURRENT_TIMESTAMP) +WHERE create_time IS NULL; + +ALTER TABLE mate_team_run ALTER COLUMN create_time SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_team_run_team_history_stable + ON mate_team_run (team_id, create_time, id); +CREATE INDEX IF NOT EXISTS idx_team_run_conversation_history_stable + ON mate_team_run (lead_conversation_id, create_time, id); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V183__conversation_kind.sql b/mateclaw-server/src/main/resources/db/migration/h2/V183__conversation_kind.sql new file mode 100644 index 00000000..2899e86a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V183__conversation_kind.sql @@ -0,0 +1,2 @@ +ALTER TABLE mate_conversation + ADD COLUMN IF NOT EXISTS conversation_kind VARCHAR(32) NOT NULL DEFAULT 'primary'; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V184__team_task_conversation_index.sql b/mateclaw-server/src/main/resources/db/migration/h2/V184__team_task_conversation_index.sql new file mode 100644 index 00000000..2c8271a2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V184__team_task_conversation_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_team_task_conversation + ON mate_team_task (conversation_id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V182__team_run_stable_history_indexes.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V182__team_run_stable_history_indexes.sql new file mode 100644 index 00000000..6b22976c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V182__team_run_stable_history_indexes.sql @@ -0,0 +1,10 @@ +UPDATE mate_team_run +SET create_time = COALESCE(update_time, CURRENT_TIMESTAMP) +WHERE create_time IS NULL; + +ALTER TABLE mate_team_run ALTER COLUMN create_time SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_team_run_team_history_stable + ON mate_team_run (team_id, create_time, id); +CREATE INDEX IF NOT EXISTS idx_team_run_conversation_history_stable + ON mate_team_run (lead_conversation_id, create_time, id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V183__conversation_kind.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V183__conversation_kind.sql new file mode 100644 index 00000000..2899e86a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V183__conversation_kind.sql @@ -0,0 +1,2 @@ +ALTER TABLE mate_conversation + ADD COLUMN IF NOT EXISTS conversation_kind VARCHAR(32) NOT NULL DEFAULT 'primary'; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V184__team_task_conversation_index.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V184__team_task_conversation_index.sql new file mode 100644 index 00000000..2c8271a2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V184__team_task_conversation_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_team_task_conversation + ON mate_team_task (conversation_id); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V182__team_run_stable_history_indexes.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V182__team_run_stable_history_indexes.sql new file mode 100644 index 00000000..5f6babf5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V182__team_run_stable_history_indexes.sql @@ -0,0 +1,22 @@ +UPDATE mate_team_run +SET create_time = COALESCE(update_time, CURRENT_TIMESTAMP) +WHERE create_time IS NULL; + +ALTER TABLE mate_team_run + MODIFY COLUMN create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_team_run' + AND INDEX_NAME = 'idx_team_run_team_history_stable'); +SET @s := IF(@c = 0, + 'CREATE INDEX idx_team_run_team_history_stable ON mate_team_run (team_id, create_time, id)', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_team_run' + AND INDEX_NAME = 'idx_team_run_conversation_history_stable'); +SET @s := IF(@c = 0, + 'CREATE INDEX idx_team_run_conversation_history_stable ON mate_team_run (lead_conversation_id, create_time, id)', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V183__conversation_kind.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V183__conversation_kind.sql new file mode 100644 index 00000000..f1765a42 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V183__conversation_kind.sql @@ -0,0 +1,10 @@ +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_conversation' + AND COLUMN_NAME = 'conversation_kind'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_conversation ADD COLUMN conversation_kind VARCHAR(32) NOT NULL DEFAULT ''primary''', + 'SELECT 1'); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V184__team_task_conversation_index.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V184__team_task_conversation_index.sql new file mode 100644 index 00000000..de206cc8 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V184__team_task_conversation_index.sql @@ -0,0 +1,10 @@ +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_team_task' + AND INDEX_NAME = 'idx_team_task_conversation'); +SET @s := IF(@c = 0, + 'CREATE INDEX idx_team_task_conversation ON mate_team_task (conversation_id)', + 'SELECT 1'); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java new file mode 100644 index 00000000..15d61aed --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java @@ -0,0 +1,75 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import vip.mate.agent.AgentService; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.memory.identity.MemoryOwnerResolver; +import vip.mate.memory.event.ConversationCompletionPublisher; +import vip.mate.tool.document.preview.OfficePreviewService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ChatControllerWorkerReadOnlyTest { + + @Mock private AgentService agentService; + @Mock private ConversationService conversationService; + @Mock private ApprovalWorkflowService approvalService; + @Mock private ChatStreamTracker streamTracker; + @Mock private ObjectMapper objectMapper; + @Mock private ConversationCompletionPublisher completionPublisher; + @Mock private MemoryOwnerResolver memoryOwnerResolver; + @Mock private ChatUploadLocationResolver uploadLocationResolver; + @Mock private OfficePreviewService officePreviewService; + @Mock private Authentication authentication; + + private ChatController controller; + + @BeforeEach + void setUp() { + controller = new ChatController(agentService, conversationService, approvalService, + streamTracker, objectMapper, completionPublisher, memoryOwnerResolver, + uploadLocationResolver, officePreviewService); + } + + @Test + void rejectsWorkerBeforeRegisteringOrStartingAUserStream() { + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("worker-conversation"); + request.setMessage("try to continue"); + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("worker-conversation")).thenReturn(false); + + controller.chatStream(request, 1L, authentication); + + verify(conversationService).isUserMessageAllowed("worker-conversation"); + verify(streamTracker, never()).register(any()); + verify(agentService, never()).chatStructuredStream(any(), any(), any(), any(), any(), any()); + } + + @Test + void rejectsLegacyWorkerBeforeRegisteringOrStartingAUserStream() { + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("team-task-legacy"); + request.setMessage("try to continue"); + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("team-task-legacy")).thenReturn(false); + + controller.chatStream(request, 1L, authentication); + + verify(conversationService).isUserMessageAllowed("team-task-legacy"); + verify(streamTracker, never()).register(any()); + verify(agentService, never()).chatStructuredStream(any(), any(), any(), any(), any(), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/config/SecurityAsyncDispatchTest.java b/mateclaw-server/src/test/java/vip/mate/config/SecurityAsyncDispatchTest.java new file mode 100644 index 00000000..3ff3be20 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/config/SecurityAsyncDispatchTest.java @@ -0,0 +1,33 @@ +package vip.mate.config; + +import jakarta.servlet.DispatcherType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.web.FilterChainProxy; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class SecurityAsyncDispatchTest { + + @Autowired + private FilterChainProxy springSecurityFilterChain; + + @Test + void asyncSseRedispatchDoesNotRequireAuthenticationAfterResponseCommit() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/teams/20/events"); + request.setServletPath("/api/v1/teams/20/events"); + request.setDispatcherType(DispatcherType.ASYNC); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean continued = new AtomicBoolean(); + + springSecurityFilterChain.doFilter(request, response, (req, res) -> continued.set(true)); + + assertThat(continued).isTrue(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/MigrationSmokeTest.java b/mateclaw-server/src/test/java/vip/mate/team/MigrationSmokeTest.java index cdb3ff09..729242d3 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/MigrationSmokeTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/MigrationSmokeTest.java @@ -11,6 +11,7 @@ import vip.mate.MateClawApplication; import vip.mate.team.model.TeamRunEntity; import vip.mate.team.model.TeamRunStatus; import vip.mate.team.repository.TeamRunMapper; +import vip.mate.team.service.TeamRunService; import java.nio.file.Files; import java.nio.file.Path; @@ -43,6 +44,9 @@ class MigrationSmokeTest { @Autowired private TeamRunMapper runMapper; + @Autowired + private TeamRunService runService; + @Test @DisplayName("team run migration creates the run table with a BIGINT workspace") void teamRunTableExists() { @@ -82,6 +86,75 @@ class MigrationSmokeTest { } } + @Test + @DisplayName("all database dialects index stable run history and backfill create time") + void allDialectsContainStableHistoryIndexMigration() throws Exception { + for (String dialect : List.of("h2", "mysql", "kingbase")) { + Path migration = MIGRATIONS.resolve(dialect).resolve("V182__team_run_stable_history_indexes.sql"); + assertTrue(Files.exists(migration), dialect + " migration must contain version 182"); + String sql = Files.readString(migration).toLowerCase(Locale.ROOT); + assertTrue(sql.contains("idx_team_run_team_history_stable")); + assertTrue(sql.contains("team_id, create_time, id")); + assertTrue(sql.contains("idx_team_run_conversation_history_stable")); + assertTrue(sql.contains("lead_conversation_id, create_time, id")); + assertTrue(sql.matches("(?s).*update\\s+mate_team_run\\s+set\\s+create_time.*" + + "where\\s+create_time\\s+is\\s+null.*")); + assertTrue(sql.contains("not null")); + } + assertEquals("NO", columnNullable("mate_team_run", "create_time")); + } + + @Test + @DisplayName("all database dialects persist conversation kind with a primary default") + void allDialectsContainConversationKindMigration() throws Exception { + for (String dialect : List.of("h2", "mysql", "kingbase")) { + Path migration = MIGRATIONS.resolve(dialect).resolve("V183__conversation_kind.sql"); + assertTrue(Files.exists(migration), dialect + " migration must contain version 183"); + String sql = Files.readString(migration).toLowerCase(Locale.ROOT); + String normalizedSql = sql.replace("''", "'"); + assertTrue(sql.contains("conversation_kind")); + assertTrue(normalizedSql.contains("default 'primary'")); + assertTrue(sql.contains("not null")); + } + assertEquals("NO", columnNullable("mate_conversation", "conversation_kind")); + } + + @Test + @DisplayName("all database dialects index nullable team task conversation linkage") + void allDialectsContainTeamTaskConversationIndex() throws Exception { + for (String dialect : List.of("h2", "mysql", "kingbase")) { + Path migration = MIGRATIONS.resolve(dialect).resolve("V184__team_task_conversation_index.sql"); + assertTrue(Files.exists(migration), dialect + " migration must contain version 184"); + String sql = Files.readString(migration).toLowerCase(Locale.ROOT); + assertTrue(sql.contains("idx_team_task_conversation")); + assertTrue(sql.matches("(?s).*idx_team_task_conversation.*conversation_id.*")); + } + assertEquals("YES", columnNullable("mate_team_task", "conversation_id")); + assertEquals(1L, countIndexes("mate_team_task", "idx_team_task_conversation")); + } + + @Test + @DisplayName("H2 run history pages equal timestamps by id and ends without a cursor") + void h2StableCursorPaginationAcrossEqualTimestamps() { + LocalDateTime sameTime = LocalDateTime.of(2026, 8, 14, 12, 0); + TeamRunEntity high = newRun(9_813_002L, "lead-page", null); + high.setTeamId(321L); + high.setCreateTime(sameTime); + TeamRunEntity low = newRun(9_813_001L, "lead-page", null); + low.setTeamId(321L); + low.setCreateTime(sameTime); + runMapper.insert(high); + runMapper.insert(low); + + TeamRunService.RunPage first = runService.pageTeamRuns(321L, 41L, false, null, 1); + TeamRunService.RunPage second = runService.pageTeamRuns(321L, 41L, false, first.nextCursor(), 1); + + assertEquals(high.getId(), first.items().getFirst().id()); + assertNotNull(first.nextCursor()); + assertEquals(low.getId(), second.items().getFirst().id()); + assertNull(second.nextCursor()); + } + @Test @DisplayName("origin message identity is unique while manual runs allow null origins") void originMessageUniquenessAllowsManualRuns() { @@ -178,4 +251,11 @@ class MigrationSmokeTest { tableName, columnName); } + + private Long countIndexes(String tableName, String indexName) { + return jdbc.queryForObject( + "SELECT COUNT(DISTINCT index_name) FROM information_schema.indexes " + + "WHERE table_name = ? AND index_name = ?", + Long.class, tableName, indexName); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java index dc77014f..feb48f0d 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java @@ -9,6 +9,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import vip.mate.agent.repository.AgentMapper; +import vip.mate.agent.model.AgentEntity; import vip.mate.auth.model.UserEntity; import vip.mate.auth.service.AuthService; import vip.mate.common.result.R; @@ -32,6 +33,7 @@ import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.workspace.core.service.WorkspaceService; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -165,6 +167,44 @@ class TeamControllerTest { // ==================== task board ==================== + @Test + void listTasksBatchLoadsDistinctAssigneesAndOwnersOnce() { + TeamTaskEntity first = task(TEAM_ID, TeamTaskStatus.PENDING); + first.setId(101L); + first.setAssigneeAgentId(11L); + first.setOwnerAgentId(12L); + TeamTaskEntity second = task(TEAM_ID, TeamTaskStatus.IN_PROGRESS); + second.setId(102L); + second.setAssigneeAgentId(11L); + second.setOwnerAgentId(13L); + TeamTaskEntity third = task(TEAM_ID, TeamTaskStatus.COMPLETED); + third.setId(103L); + third.setAssigneeAgentId(13L); + third.setOwnerAgentId(null); + when(taskService.listTasks(TEAM_ID, null, null, null)).thenReturn(List.of(first, second, third)); + AgentEntity assignee = new AgentEntity(); + assignee.setId(11L); + assignee.setName("Assignee"); + AgentEntity owner = new AgentEntity(); + owner.setId(12L); + owner.setName("Owner"); + AgentEntity shared = new AgentEntity(); + shared.setId(13L); + shared.setName("Shared"); + when(agentMapper.selectBatchIds(any())).thenReturn(List.of(assignee, owner, shared)); + + R> response = controller.listTasks(TEAM_ID, null, null, null); + + assertEquals(List.of("Assignee", "Assignee", "Shared"), + response.getData().stream().map(TeamController.TaskVO::assigneeName).toList()); + assertEquals(java.util.Arrays.asList("Owner", "Shared", null), + response.getData().stream().map(TeamController.TaskVO::ownerName).toList()); + ArgumentCaptor> ids = ArgumentCaptor.forClass(java.util.Collection.class); + verify(agentMapper, times(1)).selectBatchIds(ids.capture()); + assertEquals(Set.of(11L, 12L, 13L), Set.copyOf(ids.getValue())); + verify(agentMapper, never()).selectById(anyLong()); + } + @Test void createTaskSurfacesUnknownAssigneeAsReadableFailure() { TeamRunEntity planning = run(TEAM_ID, 1L, TeamRunStatus.PLANNING); diff --git a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamRunControllerTest.java b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamRunControllerTest.java index f64fafbb..2725cfd5 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamRunControllerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamRunControllerTest.java @@ -51,15 +51,20 @@ class TeamRunControllerTest { @Test void detailAndListsAreScopedToTheRequestedWorkspace() { TeamRunView view = view(); + TeamRunService.RunPage page = new TeamRunService.RunPage(List.of(view), null); when(runService.getRun(RUN_ID, WORKSPACE_ID)).thenReturn(view); - when(runService.listTeamRuns(TEAM_ID, WORKSPACE_ID)).thenReturn(List.of(view)); - when(runService.listConversationRuns(CONVERSATION_ID, WORKSPACE_ID)) - .thenReturn(List.of(view)); + when(runService.pageTeamRuns(TEAM_ID, WORKSPACE_ID, false, null, 20)).thenReturn(page); + when(runService.pageConversationRuns(CONVERSATION_ID, WORKSPACE_ID, null, 20)) + .thenReturn(page); + when(runService.listTeamRuns(TEAM_ID, WORKSPACE_ID, false)).thenReturn(List.of(view)); + when(runService.listConversationRuns(CONVERSATION_ID, WORKSPACE_ID)).thenReturn(List.of(view)); assertEquals(view, controller.get(RUN_ID, WORKSPACE_ID).getData()); - assertEquals(List.of(view), controller.listTeamRuns(TEAM_ID, WORKSPACE_ID).getData()); - assertEquals(List.of(view), - controller.listConversationRuns(CONVERSATION_ID, WORKSPACE_ID).getData()); + assertEquals(List.of(view), controller.listTeamRuns(TEAM_ID, false, WORKSPACE_ID).getData()); + assertEquals(List.of(view), controller.listConversationRuns(CONVERSATION_ID, WORKSPACE_ID).getData()); + assertEquals(page, controller.pageTeamRuns(TEAM_ID, false, null, 20, WORKSPACE_ID).getData()); + assertEquals(page, controller.pageConversationRuns( + CONVERSATION_ID, null, 20, WORKSPACE_ID).getData()); } @Test @@ -89,9 +94,14 @@ class TeamRunControllerTest { @Test void endpointsDeclareExactPathsAndRoles() throws Exception { assertEndpoint("get", "viewer", "/team-runs/{runId}", Long.class, Long.class); - assertEndpoint("listTeamRuns", "viewer", "/teams/{teamId}/runs", Long.class, Long.class); + assertEndpoint("listTeamRuns", "viewer", "/teams/{teamId}/runs", + Long.class, boolean.class, Long.class); assertEndpoint("listConversationRuns", "viewer", "/conversations/{conversationId}/team-runs", String.class, Long.class); + assertEndpoint("pageTeamRuns", "viewer", "/teams/{teamId}/runs/page", + Long.class, boolean.class, String.class, int.class, Long.class); + assertEndpoint("pageConversationRuns", "viewer", "/conversations/{conversationId}/team-runs/page", + String.class, String.class, int.class, Long.class); assertEndpoint("cancel", "admin", "/team-runs/{runId}/cancel", Long.class, TeamRunController.CancelRunRequest.class, Long.class); } diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java index 3fae6d4e..b2d928d2 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java @@ -216,7 +216,8 @@ class TeamDispatchServiceTest { eq(MEMBER_A), eq("system"), eq(WORKSPACE_ID), - eq("lead-conv")); + eq("lead-conv"), + eq("team_worker")); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java index c2c29aab..7f763a91 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunProjectorTest.java @@ -17,6 +17,7 @@ import vip.mate.team.repository.TeamRunMapper; import vip.mate.team.repository.TeamTaskMapper; import java.util.List; +import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -82,6 +83,170 @@ class TeamRunProjectorTest { assertEquals("{\"deliverables\":[],\"planId\":\"9007199254740995\"}", projected.metadata()); } + @Test + void projectsCanonicalDeliveryContractAndDeduplicatesDeliverables() { + LocalDateTime completedAt = LocalDateTime.of(2026, 8, 14, 12, 0); + TeamRunEntity run = run(TeamRunStatus.PARTIAL, "{\"projectedOutcome\":\"partial\"}"); + run.setFinalSummary("Synthesized result"); + run.setStartedAt(completedAt.minusMinutes(5)); + run.setCompletedAt(completedAt); + run.setUpdateTime(completedAt); + + TeamTaskEntity completed = task(TeamTaskStatus.COMPLETED); + completed.setId(101L); + completed.setSubject("Report"); + completed.setAssigneeAgentId(201L); + completed.setResult("Completed report"); + completed.setConversationId("worker-101"); + completed.setUpdateTime(completedAt.minusMinutes(1)); + completed.setMetadata("{\"deliverables\":[{\"name\":\"report.pdf\"," + + "\"url\":\"/api/v1/files/generated/report.pdf\"," + + "\"time\":\"2026-08-14T11:59:00\"}]}"); + TeamTaskEntity review = task(TeamTaskStatus.IN_REVIEW); + review.setId(102L); + review.setSubject("Review"); + review.setAssigneeAgentId(202L); + review.setUpdateTime(completedAt); + review.setMetadata(completed.getMetadata()); + + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of(completed, review)); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals("synthesized", view.outcomeQuality()); + assertEquals(1, view.deliverables().size()); + assertEquals(List.of(101L, 102L), view.deliverables().getFirst().sourceTaskIds()); + assertEquals(2, view.contributions().size()); + assertEquals("review", view.attentionItems().getFirst().type()); + assertEquals("terminal", view.liveness().state()); + assertEquals(completedAt, view.liveness().lastActivityAt()); + assertEquals(300L, view.metrics().durationSeconds()); + assertEquals(2, view.metrics().totalTasks()); + } + + @Test + void marksTaskResultFallbackAndStalledActiveRunWithoutRecentActivity() { + LocalDateTime old = LocalDateTime.now().minusHours(1); + TeamRunEntity run = run(TeamRunStatus.PLANNING, null); + run.setUpdateTime(old); + TeamTaskEntity completed = task(TeamTaskStatus.COMPLETED); + completed.setResult("Raw member result"); + completed.setUpdateTime(old); + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of(completed)); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals("fallback", view.outcomeQuality()); + assertEquals("stalled", view.liveness().state()); + assertEquals(old, view.liveness().lastActivityAt()); + } + + @Test + void rejectsAbsoluteGeneratedDeliverableUrlsInsteadOfRewritingThemAsLocalPaths() { + TeamRunEntity run = run(TeamRunStatus.COMPLETED, null); + TeamTaskEntity task = task(TeamTaskStatus.COMPLETED); + task.setMetadata("{\"deliverables\":[" + + "{\"name\":\"safe\",\"url\":\"/api/v1/files/generated/safe.pdf\"}," + + "{\"name\":\"external\",\"url\":\"https://evil.test/api/v1/files/generated/x.pdf\"}]}"); + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals(1, view.deliverables().size()); + assertEquals(List.of("/api/v1/files/generated/safe.pdf"), + view.deliverables().stream().map(TeamRunView.Deliverable::url).toList()); + } + + @Test + void rejectsGeneratedUrlsThatEscapeTheirPrefixAfterPathNormalization() { + TeamRunEntity run = run(TeamRunStatus.COMPLETED, null); + TeamTaskEntity task = task(TeamTaskStatus.COMPLETED); + task.setMetadata("{\"deliverables\":[" + + "{\"name\":\"safe\",\"url\":\"/api/v1/files/generated/safe.pdf\"}," + + "{\"name\":\"dots\",\"url\":\"/api/v1/files/generated/../secret.txt\"}," + + "{\"name\":\"encoded\",\"url\":\"/api/v1/files/generated/%2e%2e/secret.txt\"}," + + "{\"name\":\"slash\",\"url\":\"/api/v1/files/generated/..\\\\secret.txt\"}," + + "{\"name\":\"absolute\",\"url\":\"https://evil.test/api/v1/files/generated/a/../../secret.txt\"}]}"); + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals(1, view.deliverables().size()); + assertEquals("/api/v1/files/generated/safe.pdf", view.deliverables().getFirst().url()); + } + + @Test + void recentInProgressLeaseIsCrediblyLive() { + TeamRunEntity run = run(TeamRunStatus.PLANNING, null); + TeamTaskEntity task = task(TeamTaskStatus.IN_PROGRESS); + task.setLockExpiresAt(LocalDateTime.now().plusMinutes(5)); + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + + assertEquals("live", projector.project(RUN_ID).liveness().state()); + } + + @Test + void expiredInProgressLeaseIsNotLive() { + TeamRunEntity run = run(TeamRunStatus.PLANNING, null); + run.setUpdateTime(LocalDateTime.now()); + TeamTaskEntity task = task(TeamTaskStatus.IN_PROGRESS); + task.setLockExpiresAt(LocalDateTime.now().minusSeconds(1)); + task.setUpdateTime(LocalDateTime.now()); + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + + assertEquals("quiet", projector.project(RUN_ID).liveness().state()); + } + + @Test + void recentDatabaseUpdateIsQuietRatherThanCrediblyLive() { + TeamRunEntity run = run(TeamRunStatus.PLANNING, null); + run.setUpdateTime(LocalDateTime.now()); + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of()); + + assertEquals("quiet", projector.project(RUN_ID).liveness().state()); + } + + @Test + void fallbackAndStopReasonProduceAttentionWithHumanActionFirst() { + LocalDateTime now = LocalDateTime.now(); + TeamRunEntity run = run(TeamRunStatus.CANCELLED, "{\"summaryQuality\":\"fallback\"}"); + run.setFinalSummary("raw results"); + run.setStopReason("cancelled by operator"); + run.setUpdateTime(now); + TeamTaskEntity failed = task(TeamTaskStatus.FAILED); + failed.setId(1L); + failed.setReason("worker failed"); + failed.setUpdateTime(now); + TeamTaskEntity review = task(TeamTaskStatus.IN_REVIEW); + review.setId(2L); + review.setUpdateTime(now.minusHours(1)); + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of(failed, review)); + + TeamRunView view = projector.project(RUN_ID); + + assertEquals("review", view.attentionItems().getFirst().type()); + assertTrue(view.attentionItems().stream().anyMatch(item -> "synthesis".equals(item.type()))); + assertTrue(view.attentionItems().stream().anyMatch(item -> "stopped".equals(item.type()))); + } + + @Test + void invalidOutcomeQualityMetadataSafelyFallsBackToKnownValue() { + TeamRunEntity run = run(TeamRunStatus.COMPLETED, "{\"summaryQuality\":\"invented\"}"); + run.setFinalSummary("summary"); + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of()); + + assertEquals("synthesized", projector.project(RUN_ID).outcomeQuality()); + } + @Test void terminalRunCannotBeMovedByLateTaskEvents() { when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.CANCELLED, "{\"traceId\":\"a\"}")); diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunServiceTest.java index c8f0d484..0f6ddf32 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunServiceTest.java @@ -1,6 +1,7 @@ package vip.mate.team.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; @@ -21,10 +22,12 @@ import vip.mate.team.repository.TeamTaskMapper; import java.util.List; import java.util.Set; +import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -34,6 +37,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; class TeamRunServiceTest { @@ -301,6 +305,27 @@ class TeamRunServiceTest { verify(runMapper, never()).update(isNull(), any()); } + @Test + void reconcileMarksRawTaskSummaryAsFallbackQuality() { + TeamRunEntity finalizing = run(TeamRunStatus.FINALIZING); + finalizing.setMetadata("{\"traceId\":\"abc\"}"); + TeamTaskEntity completed = task(101L, TeamTaskStatus.COMPLETED); + completed.setTaskNumber(1); + completed.setResult("raw result"); + when(runMapper.selectList(any())).thenReturn(List.of(finalizing)); + when(taskMapper.selectList(any())).thenReturn(List.of(completed)); + + service.reconcileFinalizingRuns(); + + @SuppressWarnings({"unchecked", "rawtypes"}) + ArgumentCaptor> update = + ArgumentCaptor.forClass((Class) LambdaUpdateWrapper.class); + verify(runMapper).update(isNull(), update.capture()); + assertTrue(update.getValue().getParamNameValuePairs().values().stream() + .map(String::valueOf) + .anyMatch(value -> value.contains("summaryQuality") && value.contains("fallback"))); + } + @Test void getRunBuildsStableViewWithTasksAndProgress() { TeamRunEntity running = run(TeamRunStatus.RUNNING); @@ -316,6 +341,145 @@ class TeamRunServiceTest { assertEquals(50, view.progress().percent()); } + @Test + void pagedTeamRunListUsesOneBoundedTaskSummaryQueryWithoutTaskResults() { + TeamRunEntity newest = run(TeamRunStatus.COMPLETED); + newest.setId(22L); + newest.setCreateTime(LocalDateTime.of(2026, 8, 14, 12, 0)); + TeamRunEntity older = run(TeamRunStatus.RUNNING); + older.setId(21L); + older.setCreateTime(LocalDateTime.of(2026, 8, 14, 11, 0)); + TeamRunEntity lookahead = run(TeamRunStatus.RUNNING); + lookahead.setId(20L); + lookahead.setCreateTime(LocalDateTime.of(2026, 8, 14, 10, 0)); + TeamTaskEntity summary = task(101L, TeamTaskStatus.COMPLETED); + summary.setRunId(22L); + summary.setSubject("Summary task"); + summary.setDescription("must not be returned"); + summary.setConversationId("worker-101"); + summary.setProgressPercent(100); + summary.setProgressStep("done"); + summary.setResult("must not be selected or returned"); + when(runMapper.selectList(any())).thenReturn(List.of(newest, older, lookahead)); + when(taskMapper.selectList(any())).thenReturn(List.of(summary)); + + TeamRunService.RunPage page = service.pageTeamRuns(TEAM_ID, WORKSPACE_ID, false, null, 2); + + assertEquals(2, page.items().size()); + assertNotNull(page.nextCursor()); + assertTrue(page.items().stream().allMatch(view -> "summary".equals(view.projectionCompleteness()))); + assertEquals(1, page.items().getFirst().tasks().size()); + var lightweight = page.items().getFirst().tasks().getFirst(); + assertEquals(101L, lightweight.id()); + assertEquals(22L, lightweight.runId()); + assertEquals("worker-101", lightweight.conversationId()); + assertEquals("Summary task", lightweight.subject()); + assertEquals(100, lightweight.progressPercent()); + assertEquals("done", lightweight.progressStep()); + assertNull(lightweight.description()); + assertNull(lightweight.result()); + verify(taskMapper, times(1)).selectList(any()); + } + + @Test + void lightweightListAggregatesRealDeliverableAndTaskCounts() { + TeamRunEntity run = run(TeamRunStatus.COMPLETED); + run.setCreateTime(LocalDateTime.now()); + TeamTaskEntity summary = task(101L, TeamTaskStatus.COMPLETED); + summary.setMetadata("{\"deliverables\":[{\"name\":\"report\"," + + "\"url\":\"/api/v1/files/generated/report.pdf\"}]}"); + when(runMapper.selectList(any())).thenReturn(List.of(run)); + when(taskMapper.selectList(any())).thenReturn(List.of(summary)); + + TeamRunService.RunPage page = service.pageTeamRuns(TEAM_ID, WORKSPACE_ID, false, null, 20); + + assertEquals(1, page.items().getFirst().metrics().deliverableCount()); + assertEquals(1, page.items().getFirst().metrics().totalTasks()); + } + + @Test + void legacyArrayListIsNotTruncatedByPageLimit() { + List runs = java.util.stream.LongStream.rangeClosed(1, 101) + .mapToObj(id -> { + TeamRunEntity run = run(TeamRunStatus.COMPLETED); + run.setId(id); + run.setCreateTime(LocalDateTime.now()); + return run; + }).toList(); + when(runMapper.selectList(any())).thenReturn(runs); + when(taskMapper.selectList(any())).thenReturn(List.of()); + + assertEquals(101, service.listTeamRuns(TEAM_ID, WORKSPACE_ID, false).size()); + } + + @Test + void legacyArraySummaryLoadsTasksInSafeBatches() { + List runs = java.util.stream.LongStream.rangeClosed(1, 1201) + .mapToObj(id -> { + TeamRunEntity run = run(TeamRunStatus.COMPLETED); + run.setId(id); + run.setCreateTime(LocalDateTime.now()); + return run; + }).toList(); + when(runMapper.selectList(any())).thenReturn(runs); + when(taskMapper.selectList(any())).thenReturn(List.of()); + + assertEquals(1201, service.listTeamRuns(TEAM_ID, WORKSPACE_ID, false).size()); + verify(taskMapper, times(3)).selectList(any()); + } + + @Test + void cursorPaginationIsStableForRunsWithTheSameCreateTime() { + LocalDateTime sameTime = LocalDateTime.of(2026, 8, 14, 12, 0); + TeamRunEntity firstRun = run(TeamRunStatus.COMPLETED); + firstRun.setId(40L); + firstRun.setCreateTime(sameTime); + TeamRunEntity secondRun = run(TeamRunStatus.COMPLETED); + secondRun.setId(39L); + secondRun.setCreateTime(sameTime); + when(runMapper.selectList(any())).thenReturn(List.of(firstRun, secondRun), List.of(secondRun)); + when(taskMapper.selectList(any())).thenReturn(List.of()); + + TeamRunService.RunPage first = service.pageTeamRuns(TEAM_ID, WORKSPACE_ID, false, null, 1); + TeamRunService.RunPage second = service.pageTeamRuns( + TEAM_ID, WORKSPACE_ID, false, first.nextCursor(), 1); + + assertEquals(40L, first.items().getFirst().id()); + assertEquals(39L, second.items().getFirst().id()); + assertFalse(first.nextCursor().isBlank()); + verify(runMapper, times(2)).selectList(any()); + @SuppressWarnings({"unchecked", "rawtypes"}) + ArgumentCaptor> queries = + ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class); + verify(runMapper, times(2)).selectList(queries.capture()); + LambdaQueryWrapper secondQuery = queries.getAllValues().get(1); + String sql = secondQuery.getSqlSegment().toLowerCase(); + assertTrue(sql.contains("create_time") || sql.contains("createtime"), sql); + assertTrue(sql.contains("id")); + assertTrue(secondQuery.getParamNameValuePairs().containsValue(sameTime)); + assertTrue(secondQuery.getParamNameValuePairs().containsValue(40L)); + } + + @Test + void invalidCursorIsRejectedBeforeQuerying() { + assertThrows(IllegalArgumentException.class, + () -> service.pageTeamRuns(TEAM_ID, WORKSPACE_ID, false, "not-a-cursor", 20)); + verify(runMapper, never()).selectList(any()); + } + + @Test + void detailStillReturnsLongTaskResultWhileListSummaryDoesNot() { + TeamRunEntity run = run(TeamRunStatus.COMPLETED); + TeamTaskEntity task = task(101L, TeamTaskStatus.COMPLETED); + task.setResult("full markdown result"); + when(runMapper.selectById(RUN_ID)).thenReturn(run); + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + + var detail = service.getRun(RUN_ID, WORKSPACE_ID); + + assertEquals("full markdown result", detail.tasks().getFirst().result()); + } + private TeamRunCreateCommand.TeamRunCreateCommandBuilder command() { return TeamRunCreateCommand.builder() .teamId(TEAM_ID) diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunViewFactoryTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunViewFactoryTest.java new file mode 100644 index 00000000..b8600c0a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunViewFactoryTest.java @@ -0,0 +1,240 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.Test; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamRunStatus; +import vip.mate.team.model.TeamRunView; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.model.TeamTaskStatus; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class TeamRunViewFactoryTest { + + private static final String REPORT_URL = "/api/v1/files/generated/report.pdf"; + + @Test + void summaryProjectionIncludesLightweightTasksAndDropsHeavyFields() { + TeamTaskEntity task = task(101L, 201L, "{\"phase\":\"research\"}"); + task.setRunId(1L); + task.setSubject("Collect evidence"); + task.setDescription("large prompt"); + task.setProgressPercent(65); + task.setProgressStep("verifying sources"); + task.setReason("waiting for source"); + task.setConversationId("worker-101"); + task.setResult("large result"); + task.setCreateTime(java.time.LocalDateTime.of(2026, 8, 14, 10, 0)); + task.setUpdateTime(java.time.LocalDateTime.of(2026, 8, 14, 10, 5)); + + TeamRunView view = TeamRunViewFactory.create(run("{}"), TeamRunStatus.RUNNING, + new TeamRunView.Progress(1, 0, 0, 0, 65), List.of(task), false); + + assertEquals("summary", view.projectionCompleteness()); + assertEquals(1, view.tasks().size()); + TeamRunView.Task summary = view.tasks().getFirst(); + assertEquals(101L, summary.id()); + assertEquals(1L, summary.runId()); + assertEquals(TeamTaskStatus.COMPLETED, summary.status()); + assertEquals(201L, summary.assigneeAgentId()); + assertEquals("worker-101", summary.conversationId()); + assertEquals("Collect evidence", summary.subject()); + assertEquals(65, summary.progressPercent()); + assertEquals("verifying sources", summary.progressStep()); + assertEquals("waiting for source", summary.reason()); + assertNull(summary.metadata()); + assertEquals(java.time.LocalDateTime.of(2026, 8, 14, 10, 0), summary.createTime()); + assertEquals(java.time.LocalDateTime.of(2026, 8, 14, 10, 5), summary.updateTime()); + assertNull(summary.description()); + assertNull(summary.result()); + } + + @Test + void fullAndSummaryOutcomeQualityUseStableTaskStatusEvidence() { + TeamTaskEntity completed = task(101L, 201L, null); + completed.setResult("available only in full projection"); + TeamTaskEntity failed = task(102L, 202L, null); + failed.setStatus(TeamTaskStatus.FAILED); + + TeamRunView full = TeamRunViewFactory.create(run("{}"), TeamRunStatus.PARTIAL, + new TeamRunView.Progress(2, 1, 1, 0, 100), List.of(completed, failed), true); + completed.setResult(null); + TeamRunView summary = TeamRunViewFactory.create(run("{}"), TeamRunStatus.PARTIAL, + new TeamRunView.Progress(2, 1, 1, 0, 100), List.of(completed, failed), false); + + assertEquals("partial", full.outcomeQuality()); + assertEquals(full.outcomeQuality(), summary.outcomeQuality()); + } + + @Test + void aggregatesRunOnlyDeliverables() { + TeamRunEntity run = run("{\"deliverables\":[{\"name\":\"report.pdf\"," + + "\"url\":\"" + REPORT_URL + "\"}]}"); + + TeamRunView view = project(run, List.of()); + + assertEquals(1, view.deliverables().size()); + assertEquals("report.pdf", view.deliverables().getFirst().name()); + assertEquals(REPORT_URL, view.deliverables().getFirst().url()); + assertEquals(List.of(), view.deliverables().getFirst().sourceTaskIds()); + assertEquals(List.of(), view.deliverables().getFirst().sourceAgentIds()); + } + + @Test + void mergesExplicitAndImplicitSourcesForDuplicateRunAndTaskDeliverables() { + TeamRunEntity run = run("{\"deliverables\":[{\"name\":\"report.pdf\"," + + "\"url\":\"" + REPORT_URL + "\"," + + "\"sourceTaskIds\":[90],\"sourceAgentIds\":[190]}]}"); + TeamTaskEntity task = task(101L, 201L, + "{\"deliverables\":[{\"name\":\"report.pdf\"," + + "\"url\":\"" + REPORT_URL + "\"," + + "\"sourceTaskIds\":[91,90],\"sourceAgentIds\":[191,190]}]}"); + + TeamRunView view = project(run, List.of(task)); + + assertEquals(1, view.deliverables().size()); + assertEquals(List.of(90L, 91L, 101L), view.deliverables().getFirst().sourceTaskIds()); + assertEquals(List.of(190L, 191L, 201L), view.deliverables().getFirst().sourceAgentIds()); + } + + @Test + void deduplicatesBySafeUrlWhenNamesDifferAndMergesSources() { + TeamRunEntity run = run("{\"deliverables\":[{\"name\":\"draft.pdf\"," + + "\"url\":\"" + REPORT_URL + "\",\"sourceTaskIds\":[90]}]}"); + TeamTaskEntity task = task(101L, 201L, + "{\"deliverables\":[{\"name\":\"final-report.pdf\"," + + "\"url\":\"" + REPORT_URL + "\"}]}"); + + TeamRunView view = project(run, List.of(task)); + + assertEquals(1, view.deliverables().size()); + assertEquals("draft.pdf", view.deliverables().getFirst().name()); + assertEquals(List.of(90L, 101L), view.deliverables().getFirst().sourceTaskIds()); + assertEquals(List.of(201L), view.deliverables().getFirst().sourceAgentIds()); + } + + @Test + void fillsMissingCreatedAtAndUsesTheStrongestVerificationStatusFromDuplicates() { + TeamRunEntity run = run("{\"deliverables\":[{\"name\":\"report.pdf\"," + + "\"url\":\"" + REPORT_URL + "\"}]}"); + TeamTaskEntity verified = task(101L, 201L, + "{\"deliverables\":[{\"name\":\"report.pdf\"," + + "\"url\":\"" + REPORT_URL + "\"," + + "\"createdAt\":\"2026-08-14T12:30:00\"," + + "\"verificationStatus\":\"verified\"}]}"); + TeamTaskEntity degraded = task(102L, 202L, + "{\"deliverables\":[{\"name\":\"report.pdf\"," + + "\"url\":\"" + REPORT_URL + "\"," + + "\"createdAt\":\"2026-08-14T12:31:00\"," + + "\"verificationStatus\":\"failed\"}]}"); + + TeamRunView view = project(run, List.of(verified, degraded)); + + assertEquals(1, view.deliverables().size()); + assertEquals(java.time.LocalDateTime.of(2026, 8, 14, 12, 30), + view.deliverables().getFirst().createdAt()); + assertEquals("verified", view.deliverables().getFirst().verificationStatus()); + assertEquals(List.of(101L, 102L), view.deliverables().getFirst().sourceTaskIds()); + assertEquals(List.of(201L, 202L), view.deliverables().getFirst().sourceAgentIds()); + } + + @Test + void malformedRunAndTaskMetadataAreIgnoredWithoutDroppingOtherValidDeliverables() { + TeamTaskEntity malformed = task(101L, 201L, "{not-json"); + TeamTaskEntity valid = task(102L, 202L, + "{\"deliverables\":[{\"name\":\"valid.csv\"," + + "\"url\":\"/api/v1/files/generated/valid.csv\"}]}"); + + TeamRunView view = project(run("{"), List.of(malformed, valid)); + + assertEquals(1, view.deliverables().size()); + assertEquals("valid.csv", view.deliverables().getFirst().name()); + assertEquals(List.of(102L), view.deliverables().getFirst().sourceTaskIds()); + assertEquals(List.of(202L), view.deliverables().getFirst().sourceAgentIds()); + } + + @Test + void rejectsAbsoluteSchemeRelativeAndEncodedTraversalDeliverableUrls() { + TeamRunEntity run = run("{\"deliverables\":[" + + "{\"name\":\"safe\",\"url\":\"" + REPORT_URL + "\"}," + + "{\"name\":\"http\",\"url\":\"http://files.test/api/v1/files/generated/http.pdf\"}," + + "{\"name\":\"https\",\"url\":\"https://files.test/api/v1/files/generated/https.pdf\"}," + + "{\"name\":\"relative\",\"url\":\"//files.test/api/v1/files/generated/relative.pdf\"}," + + "{\"name\":\"encoded\",\"url\":\"/api/v1/files/generated/%252e%252e/secret.txt\"}]} "); + + TeamRunView view = project(run, List.of()); + + assertEquals(List.of(REPORT_URL), + view.deliverables().stream().map(TeamRunView.Deliverable::url).toList()); + } + + @Test + void mapsUnknownVerificationStatusToAvailableWithoutLeakingMetadataValue() { + TeamRunEntity run = run("{\"deliverables\":[{\"name\":\"report.pdf\"," + + "\"url\":\"" + REPORT_URL + "\"," + + "\"verificationStatus\":\"INTERNAL_ONLY\"}]}"); + + TeamRunView view = project(run, List.of()); + + assertEquals("available", view.deliverables().getFirst().verificationStatus()); + } + + @Test + void acceptsOnlyPositiveLongSourceIdsAndKeepsValidMixedArrayEntries() { + TeamRunEntity run = run("{\"deliverables\":[{\"name\":\"report.pdf\"," + + "\"url\":\"" + REPORT_URL + "\"," + + "\"sourceTaskIds\":[1,2.5,0,-3,9223372036854775808,\"4\",\"5.5\",\"bad\"]," + + "\"sourceAgentIds\":[6,0,-7,9223372036854775808,\"8\",\"9223372036854775808\"]}]}"); + + TeamRunView view = project(run, List.of()); + + assertEquals(List.of(1L, 4L), view.deliverables().getFirst().sourceTaskIds()); + assertEquals(List.of(6L, 8L), view.deliverables().getFirst().sourceAgentIds()); + } + + @Test + void preservesLiteralPlusAndKeepsPlusAndSpaceUrlIdentitiesDistinct() { + TeamRunEntity run = run("{\"deliverables\":[" + + "{\"name\":\"literal-plus\",\"url\":\"/api/v1/files/generated/a+b.pdf\",\"sourceTaskIds\":[1]}," + + "{\"name\":\"encoded-plus\",\"url\":\"/api/v1/files/generated/a%2Bb.pdf\",\"sourceTaskIds\":[2]}," + + "{\"name\":\"encoded-space\",\"url\":\"/api/v1/files/generated/a%20b.pdf\",\"sourceTaskIds\":[3]}]}"); + + TeamRunView view = project(run, List.of()); + + assertEquals(2, view.deliverables().size()); + assertEquals(List.of("/api/v1/files/generated/a+b.pdf", "/api/v1/files/generated/a%20b.pdf"), + view.deliverables().stream().map(TeamRunView.Deliverable::url).toList()); + assertEquals(List.of(1L, 2L), view.deliverables().getFirst().sourceTaskIds()); + assertEquals(List.of(3L), view.deliverables().get(1).sourceTaskIds()); + } + + private static TeamRunView project(TeamRunEntity run, List tasks) { + return TeamRunViewFactory.create(run, run.getStatus(), + new TeamRunView.Progress(tasks.size(), 0, 0, 0, 0), tasks, true); + } + + private static TeamRunEntity run(String metadata) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(1L); + run.setTeamId(2L); + run.setWorkspaceId(3L); + run.setLeadAgentId(4L); + run.setStatus(TeamRunStatus.RUNNING); + run.setMetadata(metadata); + return run; + } + + private static TeamTaskEntity task(Long id, Long assigneeId, String metadata) { + TeamTaskEntity task = new TeamTaskEntity(); + task.setId(id); + task.setTeamId(2L); + task.setRunId(1L); + task.setStatus(TeamTaskStatus.COMPLETED); + task.setAssigneeAgentId(assigneeId); + task.setMetadata(metadata); + return task; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerConversationGovernanceServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerConversationGovernanceServiceTest.java new file mode 100644 index 00000000..7ef5d688 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerConversationGovernanceServiceTest.java @@ -0,0 +1,138 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.team.model.TeamRunEntity; +import vip.mate.team.model.TeamTaskEntity; +import vip.mate.team.repository.TeamRunMapper; +import vip.mate.team.repository.TeamTaskMapper; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TeamWorkerConversationGovernanceServiceTest { + + @Mock private TeamTaskMapper taskMapper; + @Mock private TeamRunMapper runMapper; + @Mock private ConversationMapper conversationMapper; + + @Test + void returnsVerifiedCanonicalContextOnlyWhenRequestedLinkageMatches() { + TeamTaskEntity task = task(501L, 77L, "worker-conversation"); + TeamRunEntity run = run(77L, 20L, "lead-conversation"); + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "worker-conversation", 30L, 41L, "lead-conversation", "team_worker")); + when(taskMapper.selectOne(any())).thenReturn(task); + when(runMapper.selectById(77L)).thenReturn(run); + TeamWorkerConversationGovernanceService service = service(); + + assertThat(service.resolve("worker-conversation", 77L, 501L)) + .isPresent().get() + .extracting(TeamWorkerConversationContext::verified, + TeamWorkerConversationContext::conversationKind, + TeamWorkerConversationContext::runId, + TeamWorkerConversationContext::taskId) + .containsExactly(true, "team_worker", 77L, 501L); + + assertThat(service.resolve("worker-conversation", 88L, 501L)).isEmpty(); + assertThat(service.resolve("worker-conversation", 77L, 999L)).isEmpty(); + } + + @Test + void ordinaryConversationCannotBecomeWorkerFromForgedRouteIds() { + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "ordinary-conversation", 30L, 41L, null, "primary")); + + assertThat(service().resolve("ordinary-conversation", 77L, 501L)).isEmpty(); + } + + @Test + void rejectsCrossWorkspaceAgentAndParentConversationMismatches() { + TeamTaskEntity task = task(501L, 77L, "worker-conversation"); + TeamRunEntity run = run(77L, 20L, "lead-conversation"); + when(taskMapper.selectOne(any())).thenReturn(task); + when(runMapper.selectById(77L)).thenReturn(run); + + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "worker-conversation", 99L, 41L, "lead-conversation", "team_worker")); + assertThat(service().resolve("worker-conversation", null, null)).isEmpty(); + + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "worker-conversation", 30L, 999L, "lead-conversation", "team_worker")); + assertThat(service().resolve("worker-conversation", null, null)).isEmpty(); + + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "worker-conversation", 30L, 41L, "other-lead", "team_worker")); + assertThat(service().resolve("worker-conversation", null, null)).isEmpty(); + } + + @Test + void ordinaryDelegatedChildIsNotATeamWorker() { + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "delegate-child", 30L, 41L, "lead-conversation", "primary")); + + assertThat(service().resolve("delegate-child", null, null)).isEmpty(); + } + + @Test + void recognizesPersistedLegacyWorkerLinkageWithoutTrustingItsPrefix() { + TeamTaskEntity task = task(501L, 77L, "team-task-legacy"); + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "team-task-legacy", 30L, 41L, "lead", null)); + when(taskMapper.selectOne(any())).thenReturn(task); + when(runMapper.selectById(77L)).thenReturn(run(77L, 20L, "lead")); + + assertThat(service().resolve("team-task-legacy", null, null)).isPresent(); + } + + @Test + void recognizesLegacyWorkerWithoutPersistedParentFromCanonicalTaskRunLinkage() { + TeamTaskEntity task = task(501L, 77L, "team-task-legacy-no-parent"); + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "team-task-legacy-no-parent", 30L, 41L, null, null)); + when(taskMapper.selectOne(any())).thenReturn(task); + when(runMapper.selectById(77L)).thenReturn(run(77L, 20L, "lead")); + + assertThat(service().resolve("team-task-legacy-no-parent", 77L, 501L)).isPresent(); + } + + private TeamWorkerConversationGovernanceService service() { + return new TeamWorkerConversationGovernanceService(taskMapper, runMapper, conversationMapper); + } + + private static TeamTaskEntity task(Long id, Long runId, String conversationId) { + TeamTaskEntity task = new TeamTaskEntity(); + task.setId(id); + task.setRunId(runId); + task.setTeamId(20L); + task.setAssigneeAgentId(41L); + task.setConversationId(conversationId); + return task; + } + + private static TeamRunEntity run(Long id, Long teamId, String leadConversationId) { + TeamRunEntity run = new TeamRunEntity(); + run.setId(id); + run.setTeamId(teamId); + run.setWorkspaceId(30L); + run.setLeadConversationId(leadConversationId); + return run; + } + + private static ConversationEntity conversation(String id, Long workspaceId, Long agentId, + String parentId, String kind) { + ConversationEntity conversation = new ConversationEntity(); + conversation.setConversationId(id); + conversation.setWorkspaceId(workspaceId); + conversation.setAgentId(agentId); + conversation.setParentConversationId(parentId); + conversation.setConversationKind(kind); + return conversation; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceUserWriteGuardTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceUserWriteGuardTest.java new file mode 100644 index 00000000..cad39404 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceUserWriteGuardTest.java @@ -0,0 +1,55 @@ +package vip.mate.workspace.conversation; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConversationServiceUserWriteGuardTest { + + @Mock private ConversationMapper conversationMapper; + @InjectMocks private ConversationService service; + + @Test + void rejectsUserWritesToPersistedTeamWorkersButAllowsSystemPersistence() { + when(conversationMapper.selectOne(any())).thenReturn(conversation("team_worker")); + + assertThat(service.isUserMessageAllowed("worker")).isFalse(); + // The guard is deliberately separate from saveMessage: internal team execution + // continues to persist user/assistant evidence through the existing API. + } + + @Test + void allowsPrimaryAndNotYetPersistedConversations() { + when(conversationMapper.selectOne(any())) + .thenReturn(conversation("primary")) + .thenReturn(null); + + assertThat(service.isUserMessageAllowed("primary")).isTrue(); + assertThat(service.isUserMessageAllowed("new-conversation")).isTrue(); + } + + @Test + void rejectsLegacyWorkerEvenWhenMigrationDefaultedItsKindToPrimary() { + ConversationEntity legacy = conversation("primary"); + legacy.setConversationId("team-task-legacy"); + when(conversationMapper.selectOne(any())).thenReturn(legacy); + + assertThat(service.isUserMessageAllowed("team-task-legacy")).isFalse(); + } + + private static ConversationEntity conversation(String kind) { + ConversationEntity entity = new ConversationEntity(); + entity.setConversationId("worker"); + entity.setConversationKind(kind); + return entity; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java index 9e698582..b37e54e3 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java @@ -139,6 +139,40 @@ class ConversationServiceWebchatVisibilityTest { .doesNotContain("webchat:%"); } + @Test + @DisplayName("ordinary list excludes typed and legacy team-worker rows in SQL while retaining null kinds") + void ordinaryListAppliesWorkerConversationGuardInSql() { + when(authService.findByUsername("admin")).thenReturn(user("admin")); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectList(captor.capture())).thenReturn(List.of()); + + service.listConversations("admin", 1L, true); + + String sql = captor.getValue().getTargetSql().toLowerCase(); + assertThat(sql).contains("conversation_kind is null"); + assertThat(sql).contains("conversation_kind <>"); + assertThat(captor.getValue().getParamNameValuePairs().values()) + .contains("team_worker", "team-task-%"); + } + + @Test + @DisplayName("ordinary page applies the same worker conversation SQL guard") + void ordinaryPageAppliesWorkerConversationGuardInSql() { + when(authService.findByUsername("admin")).thenReturn(user("admin")); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + when(conversationMapper.selectPage(any(Page.class), captor.capture())).thenReturn(new Page<>()); + + service.pageConversations("admin", 1L, 1, 20, null); + + String sql = captor.getValue().getTargetSql().toLowerCase(); + assertThat(sql).contains("conversation_kind is null"); + assertThat(sql).contains("conversation_kind <>"); + assertThat(captor.getValue().getParamNameValuePairs().values()) + .contains("team_worker", "team-task-%"); + } + // ------------------------------------------------------------------ // Malformed conversationId guard — rows whose id ends in ":" (e.g. an // empty-visitorId webchat thread) are filtered out of every admin list diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/vo/ConversationVOConversationKindTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/vo/ConversationVOConversationKindTest.java new file mode 100644 index 00000000..6f4c6116 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/vo/ConversationVOConversationKindTest.java @@ -0,0 +1,26 @@ +package vip.mate.workspace.conversation.vo; + +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.model.ConversationEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConversationVOConversationKindTest { + + @Test + void classifiesExplicitChildLegacyScheduledAndPrimaryConversations() { + assertThat(vo("worker-any-name", "lead", "team_worker").getConversationKind()).isEqualTo("team_worker"); + assertThat(vo("delegate-child", "lead", null).getConversationKind()).isEqualTo("primary"); + assertThat(vo("team-task-legacy", null, null).getConversationKind()).isEqualTo("team_worker"); + assertThat(vo("tasks_1", null, null).getConversationKind()).isEqualTo("scheduled"); + assertThat(vo("ordinary-team-task-note", null, null).getConversationKind()).isEqualTo("primary"); + } + + private static ConversationVO vo(String id, String parentId, String kind) { + ConversationEntity entity = new ConversationEntity(); + entity.setConversationId(id); + entity.setParentConversationId(parentId); + entity.setConversationKind(kind); + return ConversationVO.from(entity, null, null); + } +} diff --git a/mateclaw-ui/src/api/__tests__/teamRuns.test.ts b/mateclaw-ui/src/api/__tests__/teamRuns.test.ts index a40d63a0..7d3df40c 100644 --- a/mateclaw-ui/src/api/__tests__/teamRuns.test.ts +++ b/mateclaw-ui/src/api/__tests__/teamRuns.test.ts @@ -38,6 +38,24 @@ describe('teamRunApi', () => { }) }) + it('uses the real paged run URLs and passes cursor and limit as request params', () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({} as never) + const teamId = '9007199254740995' + const conversationId = 'lead/conversation#one' + + teamRunApi.listByTeamPage(teamId, { activeOnly: true, cursor: 'team-cursor', limit: 17 }) + teamRunApi.listByConversationPage(conversationId, { cursor: 'conversation-cursor', limit: 19 }) + + expect(get).toHaveBeenNthCalledWith(1, `/teams/${teamId}/runs/page`, { + params: { activeOnly: true, cursor: 'team-cursor', limit: 17 }, + }) + expect(get).toHaveBeenNthCalledWith( + 2, + `/conversations/${encodeURIComponent(conversationId)}/team-runs/page`, + { params: { cursor: 'conversation-cursor', limit: 19 } }, + ) + }) + it('models every run projection id as a string', () => { const run = { id: '9007199254740993', diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 877cd5a2..18344d85 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -208,6 +208,8 @@ export const conversationApi = { http.get(`/conversations/${encId(conversationId)}/messages`, { params }), getStatus: (conversationId: string) => http.get(`/conversations/${encId(conversationId)}/status`), + getTeamWorkerContext: (conversationId: string, params?: { runId?: string; taskId?: string }) => + http.get(`/conversations/${encId(conversationId)}/team-worker-context`, { params }), delete: (conversationId: string) => http.delete(`/conversations/${encId(conversationId)}`), clearMessages: (conversationId: string) => @@ -1039,6 +1041,15 @@ export interface TeamRunTask { updateTime: string | null } +export type TeamRunOutcomeQuality = 'synthesized' | 'fallback' | 'partial' | 'pending' +export type TeamRunLivenessState = 'live' | 'quiet' | 'stalled' | 'terminal' +export interface TeamRunDeliverable { id: string; name: string; url: string; type: string; sourceTaskIds: string[]; sourceAgentIds: string[]; createdAt: string | null; verificationStatus: string } +export interface TeamRunContribution { taskId: string; agentId: string; subject: string; status: string; durationSeconds: number | null; lastActivityAt: string | null; resultSummary: string | null; conversationId: string | null } +export interface TeamRunAttentionItem { id: string; type: string; severity: string; priority: number; taskId: string | null; message: string; createdAt: string | null } +export interface TeamRunLiveness { state: TeamRunLivenessState; lastActivityAt: string | null } +export interface TeamRunMetrics { durationSeconds: number | null; totalTasks: number; completedTasks: number; failedTasks: number; deliverableCount: number } +export interface TeamRunPage { items: TeamRun[]; nextCursor: string | null } + export interface TeamRun { id: string teamId: string @@ -1056,16 +1067,53 @@ export interface TeamRun { completedAt: string | null createTime: string | null updateTime: string | null + projectionCompleteness?: 'full' | 'summary' | string + outcomeQuality?: TeamRunOutcomeQuality | null + deliverables?: TeamRunDeliverable[] + contributions?: TeamRunContribution[] + attentionItems?: TeamRunAttentionItem[] + liveness?: TeamRunLiveness | null + metrics?: TeamRunMetrics | null progress: TeamRunProgress tasks: TeamRunTask[] } export const teamRunApi = { get: (runId: string) => http.get(`/team-runs/${runId}`), - listByTeam: (teamId: string, activeOnly = false) => - http.get(`/teams/${teamId}/runs${activeOnly ? '?activeOnly=true' : ''}`), - listByConversation: (conversationId: string) => - http.get(`/conversations/${encId(conversationId)}/team-runs`), + listByTeamPage: ( + teamId: string, + options: { activeOnly?: boolean; cursor?: string; limit?: number } = {}, + ) => { + const params: { activeOnly?: boolean; cursor?: string; limit: number } = { + limit: options.limit ?? 20, + } + if (options.activeOnly) params.activeOnly = true + if (options.cursor) params.cursor = options.cursor + return http.get(`/teams/${teamId}/runs/page`, { params }) + }, + listByConversationPage: ( + conversationId: string, + options: { cursor?: string; limit?: number } = {}, + ) => { + const params: { cursor?: string; limit: number } = { limit: options.limit ?? 20 } + if (options.cursor) params.cursor = options.cursor + return http.get(`/conversations/${encId(conversationId)}/team-runs/page`, { params }) + }, + listByTeam: (teamId: string, activeOnly = false, cursor?: string, limit = 30) => { + const query = new URLSearchParams() + if (activeOnly) query.set('activeOnly', 'true') + if (cursor) query.set('cursor', cursor) + if (limit !== 30) query.set('limit', String(limit)) + const suffix = query.size ? `?${query}` : '' + return http.get(`/teams/${teamId}/runs${suffix}`) + }, + listByConversation: (conversationId: string, cursor?: string, limit = 30) => { + const query = new URLSearchParams() + if (cursor) query.set('cursor', cursor) + if (limit !== 30) query.set('limit', String(limit)) + const suffix = query.size ? `?${query}` : '' + return http.get(`/conversations/${encId(conversationId)}/team-runs${suffix}`) + }, cancel: (runId: string, reason?: string) => http.post(`/team-runs/${runId}/cancel`, { reason }), } diff --git a/mateclaw-ui/src/components/chat/ConversationSidebar.vue b/mateclaw-ui/src/components/chat/ConversationSidebar.vue index 6c3c4a89..f63b118b 100644 --- a/mateclaw-ui/src/components/chat/ConversationSidebar.vue +++ b/mateclaw-ui/src/components/chat/ConversationSidebar.vue @@ -199,6 +199,7 @@ import { mcToast } from '@/composables/useMcToast' import { mcConfirm } from '@/components/common/useConfirm' import type { Conversation, Agent } from '@/types' import { useGoalStore } from '@/stores/useGoalStore' +import { isSidebarConversation } from '@/utils/conversationGovernance' const goalStore = useGoalStore() /** Sidebar marker: a 6 px dot next to the conv title when that conv @@ -237,12 +238,13 @@ function onAgentChange(value: string | number | null) { // ==================== Agent filter ==================== // Narrow the list down to a single agent's conversations. const convAgentFilter = ref('') +const sidebarConversations = computed(() => props.conversations.filter(isSidebarConversation)) // Distinct agents present in the conversation list — drives the filter // dropdown. Hidden when fewer than two agents have conversations. const agentFilterOptions = computed(() => { const seen = new Map() - for (const conv of props.conversations) { + for (const conv of sidebarConversations.value) { if (conv.agentId == null || conv.agentId === '') continue const id = String(conv.agentId) if (!seen.has(id)) seen.set(id, conv.agentName || id) @@ -270,7 +272,7 @@ const groupedConversations = computed(() => { ] const agentFilter = convAgentFilter.value - for (const conv of props.conversations) { + for (const conv of sidebarConversations.value) { if (agentFilter && String(conv.agentId ?? '') !== agentFilter) continue if ((conv.conversationId && conv.conversationId.startsWith('tasks_')) || conv.pinned) { pinned.push(conv) diff --git a/mateclaw-ui/src/components/chat/MessageList.vue b/mateclaw-ui/src/components/chat/MessageList.vue index 1cc2a428..15d99760 100644 --- a/mateclaw-ui/src/components/chat/MessageList.vue +++ b/mateclaw-ui/src/components/chat/MessageList.vue @@ -93,6 +93,11 @@ @deny="(pendingId) => $emit('deny', pendingId)" /> +
+ +
@@ -169,6 +174,8 @@ interface Props { teamRuns?: TeamRun[] expandedTeamRunId?: string | null selectedTeamTaskId?: string | null + teamRunsHasMore?: boolean + teamRunsLoadingMore?: boolean } const props = withDefaults(defineProps(), { @@ -181,6 +188,8 @@ const props = withDefaults(defineProps(), { autoScroll: true, hasMore: false, loadingOlder: false, + teamRunsHasMore: false, + teamRunsLoadingMore: false, }) const emit = defineEmits<{ @@ -196,6 +205,7 @@ const emit = defineEmits<{ 'team-run-select-task': [task: TeamRunTask] 'team-run-cancel': [runId: string] 'team-run-navigate': [route: TeamRunRoute] + 'team-runs-load-more': [] }>() const timelineItems = computed(() => { @@ -374,6 +384,7 @@ onUnmounted(() => { width: min(760px, calc(100% - 32px)); margin: 8px auto; } +.team-runs-more{display:flex;justify-content:center;padding:8px 0 14px}.team-runs-more button{min-height:32px;padding:5px 12px;border:1px solid var(--mc-border);border-radius:6px;background:var(--mc-bg-elevated);color:#16795a;cursor:pointer}.team-runs-more button:disabled{cursor:wait;opacity:.65} /* ==================== 空状态 / 欢迎屏 ==================== */ .empty-state { diff --git a/mateclaw-ui/src/components/chat/__tests__/teamRunTimelineRendering.test.ts b/mateclaw-ui/src/components/chat/__tests__/teamRunTimelineRendering.test.ts index e0d7bdbc..bf260919 100644 --- a/mateclaw-ui/src/components/chat/__tests__/teamRunTimelineRendering.test.ts +++ b/mateclaw-ui/src/components/chat/__tests__/teamRunTimelineRendering.test.ts @@ -38,11 +38,12 @@ const messages = { const message = (id: string, role: Message['role'], content: string, metadata?: unknown): Message => ({ id, conversationId: 'lead', role, content, contentParts: [], metadata: metadata as never, }) -const run = (): TeamRun => ({ +const run = (extra: Partial = {}): TeamRun => ({ id: '10', teamId: '20', workspaceId: '30', leadAgentId: '40', leadConversationId: 'lead', originMessageId: '1', title: 'Launch research', objective: 'Collect evidence', status: 'running', finalSummary: null, stopReason: null, metadata: null, startedAt: null, completedAt: null, createTime: null, updateTime: null, progress: { total: 0, done: 0, failed: 0, inReview: 0, percent: 0 }, tasks: [], + ...extra, }) const apps: Array> = [] @@ -89,4 +90,46 @@ describe('MessageList team run timeline', () => { expect(host.querySelector('[data-team-run-toggle]')?.getAttribute('aria-expanded')).toBe('true') expect(host.textContent).toContain('Launch research') }) + + it('renders one expandable run card for ten tasks and replayed lifecycle messages', async () => { + const tasks = Array.from({ length: 10 }, (_, index) => ({ + id: String(500 + index), teamId: '20', runId: '10', taskNumber: index + 1, + subject: `Evidence task ${index + 1}`, description: null, status: 'completed' as const, + priority: 0, taskType: 'general', assigneeAgentId: `agent-${index + 1}`, ownerAgentId: null, + blockedBy: null, requireApproval: false, progressPercent: 100, progressStep: null, + result: `Result ${index + 1}`, reason: null, conversationId: `worker-${index + 1}`, + metadata: null, createTime: null, updateTime: null, + })) + const lifecycleMessages = tasks.flatMap(task => [ + message(`progress-${task.id}`, 'system', 'progress', { + type: 'team_task_progress', runId: '10', taskId: task.id, eventId: `progress-${task.id}`, + }), + message(`complete-${task.id}`, 'system', 'complete', { + type: 'team_task_completed', runId: '10', taskId: task.id, eventId: `complete-${task.id}`, + }), + message(`replay-${task.id}`, 'system', 'complete replay', { + type: 'team_task_completed', runId: '10', taskId: task.id, eventId: `complete-${task.id}`, + }), + ]) + const projectedRun = run({ + status: 'completed', tasks, + progress: { total: 10, done: 10, failed: 0, inReview: 0, percent: 100 }, + }) + const host = mount({ + messages: [message('1', 'user', 'delegate ten tasks'), ...lifecycleMessages], + teamRuns: [projectedRun, projectedRun], + }) + await nextTick() + + expect(host.querySelectorAll('[data-team-run-toggle]')).toHaveLength(1) + expect(host.querySelectorAll('[data-message-id]')).toHaveLength(1) + expect(host.querySelectorAll('.run-task-row')).toHaveLength(0) + + host.querySelector('[data-team-run-toggle]')!.click() + await nextTick() + + expect(host.querySelectorAll('[data-team-run-toggle]')).toHaveLength(1) + expect(host.querySelectorAll('.run-task-row')).toHaveLength(0) + expect(host.querySelector('[data-team-run-outcome]')).not.toBeNull() + }) }) diff --git a/mateclaw-ui/src/components/live/AgentRunGroups.vue b/mateclaw-ui/src/components/live/AgentRunGroups.vue index 20589d07..1953bbed 100644 --- a/mateclaw-ui/src/components/live/AgentRunGroups.vue +++ b/mateclaw-ui/src/components/live/AgentRunGroups.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import type { AgentRunGroup, AgentRunWorker } from '@/composables/useAgentRunGroups' import TeamRunProgress from '@/components/team-run/TeamRunProgress.vue' import TeamRunStatus from '@/components/team-run/TeamRunStatus.vue' +import TeamRunRuntime from '@/components/team-run/TeamRunRuntime.vue' import AgentRunWorkerRow from './AgentRunWorkerRow.vue' withDefaults(defineProps<{ @@ -36,11 +37,12 @@ const { t } = useI18n() -
+
{{ t('live.teamRuns.lead') }} - {{ group.leadRuntime?.agentName || group.run.leadAgentId }} - {{ group.leadRuntime?.currentPhase || group.state }} + {{ group.leadRuntime?.agentName || group.run.leadAgentId }} + {{ group.leadRuntime?.currentPhase || group.state }}
+
span { overflow: hidden; color: var(--mc-text-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } -.agent-run-group__lead { display: flex; gap: 8px; padding: 7px 10px; border-top: 1px solid var(--mc-border-light); background: rgba(71, 85, 105, 0.035); color: var(--mc-text-tertiary); font-size: 11px; }.agent-run-group__lead strong { color: var(--mc-text-secondary); }.agent-run-group__lead span:last-child { margin-left: auto; } +.agent-run-group__lead { display: grid; grid-template-columns: auto minmax(0,1fr) minmax(0,.6fr); align-items:center; gap: 8px; padding: 7px 10px; border-top: 1px solid var(--mc-border-light); background: rgba(71, 85, 105, 0.035); color: var(--mc-text-tertiary); font-size: 11px; } +.agent-run-group__lead-name,.agent-run-group__phase{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.agent-run-group__lead-name{color:var(--mc-text-secondary)}.agent-run-group__phase{text-align:right} .agent-run-group__empty { margin: 0; padding: 12px; border-top: 1px solid var(--mc-border-light); color: var(--mc-text-tertiary); font-size: 11px; } +@media(max-width:520px){.agent-run-group__open{grid-template-columns:minmax(0,1fr) auto;gap:8px}.agent-run-group__open>el-icon{display:none}.agent-run-group__lead{grid-template-columns:auto minmax(0,1fr)}.agent-run-group__phase{grid-column:2;text-align:left}.agent-run-group__copy>span{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}} diff --git a/mateclaw-ui/src/components/live/AgentRunWorkerRow.vue b/mateclaw-ui/src/components/live/AgentRunWorkerRow.vue index 763cb5fb..68d0b6d3 100644 --- a/mateclaw-ui/src/components/live/AgentRunWorkerRow.vue +++ b/mateclaw-ui/src/components/live/AgentRunWorkerRow.vue @@ -22,14 +22,14 @@ const icons: Record = { @click="emit('open', worker)" > - + {{ worker.task.taskNumber }}. {{ worker.task.subject }} {{ worker.task.assigneeAgentId }} {{ t('teamRuns.dependencies') }}: {{ taskDependencyIds(worker.task).join(', ') }} - {{ t(`live.teamRuns.${worker.state}`) }} + {{ t(`live.teamRuns.${worker.state}`) }} @@ -40,6 +40,7 @@ const icons: Record = { .agent-run-worker:disabled { cursor: default; opacity: 0.8; } .agent-run-worker__copy { display: grid; min-width: 0; gap: 3px; } .agent-run-worker__copy strong { color: var(--mc-text-primary); font-size: 12px; overflow-wrap: anywhere; } -.agent-run-worker__copy span, .agent-run-worker__state { color: var(--mc-text-tertiary); font-size: 11px; } +.agent-run-worker__copy span, .agent-run-worker__state { min-width:0; overflow:hidden; color: var(--mc-text-tertiary); font-size: 11px; text-overflow:ellipsis; white-space:nowrap; } .agent-run-worker.is-stuck { color: #c13d3d; }.agent-run-worker.is-review, .agent-run-worker.is-waiting { color: #a15c05; }.agent-run-worker.is-completed { color: #16835b; } +@media(max-width:520px){.agent-run-worker{grid-template-columns:20px minmax(0,1fr)}.agent-run-worker__state{grid-column:2;max-width:100%!important;justify-self:start}.agent-run-worker__copy strong{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2}} diff --git a/mateclaw-ui/src/components/live/__tests__/AgentRunGroups.test.ts b/mateclaw-ui/src/components/live/__tests__/AgentRunGroups.test.ts index 0501bb0f..dd599a28 100644 --- a/mateclaw-ui/src/components/live/__tests__/AgentRunGroups.test.ts +++ b/mateclaw-ui/src/components/live/__tests__/AgentRunGroups.test.ts @@ -7,7 +7,7 @@ import AgentRunGroups from '../AgentRunGroups.vue' const messages = { live: { teamRuns: { title: 'Team runs', lead: 'Lead', elapsed: 'Elapsed', waiting: 'Waiting', active: 'Active', review: 'Review', stuck: 'Stuck', cancelled: 'Cancelled', finalizing: 'Finalizing', openRun: 'Open run', noWorkers: 'No worker tasks', -} }, teamRuns: { status: { running: 'Running' }, duration: { day: 'd', hour: 'h', minute: 'm', second: 's' } } } +} }, teamRuns: { runtime: 'Runtime', liveness: { quiet: 'Quiet' }, status: { running: 'Running' }, duration: { day: 'd', hour: 'h', minute: 'm', second: 's' } } } const group: AgentRunGroup = { run: { id: '20', teamId: '10', workspaceId: '1', leadAgentId: '2', leadConversationId: 'lead', originMessageId: null, @@ -29,6 +29,22 @@ const apps: Array> = [] afterEach(() => { apps.splice(0).forEach(app => app.unmount()); document.body.innerHTML = '' }) describe('AgentRunGroups', () => { + it('constrains long lead, worker and phase labels inside a 375px surface', () => { + const longGroup = structuredClone(group) + longGroup.run.leadAgentId = 'lead-agent-'.repeat(20) + longGroup.workers[0].task.assigneeAgentId = 'worker-agent-'.repeat(20) + longGroup.leadRuntime = { agentName: 'lead-name-'.repeat(20), currentPhase: 'phase-'.repeat(30) } as never + const host = document.createElement('div'); host.style.width = '375px'; document.body.appendChild(host) + const app = createApp(AgentRunGroups, { groups: [longGroup] }) + app.use(createI18n({ legacy: false, locale: 'en', messages: { en: messages } })); app.mount(host); apps.push(app) + const lead = host.querySelector('.agent-run-group__lead')! + const workerCopy = host.querySelector('.agent-run-worker__copy')! + const workerState = host.querySelector('.agent-run-worker__state')! + expect(['0', '0px']).toContain(getComputedStyle(lead).minWidth) + expect(['0', '0px']).toContain(getComputedStyle(workerCopy).minWidth) + expect(getComputedStyle(workerState).maxWidth).not.toBe('none') + expect(lead.scrollWidth).toBeLessThanOrEqual(lead.clientWidth || 375) + }) it('hydrates selected run and emits route actions', async () => { const opened: string[] = [] const host = document.createElement('div') diff --git a/mateclaw-ui/src/components/team-run/TeamRunAttention.vue b/mateclaw-ui/src/components/team-run/TeamRunAttention.vue new file mode 100644 index 00000000..9e3b1623 --- /dev/null +++ b/mateclaw-ui/src/components/team-run/TeamRunAttention.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/mateclaw-ui/src/components/team-run/TeamRunCard.vue b/mateclaw-ui/src/components/team-run/TeamRunCard.vue index 72b81d1d..296a62a6 100644 --- a/mateclaw-ui/src/components/team-run/TeamRunCard.vue +++ b/mateclaw-ui/src/components/team-run/TeamRunCard.vue @@ -1,9 +1,9 @@ + + diff --git a/mateclaw-ui/src/components/team-run/TeamRunDeliverables.vue b/mateclaw-ui/src/components/team-run/TeamRunDeliverables.vue new file mode 100644 index 00000000..49466e30 --- /dev/null +++ b/mateclaw-ui/src/components/team-run/TeamRunDeliverables.vue @@ -0,0 +1,3 @@ + + + diff --git a/mateclaw-ui/src/components/team-run/TeamRunDeliverySummary.vue b/mateclaw-ui/src/components/team-run/TeamRunDeliverySummary.vue new file mode 100644 index 00000000..421b5f4b --- /dev/null +++ b/mateclaw-ui/src/components/team-run/TeamRunDeliverySummary.vue @@ -0,0 +1,3 @@ + + + diff --git a/mateclaw-ui/src/components/team-run/TeamRunDetail.vue b/mateclaw-ui/src/components/team-run/TeamRunDetail.vue index 31a95cf9..7b5604e5 100644 --- a/mateclaw-ui/src/components/team-run/TeamRunDetail.vue +++ b/mateclaw-ui/src/components/team-run/TeamRunDetail.vue @@ -1,36 +1,47 @@