mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(team): unify Team Run delivery experience (#596)
This commit is contained in:
parent
f629b04f2a
commit
9a3b95df53
@ -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);
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<TeamTaskEntity> tasks = taskService.listTasks(id, status, limit, offset);
|
||||
Set<Long> 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<Long, AgentEntity> 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<Long, AgentEntity> agents) {
|
||||
return new TaskVO(task,
|
||||
agentName(task.getAssigneeAgentId(), agents),
|
||||
agentName(task.getOwnerAgentId(), agents),
|
||||
task.getRunId());
|
||||
}
|
||||
|
||||
private String agentName(Long agentId, Map<Long, AgentEntity> 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;
|
||||
|
||||
@ -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<TeamRunService.RunPage> 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<TeamRunService.RunPage> 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")
|
||||
|
||||
@ -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<TeamWorkerConversationContext> 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));
|
||||
}
|
||||
}
|
||||
@ -21,13 +21,54 @@ public record TeamRunView(
|
||||
LocalDateTime completedAt,
|
||||
LocalDateTime createTime,
|
||||
LocalDateTime updateTime,
|
||||
String projectionCompleteness,
|
||||
String outcomeQuality,
|
||||
List<Deliverable> deliverables,
|
||||
List<MemberContribution> contributions,
|
||||
List<AttentionItem> attentionItems,
|
||||
Liveness liveness,
|
||||
Metrics metrics,
|
||||
Progress progress,
|
||||
List<Task> 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<Task> 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<Long> sourceTaskIds, List<Long> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -92,11 +92,7 @@ public class TeamRunProjector {
|
||||
|
||||
private TeamRunView view(TeamRunEntity run, TeamRunStateMachine.Projection projection,
|
||||
List<TeamTaskEntity> 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) {
|
||||
|
||||
@ -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<TeamRunView> 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<String> 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.<TeamRunEntity>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<TeamRunView> 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<TeamRunView> 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<TeamRunView> listConversationRuns(String conversationId, Long workspaceId) {
|
||||
return listRuns(null, conversationId, workspaceId, false);
|
||||
}
|
||||
|
||||
private List<TeamRunView> listRuns(Long teamId, String conversationId, Long workspaceId,
|
||||
boolean activeOnly) {
|
||||
var query = Wrappers.<TeamRunEntity>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<TeamRunEntity> runs = runMapper.selectList(query
|
||||
.orderByDesc(TeamRunEntity::getCreateTime).orderByDesc(TeamRunEntity::getId));
|
||||
return summaryViews(runs);
|
||||
}
|
||||
|
||||
public List<TeamRunView> listConversationRuns(String conversationId, Long workspaceId) {
|
||||
return runMapper.selectList(Wrappers.<TeamRunEntity>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.<TeamRunEntity>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<TeamRunEntity> fetched = runMapper.selectList(query
|
||||
.orderByDesc(TeamRunEntity::getCreateTime).orderByDesc(TeamRunEntity::getId)
|
||||
.last("LIMIT " + (limit + 1)));
|
||||
boolean hasMore = fetched.size() > limit;
|
||||
List<TeamRunEntity> runs = hasMore ? fetched.subList(0, limit) : fetched;
|
||||
List<TeamRunView> items = summaryViews(runs);
|
||||
TeamRunEntity last = runs.isEmpty() ? null : runs.getLast();
|
||||
return new RunPage(items, hasMore && last != null ? encodeCursor(last) : null);
|
||||
}
|
||||
|
||||
private List<TeamRunView> summaryViews(List<TeamRunEntity> runs) {
|
||||
Map<Long, List<TeamTaskEntity>> tasksByRun = summaryTasks(runs);
|
||||
return runs.stream().map(run -> {
|
||||
List<TeamTaskEntity> 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<Long, List<TeamTaskEntity>> summaryTasks(List<TeamRunEntity> runs) {
|
||||
if (runs.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, List<TeamTaskEntity>> grouped = new LinkedHashMap<>();
|
||||
List<Long> runIds = runs.stream().map(TeamRunEntity::getId).toList();
|
||||
for (int start = 0; start < runIds.size(); start += TASK_SUMMARY_BATCH_SIZE) {
|
||||
List<Long> batch = runIds.subList(start, Math.min(start + TASK_SUMMARY_BATCH_SIZE, runIds.size()));
|
||||
List<TeamTaskEntity> tasks = taskMapper.selectList(Wrappers.<TeamTaskEntity>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<TeamTaskEntity> 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<TeamTaskEntity> tasksForRun(Long runId) {
|
||||
|
||||
@ -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<String> OUTCOME_QUALITIES = Set.of("synthesized", "fallback", "partial", "pending");
|
||||
|
||||
private TeamRunViewFactory() {
|
||||
}
|
||||
|
||||
static TeamRunView create(TeamRunEntity run, String status, TeamRunView.Progress progress,
|
||||
List<TeamTaskEntity> tasks, boolean includeTasks) {
|
||||
List<TeamRunView.Deliverable> 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<TeamTaskEntity> 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<TeamRunView.Deliverable> deliverables(TeamRunEntity run,
|
||||
List<TeamTaskEntity> tasks) {
|
||||
Map<String, MutableDeliverable> 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<String, MutableDeliverable> 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<Long> 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<TeamRunView.MemberContribution> contributions(List<TeamTaskEntity> 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<TeamRunView.AttentionItem> attentionItems(TeamRunEntity run,
|
||||
List<TeamTaskEntity> tasks) {
|
||||
List<TeamRunView.AttentionItem> 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<TeamTaskEntity> 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<TeamTaskEntity> 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<TeamTaskEntity> 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 <T> void add(LinkedHashSet<T> 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<Long> taskIds = new LinkedHashSet<>();
|
||||
private final LinkedHashSet<Long> 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) {
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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<TeamWorkerConversationContext> resolve(
|
||||
String conversationId, Long requestedRunId, Long requestedTaskId) {
|
||||
if (conversationId == null || conversationId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ConversationEntity conversation = conversationMapper.selectOne(
|
||||
new LambdaQueryWrapper<ConversationEntity>()
|
||||
.eq(ConversationEntity::getConversationId, conversationId)
|
||||
.last("LIMIT 1"));
|
||||
if (!ConversationService.isTeamWorkerConversation(conversation)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
TeamTaskEntity task = taskMapper.selectOne(new LambdaQueryWrapper<TeamTaskEntity>()
|
||||
.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()));
|
||||
}
|
||||
}
|
||||
@ -158,6 +158,7 @@ public class ConversationService {
|
||||
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
|
||||
.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<ConversationEntity> 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<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
|
||||
.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.
|
||||
*
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE mate_conversation
|
||||
ADD COLUMN IF NOT EXISTS conversation_kind VARCHAR(32) NOT NULL DEFAULT 'primary';
|
||||
@ -0,0 +1,2 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_team_task_conversation
|
||||
ON mate_team_task (conversation_id);
|
||||
@ -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);
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE mate_conversation
|
||||
ADD COLUMN IF NOT EXISTS conversation_kind VARCHAR(32) NOT NULL DEFAULT 'primary';
|
||||
@ -0,0 +1,2 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_team_task_conversation
|
||||
ON mate_team_task (conversation_id);
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<List<TeamController.TaskVO>> 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<java.util.Collection<Long>> 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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -216,7 +216,8 @@ class TeamDispatchServiceTest {
|
||||
eq(MEMBER_A),
|
||||
eq("system"),
|
||||
eq(WORKSPACE_ID),
|
||||
eq("lead-conv"));
|
||||
eq("lead-conv"),
|
||||
eq("team_worker"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -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\"}"));
|
||||
|
||||
@ -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<LambdaUpdateWrapper<TeamRunEntity>> 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<TeamRunEntity> 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<TeamRunEntity> 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<LambdaQueryWrapper<TeamRunEntity>> queries =
|
||||
ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
|
||||
verify(runMapper, times(2)).selectList(queries.capture());
|
||||
LambdaQueryWrapper<TeamRunEntity> 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)
|
||||
|
||||
@ -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<TeamTaskEntity> 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<LambdaQueryWrapper<ConversationEntity>> 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<LambdaQueryWrapper<ConversationEntity>> 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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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 }),
|
||||
}
|
||||
|
||||
@ -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<string, string>()
|
||||
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)
|
||||
|
||||
@ -93,6 +93,11 @@
|
||||
@deny="(pendingId) => $emit('deny', pendingId)"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="teamRunsHasMore" class="team-runs-more">
|
||||
<button data-chat-team-runs-load-more type="button" :disabled="teamRunsLoadingMore" @click="$emit('team-runs-load-more')">
|
||||
{{ teamRunsLoadingMore ? t('teamRuns.loadingMore') : t('teamRuns.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 加载指示器:只在无消息时显示(有消息时由输入框显示停止按钮) -->
|
||||
@ -169,6 +174,8 @@ interface Props {
|
||||
teamRuns?: TeamRun[]
|
||||
expandedTeamRunId?: string | null
|
||||
selectedTeamTaskId?: string | null
|
||||
teamRunsHasMore?: boolean
|
||||
teamRunsLoadingMore?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@ -181,6 +188,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
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<TeamRunTimelineItem[]>(() => {
|
||||
@ -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 {
|
||||
|
||||
@ -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> = {}): 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<ReturnType<typeof createApp>> = []
|
||||
@ -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<HTMLButtonElement>('[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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
<el-icon :size="15"><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</header>
|
||||
<div class="agent-run-group__lead">
|
||||
<div class="agent-run-group__lead" style="min-width: 0">
|
||||
<span>{{ t('live.teamRuns.lead') }}</span>
|
||||
<strong>{{ group.leadRuntime?.agentName || group.run.leadAgentId }}</strong>
|
||||
<span>{{ group.leadRuntime?.currentPhase || group.state }}</span>
|
||||
<strong class="agent-run-group__lead-name">{{ group.leadRuntime?.agentName || group.run.leadAgentId }}</strong>
|
||||
<span class="agent-run-group__phase">{{ group.leadRuntime?.currentPhase || group.state }}</span>
|
||||
</div>
|
||||
<TeamRunRuntime :run="group.run" />
|
||||
<div v-if="group.workers.length" data-agent-run-workers>
|
||||
<AgentRunWorkerRow
|
||||
v-for="worker in group.workers"
|
||||
@ -63,6 +65,8 @@ const { t } = useI18n()
|
||||
.agent-run-group__open { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 12px; width: 100%; min-height: 68px; padding: 10px 12px; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; letter-spacing: 0; }
|
||||
.agent-run-group__open:hover { background: rgba(71, 85, 105, 0.04); }.agent-run-group__open:focus-visible { outline: 2px solid #16835b; outline-offset: -2px; }
|
||||
.agent-run-group__copy { display: grid; min-width: 0; gap: 3px; }.agent-run-group__copy strong { color: var(--mc-text-primary); font-size: 13px; }.agent-run-group__copy > 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}}
|
||||
</style>
|
||||
|
||||
@ -22,14 +22,14 @@ const icons: Record<AgentWorkerState, unknown> = {
|
||||
@click="emit('open', worker)"
|
||||
>
|
||||
<el-icon :class="{ 'is-loading': worker.state === 'active' }" :size="15"><component :is="icons[worker.state]" /></el-icon>
|
||||
<span class="agent-run-worker__copy">
|
||||
<span class="agent-run-worker__copy" style="min-width: 0">
|
||||
<strong>{{ worker.task.taskNumber }}. {{ worker.task.subject }}</strong>
|
||||
<span>{{ worker.task.assigneeAgentId }}</span>
|
||||
<span v-if="taskDependencyIds(worker.task).length">
|
||||
{{ t('teamRuns.dependencies') }}: {{ taskDependencyIds(worker.task).join(', ') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="agent-run-worker__state">{{ t(`live.teamRuns.${worker.state}`) }}</span>
|
||||
<span class="agent-run-worker__state" style="max-width: 96px">{{ t(`live.teamRuns.${worker.state}`) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@ -40,6 +40,7 @@ const icons: Record<AgentWorkerState, unknown> = {
|
||||
.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}}
|
||||
</style>
|
||||
|
||||
@ -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<ReturnType<typeof createApp>> = []
|
||||
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<HTMLElement>('.agent-run-group__lead')!
|
||||
const workerCopy = host.querySelector<HTMLElement>('.agent-run-worker__copy')!
|
||||
const workerState = host.querySelector<HTMLElement>('.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')
|
||||
|
||||
89
mateclaw-ui/src/components/team-run/TeamRunAttention.vue
Normal file
89
mateclaw-ui/src/components/team-run/TeamRunAttention.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { TeamRun } from '@/api'
|
||||
import { runAttention } from './teamRunProjection'
|
||||
import TeamRunReadingSurface from './TeamRunReadingSurface.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
run: TeamRun
|
||||
managementActions?: boolean
|
||||
pendingActions?: string[]
|
||||
}>(), { managementActions: false, pendingActions: () => [] })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'view-task': [taskId: string]
|
||||
'retry-task': [taskId: string]
|
||||
'approve-task': [taskId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const items = computed(() => runAttention(props.run))
|
||||
const retryable = (type: string) => ['failed', 'failure', 'stale'].includes(type.toLowerCase())
|
||||
const reviewable = (type: string) => ['review', 'in_review'].includes(type.toLowerCase())
|
||||
const isPending = (taskId: string, action: 'retry' | 'approve') => props.pendingActions.includes(`${taskId}:${action}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeamRunReadingSurface data-team-run-attention class="run-attention">
|
||||
<h3>{{ t('teamRuns.attention') }}</h3>
|
||||
<ul v-if="items.length">
|
||||
<li v-for="item in items" :key="item.id" :class="`is-${item.severity}`">
|
||||
<div class="run-attention__copy">
|
||||
<strong>{{ item.type }}</strong>
|
||||
<span>{{ item.message }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="managementActions && item.taskId"
|
||||
data-team-run-attention-actions
|
||||
class="run-attention__actions"
|
||||
:aria-label="item.message"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:data-attention-view-task="item.taskId"
|
||||
@click="emit('view-task', item.taskId)"
|
||||
>{{ t('teamRuns.openTask') }}</button>
|
||||
<button
|
||||
v-if="retryable(item.type)"
|
||||
type="button"
|
||||
class="is-primary"
|
||||
:data-attention-retry-task="item.taskId"
|
||||
:disabled="isPending(item.taskId, 'retry')"
|
||||
:aria-busy="isPending(item.taskId, 'retry')"
|
||||
@click="emit('retry-task', item.taskId)"
|
||||
>{{ t('common.retry') }}</button>
|
||||
<button
|
||||
v-if="reviewable(item.type)"
|
||||
type="button"
|
||||
class="is-primary"
|
||||
:data-attention-approve-task="item.taskId"
|
||||
:disabled="isPending(item.taskId, 'approve')"
|
||||
:aria-busy="isPending(item.taskId, 'approve')"
|
||||
@click="emit('approve-task', item.taskId)"
|
||||
>{{ t('common.approve') }}</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>{{ t('teamRuns.noAttention') }}</p>
|
||||
</TeamRunReadingSurface>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h3 { margin: 0 0 7px; font-size: 13px; }
|
||||
ul { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; }
|
||||
li { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 9px; border-left: 3px solid #b96c08; background: #fff9ef; font-size: 12px; }
|
||||
li.is-error { border-left-color: #c13d3d; background: #fff5f5; }
|
||||
.run-attention__copy { display: grid; min-width: 0; gap: 2px; overflow-wrap: anywhere; }
|
||||
.run-attention__copy strong { text-transform: capitalize; }
|
||||
.run-attention__actions { display: flex; min-width: 0; flex: none; flex-wrap: wrap; justify-content: flex-end; gap: 5px; }
|
||||
.run-attention__actions button { min-height: 28px; padding: 4px 8px; border: 1px solid var(--mc-border, #d9e1e7); border-radius: 5px; background: #fff; color: var(--mc-text-secondary, #475569); cursor: pointer; font: inherit; white-space: nowrap; }
|
||||
.run-attention__actions button.is-primary { border-color: #16835b; color: #126c4d; }
|
||||
.run-attention__actions button:disabled { cursor: wait; opacity: 0.6; }
|
||||
.run-attention__actions button:focus-visible { outline: 2px solid #16835b; outline-offset: 2px; }
|
||||
p { margin: 0; color: var(--mc-text-tertiary); font-size: 12px; }
|
||||
@media (max-width: 520px) {
|
||||
li { align-items: stretch; flex-direction: column; }
|
||||
.run-attention__actions { justify-content: flex-start; }
|
||||
}
|
||||
</style>
|
||||
@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import type { TeamRun, TeamRunTask } from '@/api'
|
||||
import TeamRunDetail from './TeamRunDetail.vue'
|
||||
import TeamRunDeliverySummary from './TeamRunDeliverySummary.vue'
|
||||
import TeamRunProgress from './TeamRunProgress.vue'
|
||||
import TeamRunStatus from './TeamRunStatus.vue'
|
||||
import type { TeamRunRoute } from './teamRunPresentation'
|
||||
@ -28,6 +28,16 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const isExpanded = ref(props.expanded)
|
||||
const outcomePreview = computed(() => {
|
||||
const text = (props.run.finalSummary || '')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/[#>*_`|\[\]()~-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return text.length > 160 ? `${text.slice(0, 157)}...` : text
|
||||
})
|
||||
const deliverableCount = computed(() => props.run.metrics?.deliverableCount ?? props.run.deliverables?.length ?? 0)
|
||||
const attentionCount = computed(() => props.run.attentionItems?.length ?? 0)
|
||||
watch(() => props.expanded, value => { isExpanded.value = value })
|
||||
|
||||
function toggle() {
|
||||
@ -51,6 +61,12 @@ function toggle() {
|
||||
<span class="run-card__copy">
|
||||
<span class="run-card__title">{{ run.title }}</span>
|
||||
<span class="run-card__objective">{{ run.objective }}</span>
|
||||
<span v-if="outcomePreview" data-team-run-outcome-preview class="run-card__outcome">{{ outcomePreview }}</span>
|
||||
<span class="run-card__facts">
|
||||
<span v-if="run.outcomeQuality" data-team-run-outcome-quality>{{ t(`teamRuns.quality.${run.outcomeQuality}`) }}</span>
|
||||
<span data-team-run-deliverable-count>{{ deliverableCount }} {{ t('teamRuns.deliverables') }}</span>
|
||||
<span data-team-run-attention-count>{{ attentionCount }} {{ t('teamRuns.attention') }}</span>
|
||||
</span>
|
||||
<TeamRunStatus
|
||||
:status="run.status"
|
||||
:started-at="run.startedAt"
|
||||
@ -63,15 +79,10 @@ function toggle() {
|
||||
<el-icon class="run-card__arrow" :class="{ 'is-expanded': isExpanded }" :size="15"><ArrowDown /></el-icon>
|
||||
</span>
|
||||
</button>
|
||||
<TeamRunDetail
|
||||
<TeamRunDeliverySummary
|
||||
v-if="isExpanded"
|
||||
:id="`team-run-detail-${run.id}`"
|
||||
:run="run"
|
||||
:can-cancel="canCancel"
|
||||
:selected-task-id="selectedTaskId"
|
||||
@select-task="emit('select-task', $event)"
|
||||
@cancel="emit('cancel', $event)"
|
||||
@navigate="emit('navigate', $event)"
|
||||
/>
|
||||
</article>
|
||||
</template>
|
||||
@ -95,9 +106,12 @@ function toggle() {
|
||||
.run-card__copy { display: grid; min-width: 0; gap: 4px; }
|
||||
.run-card__title { color: var(--mc-text-primary, #1f2937); font-size: 14px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.run-card__objective { display: -webkit-box; overflow: hidden; color: var(--mc-text-secondary, #475569); font-size: 12px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.run-card__outcome { display: -webkit-box; max-width: 72ch; overflow: hidden; color: var(--mc-text-secondary, #475569); font-size: 12px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.run-card__facts { display: flex; min-width: 0; flex-wrap: wrap; gap: 4px 10px; color: var(--mc-text-tertiary, #64748b); font-size: 11px; }
|
||||
.run-card__controls { display: flex; align-items: center; gap: 10px; flex: none; }
|
||||
.run-card__arrow { color: var(--mc-text-tertiary, #64748b); transition: transform 0.18s ease; }
|
||||
.run-card__arrow.is-expanded { transform: rotate(180deg); }
|
||||
@media (prefers-reduced-motion: reduce) { .run-card__arrow { transition: none; } }
|
||||
@media (max-width: 520px) {
|
||||
.run-card__toggle { align-items: flex-start; gap: 8px; padding: 11px 10px; }
|
||||
.run-card__controls { gap: 5px; }
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import type { TeamRun } from '@/api'; import { runContributions } from './teamRunProjection'; import TeamRunReadingSurface from './TeamRunReadingSurface.vue'; const props=defineProps<{run:TeamRun}>(); const {t}=useI18n(); const items=computed(()=>runContributions(props.run))</script>
|
||||
<template><TeamRunReadingSurface data-team-run-contributions class="run-contributions"><h3>{{ t('teamRuns.contributions') }}</h3><ol><li v-for="item in items" :key="item.taskId"><span><strong>{{ item.subject }}</strong><small>{{ item.agentId }} · {{ item.status }}</small></span><p v-if="item.resultSummary">{{ item.resultSummary }}</p></li></ol></TeamRunReadingSurface></template>
|
||||
<style scoped>h3{margin:0 0 7px;font-size:13px}ol{display:grid;gap:1px;margin:0;padding:0;list-style:none}li{display:grid;grid-template-columns:minmax(120px,.35fr) minmax(0,1fr);gap:10px;padding:8px 0;border-top:1px solid var(--mc-border-light);font-size:12px}li>span{display:grid;gap:2px}small{color:var(--mc-text-tertiary)}p{margin:0;color:var(--mc-text-secondary);overflow-wrap:anywhere}@media(max-width:520px){li{grid-template-columns:1fr}}</style>
|
||||
@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import type { TeamRun } from '@/api'; import { runDeliverables } from './teamRunProjection'; import TeamRunReadingSurface from './TeamRunReadingSurface.vue'; const props=defineProps<{run:TeamRun}>(); const {t}=useI18n(); const items=computed(()=>runDeliverables(props.run))</script>
|
||||
<template><TeamRunReadingSurface data-team-run-deliverables class="run-deliverables"><h3>{{ t('teamRuns.deliverables') }}</h3><div v-if="items.length"><a v-for="item in items" :key="item.id" :href="item.url" target="_blank" rel="noopener"><strong>{{ item.name }}</strong><span>{{ item.type }}</span></a></div><p v-else>{{ t('teamRuns.noDeliverables') }}</p></TeamRunReadingSurface></template>
|
||||
<style scoped>h3{margin:0 0 7px;font-size:13px}.run-deliverables>div{display:grid;gap:6px}.run-deliverables a{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0;padding:7px 9px;border:1px solid var(--mc-border-light);border-radius:6px;color:#126c4d;text-decoration:none}.run-deliverables strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.run-deliverables span,p{font-size:11px;color:var(--mc-text-tertiary)}</style>
|
||||
@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import type { TeamRun } from '@/api'; import TeamRunOutcome from './TeamRunOutcome.vue'; import TeamRunDeliverables from './TeamRunDeliverables.vue'; import TeamRunAttention from './TeamRunAttention.vue'; defineProps<{run:TeamRun}>()</script>
|
||||
<template><div class="run-delivery-summary"><TeamRunOutcome :run="run"/><TeamRunDeliverables :run="run"/><TeamRunAttention :run="run"/></div></template>
|
||||
<style scoped>.run-delivery-summary{display:grid;gap:1px;background:var(--mc-border-light,#e7ebef)}</style>
|
||||
@ -1,36 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Document, Link, VideoPause } from '@element-plus/icons-vue'
|
||||
import { VideoPause } from '@element-plus/icons-vue'
|
||||
import type { TeamRun, TeamRunTask } from '@/api'
|
||||
import TeamRunProgress from './TeamRunProgress.vue'
|
||||
import TeamRunTaskList from './TeamRunTaskList.vue'
|
||||
import { buildTeamRunRoute, extractRunDeliverables } from './teamRunPresentation'
|
||||
import TeamRunTaskEvidence from './TeamRunTaskEvidence.vue'
|
||||
import TeamRunOutcome from './TeamRunOutcome.vue'
|
||||
import TeamRunDeliverables from './TeamRunDeliverables.vue'
|
||||
import TeamRunAttention from './TeamRunAttention.vue'
|
||||
import TeamRunContributions from './TeamRunContributions.vue'
|
||||
import TeamRunRuntime from './TeamRunRuntime.vue'
|
||||
import TeamRunReadingSurface from './TeamRunReadingSurface.vue'
|
||||
import { buildTeamRunRoute } from './teamRunPresentation'
|
||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
run: TeamRun
|
||||
selectedTaskId?: string | null
|
||||
canCancel?: boolean
|
||||
managementActions?: boolean
|
||||
pendingActions?: string[]
|
||||
}>(), {
|
||||
selectedTaskId: null,
|
||||
canCancel: false,
|
||||
managementActions: false,
|
||||
pendingActions: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-task': [task: TeamRunTask]
|
||||
cancel: [runId: string]
|
||||
navigate: [route: ReturnType<typeof buildTeamRunRoute>]
|
||||
'view-task': [taskId: string]
|
||||
'retry-task': [taskId: string]
|
||||
'approve-task': [taskId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { renderMarkdown } = useMarkdownRenderer()
|
||||
const localTaskId = ref<string | null>(props.selectedTaskId)
|
||||
const selectedTaskSection = ref<HTMLElement | null>(null)
|
||||
watch(() => props.selectedTaskId, value => { localTaskId.value = value })
|
||||
const selectedTask = computed(() => props.run.tasks.find(task => task.id === localTaskId.value) ?? null)
|
||||
const deliverables = computed(() => extractRunDeliverables(props.run))
|
||||
const terminal = computed(() => ['completed', 'partial', 'failed', 'cancelled'].includes(props.run.status))
|
||||
const renderedSummary = computed(() => renderMarkdown(props.run.finalSummary || ''))
|
||||
const renderedTaskDescription = computed(() => renderMarkdown(selectedTask.value?.description || ''))
|
||||
const renderedTaskResult = computed(() => renderMarkdown(selectedTask.value?.result || ''))
|
||||
|
||||
@ -38,58 +49,51 @@ function selectTask(task: TeamRunTask) {
|
||||
localTaskId.value = task.id
|
||||
emit('select-task', task)
|
||||
}
|
||||
|
||||
async function viewAttentionTask(taskId: string) {
|
||||
emit('view-task', taskId)
|
||||
const task = props.run.tasks.find(item => item.id === taskId)
|
||||
if (!task) return
|
||||
localTaskId.value = task.id
|
||||
await nextTick()
|
||||
const section = selectedTaskSection.value
|
||||
if (!section) return
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
section.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'nearest' })
|
||||
section.focus({ preventScroll: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="run-detail">
|
||||
<section class="run-detail__summary">
|
||||
<div class="run-detail__summary-copy">
|
||||
<h4>{{ t('teamRuns.summary') }}</h4>
|
||||
<div v-if="run.finalSummary" class="run-detail__markdown markdown-body" v-html="renderedSummary" />
|
||||
<p v-else>{{ t('teamRuns.noSummary') }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('teamRuns.objective') }}</dt>
|
||||
<dd>{{ run.objective }}</dd>
|
||||
</div>
|
||||
<div v-if="run.stopReason">
|
||||
<dt>{{ t('teamRuns.stopReason') }}</dt>
|
||||
<dd>{{ run.stopReason }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<TeamRunProgress :progress="run.progress" />
|
||||
</section>
|
||||
|
||||
<section class="run-detail__section">
|
||||
<h4>{{ t('teamRuns.deliverables') }}</h4>
|
||||
<div v-if="deliverables.length" class="run-detail__deliverables">
|
||||
<a
|
||||
v-for="item in deliverables"
|
||||
:key="`${item.url}:${item.name}`"
|
||||
:href="item.url"
|
||||
class="run-detail__deliverable"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<el-icon :size="14"><Document /></el-icon>
|
||||
<span>{{ item.name }}</span>
|
||||
<el-icon :size="12"><Link /></el-icon>
|
||||
</a>
|
||||
</div>
|
||||
<p v-else class="run-detail__empty">{{ t('teamRuns.noDeliverables') }}</p>
|
||||
</section>
|
||||
|
||||
<TeamRunAttention
|
||||
:run="run"
|
||||
:management-actions="managementActions"
|
||||
:pending-actions="pendingActions"
|
||||
@view-task="viewAttentionTask"
|
||||
@retry-task="emit('retry-task', $event)"
|
||||
@approve-task="emit('approve-task', $event)"
|
||||
/>
|
||||
<TeamRunOutcome :run="run" />
|
||||
<TeamRunDeliverables :run="run" />
|
||||
<TeamRunContributions :run="run" />
|
||||
<TeamRunRuntime :run="run" />
|
||||
<section class="run-detail__section">
|
||||
<h4>{{ t('teamRuns.tasks') }}</h4>
|
||||
<TeamRunTaskList
|
||||
<TeamRunTaskEvidence
|
||||
:tasks="run.tasks"
|
||||
:selected-task-id="localTaskId"
|
||||
@select-task="selectTask"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="selectedTask" class="run-detail__task-detail">
|
||||
<section
|
||||
v-if="selectedTask"
|
||||
ref="selectedTaskSection"
|
||||
data-team-run-selected-task
|
||||
class="run-detail__task-detail"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="run-detail__task-heading">
|
||||
<h4>{{ selectedTask.taskNumber }}. {{ selectedTask.subject }}</h4>
|
||||
<button
|
||||
@ -98,7 +102,9 @@ function selectTask(task: TeamRunTask) {
|
||||
@click="emit('navigate', buildTeamRunRoute(run.teamId, run.id, selectedTask.id))"
|
||||
>{{ t('teamRuns.openTask') }}</button>
|
||||
</div>
|
||||
<div v-if="selectedTask.description" class="run-detail__markdown markdown-body" v-html="renderedTaskDescription" />
|
||||
<TeamRunReadingSurface v-if="selectedTask.description" data-team-run-task-markdown class="run-detail__task-markdown">
|
||||
<div class="markdown-body compact-markdown" v-html="renderedTaskDescription" />
|
||||
</TeamRunReadingSurface>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('teamRuns.assignee') }}</dt>
|
||||
@ -106,7 +112,11 @@ function selectTask(task: TeamRunTask) {
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('teamRuns.result') }}</dt>
|
||||
<dd v-if="selectedTask.result" class="run-detail__result run-detail__markdown markdown-body" v-html="renderedTaskResult" />
|
||||
<dd v-if="selectedTask.result" class="run-detail__result">
|
||||
<TeamRunReadingSurface data-team-run-task-markdown class="run-detail__task-markdown">
|
||||
<div class="markdown-body compact-markdown" v-html="renderedTaskResult" />
|
||||
</TeamRunReadingSurface>
|
||||
</dd>
|
||||
<dd v-else class="run-detail__result">{{ t('teamRuns.noResult') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@ -125,22 +135,7 @@ function selectTask(task: TeamRunTask) {
|
||||
.run-detail { border-top: 1px solid var(--mc-border-light, #e7ebef); letter-spacing: 0; }
|
||||
.run-detail h4 { margin: 0; color: var(--mc-text-primary, #1f2937); font-size: 12px; font-weight: 700; }
|
||||
.run-detail p { margin: 6px 0 0; color: var(--mc-text-secondary, #475569); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.run-detail__markdown { margin-top: 6px; color: var(--mc-text-secondary, #475569); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.run-detail__markdown :deep(p) { margin: 0 0 8px; }
|
||||
.run-detail__markdown :deep(p:last-child) { margin-bottom: 0; }
|
||||
.run-detail__markdown :deep(h1), .run-detail__markdown :deep(h2), .run-detail__markdown :deep(h3), .run-detail__markdown :deep(h4) { margin: 12px 0 6px; color: var(--mc-text-primary, #1f2937); line-height: 1.3; }
|
||||
.run-detail__markdown :deep(h1) { font-size: 18px; }
|
||||
.run-detail__markdown :deep(h2) { font-size: 15px; }
|
||||
.run-detail__markdown :deep(h3), .run-detail__markdown :deep(h4) { font-size: 13px; }
|
||||
.run-detail__markdown :deep(ul), .run-detail__markdown :deep(ol) { margin: 6px 0 8px; padding-left: 20px; }
|
||||
.run-detail__markdown :deep(li) { margin: 3px 0; }
|
||||
.run-detail__markdown :deep(blockquote) { margin: 8px 0; padding: 6px 10px; border-left: 3px solid #9bcdbb; background: rgba(27, 143, 104, 0.05); }
|
||||
.run-detail__markdown :deep(table) { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 8px 0; }
|
||||
.run-detail__markdown :deep(th), .run-detail__markdown :deep(td) { padding: 5px 8px; border: 1px solid var(--mc-border-light, #e7ebef); text-align: left; white-space: nowrap; }
|
||||
.run-detail__markdown :deep(th) { background: rgba(71, 85, 105, 0.06); color: var(--mc-text-primary, #1f2937); }
|
||||
.run-detail__markdown :deep(code) { padding: 1px 4px; border-radius: 3px; background: rgba(71, 85, 105, 0.09); font-family: var(--mc-font-mono, ui-monospace, monospace); font-size: .92em; }
|
||||
.run-detail__markdown :deep(pre) { max-width: 100%; overflow-x: auto; padding: 9px 10px; border-radius: 6px; background: var(--mc-code-bg, #faf6f1); }
|
||||
.run-detail__markdown :deep(a) { color: #16795a; }
|
||||
.run-detail__task-markdown{margin-top:8px;padding:10px 12px;border:1px solid var(--mc-border-light);border-radius:6px}
|
||||
.run-detail__summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; background: rgba(71, 85, 105, 0.035); }
|
||||
.run-detail__summary-copy { min-width: 0; flex: 1; }
|
||||
.run-detail__section, .run-detail__task-detail { padding: 14px 16px; border-top: 1px solid var(--mc-border-light, #e7ebef); }
|
||||
@ -155,7 +150,7 @@ function selectTask(task: TeamRunTask) {
|
||||
.run-detail__empty { color: var(--mc-text-tertiary, #64748b) !important; }
|
||||
.run-detail__task-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.run-detail__link-button { padding: 0; border: 0; background: transparent; color: #16795a; cursor: pointer; font-size: 12px; }
|
||||
.run-detail__link-button:focus-visible, .run-detail__cancel:focus-visible { outline: 2px solid #1b8f68; outline-offset: 2px; }
|
||||
.run-detail__link-button:focus-visible, .run-detail__cancel:focus-visible, .run-detail__task-detail:focus-visible { outline: 2px solid #1b8f68; outline-offset: -2px; }
|
||||
.run-detail__result { white-space: pre-wrap; }
|
||||
.run-detail__actions { display: flex; justify-content: flex-end; padding: 10px 16px 14px; border-top: 1px solid var(--mc-border-light, #e7ebef); }
|
||||
.run-detail__cancel { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 5px 10px; border: 1px solid #e6b7b7; border-radius: 6px; background: transparent; color: #b53535; cursor: pointer; font-size: 12px; letter-spacing: 0; }
|
||||
@ -163,5 +158,6 @@ function selectTask(task: TeamRunTask) {
|
||||
@media (max-width: 640px) {
|
||||
.run-detail__summary { align-items: center; }
|
||||
.run-detail dl > div { grid-template-columns: 1fr; gap: 2px; }
|
||||
.run-detail__section, .run-detail__task-detail { min-width: 0; padding: 12px; overflow-x: hidden; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { TeamRun, TeamRunTask } from '@/api'
|
||||
@ -6,43 +7,96 @@ import TeamRunDetail from './TeamRunDetail.vue'
|
||||
import TeamRunStatus from './TeamRunStatus.vue'
|
||||
import type { TeamRunRoute } from './teamRunPresentation'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
run: TeamRun | null
|
||||
open?: boolean
|
||||
selectedTaskId?: string | null
|
||||
canCancel?: boolean
|
||||
}>(), { open: false, selectedTaskId: null, canCancel: false })
|
||||
detailLoading?: boolean
|
||||
detailError?: string | null
|
||||
managementActions?: boolean
|
||||
pendingActions?: string[]
|
||||
}>(), { open: false, selectedTaskId: null, canCancel: false, detailLoading: false, detailError: null, managementActions: false, pendingActions: () => [] })
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
cancel: [runId: string]
|
||||
'select-task': [task: TeamRunTask]
|
||||
navigate: [route: TeamRunRoute]
|
||||
'retry-detail': []
|
||||
'view-task': [taskId: string]
|
||||
'retry-task': [taskId: string]
|
||||
'approve-task': [taskId: string]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const closeButton = ref<HTMLButtonElement | null>(null)
|
||||
const drawer = ref<HTMLElement | null>(null)
|
||||
let returnFocus: HTMLElement | null = null
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
void nextTick(() => returnFocus?.focus())
|
||||
}
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!props.open) return
|
||||
if (event.key === 'Escape') { event.preventDefault(); close() }
|
||||
if (event.key !== 'Tab') return
|
||||
const focusable = Array.from(drawer.value?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [])
|
||||
if (!focusable.length) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault(); last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault(); first.focus()
|
||||
}
|
||||
}
|
||||
watch(() => props.open, async (open) => {
|
||||
if (!open) return
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
await nextTick(); closeButton.value?.focus()
|
||||
}, { immediate: true })
|
||||
watch(() => props.open, open => { if (!open) document.removeEventListener('keydown', onKeydown) })
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open && run" class="run-drawer-layer" @click.self="emit('close')">
|
||||
<aside class="run-drawer" role="dialog" aria-modal="true" :aria-label="run.title">
|
||||
<div v-if="open && run" class="run-drawer-layer" @click.self="close">
|
||||
<aside ref="drawer" class="run-drawer" role="dialog" aria-modal="true" :aria-label="run.title">
|
||||
<header class="run-drawer__header">
|
||||
<div>
|
||||
<h2>{{ run.title }}</h2>
|
||||
<TeamRunStatus :status="run.status" :started-at="run.startedAt" :completed-at="run.completedAt" show-duration />
|
||||
</div>
|
||||
<button data-team-run-drawer-close type="button" :aria-label="t('teamRuns.close')" @click="emit('close')">
|
||||
<button ref="closeButton" data-team-run-drawer-close type="button" :aria-label="t('teamRuns.close')" @click="close">
|
||||
<el-icon :size="17"><Close /></el-icon>
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="run.status === 'partial'" class="run-drawer__notice is-partial">{{ t('teamRuns.partialNotice') }}</p>
|
||||
<p v-if="run.status === 'cancelled'" class="run-drawer__notice">{{ run.stopReason || t('teamRuns.status.cancelled') }}</p>
|
||||
<div v-if="detailLoading || run.projectionCompleteness === 'summary'" class="run-drawer__detail-state" role="status">
|
||||
<template v-if="detailLoading">{{ t('teamRuns.detailLoading') }}</template>
|
||||
<template v-else>
|
||||
<span>{{ detailError || t('teamRuns.detailUnavailable') }}</span>
|
||||
<button data-team-run-detail-retry type="button" @click="emit('retry-detail')">{{ t('teamRuns.retryLoad') }}</button>
|
||||
</template>
|
||||
</div>
|
||||
<TeamRunDetail
|
||||
v-else
|
||||
:run="run"
|
||||
:selected-task-id="selectedTaskId"
|
||||
:can-cancel="canCancel"
|
||||
:management-actions="managementActions"
|
||||
:pending-actions="pendingActions"
|
||||
@select-task="emit('select-task', $event)"
|
||||
@cancel="emit('cancel', $event)"
|
||||
@navigate="emit('navigate', $event)"
|
||||
@view-task="emit('view-task', $event)"
|
||||
@retry-task="emit('retry-task', $event)"
|
||||
@approve-task="emit('approve-task', $event)"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
@ -51,11 +105,15 @@ const { t } = useI18n()
|
||||
<style scoped>
|
||||
.run-drawer-layer { position: fixed; z-index: 1200; inset: 0; display: flex; justify-content: flex-end; background: rgba(15, 23, 42, 0.28); }
|
||||
.run-drawer { width: min(620px, 94vw); height: 100%; overflow-y: auto; border-left: 1px solid var(--mc-border); background: var(--mc-bg-elevated, #fff); box-shadow: -12px 0 28px rgba(15, 23, 42, 0.14); letter-spacing: 0; }
|
||||
.run-drawer__header { position: sticky; z-index: 1; top: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 16px; border-bottom: 1px solid var(--mc-border); background: var(--mc-bg-elevated, #fff); }
|
||||
.run-drawer__header { position: sticky; z-index: 1; top: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 16px; border-bottom: 1px solid var(--mc-border); background: var(--mc-team-run-glass-bg, rgba(255,255,255,.88)); backdrop-filter: blur(12px) saturate(1.05); -webkit-backdrop-filter: blur(12px) saturate(1.05); }
|
||||
.run-drawer__header h2 { margin: 0 0 6px; color: var(--mc-text-primary); font-size: 16px; overflow-wrap: anywhere; }
|
||||
.run-drawer__header button { display: grid; width: 30px; height: 30px; flex: none; place-items: center; border: 0; border-radius: 6px; background: transparent; color: var(--mc-text-secondary); cursor: pointer; }
|
||||
.run-drawer__header button:hover { background: var(--mc-bg-sunken); }
|
||||
.run-drawer__header button:focus-visible { outline: 2px solid #16835b; outline-offset: 2px; }
|
||||
.run-drawer__notice { margin: 0; padding: 9px 16px; border-bottom: 1px solid var(--mc-border-light); background: rgba(71, 85, 105, 0.06); color: var(--mc-text-secondary); font-size: 12px; }
|
||||
.run-drawer__notice.is-partial { background: rgba(185, 108, 8, 0.08); color: #8a5108; }
|
||||
.run-drawer__detail-state{display:grid;min-height:180px;place-content:center;gap:8px;padding:24px;color:var(--mc-text-secondary);font-size:13px;text-align:center}.run-drawer__detail-state button{border:0;background:transparent;color:#16795a;cursor:pointer}
|
||||
@media (prefers-reduced-motion: reduce) { .run-drawer, .run-drawer * { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
|
||||
@media (prefers-reduced-transparency: reduce) { .run-drawer__header { background: var(--mc-bg-elevated, #fff); backdrop-filter: none; -webkit-backdrop-filter: none; } }
|
||||
@supports not (backdrop-filter: blur(1px)) { .run-drawer__header { background: var(--mc-bg-elevated, #fff); } }
|
||||
</style>
|
||||
|
||||
6
mateclaw-ui/src/components/team-run/TeamRunOutcome.vue
Normal file
6
mateclaw-ui/src/components/team-run/TeamRunOutcome.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import type { TeamRun } from '@/api'; import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'; import TeamRunReadingSurface from './TeamRunReadingSurface.vue'
|
||||
const props = defineProps<{ run: TeamRun; preview?: boolean }>(); const { t } = useI18n(); const { renderMarkdown } = useMarkdownRenderer(); const html = computed(() => renderMarkdown(props.run.finalSummary || ''))
|
||||
</script>
|
||||
<template><TeamRunReadingSurface data-team-run-outcome class="run-outcome"><header><h3>{{ t('teamRuns.outcome') }}</h3><span v-if="run.outcomeQuality">{{ t(`teamRuns.quality.${run.outcomeQuality}`) }}</span></header><div v-if="run.finalSummary" class="markdown-body compact-markdown" :class="{ 'is-preview': preview }" v-html="html" /><p v-else>{{ t('teamRuns.noSummary') }}</p></TeamRunReadingSurface></template>
|
||||
<style scoped>.run-outcome header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px}.run-outcome h3{margin:0;font-size:13px}.run-outcome header span{font-size:11px;color:var(--mc-text-tertiary)}.is-preview{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:4}</style>
|
||||
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">defineProps<{ as?: string }>()</script>
|
||||
<template><component :is="as || 'section'" class="team-run-reading"><slot /></component></template>
|
||||
<style scoped>
|
||||
.team-run-reading { min-width: 0; max-width: 100%; padding: 12px 14px; background: var(--mc-team-run-reading-bg, #fff); color: var(--mc-text-primary, #1f2937); overflow-wrap: anywhere; }
|
||||
.team-run-reading :deep(.markdown-body) { min-width: 0; max-width: 100%; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.team-run-reading :deep(.markdown-body > *:first-child) { margin-top: 0; }
|
||||
.team-run-reading :deep(.markdown-body > *:last-child) { margin-bottom: 0; }
|
||||
.team-run-reading :deep(.markdown-body p), .team-run-reading :deep(.markdown-body ul), .team-run-reading :deep(.markdown-body ol) { margin-block: 6px; }
|
||||
.team-run-reading :deep(.markdown-body h1), .team-run-reading :deep(.markdown-body h2), .team-run-reading :deep(.markdown-body h3) { margin: 12px 0 6px; line-height: 1.3; }
|
||||
.team-run-reading :deep(.markdown-body table) { display: block; width: 100%; max-width: 100%; overflow-x: auto; }
|
||||
.team-run-reading :deep(.markdown-body thead), .team-run-reading :deep(.markdown-body tbody) { display: table; width: max-content; min-width: 100%; table-layout: auto; }
|
||||
.team-run-reading :deep(.markdown-body pre) { display: block; max-width: 100%; overflow-x: auto; }
|
||||
.team-run-reading :deep(.markdown-body th), .team-run-reading :deep(.markdown-body td) { overflow-wrap: normal; word-break: normal; }
|
||||
.team-run-reading :deep(.markdown-body th code), .team-run-reading :deep(.markdown-body td code) { white-space: nowrap; overflow-wrap: normal; word-break: normal; }
|
||||
.team-run-reading :deep(.markdown-body a), .team-run-reading :deep(.markdown-body code) { overflow-wrap: anywhere; word-break: break-word; }
|
||||
@media (max-width: 640px) {
|
||||
.team-run-reading :deep(.markdown-body th), .team-run-reading :deep(.markdown-body td) { min-width: 6.5rem; }
|
||||
}
|
||||
</style>
|
||||
3
mateclaw-ui/src/components/team-run/TeamRunRuntime.vue
Normal file
3
mateclaw-ui/src/components/team-run/TeamRunRuntime.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import type { TeamRun } from '@/api'; const props=defineProps<{run:TeamRun}>(); const {t}=useI18n(); const state=computed(()=>props.run.liveness?.state ?? (['completed','partial','failed','cancelled'].includes(props.run.status)?'terminal':'quiet'))</script>
|
||||
<template><section data-team-run-runtime class="run-runtime" :class="`is-${state}`"><span class="run-runtime__dot" :class="{'is-loading':state==='live'}"/><strong>{{ t('teamRuns.runtime') }}</strong><span>{{ t(`teamRuns.liveness.${state}`) }}</span><time v-if="run.liveness?.lastActivityAt">{{ run.liveness.lastActivityAt }}</time></section></template>
|
||||
<style scoped>.run-runtime{display:flex;align-items:center;gap:7px;min-width:0;padding:7px 10px;background:var(--mc-team-run-status-bg,rgba(255,255,255,.82));font-size:11px;color:var(--mc-text-tertiary)}.run-runtime__dot{width:7px;height:7px;border-radius:50%;background:#94a3b8}.run-runtime.is-live .run-runtime__dot{background:#1b8f68}.run-runtime.is-stalled .run-runtime__dot{background:#c13d3d}.run-runtime time{margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(prefers-reduced-motion:reduce){.run-runtime__dot{animation:none!important}}</style>
|
||||
@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">import type { TeamRunTask } from '@/api'; import TeamRunTaskList from './TeamRunTaskList.vue'; defineProps<{tasks:TeamRunTask[];selectedTaskId?:string|null}>(); defineEmits<{'select-task':[task:TeamRunTask]}>()</script>
|
||||
<template><section data-team-run-task-evidence class="run-task-evidence"><TeamRunTaskList :tasks="tasks" :selected-task-id="selectedTaskId" @select-task="$emit('select-task',$event)"/></section></template>
|
||||
<style scoped>.run-task-evidence{min-width:0;max-width:100%;background:var(--mc-team-run-reading-bg,#fff)}</style>
|
||||
@ -10,11 +10,14 @@ withDefaults(defineProps<{
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
selectedRunId?: string | null
|
||||
}>(), { loading: false, error: null, selectedRunId: null })
|
||||
hasMore?: boolean
|
||||
loadingMore?: boolean
|
||||
}>(), { loading: false, error: null, selectedRunId: null, hasMore: false, loadingMore: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
'select-run': [run: TeamRun]
|
||||
'load-more': []
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@ -52,6 +55,9 @@ const { t } = useI18n()
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error && runs.length > 0" class="runs-panel__stale">{{ t('teamRuns.loadError') }}</p>
|
||||
<button v-if="hasMore" data-team-runs-load-more type="button" class="runs-panel__more" :disabled="loadingMore" @click="emit('load-more')">
|
||||
{{ loadingMore ? t('teamRuns.loadingMore') : t('teamRuns.loadMore') }}
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@ -71,4 +77,5 @@ const { t } = useI18n()
|
||||
.runs-panel__state.is-error, .runs-panel__stale { color: #b53535; }
|
||||
.runs-panel__state button { border: 0; background: transparent; color: #16795a; cursor: pointer; }
|
||||
.runs-panel__stale { margin: 8px 0 0; font-size: 12px; }
|
||||
.runs-panel__more { display:block; width:100%; margin-top:10px; min-height:34px; border:1px solid var(--mc-border); border-radius:6px; background:var(--mc-bg-elevated); color:#16795a; cursor:pointer }.runs-panel__more:disabled{cursor:wait;opacity:.65}
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
canManageTeamRunAttention,
|
||||
refreshAttentionTaskContext,
|
||||
runAttentionTaskAction,
|
||||
type TeamAttentionActionContext,
|
||||
} from '../teamRunAttentionHandlers'
|
||||
|
||||
const context: TeamAttentionActionContext = { teamId: '10', runId: '20', taskId: '101' }
|
||||
|
||||
describe('Team Run attention handlers', () => {
|
||||
it('allows only backend-issued workspace admin roles for the matching workspace', () => {
|
||||
expect(canManageTeamRunAttention('viewer', '1', '1')).toBe(false)
|
||||
expect(canManageTeamRunAttention('member', '1', '1')).toBe(false)
|
||||
expect(canManageTeamRunAttention('admin', '1', '1')).toBe(true)
|
||||
expect(canManageTeamRunAttention('owner', '1', '1')).toBe(true)
|
||||
expect(canManageTeamRunAttention('admin', '2', '1')).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes the captured original run when another run becomes selected', async () => {
|
||||
const refreshRun = vi.fn().mockResolvedValue(undefined)
|
||||
await refreshAttentionTaskContext({
|
||||
context,
|
||||
currentTeamId: () => '10',
|
||||
currentTaskId: () => '101',
|
||||
reloadTask: vi.fn().mockResolvedValue(undefined),
|
||||
refreshBoard: vi.fn().mockResolvedValue(undefined),
|
||||
refreshRun,
|
||||
})
|
||||
expect(refreshRun).toHaveBeenCalledWith('20', '10')
|
||||
})
|
||||
|
||||
it('does not refresh or mutate a newly selected team', async () => {
|
||||
const reloadTask = vi.fn()
|
||||
const refreshBoard = vi.fn()
|
||||
const refreshRun = vi.fn()
|
||||
await refreshAttentionTaskContext({
|
||||
context,
|
||||
currentTeamId: () => '11',
|
||||
currentTaskId: () => '101',
|
||||
reloadTask,
|
||||
refreshBoard,
|
||||
refreshRun,
|
||||
})
|
||||
expect(reloadTask).not.toHaveBeenCalled()
|
||||
expect(refreshBoard).not.toHaveBeenCalled()
|
||||
expect(refreshRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('locks each task action against double click and releases it after failure', async () => {
|
||||
const pending = new Set<string>()
|
||||
let reject!: (reason: Error) => void
|
||||
const execute = vi.fn(() => new Promise<void>((_, rejectPromise) => { reject = rejectPromise }))
|
||||
const refresh = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const first = runAttentionTaskAction({ context, action: 'retry', pending, execute, refresh, onError })
|
||||
const duplicate = runAttentionTaskAction({ context, action: 'retry', pending, execute, refresh, onError })
|
||||
expect(execute).toHaveBeenCalledOnce()
|
||||
expect(pending.size).toBe(1)
|
||||
await duplicate
|
||||
reject(new Error('offline'))
|
||||
await first
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalledOnce()
|
||||
expect(pending.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the captured context and skips UI refresh when the team switches during the action', async () => {
|
||||
let currentTeamId = '10'
|
||||
let resolve!: () => void
|
||||
const execute = vi.fn(() => new Promise<void>(resolvePromise => { resolve = resolvePromise }))
|
||||
const refreshBoard = vi.fn().mockResolvedValue(undefined)
|
||||
const refreshRun = vi.fn().mockResolvedValue(undefined)
|
||||
const refresh = () => refreshAttentionTaskContext({
|
||||
context,
|
||||
currentTeamId: () => currentTeamId,
|
||||
currentTaskId: () => null,
|
||||
reloadTask: vi.fn(),
|
||||
refreshBoard,
|
||||
refreshRun,
|
||||
})
|
||||
|
||||
const action = runAttentionTaskAction({
|
||||
context, action: 'approve', pending: new Set(), execute, refresh, onError: vi.fn(),
|
||||
})
|
||||
currentTeamId = '11'
|
||||
resolve()
|
||||
await action
|
||||
|
||||
expect(execute).toHaveBeenCalledOnce()
|
||||
expect(refreshBoard).not.toHaveBeenCalled()
|
||||
expect(refreshRun).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import { createApp, nextTick, type Component } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TeamRun } from '@/api'
|
||||
import TeamRunCard from '../TeamRunCard.vue'
|
||||
import TeamRunDetail from '../TeamRunDetail.vue'
|
||||
@ -15,9 +15,11 @@ const messages = {
|
||||
progress: '{done} of {total} complete',
|
||||
tasks: 'Tasks', emptyTasks: 'No tasks in this run', assignee: 'Assignee', dependencies: 'Dependencies',
|
||||
noDependencies: 'None', result: 'Result', noResult: 'No result yet', summary: 'Summary',
|
||||
noSummary: 'No summary yet', deliverables: 'Deliverables', noDeliverables: 'No deliverables',
|
||||
noSummary: 'No summary yet', outcome: 'Outcome', attention: 'Needs attention', noAttention: 'No action needed',
|
||||
deliverables: 'Deliverables', noDeliverables: 'No deliverables',
|
||||
cancel: 'Cancel run', expand: 'Expand run', collapse: 'Collapse run', openTask: 'Open task',
|
||||
objective: 'Objective', taskProgress: 'Task progress', stopReason: 'Stop reason',
|
||||
quality: { synthesized: 'Synthesized', fallback: 'Fallback', partial: 'Partial', pending: 'Pending' },
|
||||
},
|
||||
}
|
||||
|
||||
@ -49,6 +51,20 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('TeamRunCard', () => {
|
||||
it('renders a bounded collapsed delivery preview and canonical counts', () => {
|
||||
const long = `## Decision\n\n${'evidence '.repeat(80)}`
|
||||
const host = mount(TeamRunCard, { run: sampleRun({
|
||||
finalSummary: long, outcomeQuality: 'fallback', projectionCompleteness: 'summary',
|
||||
metrics: { durationSeconds: 30, totalTasks: 3, completedTasks: 2, failedTasks: 1, deliverableCount: 4 },
|
||||
attentionItems: [{ id: 'a', type: 'failure', severity: 'error', priority: 1, taskId: null, message: 'Review', createdAt: null }],
|
||||
}) })
|
||||
const preview = host.querySelector('[data-team-run-outcome-preview]')!
|
||||
expect(preview.textContent!.length).toBeLessThanOrEqual(163)
|
||||
expect(host.querySelector('[data-team-run-deliverable-count]')?.textContent).toContain('4')
|
||||
expect(host.querySelector('[data-team-run-attention-count]')?.textContent).toContain('1')
|
||||
expect(host.querySelector('[data-team-run-outcome-quality]')?.textContent).toContain('Fallback')
|
||||
expect(host.querySelector('[data-team-run-outcome]')).toBeNull()
|
||||
})
|
||||
it.each([
|
||||
['planning', 'Planning'],
|
||||
['running', 'Running'],
|
||||
@ -77,7 +93,8 @@ describe('TeamRunCard', () => {
|
||||
|
||||
expect(enter.defaultPrevented).toBe(true)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(host.textContent).toContain('No tasks in this run')
|
||||
expect(host.textContent).toContain('No summary yet')
|
||||
expect(host.querySelector('[data-team-run-task-list]')).toBeNull()
|
||||
expect(toggles).toEqual([true])
|
||||
|
||||
const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
|
||||
@ -90,6 +107,45 @@ describe('TeamRunCard', () => {
|
||||
})
|
||||
|
||||
describe('TeamRunDetail', () => {
|
||||
it('forwards attention recovery actions only in management context', async () => {
|
||||
const actions: string[] = []
|
||||
const attentionItems = [{ id: 'a', type: 'failed', severity: 'error', priority: 1, taskId: '101', message: 'Failed', createdAt: null }]
|
||||
const host = mount(TeamRunDetail, {
|
||||
run: sampleRun({ attentionItems }), managementActions: true,
|
||||
onViewTask: (id: string) => actions.push(`view:${id}`),
|
||||
onRetryTask: (id: string) => actions.push(`retry:${id}`),
|
||||
})
|
||||
host.querySelector<HTMLButtonElement>('[data-attention-view-task="101"]')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[data-attention-retry-task="101"]')!.click()
|
||||
await nextTick()
|
||||
expect(actions).toEqual(['view:101', 'retry:101'])
|
||||
expect(mount(TeamRunDetail, { run: sampleRun({ attentionItems }) }).querySelector('button[data-attention-view-task]')).toBeNull()
|
||||
})
|
||||
|
||||
it('drills into and focuses task evidence inside the detail instead of requesting the legacy modal', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView
|
||||
const viewed: string[] = []
|
||||
const selected: string[] = []
|
||||
const task = { id: '101', teamId: '10', runId: '20', taskNumber: 1, subject: 'Blocked evidence', description: 'Wait for dependency', status: 'blocked', priority: 0, taskType: 'execution', assigneeAgentId: '31', ownerAgentId: null, blockedBy: '100', requireApproval: false, progressPercent: 0, progressStep: null, result: null, reason: 'Dependency pending', conversationId: 'worker', metadata: null, createTime: null, updateTime: null }
|
||||
const host = mount(TeamRunDetail, {
|
||||
run: sampleRun({ tasks: [task], attentionItems: [{ id: 'blocked', type: 'blocked', severity: 'error', priority: 1, taskId: '101', message: 'Dependency pending', createdAt: null }] }),
|
||||
managementActions: true,
|
||||
onViewTask: (id: string) => viewed.push(id),
|
||||
onSelectTask: (value: { id: string }) => selected.push(value.id),
|
||||
})
|
||||
|
||||
host.querySelector<HTMLButtonElement>('[data-attention-view-task="101"]')!.click()
|
||||
await nextTick()
|
||||
|
||||
const detail = host.querySelector<HTMLElement>('[data-team-run-selected-task]')!
|
||||
expect(detail.textContent).toContain('Blocked evidence')
|
||||
expect(document.activeElement).toBe(detail)
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' })
|
||||
expect(viewed).toEqual(['101'])
|
||||
expect(selected).toEqual([])
|
||||
})
|
||||
|
||||
it('renders summary, task drilldown and emits cancel', async () => {
|
||||
let cancelled = 0
|
||||
const task = {
|
||||
@ -122,8 +178,17 @@ describe('TeamRunDetail', () => {
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(host.querySelector('.run-detail__markdown h2')?.textContent).toBe('结论')
|
||||
expect(host.querySelector('.run-detail__markdown table')).not.toBeNull()
|
||||
expect(host.querySelector('.run-detail__markdown strong')?.textContent).toBe('完成')
|
||||
expect(host.querySelector('[data-team-run-outcome] h2')?.textContent).toBe('结论')
|
||||
expect(host.querySelector('[data-team-run-outcome] table')).not.toBeNull()
|
||||
expect(host.querySelector('[data-team-run-outcome] strong')?.textContent).toBe('完成')
|
||||
})
|
||||
|
||||
it('renders task description and result through shared reading surfaces', async () => {
|
||||
const task = { id: '101', teamId: '10', runId: '20', taskNumber: 1, subject: 'Evidence', description: '## Method', status: 'completed', priority: 0, taskType: 'execution', assigneeAgentId: '31', ownerAgentId: null, blockedBy: null, requireApproval: false, progressPercent: 100, progressStep: null, result: '```ts\nconst ok = true\n```', reason: null, conversationId: 'worker', metadata: null, createTime: null, updateTime: null }
|
||||
const host = mount(TeamRunDetail, { run: sampleRun({ tasks: [task] }), selectedTaskId: '101' })
|
||||
await nextTick()
|
||||
expect(host.querySelectorAll('[data-team-run-task-markdown]')).toHaveLength(2)
|
||||
expect(host.querySelector('[data-team-run-task-markdown] h2')?.textContent).toBe('Method')
|
||||
expect(host.querySelector('[data-team-run-task-markdown] pre code')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,122 @@
|
||||
import { createApp, nextTick, type Component } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { TeamRun } from '@/api'
|
||||
import TeamRunOutcome from '../TeamRunOutcome.vue'
|
||||
import TeamRunDeliverables from '../TeamRunDeliverables.vue'
|
||||
import TeamRunAttention from '../TeamRunAttention.vue'
|
||||
import TeamRunContributions from '../TeamRunContributions.vue'
|
||||
import TeamRunRuntime from '../TeamRunRuntime.vue'
|
||||
import TeamRunCard from '../TeamRunCard.vue'
|
||||
|
||||
const messages = { teamRuns: {
|
||||
outcome: 'Outcome', noSummary: 'No summary', deliverables: 'Deliverables', noDeliverables: 'No deliverables',
|
||||
attention: 'Needs attention', noAttention: 'No action needed', contributions: 'Contributions', runtime: 'Runtime',
|
||||
quality: { synthesized: 'Synthesized', fallback: 'Fallback', partial: 'Partial', pending: 'Pending' },
|
||||
liveness: { live: 'Live', quiet: 'Quiet', stalled: 'Stalled', terminal: 'Finished' },
|
||||
status: { running: 'Running', completed: 'Completed', failed: 'Failed', partial: 'Partial' },
|
||||
duration: { day: 'd', hour: 'h', minute: 'm', second: 's' }, progress: '{done} of {total} complete',
|
||||
expand: 'Expand', collapse: 'Collapse', objective: 'Objective', stopReason: 'Stop reason', cancel: 'Cancel',
|
||||
openTask: 'View task',
|
||||
}, common: { retry: 'Retry', approve: 'Approve' } }
|
||||
|
||||
function run(extra: Partial<TeamRun> = {}): TeamRun {
|
||||
return {
|
||||
id: '20', teamId: '10', workspaceId: '1', leadAgentId: '2', leadConversationId: 'lead', originMessageId: null,
|
||||
title: 'Research', objective: 'Collect evidence', status: 'completed', finalSummary: '## Decision\n\nShip it.',
|
||||
stopReason: null, metadata: null, startedAt: '2026-08-13T10:00:00Z', completedAt: '2026-08-13T10:05:00Z',
|
||||
createTime: null, updateTime: null, projectionCompleteness: 'full', outcomeQuality: 'synthesized',
|
||||
deliverables: [{ id: 'd1', name: 'Report', url: '/api/v1/files/generated/report.pdf', type: 'pdf', sourceTaskIds: ['1'], sourceAgentIds: ['3'], createdAt: null, verificationStatus: 'available' }],
|
||||
contributions: [{ taskId: '1', agentId: '3', subject: 'Research', status: 'completed', durationSeconds: 30, lastActivityAt: null, resultSummary: 'Evidence gathered', conversationId: 'worker' }],
|
||||
attentionItems: [{ id: 'a1', type: 'review', severity: 'action', priority: 0, taskId: '1', message: 'Approve report', createdAt: null }],
|
||||
liveness: { state: 'terminal', lastActivityAt: '2026-08-13T10:05:00Z' },
|
||||
metrics: { durationSeconds: 300, totalTasks: 1, completedTasks: 1, failedTasks: 0, deliverableCount: 1 },
|
||||
progress: { total: 1, done: 1, failed: 0, inReview: 0, percent: 100 }, tasks: [], ...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = []
|
||||
function mount(component: Component, props: Record<string, unknown>) {
|
||||
const host = document.createElement('div'); document.body.appendChild(host)
|
||||
const app = createApp(component, props)
|
||||
app.use(createI18n({ legacy: false, locale: 'en', messages: { en: messages } })); app.mount(host); apps.push(app)
|
||||
return host
|
||||
}
|
||||
afterEach(() => { apps.splice(0).forEach(app => app.unmount()); document.body.innerHTML = '' })
|
||||
|
||||
describe('Team Run projection primitives', () => {
|
||||
it('filters unsafe canonical deliverables before rendering', () => {
|
||||
const value = run({ deliverables: [
|
||||
{ id: 'safe', name: 'Safe', url: '/api/v1/files/generated/safe.pdf', type: 'pdf', sourceTaskIds: [], sourceAgentIds: [], createdAt: null, verificationStatus: 'available' },
|
||||
{ id: 'bad', name: 'Bad', url: 'javascript:alert(1)', type: 'html', sourceTaskIds: [], sourceAgentIds: [], createdAt: null, verificationStatus: 'available' },
|
||||
] })
|
||||
const host = mount(TeamRunDeliverables, { run: value })
|
||||
expect(host.textContent).toContain('Safe')
|
||||
expect(host.textContent).not.toContain('Bad')
|
||||
expect(host.querySelectorAll('a')).toHaveLength(1)
|
||||
})
|
||||
it('renders canonical outcome, deliverables, attention, contributions and terminal runtime', async () => {
|
||||
const value = run()
|
||||
const hosts = [
|
||||
mount(TeamRunOutcome, { run: value }), mount(TeamRunDeliverables, { run: value }),
|
||||
mount(TeamRunAttention, { run: value }), mount(TeamRunContributions, { run: value }),
|
||||
mount(TeamRunRuntime, { run: value }),
|
||||
]
|
||||
await nextTick()
|
||||
expect(hosts[0].querySelector('h2')?.textContent).toBe('Decision')
|
||||
expect(hosts[1].textContent).toContain('Report')
|
||||
expect(hosts[2].textContent).toContain('Approve report')
|
||||
expect(hosts[3].textContent).toContain('Evidence gathered')
|
||||
expect(hosts[4].textContent).toContain('Finished')
|
||||
expect(hosts[4].querySelector('.is-loading')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the expanded chat card outcome-first without raw task evidence', async () => {
|
||||
const host = mount(TeamRunCard, { run: run(), expanded: true })
|
||||
await nextTick()
|
||||
expect(host.querySelector('[data-team-run-outcome]')).not.toBeNull()
|
||||
expect(host.querySelector('[data-team-run-deliverables]')).not.toBeNull()
|
||||
expect(host.querySelector('[data-team-run-attention]')).not.toBeNull()
|
||||
expect(host.querySelector('[data-team-run-task-list]')).toBeNull()
|
||||
})
|
||||
|
||||
it('emits management recovery actions by attention type', async () => {
|
||||
const actions: string[] = []
|
||||
const value = run({ attentionItems: [
|
||||
{ id: 'failed', type: 'failure', severity: 'error', priority: 1, taskId: '1', message: 'Failed', createdAt: null },
|
||||
{ id: 'stale', type: 'stale', severity: 'error', priority: 2, taskId: '2', message: 'Stale', createdAt: null },
|
||||
{ id: 'review', type: 'review', severity: 'action', priority: 0, taskId: '3', message: 'Review', createdAt: null },
|
||||
{ id: 'blocked', type: 'blocked', severity: 'error', priority: 3, taskId: '4', message: 'Dependency pending', createdAt: null },
|
||||
] })
|
||||
const host = mount(TeamRunAttention, {
|
||||
run: value,
|
||||
managementActions: true,
|
||||
onViewTask: (id: string) => actions.push(`view:${id}`),
|
||||
onRetryTask: (id: string) => actions.push(`retry:${id}`),
|
||||
onApproveTask: (id: string) => actions.push(`approve:${id}`),
|
||||
})
|
||||
host.querySelector<HTMLButtonElement>('[data-attention-view-task="1"]')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[data-attention-retry-task="1"]')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[data-attention-retry-task="2"]')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[data-attention-approve-task="3"]')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[data-attention-view-task="4"]')!.click()
|
||||
await nextTick()
|
||||
expect(actions).toEqual(['view:1', 'retry:1', 'retry:2', 'approve:3', 'view:4'])
|
||||
})
|
||||
|
||||
it('keeps shared attention cards read-only outside Teams management context', () => {
|
||||
const host = mount(TeamRunAttention, { run: run() })
|
||||
expect(host.querySelector('[data-team-run-attention-actions]')).toBeNull()
|
||||
expect(host.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('disables and marks only the pending task action as busy', () => {
|
||||
const value = run({ attentionItems: [{ id: 'failed', type: 'failed', severity: 'error', priority: 1, taskId: '1', message: 'Failed', createdAt: null }] })
|
||||
const host = mount(TeamRunAttention, { run: value, managementActions: true, pendingActions: ['1:retry'] })
|
||||
const retry = host.querySelector<HTMLButtonElement>('[data-attention-retry-task="1"]')!
|
||||
const view = host.querySelector<HTMLButtonElement>('[data-attention-view-task="1"]')!
|
||||
expect(retry.disabled).toBe(true)
|
||||
expect(retry.getAttribute('aria-busy')).toBe('true')
|
||||
expect(view.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import detail from '../TeamRunReadingSurface.vue?raw'
|
||||
import drawer from '../TeamRunDrawer.vue?raw'
|
||||
import teamsView from '../../../views/Teams.vue?raw'
|
||||
|
||||
describe('Team Run visual contract', () => {
|
||||
it('keeps dense reading surfaces opaque and responsive', () => {
|
||||
expect(detail).toContain('background: var(--mc-team-run-reading-bg')
|
||||
expect(detail).toContain('overflow-wrap: anywhere')
|
||||
expect(detail).toContain('overflow-x: auto')
|
||||
expect(detail).toContain('max-width: 100%')
|
||||
})
|
||||
|
||||
it('keeps markdown table cells atomic while allowing horizontal scrolling', () => {
|
||||
expect(detail).toContain('.markdown-body th)')
|
||||
expect(detail).toContain('.markdown-body td)')
|
||||
expect(detail).toContain('overflow-wrap: normal')
|
||||
expect(detail).toContain('word-break: normal')
|
||||
expect(detail).toContain('white-space: nowrap')
|
||||
expect(detail).toContain('width: 100%')
|
||||
expect(detail).toContain('width: max-content')
|
||||
expect(detail).toContain('min-width: 6.5rem')
|
||||
})
|
||||
|
||||
it('limits glass to drawer chrome with accessibility fallbacks', () => {
|
||||
expect(drawer).toContain('backdrop-filter: blur(')
|
||||
expect(drawer).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
expect(drawer).toContain('@media (prefers-reduced-transparency: reduce)')
|
||||
expect(drawer).toContain('@supports not (backdrop-filter: blur(1px))')
|
||||
})
|
||||
|
||||
it('keeps team header actions compact and readable on mobile', () => {
|
||||
expect(teamsView).toContain('class="detail-action-label"')
|
||||
expect(teamsView).toContain('<Refresh')
|
||||
expect(teamsView).toContain('<Delete')
|
||||
expect(teamsView).toContain('aria-label')
|
||||
expect(teamsView).toContain('.detail-action-label')
|
||||
expect(teamsView).toContain('display: none;')
|
||||
})
|
||||
|
||||
it('wires management attention actions through the Teams task handlers', () => {
|
||||
expect(teamsView).toContain('management-actions')
|
||||
expect(teamsView).toContain('@view-task="openAttentionTask"')
|
||||
expect(teamsView).toContain('@retry-task="retryAttentionTask"')
|
||||
expect(teamsView).toContain('@approve-task="approveAttentionTask"')
|
||||
expect(teamsView).toContain('currentTask.task.blockedBy')
|
||||
expect(teamsView).not.toContain('if (task) await openRunTask(task)')
|
||||
expect(teamsView).toContain('runHistory.select(task.runId, task.id)')
|
||||
expect(teamsView).toContain('useWorkspaceStore')
|
||||
expect(teamsView).toContain(':management-actions="canManageSelectedRun"')
|
||||
expect(teamsView).toContain(':pending-actions="attentionPendingActions"')
|
||||
})
|
||||
|
||||
it('keeps attention actions and focused task evidence inside mobile width', () => {
|
||||
expect(teamsView).toContain('management-actions')
|
||||
expect(drawer).toContain('width: min(620px, 94vw)')
|
||||
})
|
||||
})
|
||||
@ -8,6 +8,7 @@ import TeamRunsPanel from '../TeamRunsPanel.vue'
|
||||
const messages = { teamRuns: {
|
||||
history: 'Run history', refresh: 'Refresh', loading: 'Loading runs', empty: 'No runs yet',
|
||||
loadError: 'Could not load runs', retryLoad: 'Retry', close: 'Close', partialNotice: 'Some tasks did not complete.',
|
||||
loadMore: 'Load more', loadingMore: 'Loading more', detailLoading: 'Loading run details', detailUnavailable: 'Details unavailable',
|
||||
status: { planning: 'Planning', running: 'Running', awaiting_review: 'Awaiting review', finalizing: 'Finalizing', completed: 'Completed', partial: 'Partial', failed: 'Failed', cancelled: 'Cancelled' },
|
||||
duration: { day: 'd', hour: 'h', minute: 'm', second: 's' }, progress: '{done} of {total} complete',
|
||||
tasks: 'Tasks', emptyTasks: 'No tasks', assignee: 'Assignee', dependencies: 'Dependencies', noDependencies: 'None',
|
||||
@ -36,6 +37,16 @@ function mount(component: Component, props: Record<string, unknown>) {
|
||||
afterEach(() => { apps.splice(0).forEach(app => app.unmount()); document.body.innerHTML = '' })
|
||||
|
||||
describe('TeamRunsPanel', () => {
|
||||
it('offers an explicit load-more state', async () => {
|
||||
let loaded = 0
|
||||
const host = mount(TeamRunsPanel, { runs: [run()], hasMore: true, onLoadMore: () => { loaded++ } })
|
||||
host.querySelector<HTMLButtonElement>('[data-team-runs-load-more]')!.click()
|
||||
await nextTick()
|
||||
expect(loaded).toBe(1)
|
||||
const loadingHost = mount(TeamRunsPanel, { runs: [run()], hasMore: true, loadingMore: true })
|
||||
expect(loadingHost.querySelector<HTMLButtonElement>('[data-team-runs-load-more]')!.disabled).toBe(true)
|
||||
expect(loadingHost.textContent).toContain('Loading more')
|
||||
})
|
||||
it('renders compact run rows and emits selection without flattening tasks', async () => {
|
||||
let selected = ''
|
||||
const host = mount(TeamRunsPanel, { runs: [run()], onSelectRun: (value: TeamRun) => { selected = value.id } })
|
||||
@ -55,6 +66,32 @@ describe('TeamRunsPanel', () => {
|
||||
})
|
||||
|
||||
describe('TeamRunDrawer', () => {
|
||||
it('forwards Teams management attention actions while defaulting to read-only', async () => {
|
||||
const actions: string[] = []
|
||||
const attentionItems = [{ id: 'a', type: 'review', severity: 'action', priority: 0, taskId: '101', message: 'Review', createdAt: null }]
|
||||
const managed = mount(TeamRunDrawer, {
|
||||
run: run({ projectionCompleteness: 'full', attentionItems }), open: true, managementActions: true,
|
||||
onViewTask: (id: string) => actions.push(`view:${id}`),
|
||||
onApproveTask: (id: string) => actions.push(`approve:${id}`),
|
||||
})
|
||||
managed.querySelector<HTMLButtonElement>('[data-attention-view-task="101"]')!.click()
|
||||
managed.querySelector<HTMLButtonElement>('[data-attention-approve-task="101"]')!.click()
|
||||
await nextTick()
|
||||
expect(actions).toEqual(['view:101', 'approve:101'])
|
||||
const readonly = mount(TeamRunDrawer, { run: run({ projectionCompleteness: 'full', attentionItems }), open: true })
|
||||
expect(readonly.querySelector('[data-team-run-attention-actions]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows incomplete detail state and exposes retry without rendering empty evidence', async () => {
|
||||
let retries = 0
|
||||
const host = mount(TeamRunDrawer, { run: run({ projectionCompleteness: 'summary' }), open: true, detailLoading: true, onRetryDetail: () => { retries++ } })
|
||||
expect(host.textContent).toContain('Loading run details')
|
||||
expect(host.querySelector('[data-team-run-task-evidence]')).toBeNull()
|
||||
const failed = mount(TeamRunDrawer, { run: run({ projectionCompleteness: 'summary' }), open: true, detailError: 'offline', onRetryDetail: () => { retries++ } })
|
||||
failed.querySelector<HTMLButtonElement>('[data-team-run-detail-retry]')!.click()
|
||||
await nextTick()
|
||||
expect(retries).toBe(1)
|
||||
})
|
||||
it('shows partial state and forwards close and cancel actions', async () => {
|
||||
let closed = 0
|
||||
let cancelled = ''
|
||||
@ -70,4 +107,33 @@ describe('TeamRunDrawer', () => {
|
||||
expect(cancelled).toBe('20')
|
||||
expect(closed).toBe(1)
|
||||
})
|
||||
|
||||
it('cycles focus only at dialog boundaries, closes on Escape and returns focus', async () => {
|
||||
const opener = document.createElement('button')
|
||||
document.body.appendChild(opener)
|
||||
opener.focus()
|
||||
let closed = 0
|
||||
const host = mount(TeamRunDrawer, { run: run({
|
||||
deliverables: [{ id: 'd', name: 'Report', url: '/api/v1/files/generated/report.pdf', type: 'pdf', sourceTaskIds: [], sourceAgentIds: [], createdAt: null, verificationStatus: 'available' }],
|
||||
}), open: true, canCancel: true, onClose: () => { closed += 1 } })
|
||||
await nextTick()
|
||||
const close = host.querySelector<HTMLButtonElement>('[data-team-run-drawer-close]')!
|
||||
expect(document.activeElement).toBe(close)
|
||||
const link = host.querySelector<HTMLAnchorElement>('[data-team-run-deliverables] a')!
|
||||
const cancel = host.querySelector<HTMLButtonElement>('[data-team-run-cancel]')!
|
||||
link.focus()
|
||||
const middleTab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
|
||||
link.dispatchEvent(middleTab)
|
||||
expect(middleTab.defaultPrevented).toBe(false)
|
||||
cancel.focus()
|
||||
cancel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
|
||||
expect(document.activeElement).toBe(close)
|
||||
close.focus()
|
||||
close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }))
|
||||
expect(document.activeElement).toBe(cancel)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await nextTick()
|
||||
expect(closed).toBe(1)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,6 +3,12 @@ export { default as TeamRunDetail } from './TeamRunDetail.vue'
|
||||
export { default as TeamRunProgress } from './TeamRunProgress.vue'
|
||||
export { default as TeamRunStatus } from './TeamRunStatus.vue'
|
||||
export { default as TeamRunTaskList } from './TeamRunTaskList.vue'
|
||||
export { default as TeamRunOutcome } from './TeamRunOutcome.vue'
|
||||
export { default as TeamRunDeliverables } from './TeamRunDeliverables.vue'
|
||||
export { default as TeamRunAttention } from './TeamRunAttention.vue'
|
||||
export { default as TeamRunContributions } from './TeamRunContributions.vue'
|
||||
export { default as TeamRunTaskEvidence } from './TeamRunTaskEvidence.vue'
|
||||
export { default as TeamRunRuntime } from './TeamRunRuntime.vue'
|
||||
export { default as TeamRunsPanel } from './TeamRunsPanel.vue'
|
||||
export { default as TeamRunDrawer } from './TeamRunDrawer.vue'
|
||||
export * from './teamRunPresentation'
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import type { WorkspaceRole } from '@/composables/capabilities'
|
||||
|
||||
export type TeamAttentionAction = 'approve' | 'retry'
|
||||
|
||||
export interface TeamAttentionActionContext {
|
||||
teamId: string
|
||||
runId: string
|
||||
taskId: string
|
||||
}
|
||||
|
||||
export function canManageTeamRunAttention(
|
||||
role: WorkspaceRole | null,
|
||||
currentWorkspaceId: string | null,
|
||||
runWorkspaceId: string | null,
|
||||
) {
|
||||
return (role === 'admin' || role === 'owner')
|
||||
&& currentWorkspaceId !== null
|
||||
&& currentWorkspaceId === runWorkspaceId
|
||||
}
|
||||
|
||||
export function attentionActionKey(context: TeamAttentionActionContext, action: TeamAttentionAction) {
|
||||
return `${context.teamId}:${context.runId}:${context.taskId}:${action}`
|
||||
}
|
||||
|
||||
export async function runAttentionTaskAction(options: {
|
||||
context: TeamAttentionActionContext
|
||||
action: TeamAttentionAction
|
||||
pending: Set<string>
|
||||
execute: () => Promise<unknown>
|
||||
refresh: () => Promise<unknown>
|
||||
onError: (cause: unknown) => void
|
||||
}) {
|
||||
const key = attentionActionKey(options.context, options.action)
|
||||
if (options.pending.has(key)) return false
|
||||
options.pending.add(key)
|
||||
try {
|
||||
await options.execute()
|
||||
await options.refresh()
|
||||
return true
|
||||
} catch (cause) {
|
||||
options.onError(cause)
|
||||
return false
|
||||
} finally {
|
||||
options.pending.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAttentionTaskContext(options: {
|
||||
context: TeamAttentionActionContext
|
||||
currentTeamId: () => string | null
|
||||
currentTaskId: () => string | null
|
||||
reloadTask: () => Promise<unknown>
|
||||
refreshBoard: (teamId: string) => Promise<unknown>
|
||||
refreshRun: (runId: string, teamId: string) => Promise<unknown>
|
||||
}) {
|
||||
if (options.currentTeamId() !== options.context.teamId) return
|
||||
const operations: Promise<unknown>[] = [
|
||||
options.refreshBoard(options.context.teamId),
|
||||
options.refreshRun(options.context.runId, options.context.teamId),
|
||||
]
|
||||
if (options.currentTaskId() === options.context.taskId) operations.push(options.reloadTask())
|
||||
await Promise.all(operations)
|
||||
}
|
||||
9
mateclaw-ui/src/components/team-run/teamRunProjection.ts
Normal file
9
mateclaw-ui/src/components/team-run/teamRunProjection.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { TeamRun, TeamRunAttentionItem, TeamRunContribution, TeamRunDeliverable } from '@/api'
|
||||
import { extractRunDeliverables } from './teamRunPresentation'
|
||||
import { isSafeFileUrl } from '@/utils/generatedFileLinks'
|
||||
|
||||
export const runDeliverables = (run: TeamRun): TeamRunDeliverable[] => run.deliverables?.length
|
||||
? run.deliverables.filter(item => isSafeFileUrl(item.url))
|
||||
: extractRunDeliverables(run).map((item, index) => ({ id: `legacy:${index}:${item.url}`, name: item.name, url: item.url, type: 'file', sourceTaskIds: [], sourceAgentIds: [], createdAt: null, verificationStatus: 'legacy' }))
|
||||
export const runAttention = (run: TeamRun): TeamRunAttentionItem[] => [...(run.attentionItems ?? [])].sort((a, b) => a.priority - b.priority)
|
||||
export const runContributions = (run: TeamRun): TeamRunContribution[] => run.contributions ?? []
|
||||
@ -9,7 +9,7 @@ vi.mock('@/api', async (importOriginal) => {
|
||||
return {
|
||||
...original,
|
||||
teamApi: { ...original.teamApi, list: vi.fn() },
|
||||
teamRunApi: { ...original.teamRunApi, listByTeam: vi.fn(), get: vi.fn() },
|
||||
teamRunApi: { ...original.teamRunApi, listByTeam: vi.fn(), listByTeamPage: vi.fn(), get: vi.fn() },
|
||||
}
|
||||
})
|
||||
|
||||
@ -74,14 +74,12 @@ describe('projectAgentRunGroups', () => {
|
||||
})
|
||||
|
||||
describe('useAgentRunGroups hydration priority', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
|
||||
it('keeps an explicit route run when a later snapshot refresh completes first', async () => {
|
||||
const routeDetail = deferred<unknown>()
|
||||
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
||||
vi.mocked(teamRunApi.listByTeam)
|
||||
.mockResolvedValueOnce({ data: [] } as never)
|
||||
.mockResolvedValueOnce({ data: [run('running', [])] } as never)
|
||||
vi.mocked(teamRunApi.listByTeamPage).mockResolvedValue({ data: { items: [], nextCursor: null } } as never)
|
||||
vi.mocked(teamRunApi.get).mockReturnValue(routeDetail.promise as never)
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
|
||||
@ -92,6 +90,164 @@ describe('useAgentRunGroups hydration priority', () => {
|
||||
await routeLoad
|
||||
|
||||
expect(groups.runs.value.map(item => item.id)).toContain('historical')
|
||||
expect(teamRunApi.get).toHaveBeenCalledTimes(1)
|
||||
expect(teamRunApi.get).toHaveBeenCalledWith('historical')
|
||||
})
|
||||
|
||||
it('uses bounded active summary pages with at most three concurrent team requests', async () => {
|
||||
const firstTeamRuns = Array.from({ length: 3 }, (_, index) => {
|
||||
const runId = `run-${index}`
|
||||
return {
|
||||
...run('running', [{
|
||||
...task(`${index}`, 1, 'in_progress', `worker-${index}`),
|
||||
runId,
|
||||
}]),
|
||||
id: runId,
|
||||
projectionCompleteness: 'summary',
|
||||
}
|
||||
})
|
||||
const secondTeamRun = {
|
||||
...run('running', [{
|
||||
...task('3', 1, 'in_progress', 'worker-3'),
|
||||
teamId: '11', runId: 'run-3',
|
||||
}]),
|
||||
id: 'run-3', teamId: '11', projectionCompleteness: 'summary',
|
||||
}
|
||||
const teamIds = ['10', '11', '12', '13', '14']
|
||||
const gates = teamIds.map(() => deferred<unknown>())
|
||||
let active = 0
|
||||
let peak = 0
|
||||
vi.mocked(teamApi.list).mockResolvedValue({
|
||||
data: teamIds.map(id => ({ team: { id } })),
|
||||
} as never)
|
||||
vi.mocked(teamRunApi.listByTeamPage).mockImplementation((teamId) => {
|
||||
const index = teamIds.indexOf(teamId)
|
||||
active += 1
|
||||
peak = Math.max(peak, active)
|
||||
return gates[index].promise.finally(() => { active -= 1 }) as never
|
||||
})
|
||||
vi.mocked(teamRunApi.get).mockResolvedValue({ data: run('running', []) } as never)
|
||||
const groups = useAgentRunGroups(ref(snapshot([live('worker-0')])))
|
||||
|
||||
const refresh = groups.refreshForSnapshot()
|
||||
const duplicate = groups.refreshForSnapshot()
|
||||
expect(duplicate).toBe(refresh)
|
||||
await vi.waitFor(() => expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(3))
|
||||
gates[0].resolve({ data: { items: firstTeamRuns, nextCursor: null } })
|
||||
gates[1].resolve({ data: { items: [secondTeamRun], nextCursor: null } })
|
||||
gates[2].resolve({ data: { items: [], nextCursor: null } })
|
||||
await vi.waitFor(() => expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(5))
|
||||
gates[3].resolve({ data: { items: [], nextCursor: null } })
|
||||
gates[4].resolve({ data: { items: [], nextCursor: null } })
|
||||
await Promise.all([refresh, duplicate])
|
||||
|
||||
expect(teamApi.list).toHaveBeenCalledTimes(1)
|
||||
expect(peak).toBeLessThanOrEqual(3)
|
||||
expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(5)
|
||||
for (const teamId of teamIds) {
|
||||
expect(teamRunApi.listByTeamPage).toHaveBeenCalledWith(teamId, { activeOnly: true, limit: 50 })
|
||||
}
|
||||
expect(teamRunApi.listByTeam).not.toHaveBeenCalled()
|
||||
expect(teamRunApi.get).not.toHaveBeenCalled()
|
||||
expect(groups.runs.value).toHaveLength(4)
|
||||
expect(groups.projection.value.groups[0].workers[0].task.conversationId).toBe('worker-0')
|
||||
expect(groups.projection.value.groups[0].workers[0].task.runId)
|
||||
.toBe(groups.projection.value.groups[0].run.id)
|
||||
expect(groups.projection.value.groups[0].workers[0].task.description).toBeNull()
|
||||
expect(groups.projection.value.groups[0].workers[0].task.result).toBeNull()
|
||||
|
||||
await groups.refreshForSnapshot()
|
||||
expect(teamApi.list).toHaveBeenCalledTimes(2)
|
||||
expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(10)
|
||||
})
|
||||
|
||||
it('follows each team cursor until all bounded active pages are merged', async () => {
|
||||
const firstPage = Array.from({ length: 50 }, (_, index) => ({
|
||||
...run('running', []), id: `run-${index}`, projectionCompleteness: 'summary',
|
||||
}))
|
||||
const finalRun = { ...run('running', []), id: 'run-50', projectionCompleteness: 'summary' }
|
||||
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
||||
vi.mocked(teamRunApi.listByTeamPage)
|
||||
.mockResolvedValueOnce({ data: { items: firstPage, nextCursor: 'cursor-2' } } as never)
|
||||
.mockResolvedValueOnce({ data: { items: [finalRun], nextCursor: null } } as never)
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
|
||||
await groups.refreshForSnapshot()
|
||||
|
||||
expect(teamRunApi.listByTeamPage).toHaveBeenNthCalledWith(1, '10', {
|
||||
activeOnly: true, limit: 50,
|
||||
})
|
||||
expect(teamRunApi.listByTeamPage).toHaveBeenNthCalledWith(2, '10', {
|
||||
activeOnly: true, cursor: 'cursor-2', limit: 50,
|
||||
})
|
||||
expect(groups.runs.value).toHaveLength(51)
|
||||
expect(teamRunApi.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails the refresh when a team page repeats its cursor', async () => {
|
||||
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
||||
vi.mocked(teamRunApi.listByTeamPage)
|
||||
.mockResolvedValueOnce({ data: { items: [run('running', [])], nextCursor: 'loop' } } as never)
|
||||
.mockResolvedValueOnce({ data: { items: [], nextCursor: 'loop' } } as never)
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
|
||||
await expect(groups.refreshForSnapshot()).rejects.toThrow('cursor')
|
||||
|
||||
expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(2)
|
||||
expect(groups.runs.value).toEqual([])
|
||||
expect(groups.error.value).toContain('cursor')
|
||||
})
|
||||
|
||||
it('stops claiming teams on first failure but settles started loads before releasing single-flight', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
vi.mocked(teamApi.list)
|
||||
.mockResolvedValueOnce({
|
||||
data: ['10', '11', '12', '13'].map(id => ({ team: { id } })),
|
||||
} as never)
|
||||
.mockResolvedValueOnce({ data: [] } as never)
|
||||
vi.mocked(teamRunApi.listByTeamPage).mockImplementation((teamId) => {
|
||||
if (teamId === '10') return Promise.reject(new Error('team 10 failed')) as never
|
||||
if (teamId === '11') return pending.promise as never
|
||||
return Promise.resolve({ data: { items: [], nextCursor: null } }) as never
|
||||
})
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
|
||||
const first = groups.refreshForSnapshot()
|
||||
let settled = false
|
||||
void first.finally(() => { settled = true }).catch(() => undefined)
|
||||
await vi.waitFor(() => expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(3))
|
||||
const duplicate = groups.refreshForSnapshot()
|
||||
|
||||
expect(duplicate).toBe(first)
|
||||
expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(3)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
pending.resolve({ data: { items: [], nextCursor: null } })
|
||||
await expect(first).rejects.toThrow('team 10 failed')
|
||||
expect(settled).toBe(true)
|
||||
expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(3)
|
||||
|
||||
const next = groups.refreshForSnapshot()
|
||||
expect(next).not.toBe(first)
|
||||
await next
|
||||
expect(teamApi.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not publish a paged refresh that finishes after close', async () => {
|
||||
const page = deferred<unknown>()
|
||||
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
||||
vi.mocked(teamRunApi.listByTeamPage).mockReturnValue(page.promise as never)
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
|
||||
const refresh = groups.refreshForSnapshot()
|
||||
await vi.waitFor(() => expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(1))
|
||||
groups.close()
|
||||
page.resolve({ data: { items: [run('running', [])], nextCursor: null } })
|
||||
await refresh
|
||||
|
||||
expect(groups.runs.value).toEqual([])
|
||||
expect(groups.loading.value).toBe(false)
|
||||
expect(groups.error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -104,10 +260,18 @@ describe('useAgentRunGroups live scope', () => {
|
||||
|
||||
expect(result.groups.map(group => group.run.status)).toEqual(['running'])
|
||||
})
|
||||
|
||||
it('does not claim active animation without credible liveness or runtime evidence', () => {
|
||||
const quiet = { ...run('running', [task('1', 1, 'in_progress', 'worker')]), liveness: { state: 'quiet' as const, lastActivityAt: null } }
|
||||
const result = projectAgentRunGroups(snapshot([]), [quiet])
|
||||
expect(result.groups[0].state).toBe('waiting')
|
||||
expect(result.groups[0].workers[0].state).toBe('waiting')
|
||||
})
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(done => { resolve = done })
|
||||
return { promise, resolve }
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
parseTeamSseFrames,
|
||||
subscribeTeamEvents,
|
||||
type TeamBoardEvent,
|
||||
} from '@/composables/useTeamEvents'
|
||||
|
||||
function response(body: string): Response {
|
||||
@ -59,10 +60,10 @@ describe('parseTeamSseFrames', () => {
|
||||
describe('subscribeTeamEvents', () => {
|
||||
it('reconnects with Last-Event-ID and de-duplicates replayed ids', async () => {
|
||||
const fetchImpl = vi.fn()
|
||||
.mockResolvedValueOnce(response('id: 7\nevent: team_task_progress\ndata: {"step":1}\n\n'))
|
||||
.mockResolvedValueOnce(response('id: 7\nevent: team_task_progress\ndata: {"runId":"10","taskId":"101","step":1}\n\n'))
|
||||
.mockResolvedValueOnce(response(
|
||||
'id: 7\nevent: team_task_progress\ndata: {"step":1}\n\n'
|
||||
+ 'id: 8\r\nevent: team_run_progress\r\ndata: {"step":2}\r\n\r\n',
|
||||
'id: 7\nevent: team_task_progress\ndata: {"runId":"10","taskId":"101","step":1}\n\n'
|
||||
+ 'id: 8\r\nevent: team_run_progress\r\ndata: {"runId":"10","step":2}\r\n\r\n',
|
||||
)) as unknown as typeof fetch
|
||||
const { timers, options } = dependencies(fetchImpl)
|
||||
const events: Array<{ id?: string; event: string }> = []
|
||||
@ -78,6 +79,190 @@ describe('subscribeTeamEvents', () => {
|
||||
stop()
|
||||
})
|
||||
|
||||
it('merges the same run event across three replay paths exactly once', async () => {
|
||||
const replay = 'id: 77\nevent: team_task_completed\ndata: {"runId":"10","taskId":"101"}\n\n'
|
||||
const fetchImpl = vi.fn()
|
||||
.mockResolvedValueOnce(response(replay))
|
||||
.mockResolvedValueOnce(response(replay))
|
||||
.mockResolvedValueOnce(response(replay)) as unknown as typeof fetch
|
||||
const { timers, options } = dependencies(fetchImpl)
|
||||
const events: Array<{ id?: string; event: string }> = []
|
||||
const stop = subscribeTeamEvents('team-1', event => events.push(event), options)
|
||||
|
||||
await vi.waitFor(() => expect(timers).toHaveLength(1))
|
||||
timers.shift()!.callback()
|
||||
await vi.waitFor(() => expect(timers).toHaveLength(1))
|
||||
timers.shift()!.callback()
|
||||
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(3))
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('deduplicates one action mirrored through parent worker and team streams', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(response(
|
||||
'id: 1\nevent: team_task_in_review\ndata: {"runId":"10","taskId":"101","actionId":"a7","conversationId":"lead"}\n\n'
|
||||
+ 'id: 2\nevent: team_task_in_review\ndata: {"runId":"10","taskId":"101","actionId":"a7","conversationId":"worker"}\n\n'
|
||||
+ 'id: 3\nevent: team_task_in_review\ndata: {"runId":"10","taskId":"101","actionId":"a7"}\n\n',
|
||||
)) as unknown as typeof fetch
|
||||
const { options } = dependencies(fetchImpl)
|
||||
const events: string[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => events.push(String(event.data.conversationId ?? 'team')), options)
|
||||
|
||||
await vi.waitFor(() => expect(events).toEqual(['lead']))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('delivers lifecycle transitions that share an action id', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(response(
|
||||
'id: 1\nevent: team_task_approval_required\ndata: {"runId":"10","taskId":"101","actionId":"a7","conversationId":"lead"}\n\n'
|
||||
+ 'id: 2\nevent: team_task_completed\ndata: {"runId":"10","taskId":"101","actionId":"a7","conversationId":"worker"}\n\n',
|
||||
)) as unknown as typeof fetch
|
||||
const { options } = dependencies(fetchImpl)
|
||||
const events: string[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => events.push(event.event), options)
|
||||
|
||||
await vi.waitFor(() => expect(events).toEqual([
|
||||
'team_task_approval_required',
|
||||
'team_task_completed',
|
||||
]))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('does not merge different actions or conversation-scoped stream events', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(response(
|
||||
'id: 7\nevent: team_task_in_review\ndata: {"actionId":"a1","conversationId":"lead"}\n\n'
|
||||
+ 'id: 8\nevent: team_task_in_review\ndata: {"actionId":"a2","conversationId":"lead"}\n\n'
|
||||
+ 'id: 9\nevent: team_task_progress\ndata: {"conversationId":"lead"}\n\n'
|
||||
+ 'id: 9\nevent: team_task_progress\ndata: {"conversationId":"worker"}\n\n',
|
||||
)) as unknown as typeof fetch
|
||||
const { options } = dependencies(fetchImpl)
|
||||
const events: string[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => events.push(`${event.data.actionId ?? 'progress'}:${event.data.conversationId}`), options)
|
||||
|
||||
await vi.waitFor(() => expect(events).toEqual(['a1:lead', 'a2:lead', 'progress:lead', 'progress:worker']))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('does not merge unscoped actions that reuse an action id on different stream events', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(response(
|
||||
'id: 7\nevent: team_task_in_review\ndata: {"actionId":"local-1"}\n\n'
|
||||
+ 'id: 8\nevent: team_task_in_review\ndata: {"actionId":"local-1"}\n\n',
|
||||
)) as unknown as typeof fetch
|
||||
const { options } = dependencies(fetchImpl)
|
||||
const ids: string[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => ids.push(event.id!), options)
|
||||
|
||||
await vi.waitFor(() => expect(ids).toEqual(['7', '8']))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('drops an oversized incomplete remainder without losing preceding complete frames', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(response(
|
||||
`id: 1\nevent: team_run_progress\ndata: {"runId":"1"}\n\ndata: ${'x'.repeat(2_000)}`,
|
||||
)) as unknown as typeof fetch
|
||||
const { timers, options } = dependencies(fetchImpl)
|
||||
const events: TeamBoardEvent[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => events.push(event), { ...options, maxBufferBytes: 1_024 })
|
||||
|
||||
await vi.waitFor(() => expect(timers).toHaveLength(1))
|
||||
expect(events.map(event => event.id)).toEqual(['1'])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('accepts a large network chunk made of complete bounded frames', async () => {
|
||||
const frames = Array.from({ length: 30 }, (_, index) =>
|
||||
`id: ${index}\nevent: team_run_progress\ndata: {"runId":"${index}","detail":"${'x'.repeat(80)}"}\n\n`,
|
||||
).join('')
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(response(frames)) as unknown as typeof fetch
|
||||
const { options } = dependencies(fetchImpl)
|
||||
const events: TeamBoardEvent[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => events.push(event), { ...options, maxBufferBytes: 1_024 })
|
||||
|
||||
await vi.waitFor(() => expect(events).toHaveLength(30))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('recovers on the same stream after discarding an oversized incomplete remainder', async () => {
|
||||
const chunks = [
|
||||
new TextEncoder().encode(`data: ${'x'.repeat(2_000)}`),
|
||||
new TextEncoder().encode('discarded tail\n\nid: 2\nevent: team_run_progress\ndata: {"runId":"2"}\n\n'),
|
||||
]
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => ({ read: () => chunks.length
|
||||
? Promise.resolve({ done: false, value: chunks.shift()! })
|
||||
: new Promise(() => {}) }) },
|
||||
} as unknown as Response) as unknown as typeof fetch
|
||||
const { timers, options } = dependencies(fetchImpl)
|
||||
const ids: string[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => ids.push(event.id!), { ...options, maxBufferBytes: 1_024 })
|
||||
|
||||
await vi.waitFor(() => expect(ids).toEqual(['2']))
|
||||
expect(timers).toHaveLength(0)
|
||||
expect(fetchImpl).toHaveBeenCalledOnce()
|
||||
stop()
|
||||
})
|
||||
|
||||
it('does not dispatch a read that resolves after the subscription stops', async () => {
|
||||
let resolveRead!: (value: ReadableStreamReadResult<Uint8Array>) => void
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: { getReader: () => ({ read: () => new Promise(resolve => { resolveRead = resolve }) }) },
|
||||
} as unknown as Response) as unknown as typeof fetch
|
||||
const { options } = dependencies(fetchImpl)
|
||||
const onEvent = vi.fn()
|
||||
const stop = subscribeTeamEvents('team-1', onEvent, options)
|
||||
await vi.waitFor(() => expect(resolveRead).toBeTypeOf('function'))
|
||||
|
||||
stop()
|
||||
resolveRead({ done: false, value: new TextEncoder().encode('id: 9\nevent: team_run_progress\ndata: {"runId":"10"}\n\n') })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not merge equal event ids that belong to different runs', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(response(
|
||||
'id: 77\nevent: team_run_progress\ndata: {"runId":"10"}\n\n'
|
||||
+ 'id: 77\nevent: team_run_progress\ndata: {"runId":"11"}\n\n',
|
||||
)) as unknown as typeof fetch
|
||||
const { options } = dependencies(fetchImpl)
|
||||
const runIds: string[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => runIds.push(String(event.data.runId)), options)
|
||||
|
||||
await vi.waitFor(() => expect(runIds).toEqual(['10', '11']))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('does not globally merge compatibility events without an event id', async () => {
|
||||
const frame = 'event: team_task_progress\ndata: {"runId":"10","taskId":"101"}\n\n'
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(response(frame + frame)) as unknown as typeof fetch
|
||||
const { options } = dependencies(fetchImpl)
|
||||
const events: string[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => events.push(event.event), options)
|
||||
|
||||
await vi.waitFor(() => expect(events).toHaveLength(2))
|
||||
stop()
|
||||
})
|
||||
|
||||
it('deduplicates a reconnect replay by stream id when run id is absent', async () => {
|
||||
const replay = 'id: 88\nevent: workspace_status\ndata: {"status":"ready"}\n\n'
|
||||
const fetchImpl = vi.fn()
|
||||
.mockResolvedValueOnce(response(replay))
|
||||
.mockResolvedValueOnce(response(replay)) as unknown as typeof fetch
|
||||
const { timers, options } = dependencies(fetchImpl)
|
||||
const events: string[] = []
|
||||
const stop = subscribeTeamEvents('team-1', event => events.push(event.event), options)
|
||||
|
||||
await vi.waitFor(() => expect(timers).toHaveLength(1))
|
||||
timers.shift()!.callback()
|
||||
await vi.waitFor(() => expect(timers).toHaveLength(1))
|
||||
|
||||
expect(events).toEqual(['workspace_status'])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('uses exponential backoff for consecutive disconnects', async () => {
|
||||
const fetchImpl = vi.fn().mockRejectedValue(new Error('offline')) as unknown as typeof fetch
|
||||
const { timers, options } = dependencies(fetchImpl)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TeamRun } from '@/api'
|
||||
import { AxiosHeaders, type AxiosResponse } from 'axios'
|
||||
import { teamRunApi, type TeamRun } from '@/api'
|
||||
import {
|
||||
buildTeamsRouteQuery,
|
||||
clearTeamsRunSelection,
|
||||
@ -18,6 +19,10 @@ function run(id: string, createTime: string | null, status: TeamRun['status'] =
|
||||
}
|
||||
}
|
||||
|
||||
function axiosResponse<T>(data: T): AxiosResponse<T> {
|
||||
return { data, status: 200, statusText: 'OK', headers: new AxiosHeaders(), config: { headers: new AxiosHeaders() } }
|
||||
}
|
||||
|
||||
describe('teams run routes', () => {
|
||||
it('hydrates string ids and defaults an opened team to runs', () => {
|
||||
expect(parseTeamsRouteQuery({ teamId: '10', runId: '20', taskId: '30' })).toEqual({
|
||||
@ -57,6 +62,180 @@ describe('teams run routes', () => {
|
||||
})
|
||||
|
||||
describe('useTeamRunHistory', () => {
|
||||
it('uses the paged team API for the first page and cursor continuation', async () => {
|
||||
const page = vi.spyOn(teamRunApi, 'listByTeamPage')
|
||||
.mockResolvedValueOnce(axiosResponse({ items: [run('2', '2026-02-01')], nextCursor: 'older' }))
|
||||
.mockResolvedValueOnce(axiosResponse({ items: [run('1', '2026-01-01')], nextCursor: null }))
|
||||
const legacy = vi.spyOn(teamRunApi, 'listByTeam').mockResolvedValue({ data: [] } as never)
|
||||
const history = useTeamRunHistory({ subscribe: () => vi.fn() })
|
||||
|
||||
await history.open('10')
|
||||
await history.loadMore()
|
||||
|
||||
expect(page).toHaveBeenNthCalledWith(1, '10', { limit: 20 })
|
||||
expect(page).toHaveBeenNthCalledWith(2, '10', { cursor: 'older', limit: 20 })
|
||||
expect(legacy).not.toHaveBeenCalled()
|
||||
expect(history.runs.value.map(item => item.id)).toEqual(['2', '1'])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads the next cursor page and merges duplicate runs without replacing newer details', async () => {
|
||||
const listByTeam = vi.fn()
|
||||
.mockResolvedValueOnce({ data: { items: [run('2', '2026-02-01'), run('1', '2026-01-01')], nextCursor: 'c2' } })
|
||||
.mockResolvedValueOnce({ data: { items: [run('1', '2026-01-01'), run('0', '2025-12-01')], nextCursor: null } })
|
||||
const history = useTeamRunHistory({ api: { listByTeam, get: vi.fn() }, subscribe: () => vi.fn() })
|
||||
await history.open('10')
|
||||
await history.loadMore()
|
||||
expect(listByTeam).toHaveBeenNthCalledWith(2, '10', 'c2')
|
||||
expect(history.runs.value.map(item => item.id)).toEqual(['2', '1', '0'])
|
||||
expect(history.nextCursor.value).toBeNull()
|
||||
})
|
||||
|
||||
it('immediately resets pagination when the team changes during loadMore and ignores the old page', async () => {
|
||||
const oldPage = deferred<unknown>()
|
||||
const newTeam = deferred<unknown>()
|
||||
const listByTeam = vi.fn()
|
||||
.mockResolvedValueOnce({ data: { items: [run('10', '2026-02-01')], nextCursor: 'older-10' } })
|
||||
.mockReturnValueOnce(oldPage.promise)
|
||||
.mockReturnValueOnce(newTeam.promise)
|
||||
const history = useTeamRunHistory({ api: { listByTeam, get: vi.fn() }, subscribe: () => vi.fn() })
|
||||
await history.open('10')
|
||||
const loadingOldPage = history.loadMore()
|
||||
|
||||
const openingNewTeam = history.open('20')
|
||||
expect(history.nextCursor.value).toBeNull()
|
||||
expect(history.loadingMore.value).toBe(false)
|
||||
newTeam.resolve({ data: { items: [{ ...run('20', '2026-03-01'), teamId: '20' }], nextCursor: 'older-20' } })
|
||||
await openingNewTeam
|
||||
oldPage.resolve({ data: { items: [run('9', '2026-01-01')], nextCursor: null } })
|
||||
await loadingOldPage
|
||||
|
||||
expect(history.runs.value.map(item => item.id)).toEqual(['20'])
|
||||
expect(history.nextCursor.value).toBe('older-20')
|
||||
expect(history.loadingMore.value).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks detail loading and detail errors independently from list state', async () => {
|
||||
const detail = deferred<unknown>()
|
||||
const history = useTeamRunHistory({ api: { listByTeam: vi.fn().mockResolvedValue({ data: [] }), get: vi.fn().mockReturnValue(detail.promise) }, subscribe: () => vi.fn() })
|
||||
await history.open('10')
|
||||
const pending = history.refreshRun('1', '10')
|
||||
expect(history.detailLoading.value).toBe(true)
|
||||
expect(history.loading.value).toBe(false)
|
||||
detail.reject(new Error('detail unavailable'))
|
||||
await pending
|
||||
expect(history.detailError.value).toBe('detail unavailable')
|
||||
expect(history.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('does not let a background SSE refresh for run B change run A drawer detail state', async () => {
|
||||
let callback: ((event: { event: string; data: Record<string, unknown> }) => void) | undefined
|
||||
const detailA = deferred<unknown>()
|
||||
const detailB = deferred<unknown>()
|
||||
const get = vi.fn((runId: string) => runId === 'A' ? detailA.promise : detailB.promise)
|
||||
const timers: Array<() => void> = []
|
||||
const history = useTeamRunHistory({
|
||||
api: {
|
||||
listByTeam: vi.fn().mockResolvedValue({ data: [
|
||||
{ ...run('A', '2026-02-02'), projectionCompleteness: 'summary' },
|
||||
{ ...run('B', '2026-02-01'), projectionCompleteness: 'summary' },
|
||||
] }),
|
||||
get,
|
||||
},
|
||||
subscribe: (_teamId, handler) => { callback = handler; return vi.fn() },
|
||||
setTimeoutImpl: handler => { timers.push(handler); return handler },
|
||||
})
|
||||
await history.open('10')
|
||||
history.select('A')
|
||||
|
||||
const selectedDetail = history.ensureSelectedRunDetail('A', null, '10')
|
||||
expect(history.detailLoading.value).toBe(true)
|
||||
callback?.({ event: 'team_run_progress', data: { runId: 'B' } })
|
||||
timers.at(-1)?.()
|
||||
await vi.waitFor(() => expect(get).toHaveBeenCalledWith('B'))
|
||||
|
||||
detailB.reject(new Error('run B unavailable'))
|
||||
await vi.waitFor(() => expect(get).toHaveBeenCalledTimes(2))
|
||||
expect(history.detailLoading.value).toBe(true)
|
||||
expect(history.detailError.value).toBeNull()
|
||||
|
||||
detailA.resolve({ data: { ...run('A', '2026-02-02'), projectionCompleteness: 'full' } })
|
||||
await selectedDetail
|
||||
expect(history.detailLoading.value).toBe(false)
|
||||
expect(history.detailError.value).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a same-run foreground detail valid when a later silent SSE refresh fails first', async () => {
|
||||
let callback: ((event: { event: string; data: Record<string, unknown> }) => void) | undefined
|
||||
const foreground = deferred<unknown>()
|
||||
const background = deferred<unknown>()
|
||||
const get = vi.fn()
|
||||
.mockReturnValueOnce(foreground.promise)
|
||||
.mockReturnValueOnce(background.promise)
|
||||
const timers: Array<() => void> = []
|
||||
const summary = { ...run('A', '2026-02-02'), projectionCompleteness: 'summary' as const }
|
||||
const history = useTeamRunHistory({
|
||||
api: { listByTeam: vi.fn().mockResolvedValue({ data: [summary] }), get },
|
||||
subscribe: (_teamId, handler) => { callback = handler; return vi.fn() },
|
||||
setTimeoutImpl: handler => { timers.push(handler); return handler },
|
||||
})
|
||||
await history.open('10')
|
||||
history.select('A')
|
||||
|
||||
const selectedDetail = history.ensureSelectedRunDetail('A', null, '10')
|
||||
callback?.({ event: 'team_run_progress', data: { runId: 'A' } })
|
||||
timers.at(-1)?.()
|
||||
await vi.waitFor(() => expect(get).toHaveBeenCalledTimes(2))
|
||||
|
||||
background.reject(new Error('background unavailable'))
|
||||
await vi.waitFor(() => expect(history.detailLoading.value).toBe(true))
|
||||
foreground.resolve({ data: { ...summary, projectionCompleteness: 'full' as const, finalSummary: 'complete detail' } })
|
||||
await selectedDetail
|
||||
|
||||
expect(history.selectedRun.value?.projectionCompleteness).toBe('full')
|
||||
expect(history.selectedRun.value?.finalSummary).toBe('complete detail')
|
||||
expect(history.detailLoading.value).toBe(false)
|
||||
expect(history.detailError.value).toBeNull()
|
||||
})
|
||||
it('hydrates a selected summary projection to full while preserving the selected task', async () => {
|
||||
const summary = { ...run('1', '2026-01-01'), projectionCompleteness: 'summary' }
|
||||
const full = { ...summary, projectionCompleteness: 'full', tasks: [{ id: 'task-1' }] }
|
||||
const get = vi.fn().mockResolvedValue({ data: full })
|
||||
const history = useTeamRunHistory({ api: { listByTeam: vi.fn().mockResolvedValue({ data: { items: [summary], nextCursor: null } }), get }, subscribe: () => vi.fn() })
|
||||
await history.open('10')
|
||||
history.select('1', 'task-1')
|
||||
await history.ensureSelectedRunDetail('1', 'task-1', '10')
|
||||
expect(get).toHaveBeenCalledWith('1')
|
||||
expect(history.selectedRun.value?.projectionCompleteness).toBe('full')
|
||||
expect(history.selectedTaskId.value).toBe('task-1')
|
||||
})
|
||||
|
||||
it('does not let a stale summary hydration replace newer run and task selection', async () => {
|
||||
const detail = deferred<unknown>()
|
||||
const history = useTeamRunHistory({
|
||||
api: { listByTeam: vi.fn().mockResolvedValue({ data: [
|
||||
{ ...run('1', '2026-01-01'), projectionCompleteness: 'summary' },
|
||||
{ ...run('2', '2026-01-02'), projectionCompleteness: 'full' },
|
||||
] }), get: vi.fn().mockReturnValue(detail.promise) }, subscribe: () => vi.fn(),
|
||||
})
|
||||
await history.open('10')
|
||||
history.select('1', 'task-a')
|
||||
const pending = history.ensureSelectedRunDetail('1', 'task-a', '10')
|
||||
history.select('2', 'task-b')
|
||||
detail.resolve({ data: { ...run('1', '2026-01-01'), projectionCompleteness: 'full' } })
|
||||
await pending
|
||||
expect(history.selectedRunId.value).toBe('2')
|
||||
expect(history.selectedTaskId.value).toBe('task-b')
|
||||
})
|
||||
it('loads the new paged team history response', async () => {
|
||||
const history = useTeamRunHistory({
|
||||
api: { listByTeam: vi.fn().mockResolvedValue({ data: { items: [run('1', '2026-01-01')], nextCursor: 'next' } }), get: vi.fn() },
|
||||
subscribe: () => vi.fn(),
|
||||
})
|
||||
await history.open('10')
|
||||
expect(history.runs.value.map(item => item.id)).toEqual(['1'])
|
||||
expect(history.nextCursor.value).toBe('next')
|
||||
})
|
||||
it('keeps an SSE detail overlay when the initial list resolves later', async () => {
|
||||
let resolveList!: (value: unknown) => void
|
||||
let callback: ((event: { event: string; data: Record<string, unknown> }) => void) | undefined
|
||||
|
||||
@ -68,6 +68,30 @@ describe('parseTeamMessageMetadata', () => {
|
||||
expect(parsed.runId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves canonical action, conversation, and payload event identities', () => {
|
||||
expect(parseTeamMessageMetadata(message({
|
||||
conversationId: 'message-conversation',
|
||||
metadata: {
|
||||
type: 'team_task_progress',
|
||||
actionId: 'action-7',
|
||||
conversationId: 'worker-conversation',
|
||||
eventId: 'event-9',
|
||||
} as never,
|
||||
}))).toMatchObject({
|
||||
actionId: 'action-7',
|
||||
conversationId: 'worker-conversation',
|
||||
eventId: 'event-9',
|
||||
})
|
||||
|
||||
expect(parseTeamMessageMetadata(message({
|
||||
conversationId: 'message-conversation',
|
||||
metadata: { type: 'team_task_progress', parentActionId: 'parent-8' } as never,
|
||||
}))).toMatchObject({
|
||||
actionId: 'parent-8',
|
||||
conversationId: 'message-conversation',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the content prefix only for legacy null-run announcements', () => {
|
||||
const legacy = parseTeamMessageMetadata(message({ content: '[System Message] settled' }))
|
||||
const linked = parseTeamMessageMetadata(message({
|
||||
|
||||
@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canonicalTeamEventKey,
|
||||
classifyTeamEventOwnership,
|
||||
discoveredTeamTaskKey,
|
||||
shouldShowInGlobalTeamFeed,
|
||||
} from '../teamEventOwnership'
|
||||
|
||||
describe('team event ownership', () => {
|
||||
it('assigns normal lifecycle events to the run or task projection', () => {
|
||||
expect(classifyTeamEventOwnership({
|
||||
id: 'evt-run', event: 'team_run_progress', data: { runId: '10' },
|
||||
})).toBe('run')
|
||||
expect(classifyTeamEventOwnership({
|
||||
id: 'evt-legacy-run', event: 'team_run', data: { runId: '10' },
|
||||
})).toBe('run')
|
||||
expect(classifyTeamEventOwnership({
|
||||
id: 'evt-task', event: 'team_task_progress', data: { runId: '10', taskId: '101' },
|
||||
})).toBe('task')
|
||||
expect(classifyTeamEventOwnership({
|
||||
id: 'evt-final', event: 'team_announce', data: { runId: '10' },
|
||||
})).toBe('run')
|
||||
expect(classifyTeamEventOwnership({
|
||||
id: 'evt-final-start', event: 'team_announce_start', data: { runId: '10' },
|
||||
})).toBe('run')
|
||||
expect(classifyTeamEventOwnership({
|
||||
id: 'evt-business', event: 'invoice_failed', data: { runId: '10' },
|
||||
})).toBe('unowned')
|
||||
})
|
||||
|
||||
it('deduplicates replay by stable run and event identifiers', () => {
|
||||
const first = canonicalTeamEventKey({
|
||||
id: '9007199254740993', event: 'team_task_completed', data: { runId: '10', taskId: '101' },
|
||||
})
|
||||
const replay = canonicalTeamEventKey({
|
||||
id: '9007199254740993', event: 'team_task_completed', data: { runId: '10', taskId: '101' },
|
||||
})
|
||||
|
||||
expect(first).toBe('stream:run=10|task=101:9007199254740993')
|
||||
expect(replay).toBe(first)
|
||||
expect(canonicalTeamEventKey({
|
||||
id: 'stream-7', event: 'workspace_status', data: {},
|
||||
})).toBe('stream:stream-7')
|
||||
expect(canonicalTeamEventKey({ event: 'team_task_completed', data: { runId: '10' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes mirrored action and payload event identities across conversations', () => {
|
||||
const parent = canonicalTeamEventKey({
|
||||
id: 'stream-1', event: 'team_task_in_review',
|
||||
data: { runId: '10', taskId: '101', actionId: 'action-7', conversationId: 'lead' },
|
||||
})
|
||||
const worker = canonicalTeamEventKey({
|
||||
id: 'stream-2', event: 'team_task_in_review',
|
||||
data: { runId: '10', taskId: '101', actionId: 'action-7', conversationId: 'worker' },
|
||||
})
|
||||
const sse = canonicalTeamEventKey({
|
||||
id: 'stream-3', event: 'team_task_in_review',
|
||||
data: { runId: '10', taskId: '101', actionId: 'action-7' },
|
||||
})
|
||||
expect(parent).toBe('action:run=10|task=101:team_task_in_review:action-7')
|
||||
expect(worker).toBe(parent)
|
||||
expect(sse).toBe(parent)
|
||||
expect(canonicalTeamEventKey({
|
||||
event: 'team_task_in_review', data: { actionId: 'action-8', conversationId: 'lead' },
|
||||
})).not.toBe(parent)
|
||||
|
||||
const leadEvent = canonicalTeamEventKey({
|
||||
id: 'one', event: 'team_task_progress', data: { eventId: 'event-9', conversationId: 'lead' },
|
||||
})
|
||||
expect(leadEvent).toBe('event:conversation=lead:event-9')
|
||||
expect(canonicalTeamEventKey({
|
||||
id: 'two', event: 'team_task_progress', data: { eventId: 'event-9', conversationId: 'lead' },
|
||||
})).toBe(leadEvent)
|
||||
expect(canonicalTeamEventKey({
|
||||
id: 'two', event: 'team_task_progress', data: { eventId: 'event-9', conversationId: 'worker' },
|
||||
})).not.toBe(leadEvent)
|
||||
expect(canonicalTeamEventKey({
|
||||
event: 'team_task_progress', data: { eventId: 'event-9', runId: '10', taskId: '101' },
|
||||
})).toBe('event:run=10|task=101:event-9')
|
||||
})
|
||||
|
||||
it('keeps lifecycle transitions for the same action distinct', () => {
|
||||
const approval = canonicalTeamEventKey({
|
||||
event: 'team_task_approval_required',
|
||||
data: { runId: '10', taskId: '101', actionId: 'action-7', conversationId: 'lead' },
|
||||
})
|
||||
const completed = canonicalTeamEventKey({
|
||||
event: 'team_task_completed',
|
||||
data: { runId: '10', taskId: '101', actionId: 'action-7', conversationId: 'worker' },
|
||||
})
|
||||
|
||||
expect(approval).toBe('action:run=10|task=101:team_task_approval_required:action-7')
|
||||
expect(completed).toBe('action:run=10|task=101:team_task_completed:action-7')
|
||||
expect(completed).not.toBe(approval)
|
||||
})
|
||||
|
||||
it('scopes the same action and lifecycle event to its run and task', () => {
|
||||
const firstTask = canonicalTeamEventKey({
|
||||
event: 'team_task_completed',
|
||||
data: { runId: '10', taskId: '101', actionId: 'action-7' },
|
||||
})
|
||||
|
||||
expect(canonicalTeamEventKey({
|
||||
event: 'team_task_completed',
|
||||
data: { runId: '10', taskId: '102', actionId: 'action-7' },
|
||||
})).not.toBe(firstTask)
|
||||
expect(canonicalTeamEventKey({
|
||||
event: 'team_task_completed',
|
||||
data: { runId: '11', taskId: '101', actionId: 'action-7' },
|
||||
})).not.toBe(firstTask)
|
||||
})
|
||||
|
||||
it('scopes stream replay ids by conversation before run and task fallbacks', () => {
|
||||
expect(canonicalTeamEventKey({
|
||||
id: '7', event: 'team_task_progress', data: { conversationId: 'lead' },
|
||||
})).toBe('stream:conversation=lead:7')
|
||||
expect(canonicalTeamEventKey({
|
||||
id: '7', event: 'team_task_progress', data: { conversationId: 'worker' },
|
||||
})).toBe('stream:conversation=worker:7')
|
||||
expect(canonicalTeamEventKey({
|
||||
id: '7', event: 'team_task_progress', data: { runId: '10', taskId: '101' },
|
||||
})).toBe('stream:run=10|task=101:7')
|
||||
})
|
||||
|
||||
it('uses action or conversation evidence for known team ownership when run ids are unavailable', () => {
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_task_in_review', data: { actionId: 'action-7', conversationId: 'worker' },
|
||||
})).toBe('task')
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_run_progress', data: { eventId: 'event-9', conversationId: 'lead' },
|
||||
})).toBe('run')
|
||||
expect(classifyTeamEventOwnership({ event: 'unknown_team_signal', data: { actionId: 'action-7' } }))
|
||||
.toBe('unowned')
|
||||
})
|
||||
|
||||
it('keeps only user-action exceptions in the global feed', () => {
|
||||
const visible = [
|
||||
'team_task_failed', 'team_task_blocked', 'team_task_in_review',
|
||||
'team_task_review_requested', 'team_task_approval_required',
|
||||
'team_task_rejected', 'team_task_stale',
|
||||
]
|
||||
for (const event of visible) {
|
||||
expect(shouldShowInGlobalTeamFeed({ event, data: { runId: '10', taskId: '101' } }), event).toBe(true)
|
||||
}
|
||||
for (const event of ['team_task_created', 'team_task_started', 'team_task_progress', 'team_task_completed']) {
|
||||
expect(shouldShowInGlobalTeamFeed({ event, data: { runId: '10', taskId: '101' } }), event).toBe(false)
|
||||
}
|
||||
expect(shouldShowInGlobalTeamFeed({ event: 'workspace_failed', data: {} })).toBe(true)
|
||||
})
|
||||
|
||||
it('requires structured ids and rejects entities outside the known projection', () => {
|
||||
const known = {
|
||||
runIds: new Set(['10']),
|
||||
taskKeys: new Set(['10:101']),
|
||||
conversationIds: new Set(['lead', 'worker']),
|
||||
}
|
||||
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_run_progress', data: { runId: '10' },
|
||||
}, known)).toBe('run')
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_task_progress', data: { runId: '10', taskId: '101' },
|
||||
}, known)).toBe('task')
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_run_progress', data: {},
|
||||
}, known)).toBe('unowned')
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_task_progress', data: { runId: '10' },
|
||||
}, known)).toBe('unowned')
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_run_progress', data: { runId: '99' },
|
||||
}, known)).toBe('unowned')
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_task_progress', data: { runId: '10', taskId: '999' },
|
||||
}, known)).toBe('unowned')
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_task_progress', data: { actionId: 'action-7', conversationId: 'worker' },
|
||||
}, known)).toBe('task')
|
||||
expect(classifyTeamEventOwnership({
|
||||
event: 'team_task_progress', data: { actionId: 'action-7', conversationId: 'other' },
|
||||
}, known)).toBe('unowned')
|
||||
})
|
||||
|
||||
it('keeps malformed and unknown team events visible in the global feed', () => {
|
||||
const known = {
|
||||
runIds: new Set(['10']),
|
||||
taskKeys: new Set(['10:101']),
|
||||
}
|
||||
|
||||
expect(shouldShowInGlobalTeamFeed({
|
||||
event: 'team_task_progress', data: { runId: '10' },
|
||||
}, known)).toBe(true)
|
||||
expect(shouldShowInGlobalTeamFeed({
|
||||
event: 'team_task_progress', data: { runId: '99', taskId: '101' },
|
||||
}, known)).toBe(true)
|
||||
})
|
||||
|
||||
it('discovers the first normal task event only under a known run', () => {
|
||||
const knownRuns = new Set(['10'])
|
||||
|
||||
for (const event of [
|
||||
'team_task_created', 'team_task_started', 'team_task_progress',
|
||||
'team_task_dispatched', 'team_task_completed', 'team_task_cancelled',
|
||||
]) {
|
||||
expect(discoveredTeamTaskKey({
|
||||
event, data: { runId: '10', taskId: '101' },
|
||||
}, knownRuns), event).toBe('10:101')
|
||||
}
|
||||
expect(discoveredTeamTaskKey({
|
||||
event: 'team_task_progress', data: { runId: '99', taskId: '101' },
|
||||
}, knownRuns)).toBeNull()
|
||||
expect(discoveredTeamTaskKey({
|
||||
event: 'team_task_progress', data: { runId: '10' },
|
||||
}, knownRuns)).toBeNull()
|
||||
for (const event of [
|
||||
'team_task_failed', 'team_task_blocked', 'team_task_in_review',
|
||||
'team_task_approval_required', 'team_task_rejected', 'team_task_stale',
|
||||
]) {
|
||||
expect(discoveredTeamTaskKey({
|
||||
event, data: { runId: '10', taskId: '101' },
|
||||
}, knownRuns), event).toBeNull()
|
||||
}
|
||||
|
||||
const firstProgress = {
|
||||
event: 'team_task_progress', data: { runId: '10', taskId: '202' },
|
||||
}
|
||||
const incrementalTasks = new Set<string>()
|
||||
const discovered = discoveredTeamTaskKey(firstProgress, knownRuns)
|
||||
if (discovered) incrementalTasks.add(discovered)
|
||||
expect(classifyTeamEventOwnership(firstProgress, {
|
||||
runIds: knownRuns,
|
||||
taskKeys: incrementalTasks,
|
||||
})).toBe('task')
|
||||
expect(shouldShowInGlobalTeamFeed(firstProgress, {
|
||||
runIds: knownRuns,
|
||||
taskKeys: incrementalTasks,
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -74,4 +74,55 @@ describe('assembleTeamRunTimeline', () => {
|
||||
])
|
||||
expect(messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('collapses a replayed ten-task lifecycle into one run while keeping task evidence in the projection', () => {
|
||||
const tasks = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: String(100 + index), teamId: 'team-10', runId: '10', taskNumber: index + 1,
|
||||
subject: `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: `Evidence ${index + 1}`, reason: null, conversationId: `worker-${index + 1}`,
|
||||
metadata: null, createTime: null, updateTime: null,
|
||||
}))
|
||||
const lifecycle = tasks.flatMap(task => [
|
||||
message(`start-${task.id}`, 'system', 'started', {
|
||||
type: 'team_task_started', runId: '10', taskId: task.id, eventId: `event-${task.id}-start`,
|
||||
}),
|
||||
message(`done-${task.id}`, 'system', 'completed', {
|
||||
type: 'team_task_completed', runId: '10', taskId: task.id, eventId: `event-${task.id}-done`,
|
||||
}),
|
||||
message(`replay-${task.id}`, 'system', 'completed replay', {
|
||||
type: 'team_task_completed', runId: '10', taskId: task.id, eventId: `event-${task.id}-done`,
|
||||
}),
|
||||
])
|
||||
const projectedRun = { ...run('10', 'origin'), tasks, progress: {
|
||||
total: 10, done: 10, failed: 0, inReview: 0, percent: 100,
|
||||
} }
|
||||
|
||||
const items = assembleTeamRunTimeline([
|
||||
message('origin', 'user', 'Run ten tasks'),
|
||||
...lifecycle,
|
||||
message('announce', 'assistant', 'final', {
|
||||
type: 'team_announce_reply', runId: '10', eventId: 'event-final',
|
||||
}),
|
||||
], [projectedRun, projectedRun])
|
||||
|
||||
expect(keys(items)).toEqual(['m:origin', 'r:10'])
|
||||
const runItems = items.filter(item => item.type === 'team-run')
|
||||
expect(runItems).toHaveLength(1)
|
||||
expect(runItems[0]?.type === 'team-run' && runItems[0].run.tasks).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('does not absorb lifecycle-like messages without a known matching run', () => {
|
||||
const messages = [
|
||||
message('1', 'system', 'missing run', { type: 'team_task_progress', taskId: '101', eventId: 'e1' }),
|
||||
message('2', 'system', 'unknown run', { type: 'team_task_completed', runId: '99', taskId: '101', eventId: 'e2' }),
|
||||
message('3', 'system', 'business system message', { type: 'audit_completed', runId: '10', eventId: 'e3' }),
|
||||
message('4', 'assistant', 'business reply', { runId: '10', eventId: 'e4' }),
|
||||
]
|
||||
|
||||
expect(keys(assembleTeamRunTimeline(messages, [run('10', null)]))).toEqual([
|
||||
'm:1', 'm:2', 'm:3', 'm:4', 'r:10',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AxiosHeaders, type AxiosResponse } from 'axios'
|
||||
import { useTeamRuns, type TeamRunsDependencies } from '../useTeamRuns'
|
||||
import type { TeamRun } from '@/api'
|
||||
import { teamRunApi, type TeamRun } from '@/api'
|
||||
import type { TeamBoardEvent } from '@/composables/useTeamEvents'
|
||||
|
||||
const run = (id: string, teamId = 'team-1', status: TeamRun['status'] = 'running'): TeamRun => ({
|
||||
@ -19,7 +20,106 @@ const flush = async () => {
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function axiosResponse<T>(data: T): AxiosResponse<T> {
|
||||
return { data, status: 200, statusText: 'OK', headers: new AxiosHeaders(), config: { headers: new AxiosHeaders() } }
|
||||
}
|
||||
|
||||
describe('useTeamRuns', () => {
|
||||
it('uses the paged conversation API for the first page and cursor continuation', async () => {
|
||||
const page = vi.spyOn(teamRunApi, 'listByConversationPage')
|
||||
.mockResolvedValueOnce(axiosResponse({ items: [run('2')], nextCursor: 'older' }))
|
||||
.mockResolvedValueOnce(axiosResponse({ items: [run('1')], nextCursor: null }))
|
||||
const legacy = vi.spyOn(teamRunApi, 'listByConversation').mockResolvedValue({ data: [] } as never)
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useTeamRuns(ref('lead')))!
|
||||
await flush()
|
||||
await state.loadMore()
|
||||
|
||||
expect(page).toHaveBeenNthCalledWith(1, 'lead', { limit: 20 })
|
||||
expect(page).toHaveBeenNthCalledWith(2, 'lead', { cursor: 'older', limit: 20 })
|
||||
expect(legacy).not.toHaveBeenCalled()
|
||||
expect(state.runs.value.map(item => item.id)).toEqual(['2', '1'])
|
||||
scope.stop()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads more conversation history by cursor and keeps every run reachable once', async () => {
|
||||
const dependencies: TeamRunsDependencies = {
|
||||
listByConversation: vi.fn()
|
||||
.mockResolvedValueOnce({ data: { items: [run('2'), run('1')], nextCursor: 'older' } })
|
||||
.mockResolvedValueOnce({ data: { items: [run('1'), run('0')], nextCursor: null } }),
|
||||
getRun: vi.fn(), subscribe: vi.fn(() => vi.fn()),
|
||||
}
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useTeamRuns(ref('lead'), { dependencies }))!
|
||||
await flush()
|
||||
await state.loadMore()
|
||||
expect(dependencies.listByConversation).toHaveBeenNthCalledWith(2, 'lead', 'older')
|
||||
expect(state.runs.value.map(item => item.id)).toEqual(['2', '1', '0'])
|
||||
expect(state.nextCursor.value).toBeNull()
|
||||
scope.stop()
|
||||
})
|
||||
it('immediately resets pagination when conversation changes during loadMore and ignores the old page', async () => {
|
||||
let resolveOldPage!: (value: { data: { items: TeamRun[]; nextCursor: string | null } }) => void
|
||||
let resolveNewConversation!: (value: { data: { items: TeamRun[]; nextCursor: string | null } }) => void
|
||||
const oldPage = new Promise<{ data: { items: TeamRun[]; nextCursor: string | null } }>(resolve => { resolveOldPage = resolve })
|
||||
const newConversation = new Promise<{ data: { items: TeamRun[]; nextCursor: string | null } }>(resolve => { resolveNewConversation = resolve })
|
||||
const dependencies: TeamRunsDependencies = {
|
||||
listByConversation: vi.fn()
|
||||
.mockResolvedValueOnce({ data: { items: [run('10')], nextCursor: 'older-a' } })
|
||||
.mockReturnValueOnce(oldPage)
|
||||
.mockReturnValueOnce(newConversation),
|
||||
getRun: vi.fn(), subscribe: vi.fn(() => vi.fn()),
|
||||
}
|
||||
const conversationId = ref('A')
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useTeamRuns(conversationId, { dependencies }))!
|
||||
await flush()
|
||||
const loadingOldPage = state.loadMore()
|
||||
|
||||
conversationId.value = 'B'
|
||||
await nextTick()
|
||||
expect(state.nextCursor.value).toBeNull()
|
||||
expect(state.loadingMore.value).toBe(false)
|
||||
resolveNewConversation({ data: { items: [run('20', 'team-b')], nextCursor: 'older-b' } })
|
||||
await flush()
|
||||
resolveOldPage({ data: { items: [run('9')], nextCursor: null } })
|
||||
await loadingOldPage
|
||||
|
||||
expect(state.runs.value.map(item => item.id)).toEqual(['20'])
|
||||
expect(state.nextCursor.value).toBe('older-b')
|
||||
expect(state.loadingMore.value).toBe(false)
|
||||
scope.stop()
|
||||
})
|
||||
it('keeps an existing full projection when loadMore returns an overlapping summary', async () => {
|
||||
const full = { ...run('10'), projectionCompleteness: 'full' as const, finalSummary: 'complete outcome' }
|
||||
const summary = { ...run('10'), projectionCompleteness: 'summary' as const, finalSummary: null }
|
||||
const dependencies: TeamRunsDependencies = {
|
||||
listByConversation: vi.fn()
|
||||
.mockResolvedValueOnce({ data: { items: [full], nextCursor: 'older' } })
|
||||
.mockResolvedValueOnce({ data: { items: [summary, run('9')], nextCursor: null } }),
|
||||
getRun: vi.fn(), subscribe: vi.fn(() => vi.fn()),
|
||||
}
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useTeamRuns(ref('lead'), { dependencies }))!
|
||||
await flush()
|
||||
await state.loadMore()
|
||||
|
||||
expect(state.runs.value.find(item => item.id === '10')).toEqual(full)
|
||||
expect(state.runs.value.map(item => item.id)).toEqual(['10', '9'])
|
||||
scope.stop()
|
||||
})
|
||||
it('hydrates the new paged conversation response', async () => {
|
||||
const dependencies: TeamRunsDependencies = {
|
||||
listByConversation: vi.fn().mockResolvedValue({ data: { items: [run('10')], nextCursor: 'cursor-2' } }),
|
||||
getRun: vi.fn(), subscribe: vi.fn(() => vi.fn()),
|
||||
}
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useTeamRuns(ref('lead'), { dependencies }))!
|
||||
await flush()
|
||||
expect(state.runs.value.map(item => item.id)).toEqual(['10'])
|
||||
scope.stop()
|
||||
})
|
||||
it('hydrates by conversation, de-duplicates runs, and subscribes once per team', async () => {
|
||||
let onEvent: ((event: TeamBoardEvent) => void) | undefined
|
||||
const cleanup = vi.fn()
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useWorkerConversationGuard } from '../useWorkerConversationGuard'
|
||||
import type { VerifiedWorkerContext } from '@/utils/conversationGovernance'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const worker = (conversationId: string): VerifiedWorkerContext => ({
|
||||
verified: true,
|
||||
conversationKind: 'team_worker',
|
||||
conversationId,
|
||||
runId: '77', taskId: '501', teamId: '20', leadConversationId: 'lead', agentId: '41',
|
||||
})
|
||||
|
||||
describe('useWorkerConversationGuard', () => {
|
||||
it('fails closed while a worker-looking route is pending and after 403/500', async () => {
|
||||
const conversationId = ref('worker')
|
||||
const workerHint = ref(true)
|
||||
const pending = deferred<VerifiedWorkerContext | null>()
|
||||
const guard = useWorkerConversationGuard({
|
||||
conversationId,
|
||||
workerHint,
|
||||
load: id => id === 'worker'
|
||||
? pending.promise
|
||||
: Promise.reject(Object.assign(new Error('server error'), { status: 500 })),
|
||||
})
|
||||
|
||||
expect(guard.state.value).toBe('pending')
|
||||
expect(guard.readOnly.value).toBe(true)
|
||||
pending.reject(Object.assign(new Error('forbidden'), { status: 403 }))
|
||||
await nextTick(); await Promise.resolve()
|
||||
expect(guard.state.value).toBe('error')
|
||||
expect(guard.readOnly.value).toBe(true)
|
||||
|
||||
conversationId.value = 'worker-500'
|
||||
await nextTick(); await Promise.resolve()
|
||||
expect(guard.state.value).toBe('error')
|
||||
expect(guard.readOnly.value).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores an old verified response after switching quickly to a non-worker', async () => {
|
||||
const conversationId = ref('old-worker')
|
||||
const workerHint = ref(true)
|
||||
const oldRequest = deferred<VerifiedWorkerContext | null>()
|
||||
const newRequest = deferred<VerifiedWorkerContext | null>()
|
||||
const guard = useWorkerConversationGuard({
|
||||
conversationId,
|
||||
workerHint,
|
||||
load: id => id === 'old-worker' ? oldRequest.promise : newRequest.promise,
|
||||
})
|
||||
|
||||
conversationId.value = 'ordinary'
|
||||
workerHint.value = false
|
||||
await nextTick()
|
||||
newRequest.resolve(null)
|
||||
await nextTick(); await Promise.resolve()
|
||||
expect(guard.state.value).toBe('nonWorker')
|
||||
expect(guard.readOnly.value).toBe(false)
|
||||
|
||||
oldRequest.resolve(worker('old-worker'))
|
||||
await nextTick(); await Promise.resolve()
|
||||
expect(guard.state.value).toBe('nonWorker')
|
||||
expect(guard.context.value).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a confirmed worker read-only and only explicit nonWorker writable', async () => {
|
||||
const conversationId = ref('worker')
|
||||
const guard = useWorkerConversationGuard({
|
||||
conversationId,
|
||||
workerHint: ref(false),
|
||||
load: async id => worker(id),
|
||||
})
|
||||
await nextTick(); await Promise.resolve()
|
||||
expect(guard.state.value).toBe('verified')
|
||||
expect(guard.readOnly.value).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed when a worker-looking route has no verified context', async () => {
|
||||
const guard = useWorkerConversationGuard({
|
||||
conversationId: ref('team-task-legacy'),
|
||||
workerHint: ref(true),
|
||||
load: async () => null,
|
||||
})
|
||||
|
||||
await nextTick(); await Promise.resolve()
|
||||
expect(guard.state.value).toBe('error')
|
||||
expect(guard.readOnly.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,6 @@
|
||||
import type { TeamRun } from '@/api'
|
||||
import type { Message } from '@/types'
|
||||
import { classifyTeamEventOwnership } from './teamEventOwnership'
|
||||
|
||||
const TEAM_RUN_TYPES = new Set([
|
||||
'team_run',
|
||||
@ -16,6 +17,9 @@ export interface ParsedTeamMessageMetadata {
|
||||
originMessageId?: string
|
||||
teamId?: string
|
||||
leadConversationId?: string
|
||||
actionId?: string
|
||||
conversationId?: string
|
||||
eventId?: string
|
||||
isTeamRunProtocol: boolean
|
||||
isTeamAnnounce: boolean
|
||||
isLegacyTeamAnnounce: boolean
|
||||
@ -68,6 +72,9 @@ export function parseTeamMessageMetadata(message: Message): ParsedTeamMessageMet
|
||||
originMessageId: stringId(metadata.originMessageId),
|
||||
teamId: stringId(metadata.teamId),
|
||||
leadConversationId: stringId(metadata.leadConversationId),
|
||||
actionId: stringId(metadata.actionId) ?? stringId(metadata.parentActionId),
|
||||
conversationId: stringId(metadata.conversationId) ?? stringId(message.conversationId),
|
||||
eventId: stringId(metadata.eventId),
|
||||
isTeamRunProtocol: Boolean(type && (TEAM_RUN_TYPES.has(type) || type.startsWith('team_run_'))),
|
||||
isTeamAnnounce: explicitAnnounce || isLegacyTeamAnnounce,
|
||||
isLegacyTeamAnnounce,
|
||||
@ -77,7 +84,18 @@ export function parseTeamMessageMetadata(message: Message): ParsedTeamMessageMet
|
||||
export function isTeamRunBookkeeping(message: Message, runId: string): boolean {
|
||||
const metadata = parseTeamMessageMetadata(message)
|
||||
if (metadata.runId !== runId) return false
|
||||
return metadata.isTeamRunProtocol || metadata.type === 'team_announce' || metadata.type === 'team_announce_reply'
|
||||
if (!metadata.type) return false
|
||||
return classifyTeamEventOwnership({
|
||||
id: metadata.eventId,
|
||||
event: metadata.type,
|
||||
data: {
|
||||
runId: metadata.runId,
|
||||
taskId: metadata.taskId,
|
||||
actionId: metadata.actionId,
|
||||
conversationId: metadata.conversationId,
|
||||
eventId: metadata.eventId,
|
||||
},
|
||||
}) !== 'unowned'
|
||||
}
|
||||
|
||||
export function resolveWorkerRunContext(input: {
|
||||
|
||||
109
mateclaw-ui/src/composables/chat/teamEventOwnership.ts
Normal file
109
mateclaw-ui/src/composables/chat/teamEventOwnership.ts
Normal file
@ -0,0 +1,109 @@
|
||||
export type TeamEventOwner = 'run' | 'task' | 'unowned'
|
||||
|
||||
export interface StructuredTeamEvent {
|
||||
id?: string
|
||||
event: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TeamEventOwnershipContext {
|
||||
runIds?: ReadonlySet<string>
|
||||
taskKeys?: ReadonlySet<string>
|
||||
conversationIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
const ACTION_EVENT_SUFFIXES = new Set([
|
||||
'failed',
|
||||
'blocked',
|
||||
'in_review',
|
||||
'review_requested',
|
||||
'approval_required',
|
||||
'rejected',
|
||||
'stale',
|
||||
])
|
||||
|
||||
function stringId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
||||
}
|
||||
|
||||
function actionId(event: StructuredTeamEvent): string | null {
|
||||
return stringId(event.data.actionId) ?? stringId(event.data.parentActionId)
|
||||
}
|
||||
|
||||
function conversationId(event: StructuredTeamEvent): string | null {
|
||||
return stringId(event.data.conversationId)
|
||||
?? stringId(event.data.leadConversationId)
|
||||
?? stringId(event.data.workerConversationId)
|
||||
}
|
||||
|
||||
function normalizedEventType(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[\s-]+/g, '_')
|
||||
}
|
||||
|
||||
export function classifyTeamEventOwnership(
|
||||
event: StructuredTeamEvent,
|
||||
context: TeamEventOwnershipContext = {},
|
||||
): TeamEventOwner {
|
||||
const runId = stringId(event.data.runId)
|
||||
const conversation = conversationId(event)
|
||||
const hasStableEvidence = Boolean(actionId(event)
|
||||
|| stringId(event.data.eventId)
|
||||
|| conversation)
|
||||
if (runId && context.runIds && !context.runIds.has(runId)) return 'unowned'
|
||||
if (conversation && context.conversationIds && !context.conversationIds.has(conversation)) {
|
||||
return 'unowned'
|
||||
}
|
||||
if (event.event === 'team_run' || event.event.startsWith('team_run_')
|
||||
|| event.event === 'team_announce' || event.event.startsWith('team_announce_')) {
|
||||
return runId || hasStableEvidence ? 'run' : 'unowned'
|
||||
}
|
||||
if (event.event.startsWith('team_task_')) {
|
||||
const taskId = stringId(event.data.taskId)
|
||||
if (taskId && runId && context.taskKeys && !context.taskKeys.has(`${runId}:${taskId}`)) return 'unowned'
|
||||
return (taskId && runId) || hasStableEvidence ? 'task' : 'unowned'
|
||||
}
|
||||
return 'unowned'
|
||||
}
|
||||
|
||||
export function canonicalTeamEventKey(event: StructuredTeamEvent): string | null {
|
||||
const conversation = conversationId(event)
|
||||
const runId = stringId(event.data.runId)
|
||||
const taskId = stringId(event.data.taskId)
|
||||
const runScope = runId ? taskId ? `run=${runId}|task=${taskId}` : `run=${runId}` : null
|
||||
const scope = runScope ?? (conversation ? `conversation=${conversation}` : null)
|
||||
const action = actionId(event)
|
||||
if (action) {
|
||||
const eventType = normalizedEventType(event.event)
|
||||
if (scope) return `action:${scope}:${eventType}:${action}`
|
||||
const streamId = stringId(event.id)
|
||||
return streamId ? `action:stream=${streamId}:${eventType}:${action}` : null
|
||||
}
|
||||
const payloadEventId = stringId(event.data.eventId)
|
||||
if (payloadEventId) return scope ? `event:${scope}:${payloadEventId}` : `event:${payloadEventId}`
|
||||
|
||||
const streamId = stringId(event.id)
|
||||
if (!streamId) return null
|
||||
if (scope) return `stream:${scope}:${streamId}`
|
||||
return streamId ? `stream:${streamId}` : null
|
||||
}
|
||||
|
||||
export function discoveredTeamTaskKey(
|
||||
event: StructuredTeamEvent,
|
||||
knownRunIds: ReadonlySet<string>,
|
||||
): string | null {
|
||||
if (!event.event.startsWith('team_task_')) return null
|
||||
const suffix = event.event.slice('team_task_'.length)
|
||||
if (ACTION_EVENT_SUFFIXES.has(suffix)) return null
|
||||
const runId = stringId(event.data.runId)
|
||||
const taskId = stringId(event.data.taskId)
|
||||
return runId && taskId && knownRunIds.has(runId) ? `${runId}:${taskId}` : null
|
||||
}
|
||||
|
||||
export function shouldShowInGlobalTeamFeed(
|
||||
event: StructuredTeamEvent,
|
||||
context: TeamEventOwnershipContext = {},
|
||||
): boolean {
|
||||
if (classifyTeamEventOwnership(event, context) !== 'task') return true
|
||||
const suffix = event.event.slice('team_task_'.length)
|
||||
return ACTION_EVENT_SUFFIXES.has(suffix)
|
||||
}
|
||||
@ -1,11 +1,11 @@
|
||||
import { getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||
import { teamRunApi, type TeamRun } from '@/api'
|
||||
import { teamRunApi, type TeamRun, type TeamRunPage } from '@/api'
|
||||
import { subscribeTeamEvents, type TeamBoardEvent } from '@/composables/useTeamEvents'
|
||||
|
||||
type ApiResult<T> = T | { data: T }
|
||||
|
||||
export interface TeamRunsDependencies {
|
||||
listByConversation: (conversationId: string) => Promise<ApiResult<TeamRun[]>>
|
||||
listByConversation: (conversationId: string, cursor?: string) => Promise<ApiResult<TeamRun[] | TeamRunPage>>
|
||||
getRun: (runId: string) => Promise<ApiResult<TeamRun>>
|
||||
subscribe: (teamId: string, onEvent: (event: TeamBoardEvent) => void) => () => void
|
||||
}
|
||||
@ -16,7 +16,10 @@ export interface UseTeamRunsOptions {
|
||||
}
|
||||
|
||||
const defaultDependencies: TeamRunsDependencies = {
|
||||
listByConversation: conversationId => teamRunApi.listByConversation(conversationId),
|
||||
listByConversation: (conversationId, cursor) => teamRunApi.listByConversationPage(conversationId, {
|
||||
...(cursor ? { cursor } : {}),
|
||||
limit: 20,
|
||||
}),
|
||||
getRun: runId => teamRunApi.get(runId),
|
||||
subscribe: subscribeTeamEvents,
|
||||
}
|
||||
@ -28,7 +31,17 @@ function dataOf<T>(result: ApiResult<T>): T {
|
||||
}
|
||||
|
||||
function uniqueRuns(runs: TeamRun[]): TeamRun[] {
|
||||
return Array.from(new Map(runs.map(run => [run.id, run])).values())
|
||||
const byId = new Map<string, TeamRun>()
|
||||
for (const run of runs) {
|
||||
const current = byId.get(run.id)
|
||||
if (current?.projectionCompleteness === 'full' && run.projectionCompleteness !== 'full') continue
|
||||
byId.set(run.id, run)
|
||||
}
|
||||
return [...byId.values()]
|
||||
}
|
||||
|
||||
function pageItems(value: TeamRun[] | TeamRunPage): TeamRun[] {
|
||||
return Array.isArray(value) ? value : value?.items ?? []
|
||||
}
|
||||
|
||||
export function useTeamRuns(
|
||||
@ -38,7 +51,10 @@ export function useTeamRuns(
|
||||
runs: Ref<TeamRun[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<unknown>
|
||||
nextCursor: Ref<string | null>
|
||||
loadingMore: Ref<boolean>
|
||||
refresh: () => Promise<void>
|
||||
loadMore: () => Promise<void>
|
||||
refreshRun: (runId: string) => Promise<void>
|
||||
stop: () => void
|
||||
} {
|
||||
@ -46,6 +62,8 @@ export function useTeamRuns(
|
||||
const runs = ref<TeamRun[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<unknown>(null)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const loadingMore = ref(false)
|
||||
const subscriptions = new Map<string, () => void>()
|
||||
const inFlight = new Map<string, Promise<void>>()
|
||||
let generation = 0
|
||||
@ -114,6 +132,8 @@ export function useTeamRuns(
|
||||
|
||||
const refresh = async () => {
|
||||
const activeGeneration = ++generation
|
||||
nextCursor.value = null
|
||||
loadingMore.value = false
|
||||
cleanupSubscriptions()
|
||||
inFlight.clear()
|
||||
loading.value = true
|
||||
@ -126,7 +146,9 @@ export function useTeamRuns(
|
||||
linkedRunId ? dependencies.getRun(linkedRunId) : Promise.resolve(undefined),
|
||||
])
|
||||
if (stopped || activeGeneration !== generation) return
|
||||
const listed = listedResult.status === 'fulfilled' ? dataOf(listedResult.value) : []
|
||||
const listedPayload = listedResult.status === 'fulfilled' ? dataOf(listedResult.value) : []
|
||||
const listed = pageItems(listedPayload)
|
||||
nextCursor.value = Array.isArray(listedPayload) ? null : listedPayload.nextCursor
|
||||
const linked = linkedResult.status === 'fulfilled' && linkedResult.value
|
||||
? dataOf(linkedResult.value)
|
||||
: undefined
|
||||
@ -141,6 +163,25 @@ export function useTeamRuns(
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
const cursor = nextCursor.value
|
||||
const id = toValue(conversationId)
|
||||
const activeGeneration = generation
|
||||
if (!cursor || !id || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const payload = dataOf(await dependencies.listByConversation(id, cursor))
|
||||
if (stopped || activeGeneration !== generation) return
|
||||
runs.value = uniqueRuns([...runs.value, ...pageItems(payload)])
|
||||
nextCursor.value = Array.isArray(payload) ? null : payload.nextCursor
|
||||
ensureSubscriptions()
|
||||
} catch (cause) {
|
||||
if (!stopped && activeGeneration === generation) error.value = cause
|
||||
} finally {
|
||||
if (!stopped && activeGeneration === generation) loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopWatch = watch(
|
||||
[() => toValue(conversationId), () => options.linkedRunId ? toValue(options.linkedRunId) : undefined],
|
||||
() => { void refresh() },
|
||||
@ -156,5 +197,5 @@ export function useTeamRuns(
|
||||
}
|
||||
if (getCurrentScope()) onScopeDispose(stop)
|
||||
|
||||
return { runs, loading, error, refresh, refreshRun, stop }
|
||||
return { runs, loading, loadingMore, error, nextCursor, refresh, loadMore, refreshRun, stop }
|
||||
}
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import type { VerifiedWorkerContext } from '@/utils/conversationGovernance'
|
||||
|
||||
export type WorkerGuardState = 'pending' | 'verified' | 'nonWorker' | 'error'
|
||||
|
||||
export function useWorkerConversationGuard(options: {
|
||||
conversationId: Ref<string>
|
||||
workerHint: Ref<boolean>
|
||||
load: (conversationId: string) => Promise<VerifiedWorkerContext | null>
|
||||
}) {
|
||||
const state = ref<WorkerGuardState>('pending')
|
||||
const context = ref<VerifiedWorkerContext | null>(null)
|
||||
let requestVersion = 0
|
||||
|
||||
watch([options.conversationId, options.workerHint], async ([conversationId, workerHint]) => {
|
||||
const version = ++requestVersion
|
||||
state.value = 'pending'
|
||||
context.value = null
|
||||
if (!conversationId) {
|
||||
state.value = 'nonWorker'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await options.load(conversationId)
|
||||
if (version !== requestVersion) return
|
||||
if (result?.verified && result.conversationKind === 'team_worker'
|
||||
&& result.conversationId === conversationId) {
|
||||
context.value = result
|
||||
state.value = 'verified'
|
||||
} else {
|
||||
state.value = workerHint ? 'error' : 'nonWorker'
|
||||
}
|
||||
} catch {
|
||||
if (version !== requestVersion) return
|
||||
state.value = 'error'
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
state,
|
||||
context,
|
||||
readOnly: computed(() => state.value !== 'nonWorker'),
|
||||
}
|
||||
}
|
||||
@ -42,7 +42,7 @@ function workerState(task: TeamRunTask, runtime: LiveRunCard | null): AgentWorke
|
||||
if (task.status === 'cancelled' || task.status === 'stale') return 'cancelled'
|
||||
if (task.status === 'completed') return 'completed'
|
||||
if (task.status === 'failed') return 'failed'
|
||||
return 'active'
|
||||
return runtime ? 'active' : 'waiting'
|
||||
}
|
||||
|
||||
function runState(run: TeamRun, workers: AgentRunWorker[]): AgentRunState {
|
||||
@ -53,7 +53,7 @@ function runState(run: TeamRun, workers: AgentRunWorker[]): AgentRunState {
|
||||
if (run.status === 'failed') return 'failed'
|
||||
if (run.status === 'awaiting_review' || workers.some(worker => worker.state === 'review')) return 'review'
|
||||
if (workers.length > 0 && workers.every(worker => ['waiting', 'completed', 'cancelled'].includes(worker.state))) return 'waiting'
|
||||
return 'active'
|
||||
return run.liveness?.state === 'live' || workers.some(worker => worker.state === 'active') ? 'active' : 'waiting'
|
||||
}
|
||||
|
||||
export function projectAgentRunGroups(snapshot: LiveSnapshot | null, runs: readonly TeamRun[]): AgentRunProjection {
|
||||
@ -82,6 +82,34 @@ function relevantRuns(runs: TeamRun[], snapshot: LiveSnapshot | null): TeamRun[]
|
||||
|| run.tasks.some(task => task.conversationId != null && liveIds.has(task.conversationId)))
|
||||
}
|
||||
|
||||
async function mapConcurrent<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
load: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length)
|
||||
let cursor = 0
|
||||
let stopped = false
|
||||
let hasError = false
|
||||
let firstError: unknown
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (!stopped && cursor < items.length) {
|
||||
const index = cursor++
|
||||
try {
|
||||
results[index] = await load(items[index])
|
||||
} catch (cause) {
|
||||
if (!hasError) {
|
||||
hasError = true
|
||||
firstError = cause
|
||||
}
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
}))
|
||||
if (hasError) throw firstError
|
||||
return results
|
||||
}
|
||||
|
||||
export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
|
||||
const listedRuns = ref<TeamRun[]>([])
|
||||
const ensuredRun = ref<TeamRun | null>(null)
|
||||
@ -90,27 +118,68 @@ export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
|
||||
let listSequence = 0
|
||||
let routeRevision = 0
|
||||
let closed = false
|
||||
let refreshPromise: Promise<void> | null = null
|
||||
|
||||
async function refreshForSnapshot() {
|
||||
const request = ++listSequence
|
||||
loading.value = true
|
||||
async function loadActiveTeamRuns(teamId: string): Promise<TeamRun[]> {
|
||||
const runs: TeamRun[] = []
|
||||
const seenCursors = new Set<string>()
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const response: any = await teamRunApi.listByTeamPage(teamId, {
|
||||
activeOnly: true,
|
||||
...(cursor ? { cursor } : {}),
|
||||
limit: 50,
|
||||
})
|
||||
const payload = response?.data
|
||||
runs.push(...(Array.isArray(payload?.items) ? payload.items : []))
|
||||
const nextCursor = typeof payload?.nextCursor === 'string' && payload.nextCursor.length > 0
|
||||
? payload.nextCursor
|
||||
: undefined
|
||||
if (nextCursor && seenCursors.has(nextCursor)) {
|
||||
throw new Error(`Repeated team run cursor for team ${teamId}: ${nextCursor}`)
|
||||
}
|
||||
if (nextCursor) seenCursors.add(nextCursor)
|
||||
cursor = nextCursor
|
||||
} while (cursor)
|
||||
return runs
|
||||
}
|
||||
|
||||
async function runRefresh(request: number) {
|
||||
try {
|
||||
const teamsResponse: any = await teamApi.list()
|
||||
const teams = teamsResponse?.data ?? []
|
||||
const responses: any[] = await Promise.all(
|
||||
teams.map((entry: any) => teamRunApi.listByTeam(String(entry.team.id), true)),
|
||||
if (closed || request !== listSequence) return
|
||||
const teamRuns = await mapConcurrent(
|
||||
teams,
|
||||
3,
|
||||
(entry: any) => loadActiveTeamRuns(String(entry.team.id)),
|
||||
)
|
||||
if (closed || request !== listSequence) return
|
||||
const allRuns = responses.flatMap(response => response?.data ?? []) as TeamRun[]
|
||||
listedRuns.value = relevantRuns(allRuns, snapshot.value)
|
||||
const allRuns = teamRuns.flat()
|
||||
const relevant = relevantRuns(allRuns, snapshot.value)
|
||||
if (closed || request !== listSequence) return
|
||||
listedRuns.value = relevant
|
||||
error.value = null
|
||||
} catch (cause) {
|
||||
if (!closed && request === listSequence) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
throw cause
|
||||
} finally {
|
||||
if (!closed && request === listSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refreshForSnapshot(): Promise<void> {
|
||||
if (refreshPromise) return refreshPromise
|
||||
const request = ++listSequence
|
||||
loading.value = true
|
||||
let shared: Promise<void>
|
||||
shared = runRefresh(request).finally(() => {
|
||||
if (refreshPromise === shared) refreshPromise = null
|
||||
})
|
||||
refreshPromise = shared
|
||||
return shared
|
||||
}
|
||||
|
||||
async function ensureRun(runId: string | null, expectedRouteRevision: number) {
|
||||
routeRevision = Math.max(routeRevision, expectedRouteRevision)
|
||||
if (!runId) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { isHigherSseEventId, RecentSseEventIds } from './sseEventIds'
|
||||
import { canonicalTeamEventKey } from './chat/teamEventOwnership'
|
||||
|
||||
/** One parsed SSE frame before JSON decoding. */
|
||||
export interface TeamSseFrame {
|
||||
@ -54,6 +55,7 @@ export interface TeamEventSubscriptionOptions {
|
||||
retryBaseMs?: number
|
||||
retryMaxMs?: number
|
||||
seenEventLimit?: number
|
||||
maxBufferBytes?: number
|
||||
setTimeoutImpl?: (callback: () => void, delay: number) => unknown
|
||||
clearTimeoutImpl?: (handle: unknown) => void
|
||||
}
|
||||
@ -68,6 +70,7 @@ export function subscribeTeamEvents(
|
||||
const storage = options.storage ?? localStorage
|
||||
const retryBaseMs = options.retryBaseMs ?? 1_000
|
||||
const retryMaxMs = options.retryMaxMs ?? 30_000
|
||||
const maxBufferBytes = Math.max(1_024, options.maxBufferBytes ?? 1_048_576)
|
||||
const setTimeoutImpl = options.setTimeoutImpl
|
||||
?? ((callback, delay) => globalThis.setTimeout(callback, delay))
|
||||
const clearTimeoutImpl = options.clearTimeoutImpl
|
||||
@ -92,18 +95,21 @@ export function subscribeTeamEvents(
|
||||
|
||||
const dispatchFrames = (frames: TeamSseFrame[]) => {
|
||||
for (const frame of frames) {
|
||||
if (stopped) return
|
||||
if (frame.id !== undefined) {
|
||||
if (isHigherSseEventId(frame.id, lastEventId)) lastEventId = frame.id
|
||||
if (seenEventIds.has(frame.id)) continue
|
||||
seenEventIds.add(frame.id)
|
||||
}
|
||||
if (frame.event === 'heartbeat' || !frame.data) continue
|
||||
try {
|
||||
onEvent({
|
||||
const event: TeamBoardEvent = {
|
||||
...(frame.id === undefined ? {} : { id: frame.id }),
|
||||
event: frame.event,
|
||||
data: JSON.parse(frame.data) as Record<string, unknown>,
|
||||
})
|
||||
}
|
||||
const canonicalKey = canonicalTeamEventKey(event)
|
||||
if (canonicalKey && seenEventIds.has(canonicalKey)) continue
|
||||
if (canonicalKey) seenEventIds.add(canonicalKey)
|
||||
onEvent(event)
|
||||
retryAttempt = 0
|
||||
} catch {
|
||||
// Ignore malformed or non-JSON board events.
|
||||
@ -132,18 +138,38 @@ export function subscribeTeamEvents(
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let discardingOversizedFrame = false
|
||||
let discardBoundaryTail = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (stopped) break
|
||||
if (done) {
|
||||
buffer += decoder.decode()
|
||||
const parsed = parseTeamSseFrames(buffer)
|
||||
dispatchFrames(parsed.frames)
|
||||
break
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let chunk = decoder.decode(value, { stream: true })
|
||||
if (discardingOversizedFrame) {
|
||||
const discardInput = discardBoundaryTail + chunk
|
||||
const separator = /\r\n\r\n|\n\n|\r\r/.exec(discardInput)
|
||||
if (!separator || separator.index == null) {
|
||||
discardBoundaryTail = discardInput.slice(-3)
|
||||
continue
|
||||
}
|
||||
chunk = discardInput.slice(separator.index + separator[0].length)
|
||||
discardingOversizedFrame = false
|
||||
discardBoundaryTail = ''
|
||||
}
|
||||
buffer += chunk
|
||||
const parsed = parseTeamSseFrames(buffer)
|
||||
buffer = parsed.remainder
|
||||
dispatchFrames(parsed.frames)
|
||||
if (buffer.length > maxBufferBytes) {
|
||||
discardBoundaryTail = buffer.slice(-3)
|
||||
buffer = ''
|
||||
discardingOversizedFrame = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A dropped stream follows the same reconnect path as a clean EOF.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { computed, getCurrentInstance, onBeforeUnmount, ref } from 'vue'
|
||||
import { teamRunApi, type TeamRun } from '@/api'
|
||||
import { teamRunApi, type TeamRun, type TeamRunPage } from '@/api'
|
||||
import { subscribeTeamEvents, type TeamBoardEvent } from './useTeamEvents'
|
||||
|
||||
export function sortTeamRuns(runs: readonly TeamRun[]): TeamRun[] {
|
||||
@ -16,7 +16,7 @@ export function sortTeamRuns(runs: readonly TeamRun[]): TeamRun[] {
|
||||
}
|
||||
|
||||
interface RunHistoryApi {
|
||||
listByTeam(teamId: string): Promise<unknown>
|
||||
listByTeam(teamId: string, cursor?: string): Promise<unknown>
|
||||
get(runId: string): Promise<unknown>
|
||||
}
|
||||
|
||||
@ -33,7 +33,13 @@ function responseData<T>(response: unknown): T {
|
||||
}
|
||||
|
||||
export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
|
||||
const api = options.api ?? teamRunApi
|
||||
const api: RunHistoryApi = options.api ?? {
|
||||
listByTeam: (id, cursor) => teamRunApi.listByTeamPage(id, {
|
||||
...(cursor ? { cursor } : {}),
|
||||
limit: 20,
|
||||
}),
|
||||
get: id => teamRunApi.get(id),
|
||||
}
|
||||
const subscribe = options.subscribe ?? subscribeTeamEvents
|
||||
const debounceMs = options.debounceMs ?? 250
|
||||
const setTimeoutImpl = options.setTimeoutImpl ?? ((handler, delay) => globalThis.setTimeout(handler, delay))
|
||||
@ -41,15 +47,21 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
|
||||
const runs = ref<TeamRun[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loadingMore = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<string | null>(null)
|
||||
const teamId = ref<string | null>(null)
|
||||
const selectedRunId = ref<string | null>(null)
|
||||
const selectedTaskId = ref<string | null>(null)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const selectedRun = computed(() => runs.value.find(run => run.id === selectedRunId.value) ?? null)
|
||||
const refreshTimers = new Map<string, unknown>()
|
||||
const runRevisions = new Map<string, number>()
|
||||
const runRequestSequences = new Map<string, number>()
|
||||
const detailRequestSequences = new Map<string, number>()
|
||||
let generation = 0
|
||||
let revision = 0
|
||||
let selectionRevision = 0
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
function merge(run: TeamRun) {
|
||||
@ -76,22 +88,40 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
|
||||
runId: string,
|
||||
expectedTeamId = teamId.value,
|
||||
expectedGeneration = generation,
|
||||
options: { silent?: boolean } = {},
|
||||
): Promise<TeamRun | null> {
|
||||
const requestSequence = (runRequestSequences.get(runId) ?? 0) + 1
|
||||
runRequestSequences.set(runId, requestSequence)
|
||||
const silent = options.silent === true
|
||||
const requestKey = `${silent ? 'background' : 'foreground'}:${runId}`
|
||||
const requestSequence = (runRequestSequences.get(requestKey) ?? 0) + 1
|
||||
const requestRevision = runRevisions.get(runId) ?? 0
|
||||
runRequestSequences.set(requestKey, requestSequence)
|
||||
const detailRequestSequence = silent ? null : (detailRequestSequences.get(runId) ?? 0) + 1
|
||||
if (detailRequestSequence !== null) detailRequestSequences.set(runId, detailRequestSequence)
|
||||
const isLatestRequest = () => expectedGeneration === generation
|
||||
&& runRequestSequences.get(runId) === requestSequence
|
||||
&& runRequestSequences.get(requestKey) === requestSequence
|
||||
const isLatestDetailRequest = () => !silent
|
||||
&& expectedGeneration === generation
|
||||
&& detailRequestSequences.get(runId) === detailRequestSequence
|
||||
try {
|
||||
if (!silent) {
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
}
|
||||
const response = await api.get(runId)
|
||||
if (!isLatestRequest()) return null
|
||||
const run = responseData<TeamRun>(response)
|
||||
if (!expectedTeamId || run.teamId !== expectedTeamId) return null
|
||||
if (silent && (runRevisions.get(runId) ?? 0) > requestRevision) return run
|
||||
merge(run)
|
||||
error.value = null
|
||||
if (isLatestDetailRequest()) detailError.value = null
|
||||
return run
|
||||
} catch (cause) {
|
||||
if (isLatestRequest()) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
if (isLatestRequest() && isLatestDetailRequest()) {
|
||||
detailError.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
if (isLatestDetailRequest()) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,7 +130,7 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
|
||||
if (current !== undefined) clearTimeoutImpl(current)
|
||||
refreshTimers.set(runId, setTimeoutImpl(() => {
|
||||
refreshTimers.delete(runId)
|
||||
void refreshRun(runId, expectedTeamId, expectedGeneration)
|
||||
void refreshRun(runId, expectedTeamId, expectedGeneration, { silent: true })
|
||||
}, debounceMs))
|
||||
}
|
||||
|
||||
@ -113,6 +143,8 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
|
||||
|
||||
async function open(nextTeamId: string) {
|
||||
const expectedGeneration = ++generation
|
||||
nextCursor.value = null
|
||||
loadingMore.value = false
|
||||
unsubscribe?.()
|
||||
unsubscribe = null
|
||||
refreshTimers.forEach(clearTimeoutImpl)
|
||||
@ -121,6 +153,7 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
|
||||
runs.value = []
|
||||
runRevisions.clear()
|
||||
runRequestSequences.clear()
|
||||
detailRequestSequences.clear()
|
||||
revision = 0
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@ -129,7 +162,10 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
|
||||
try {
|
||||
const response = await api.listByTeam(nextTeamId)
|
||||
if (expectedGeneration === generation) {
|
||||
mergeList(responseData<TeamRun[]>(response) ?? [], requestRevision, nextTeamId)
|
||||
const payload = responseData<TeamRun[] | TeamRunPage>(response)
|
||||
const list = Array.isArray(payload) ? payload : payload?.items ?? []
|
||||
nextCursor.value = Array.isArray(payload) ? null : payload?.nextCursor ?? null
|
||||
mergeList(list, requestRevision, nextTeamId)
|
||||
}
|
||||
} catch (cause) {
|
||||
if (expectedGeneration === generation) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
@ -142,11 +178,47 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
|
||||
if (teamId.value) await open(teamId.value)
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
const cursor = nextCursor.value
|
||||
const expectedTeamId = teamId.value
|
||||
const expectedGeneration = generation
|
||||
if (!cursor || !expectedTeamId || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const response = await api.listByTeam(expectedTeamId, cursor)
|
||||
if (expectedGeneration !== generation) return
|
||||
const payload = responseData<TeamRun[] | TeamRunPage>(response)
|
||||
const list = Array.isArray(payload) ? payload : payload?.items ?? []
|
||||
const byId = new Map(runs.value.map(run => [run.id, run]))
|
||||
for (const run of list) if (!byId.has(run.id)) byId.set(run.id, run)
|
||||
runs.value = sortTeamRuns([...byId.values()])
|
||||
nextCursor.value = Array.isArray(payload) ? null : payload?.nextCursor ?? null
|
||||
} catch (cause) {
|
||||
if (expectedGeneration === generation) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
} finally {
|
||||
if (expectedGeneration === generation) loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function select(runId: string | null, taskId: string | null = null) {
|
||||
selectionRevision++
|
||||
selectedRunId.value = runId
|
||||
selectedTaskId.value = taskId
|
||||
}
|
||||
|
||||
async function ensureSelectedRunDetail(runId: string, taskId: string | null, expectedTeamId = teamId.value) {
|
||||
const current = runs.value.find(run => run.id === runId)
|
||||
if (current?.projectionCompleteness === 'full') return current
|
||||
const expectedSelectionRevision = selectionRevision
|
||||
const loaded = await refreshRun(runId, expectedTeamId)
|
||||
if (expectedSelectionRevision === selectionRevision
|
||||
&& selectedRunId.value === runId
|
||||
&& selectedTaskId.value === taskId) {
|
||||
selectedTaskId.value = taskId
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
function close() {
|
||||
generation++
|
||||
unsubscribe?.()
|
||||
@ -157,12 +229,16 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
|
||||
runs.value = []
|
||||
runRevisions.clear()
|
||||
runRequestSequences.clear()
|
||||
detailRequestSequences.clear()
|
||||
loading.value = false
|
||||
error.value = null
|
||||
detailLoading.value = false
|
||||
detailError.value = null
|
||||
nextCursor.value = null
|
||||
select(null)
|
||||
}
|
||||
|
||||
if (getCurrentInstance()) onBeforeUnmount(close)
|
||||
|
||||
return { runs, loading, error, selectedRun, selectedRunId, selectedTaskId, open, refresh, refreshRun, select, close }
|
||||
return { runs, loading, loadingMore, error, detailLoading, detailError, nextCursor, selectedRun, selectedRunId, selectedTaskId, open, refresh, loadMore, refreshRun, ensureSelectedRunDetail, select, close }
|
||||
}
|
||||
|
||||
@ -95,6 +95,10 @@ export default {
|
||||
empty: 'No team runs yet',
|
||||
loadError: 'Run history could not be refreshed.',
|
||||
retryLoad: 'Retry',
|
||||
loadMore: 'Load more',
|
||||
loadingMore: 'Loading more',
|
||||
detailLoading: 'Loading run details',
|
||||
detailUnavailable: 'Run details are not available yet.',
|
||||
close: 'Close run details',
|
||||
partialNotice: 'Some tasks did not complete. Available results are shown below.',
|
||||
cancelConfirm: 'Cancel this run and its active tasks?',
|
||||
@ -119,6 +123,13 @@ export default {
|
||||
noResult: 'No result yet',
|
||||
summary: 'Summary',
|
||||
noSummary: 'No summary yet',
|
||||
outcome: 'Outcome',
|
||||
attention: 'Needs attention',
|
||||
noAttention: 'No action needed',
|
||||
contributions: 'Member contributions',
|
||||
runtime: 'Runtime',
|
||||
quality: { synthesized: 'Synthesized', fallback: 'Fallback', partial: 'Partial', pending: 'Pending' },
|
||||
liveness: { live: 'Live', quiet: 'Waiting for activity', stalled: 'Stalled', terminal: 'Finished' },
|
||||
deliverables: 'Deliverables',
|
||||
noDeliverables: 'No deliverables',
|
||||
cancel: 'Cancel run',
|
||||
|
||||
@ -95,6 +95,10 @@ export default {
|
||||
empty: '暂无团队运行记录',
|
||||
loadError: '运行记录刷新失败。',
|
||||
retryLoad: '重试',
|
||||
loadMore: '加载更多',
|
||||
loadingMore: '正在加载更多',
|
||||
detailLoading: '正在加载运行详情',
|
||||
detailUnavailable: '运行详情暂不可用。',
|
||||
close: '关闭运行详情',
|
||||
partialNotice: '部分任务未完成,以下为当前可用结果。',
|
||||
cancelConfirm: '确定取消本次运行及其活动任务?',
|
||||
@ -119,6 +123,13 @@ export default {
|
||||
noResult: '暂无结果',
|
||||
summary: '总结',
|
||||
noSummary: '暂无总结',
|
||||
outcome: '成果结论',
|
||||
attention: '待处理事项',
|
||||
noAttention: '暂无待处理事项',
|
||||
contributions: '成员贡献',
|
||||
runtime: '运行状态',
|
||||
quality: { synthesized: '综合结论', fallback: '降级汇总', partial: '部分结果', pending: '等待汇总' },
|
||||
liveness: { live: '实时执行', quiet: '等待活动', stalled: '可能卡住', terminal: '已结束' },
|
||||
deliverables: '交付物',
|
||||
noDeliverables: '暂无交付物',
|
||||
cancel: '取消运行',
|
||||
|
||||
@ -88,6 +88,7 @@ export interface Conversation {
|
||||
status?: 'active' | 'closed'
|
||||
streamStatus?: 'idle' | 'running'
|
||||
source?: string
|
||||
conversationKind?: 'primary' | 'team_worker' | 'scheduled'
|
||||
pinned?: number
|
||||
/** Provider id of the model this conversation is pinned to (per-conversation model). */
|
||||
modelProvider?: string
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isSidebarConversation, isVerifiedWorkerContext } from '@/utils/conversationGovernance'
|
||||
|
||||
describe('conversation governance', () => {
|
||||
it('excludes explicit and legacy workers but keeps ordinary lookalikes', () => {
|
||||
expect(isSidebarConversation({ conversationId: 'worker', conversationKind: 'team_worker' })).toBe(false)
|
||||
expect(isSidebarConversation({ conversationId: 'team-task-legacy' })).toBe(false)
|
||||
expect(isSidebarConversation({ conversationId: 'ordinary-team-task-note' })).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts read-only mode only from a verified server context matching the conversation', () => {
|
||||
const verified = {
|
||||
verified: true as const,
|
||||
conversationKind: 'team_worker' as const,
|
||||
conversationId: 'worker',
|
||||
runId: '77', taskId: '501', teamId: '20', leadConversationId: 'lead', agentId: '41',
|
||||
}
|
||||
expect(isVerifiedWorkerContext(verified, 'worker')).toBe(true)
|
||||
expect(isVerifiedWorkerContext(verified, 'ordinary')).toBe(false)
|
||||
expect(isVerifiedWorkerContext({ ...verified, verified: false }, 'worker')).toBe(false)
|
||||
expect(isVerifiedWorkerContext(null, 'worker')).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -55,6 +55,26 @@ export function readTeamRunRouteQuery(query: Record<string, unknown>): {
|
||||
}
|
||||
}
|
||||
|
||||
export function readLegacyWorkerRouteContext(
|
||||
conversationId: string,
|
||||
query: Record<string, unknown>,
|
||||
): {
|
||||
runId: string
|
||||
taskId: string
|
||||
teamId: string
|
||||
leadConversationId?: string
|
||||
} | null {
|
||||
if (!conversationId.startsWith('team-task-')) return null
|
||||
const route = readTeamRunRouteQuery(query)
|
||||
if (!route.teamRunId || !route.taskId || !route.teamId) return null
|
||||
return {
|
||||
runId: route.teamRunId,
|
||||
taskId: route.taskId,
|
||||
teamId: route.teamId,
|
||||
...(route.leadConversationId ? { leadConversationId: route.leadConversationId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildChatRouteQuery(options: {
|
||||
currentQuery: Record<string, unknown>
|
||||
agentId?: string
|
||||
|
||||
28
mateclaw-ui/src/utils/conversationGovernance.ts
Normal file
28
mateclaw-ui/src/utils/conversationGovernance.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { Conversation } from '@/types'
|
||||
|
||||
export type ConversationKind = 'primary' | 'team_worker' | 'scheduled'
|
||||
|
||||
export interface VerifiedWorkerContext {
|
||||
verified: boolean
|
||||
conversationKind: 'team_worker'
|
||||
conversationId: string
|
||||
runId: string
|
||||
taskId: string
|
||||
teamId: string
|
||||
leadConversationId?: string
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
export function isSidebarConversation(conversation: Pick<Conversation, 'conversationId' | 'conversationKind'>): boolean {
|
||||
if (conversation.conversationKind === 'team_worker') return false
|
||||
return conversation.conversationKind != null || !conversation.conversationId.startsWith('team-task-')
|
||||
}
|
||||
|
||||
export function isVerifiedWorkerContext(
|
||||
context: VerifiedWorkerContext | null,
|
||||
conversationId: string,
|
||||
): context is VerifiedWorkerContext {
|
||||
return context?.verified === true
|
||||
&& context.conversationKind === 'team_worker'
|
||||
&& context.conversationId === conversationId
|
||||
}
|
||||
@ -126,6 +126,8 @@
|
||||
:team-runs="teamRuns"
|
||||
:expanded-team-run-id="teamRunRouteQuery.teamRunId || null"
|
||||
:selected-team-task-id="teamRunRouteQuery.taskId || null"
|
||||
:team-runs-has-more="Boolean(teamRunsNextCursor)"
|
||||
:team-runs-loading-more="teamRunsLoadingMore"
|
||||
@regenerate="handleRegenerate"
|
||||
@rewind="handleRewind"
|
||||
@suggestion-click="sendSuggestion"
|
||||
@ -134,6 +136,7 @@
|
||||
@approve-always="handleApproveAlways"
|
||||
@deny="handleDeny"
|
||||
@team-run-navigate="router.push($event)"
|
||||
@team-runs-load-more="loadMoreTeamRuns"
|
||||
>
|
||||
<!-- Issue #81 v2 R2: blocking-only popup. Recoverable cases use the
|
||||
non-blocking <RecoverableModelBanner> below instead. -->
|
||||
@ -302,12 +305,14 @@ import { useFileDrop } from '@/composables/useFileDrop'
|
||||
import { useIsMobile, useMediaQuery, BREAKPOINTS } from '@/composables/useBreakpoint'
|
||||
import { useChat } from '@/composables/chat/useChat'
|
||||
import { useTeamRuns } from '@/composables/chat/useTeamRuns'
|
||||
import { isConversationReadOnly, parseTeamMessageMetadata, resolveWorkerRunContext } from '@/composables/chat/messageMetadata'
|
||||
import { useWorkerConversationGuard } from '@/composables/chat/useWorkerConversationGuard'
|
||||
import { parseTeamMessageMetadata } from '@/composables/chat/messageMetadata'
|
||||
import RunOverviewPanel from '@/components/chat/RunOverviewPanel.vue'
|
||||
import { reconstructErrorInfo } from '@/types/chatError'
|
||||
import { reconcileMessages, extractMessages } from '@/utils/messageReconcile'
|
||||
import {
|
||||
buildChatRouteQuery,
|
||||
readLegacyWorkerRouteContext,
|
||||
readTeamRunRouteQuery,
|
||||
resolveConversationAgentSelection,
|
||||
resolveRouteHydrationQuery,
|
||||
@ -813,15 +818,34 @@ const metadataWorkerRunId = computed(() => {
|
||||
return undefined
|
||||
})
|
||||
const linkedTeamRunId = computed(() => teamRunRouteQuery.value.teamRunId ?? metadataWorkerRunId.value)
|
||||
const { runs: teamRuns } = useTeamRuns(currentConversationId, { linkedRunId: linkedTeamRunId })
|
||||
const workerRunContext = computed(() => resolveWorkerRunContext({
|
||||
messages: messages.value,
|
||||
runs: teamRuns.value,
|
||||
conversationId: currentConversationId.value,
|
||||
routeRunId: teamRunRouteQuery.value.teamRunId,
|
||||
routeTaskId: teamRunRouteQuery.value.taskId,
|
||||
}))
|
||||
const workerConversationReadOnly = computed(() => isConversationReadOnly(workerRunContext.value))
|
||||
const {
|
||||
runs: teamRuns,
|
||||
nextCursor: teamRunsNextCursor,
|
||||
loadingMore: teamRunsLoadingMore,
|
||||
loadMore: loadMoreTeamRuns,
|
||||
} = useTeamRuns(currentConversationId, { linkedRunId: linkedTeamRunId })
|
||||
const currentConversationKind = computed(() => conversations.value
|
||||
.find(conversation => conversation.conversationId === currentConversationId.value)?.conversationKind)
|
||||
const workerRouteHint = computed(() => Boolean(
|
||||
teamRunRouteQuery.value.teamRunId
|
||||
|| teamRunRouteQuery.value.taskId
|
||||
|| currentConversationKind.value === 'team_worker'))
|
||||
const workerGuard = useWorkerConversationGuard({
|
||||
conversationId: currentConversationId,
|
||||
workerHint: workerRouteHint,
|
||||
load: async (conversationId) => {
|
||||
if (isEphemeralConversation(conversationId)) return null
|
||||
const query = teamRunRouteQuery.value
|
||||
const response = await conversationApi.getTeamWorkerContext(conversationId, {
|
||||
runId: query.teamRunId,
|
||||
taskId: query.taskId,
|
||||
})
|
||||
return response.data ?? null
|
||||
},
|
||||
})
|
||||
const workerRunContext = computed(() => workerGuard.context.value
|
||||
?? readLegacyWorkerRouteContext(currentConversationId.value, route.query))
|
||||
const workerConversationReadOnly = computed(() => workerGuard.readOnly.value)
|
||||
|
||||
// ============ 连接状态 ============
|
||||
const connectionStatusClass = computed(() => {
|
||||
|
||||
@ -102,8 +102,24 @@
|
||||
class="btn-primary"
|
||||
@click="openTaskCreateDialog"
|
||||
>+ {{ t('teams.createTask') }}</button>
|
||||
<button class="btn-secondary" @click="refreshCurrentView">{{ t('common.refresh') }}</button>
|
||||
<button class="btn-danger" @click="removeTeam">{{ t('common.delete') }}</button>
|
||||
<button
|
||||
class="btn-secondary detail-action"
|
||||
:aria-label="t('common.refresh')"
|
||||
:title="t('common.refresh')"
|
||||
@click="refreshCurrentView"
|
||||
>
|
||||
<RefreshIcon class="detail-action-icon" />
|
||||
<span class="detail-action-label">{{ t('common.refresh') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn-danger detail-action"
|
||||
:aria-label="t('common.delete')"
|
||||
:title="t('common.delete')"
|
||||
@click="removeTeam"
|
||||
>
|
||||
<DeleteIcon class="detail-action-icon" />
|
||||
<span class="detail-action-label">{{ t('common.delete') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -125,7 +141,10 @@
|
||||
:loading="runHistory.loading.value"
|
||||
:error="runHistory.error.value"
|
||||
:selected-run-id="runHistory.selectedRunId.value"
|
||||
:has-more="Boolean(runHistory.nextCursor.value)"
|
||||
:loading-more="runHistory.loadingMore.value"
|
||||
@refresh="runHistory.refresh"
|
||||
@load-more="runHistory.loadMore"
|
||||
@select-run="selectRun"
|
||||
/>
|
||||
|
||||
@ -201,11 +220,19 @@
|
||||
:open="Boolean(runHistory.selectedRun.value)"
|
||||
:run="runHistory.selectedRun.value"
|
||||
:selected-task-id="runHistory.selectedTaskId.value"
|
||||
:detail-loading="runHistory.detailLoading.value"
|
||||
:detail-error="runHistory.detailError.value"
|
||||
can-cancel
|
||||
:management-actions="canManageSelectedRun"
|
||||
:pending-actions="attentionPendingActions"
|
||||
@close="closeRun"
|
||||
@cancel="cancelRun"
|
||||
@select-task="openRunTask"
|
||||
@navigate="router.push"
|
||||
@view-task="openAttentionTask"
|
||||
@retry-task="retryAttentionTask"
|
||||
@approve-task="approveAttentionTask"
|
||||
@retry-detail="runHistory.ensureSelectedRunDetail(runHistory.selectedRunId.value!, runHistory.selectedTaskId.value)"
|
||||
/>
|
||||
|
||||
<!-- ==================== Create team dialog ==================== -->
|
||||
@ -438,6 +465,10 @@
|
||||
<div v-if="currentTask.task.reason" class="task-detail__reason">
|
||||
{{ currentTask.task.reason }}
|
||||
</div>
|
||||
<div v-if="currentTask.task.blockedBy" class="task-detail__block">
|
||||
<div class="task-detail__label">{{ t('teamRuns.dependencies') }}</div>
|
||||
<div class="task-detail__text">{{ currentTask.task.blockedBy }}</div>
|
||||
</div>
|
||||
<div v-if="currentDeliverables.length > 0" class="task-detail__block">
|
||||
<div class="task-detail__label">{{ t('teams.deliverables') }}</div>
|
||||
<div class="deliverable-list">
|
||||
@ -534,9 +565,11 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete as DeleteIcon, Refresh as RefreshIcon } from '@element-plus/icons-vue'
|
||||
import { teamApi, teamRunApi } from '@/api/index'
|
||||
import type { TeamMemberVO, TeamRun, TeamRunTask, TeamTaskComment, TeamTaskDeliverable, TeamTaskEvent, TeamTaskVO } from '@/api/index'
|
||||
import { subscribeTeamEvents } from '@/composables/useTeamEvents'
|
||||
import { discoveredTeamTaskKey, shouldShowInGlobalTeamFeed } from '@/composables/chat/teamEventOwnership'
|
||||
import {
|
||||
buildTeamsRouteQuery,
|
||||
clearTeamsRunSelection,
|
||||
@ -550,16 +583,25 @@ import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||
import { buildWorkerChatRoute } from '@/components/team-run/teamRunPresentation'
|
||||
import TeamRunDrawer from '@/components/team-run/TeamRunDrawer.vue'
|
||||
import TeamRunsPanel from '@/components/team-run/TeamRunsPanel.vue'
|
||||
import {
|
||||
canManageTeamRunAttention,
|
||||
refreshAttentionTaskContext,
|
||||
runAttentionTaskAction,
|
||||
type TeamAttentionAction,
|
||||
type TeamAttentionActionContext,
|
||||
} from '@/components/team-run/teamRunAttentionHandlers'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import { agentIconColor } from '@/utils/agentIconColor'
|
||||
import { useAgentStore } from '@/stores/useAgentStore'
|
||||
import { useTeamStore } from '@/stores/useTeamStore'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useTeamStore()
|
||||
const agentStore = useAgentStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const runHistory = useTeamRunHistory()
|
||||
const { renderMarkdown } = useMarkdownRenderer()
|
||||
|
||||
@ -571,6 +613,22 @@ const newComment = ref('')
|
||||
const taskEvents = ref<TeamTaskEvent[]>([])
|
||||
const renderedCurrentTaskDescription = computed(() => renderMarkdown(currentTask.value?.task.description || ''))
|
||||
const renderedCurrentTaskResult = computed(() => renderMarkdown(currentTask.value?.task.result || ''))
|
||||
const pendingAttentionActions = reactive(new Set<string>())
|
||||
const canManageSelectedRun = computed(() => workspaceStore.accessLoaded
|
||||
&& canManageTeamRunAttention(
|
||||
workspaceStore.currentRole,
|
||||
workspaceStore.currentWorkspaceId,
|
||||
runHistory.selectedRun.value?.workspaceId ?? null,
|
||||
))
|
||||
const attentionPendingActions = computed(() => {
|
||||
const run = runHistory.selectedRun.value
|
||||
const team = store.currentTeam
|
||||
if (!run || !team) return []
|
||||
const prefix = `${team.team.id}:${run.id}:`
|
||||
return [...pendingAttentionActions]
|
||||
.filter(key => key.startsWith(prefix))
|
||||
.map(key => key.slice(prefix.length))
|
||||
})
|
||||
|
||||
function renderTaskMarkdown(value: string | null | undefined): string {
|
||||
return value ? renderMarkdown(value) : ''
|
||||
@ -642,6 +700,35 @@ const activityFeed = ref<{ key: number; text: string }[]>([])
|
||||
let activityKey = 0
|
||||
let unsubscribeEvents: (() => void) | null = null
|
||||
let refreshDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
const incrementalTaskKeys = ref<Set<string>>(new Set())
|
||||
|
||||
const baseEventOwnershipContext = computed(() => {
|
||||
const runs = runHistory.runs.value
|
||||
const boardTasks = store.tasks.map(entry => entry.task)
|
||||
const projectedTasks = runs.flatMap(run => run.tasks)
|
||||
return {
|
||||
runIds: new Set([
|
||||
...runs.map(run => run.id),
|
||||
...boardTasks.flatMap(task => task.runId ? [task.runId] : []),
|
||||
]),
|
||||
taskKeys: new Set([...boardTasks, ...projectedTasks].flatMap(task =>
|
||||
task.runId ? [`${task.runId}:${task.id}`] : [])),
|
||||
conversationIds: new Set([
|
||||
...runs.flatMap(run => run.leadConversationId ? [run.leadConversationId] : []),
|
||||
...[...boardTasks, ...projectedTasks].flatMap(task =>
|
||||
task.conversationId ? [task.conversationId] : []),
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
const eventOwnershipContext = computed(() => ({
|
||||
runIds: baseEventOwnershipContext.value.runIds,
|
||||
conversationIds: baseEventOwnershipContext.value.conversationIds,
|
||||
taskKeys: new Set([
|
||||
...baseEventOwnershipContext.value.taskKeys,
|
||||
...incrementalTaskKeys.value,
|
||||
]),
|
||||
}))
|
||||
|
||||
function onBoardEvent(e: { event: string; data: Record<string, unknown> }) {
|
||||
if (!e.event.startsWith('team_task_')) return
|
||||
@ -652,6 +739,12 @@ function onBoardEvent(e: { event: string; data: Record<string, unknown> }) {
|
||||
refreshBoard()
|
||||
}, 300)
|
||||
|
||||
const discoveredKey = discoveredTeamTaskKey(e, baseEventOwnershipContext.value.runIds)
|
||||
if (discoveredKey && !incrementalTaskKeys.value.has(discoveredKey)) {
|
||||
incrementalTaskKeys.value = new Set([...incrementalTaskKeys.value, discoveredKey])
|
||||
}
|
||||
if (!shouldShowInGlobalTeamFeed(e, eventOwnershipContext.value)) return
|
||||
|
||||
const type = e.event.slice('team_task_'.length)
|
||||
const subject = String(e.data.subject ?? '')
|
||||
const taskNumber = e.data.taskNumber != null ? `#${e.data.taskNumber} ` : ''
|
||||
@ -677,6 +770,7 @@ function stopEventSubscription() {
|
||||
unsubscribeEvents = null
|
||||
}
|
||||
activityFeed.value = []
|
||||
incrementalTaskKeys.value = new Set()
|
||||
}
|
||||
|
||||
// ==================== polling ====================
|
||||
@ -750,6 +844,15 @@ watch(
|
||||
return
|
||||
}
|
||||
}
|
||||
if (reconciliation.selectedRunId
|
||||
&& runHistory.selectedRun.value?.projectionCompleteness !== 'full') {
|
||||
await runHistory.ensureSelectedRunDetail(
|
||||
reconciliation.selectedRunId,
|
||||
reconciliation.selectedTaskId,
|
||||
state.teamId,
|
||||
)
|
||||
if (!routeIsCurrent()) return
|
||||
}
|
||||
if (reconciliation.taskAction === 'close') dismissTaskDetail()
|
||||
if (reconciliation.taskAction === 'load'
|
||||
&& reconciliation.selectedTaskId
|
||||
@ -1077,6 +1180,71 @@ async function openRunTask(
|
||||
}
|
||||
}
|
||||
|
||||
function selectedRunTask(taskId: string) {
|
||||
return runHistory.selectedRun.value?.tasks.find(task => task.id === taskId) ?? null
|
||||
}
|
||||
|
||||
async function openAttentionTask(taskId: string) {
|
||||
const task = selectedRunTask(taskId)
|
||||
if (task) runHistory.select(task.runId, task.id)
|
||||
}
|
||||
|
||||
function captureAttentionContext(taskId: string): TeamAttentionActionContext | null {
|
||||
const run = runHistory.selectedRun.value
|
||||
const teamId = String(store.currentTeam?.team.id ?? '')
|
||||
if (!run || !teamId || run.teamId !== teamId || !run.tasks.some(task => task.id === taskId)) return null
|
||||
return { teamId, runId: run.id, taskId }
|
||||
}
|
||||
|
||||
async function refreshAfterTaskAction(context: TeamAttentionActionContext) {
|
||||
await refreshAttentionTaskContext({
|
||||
context,
|
||||
currentTeamId: () => store.currentTeam ? String(store.currentTeam.team.id) : null,
|
||||
currentTaskId: () => currentTask.value?.task.id ?? null,
|
||||
reloadTask: () => reloadTask(false),
|
||||
refreshBoard: teamId => store.fetchTasks(teamId),
|
||||
refreshRun: (runId, teamId) => runHistory.refreshRun(runId, teamId),
|
||||
})
|
||||
}
|
||||
|
||||
async function performAttentionAction(taskId: string, action: TeamAttentionAction) {
|
||||
const context = captureAttentionContext(taskId)
|
||||
if (!context || !canManageSelectedRun.value) return false
|
||||
return runAttentionTaskAction({
|
||||
context,
|
||||
action,
|
||||
pending: pendingAttentionActions,
|
||||
execute: () => action === 'approve'
|
||||
? teamApi.approveTask(context.teamId, context.taskId)
|
||||
: teamApi.retryTask(context.teamId, context.taskId),
|
||||
refresh: () => refreshAfterTaskAction(context),
|
||||
onError: cause => ElMessage.error(
|
||||
cause instanceof Error && cause.message ? cause.message : t('teams.actionFailed', 'Operation failed'),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async function approveTaskById(taskId: string) {
|
||||
if (!store.currentTeam) return
|
||||
await teamApi.approveTask(store.currentTeam.team.id, taskId)
|
||||
ElMessage.success(t('teams.approved'))
|
||||
await reloadTask()
|
||||
}
|
||||
|
||||
async function retryTaskById(taskId: string) {
|
||||
if (!store.currentTeam) return
|
||||
await teamApi.retryTask(store.currentTeam.team.id, taskId)
|
||||
await reloadTask()
|
||||
}
|
||||
|
||||
async function approveAttentionTask(taskId: string) {
|
||||
if (await performAttentionAction(taskId, 'approve')) ElMessage.success(t('teams.approved'))
|
||||
}
|
||||
|
||||
async function retryAttentionTask(taskId: string) {
|
||||
await performAttentionAction(taskId, 'retry')
|
||||
}
|
||||
|
||||
async function openTask(vo: TeamTaskVO, shouldApply: () => boolean = () => true) {
|
||||
if (!store.currentTeam) return
|
||||
try {
|
||||
@ -1100,10 +1268,11 @@ async function openTask(vo: TeamTaskVO, shouldApply: () => boolean = () => true)
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadTask() {
|
||||
async function reloadTask(refreshContext = true) {
|
||||
if (currentTask.value) {
|
||||
const vo = currentTask.value
|
||||
await openTask(vo)
|
||||
if (!refreshContext) return
|
||||
await Promise.all([
|
||||
refreshBoard(),
|
||||
vo.task.runId ? runHistory.refreshRun(vo.task.runId) : Promise.resolve(),
|
||||
@ -1141,10 +1310,8 @@ async function submitComment() {
|
||||
}
|
||||
|
||||
async function approveTask() {
|
||||
if (!store.currentTeam || !currentTask.value) return
|
||||
await teamApi.approveTask(store.currentTeam.team.id, currentTask.value.task.id)
|
||||
ElMessage.success(t('teams.approved'))
|
||||
await reloadTask()
|
||||
if (!currentTask.value) return
|
||||
await approveTaskById(currentTask.value.task.id)
|
||||
}
|
||||
|
||||
async function rejectTask() {
|
||||
@ -1161,9 +1328,8 @@ async function rejectTask() {
|
||||
}
|
||||
|
||||
async function retryTask() {
|
||||
if (!store.currentTeam || !currentTask.value) return
|
||||
await teamApi.retryTask(store.currentTeam.team.id, currentTask.value.task.id)
|
||||
await reloadTask()
|
||||
if (!currentTask.value) return
|
||||
await retryTaskById(currentTask.value.task.id)
|
||||
}
|
||||
|
||||
async function cancelTask() {
|
||||
@ -1399,6 +1565,18 @@ async function cancelTask() {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.detail-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-action-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Segmented switch — same pattern as the employees page view switch. */
|
||||
.view-switch {
|
||||
@ -1446,6 +1624,35 @@ async function cancelTask() {
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.detail-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.detail-header__right {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.detail-header__right::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.view-switch {
|
||||
flex: none;
|
||||
}
|
||||
.view-seg {
|
||||
padding: 6px 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-action {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
flex: none;
|
||||
}
|
||||
.detail-action-label {
|
||||
display: none;
|
||||
}
|
||||
.board-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildChatRouteQuery,
|
||||
readLegacyWorkerRouteContext,
|
||||
readTeamRunRouteQuery,
|
||||
resolveConversationAgentSelection,
|
||||
resolveRouteHydrationQuery,
|
||||
@ -25,6 +26,15 @@ describe('resolveRouteHydrationQuery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not require a hidden worker conversation to be present in the sidebar response', () => {
|
||||
expect(resolveRouteHydrationQuery({
|
||||
routeAgentId: 'agent-visible',
|
||||
routeConversationId: 'worker-with-explicit-kind',
|
||||
agents,
|
||||
conversations,
|
||||
})).toEqual({ agentId: 'agent-visible', conversationId: 'worker-with-explicit-kind' })
|
||||
})
|
||||
|
||||
it('keeps valid route agent and conversation ids unchanged', () => {
|
||||
const result = resolveRouteHydrationQuery({
|
||||
routeAgentId: 'agent-visible',
|
||||
@ -101,3 +111,26 @@ describe('team run chat route state', () => {
|
||||
})).toEqual({ agentId: 'agent-visible', conversationId: 'another-conversation' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy worker route context', () => {
|
||||
const fullQuery = {
|
||||
teamRunId: '2088089561144729602',
|
||||
taskId: '2088089561182478338',
|
||||
teamId: '2080573857766100994',
|
||||
leadConversationId: 'conv_lead',
|
||||
}
|
||||
|
||||
it('keeps a complete historical team-task deep link visibly governed while verification hydrates', () => {
|
||||
expect(readLegacyWorkerRouteContext('team-task-legacy', fullQuery)).toEqual({
|
||||
runId: fullQuery.teamRunId,
|
||||
taskId: fullQuery.taskId,
|
||||
teamId: fullQuery.teamId,
|
||||
leadConversationId: fullQuery.leadConversationId,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not project ordinary or incomplete routes as worker context', () => {
|
||||
expect(readLegacyWorkerRouteContext('ordinary', fullQuery)).toBeNull()
|
||||
expect(readLegacyWorkerRouteContext('team-task-legacy', { taskId: fullQuery.taskId })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user