fix(team): unify Team Run delivery experience (#596)

This commit is contained in:
matevip 2026-08-15 00:37:08 -04:00
parent f629b04f2a
commit 9a3b95df53
89 changed files with 4732 additions and 200 deletions

View File

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

View File

@ -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()

View File

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

View File

@ -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")

View File

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

View File

@ -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());
}
}
}

View File

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

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {
}
}

View File

@ -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) {
}

View File

@ -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()));
}
}

View File

@ -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.
*

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
ALTER TABLE mate_conversation
ADD COLUMN IF NOT EXISTS conversation_kind VARCHAR(32) NOT NULL DEFAULT 'primary';

View File

@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS idx_team_task_conversation
ON mate_team_task (conversation_id);

View File

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

View File

@ -0,0 +1,2 @@
ALTER TABLE mate_conversation
ADD COLUMN IF NOT EXISTS conversation_kind VARCHAR(32) NOT NULL DEFAULT 'primary';

View File

@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS idx_team_task_conversation
ON mate_team_task (conversation_id);

View File

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

View File

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

View File

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

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

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

View File

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

View File

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

View File

@ -216,7 +216,8 @@ class TeamDispatchServiceTest {
eq(MEMBER_A),
eq("system"),
eq(WORKSPACE_ID),
eq("lead-conv"));
eq("lead-conv"),
eq("team_worker"));
}
@Test

View File

@ -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\"}"));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -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 }),
}

View File

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

View File

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

View File

@ -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()
})
})

View File

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

View File

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

View File

@ -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')

View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>

View File

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

View 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>

View File

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

View File

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

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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)')
})
})

View File

@ -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)
})
})

View File

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

View File

@ -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)
}

View 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 ?? []

View File

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

View File

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

View File

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

View File

@ -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({

View File

@ -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)
})
})

View File

@ -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',
])
})
})

View File

@ -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()

View File

@ -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)
})
})

View File

@ -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: {

View 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)
}

View File

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

View File

@ -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'),
}
}

View File

@ -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) {

View File

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

View File

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

View File

@ -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',

View File

@ -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: '取消运行',

View File

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

View File

@ -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)
})
})

View File

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

View 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
}

View File

@ -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(() => {

View File

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

View File

@ -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()
})
})