feat(team): unify team run experience (#596)

This commit is contained in:
matevip 2026-08-13 07:00:24 -04:00
parent bd35f28dad
commit dc3506f748
121 changed files with 9517 additions and 332 deletions

View File

@ -75,15 +75,29 @@ public record ChatOrigin(
* forwarding uses this to tell "MateClaw authenticated this user" apart * forwarding uses this to tell "MateClaw authenticated this user" apart
* from "this is an external/anonymous identifier" (RFC: identity typing). * from "this is an external/anonymous identifier" (RFC: identity typing).
*/ */
@Nullable Long requesterUserId @Nullable Long requesterUserId,
@Nullable Long originMessageId
) { ) {
public ChatOrigin(@Nullable Long agentId, @Nullable String conversationId,
@Nullable String requesterId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable Long channelId,
@Nullable ChannelTarget channelTarget, boolean cronOrigin,
@Nullable String senderName, @Nullable String channelType,
@Nullable String chatId, @Nullable String baseUrl,
@Nullable Long requesterUserId) {
this(agentId, conversationId, requesterId, workspaceId, workspaceBasePath,
channelId, channelTarget, cronOrigin, senderName, channelType,
chatId, baseUrl, requesterUserId, null);
}
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */ /** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
public static final String CTX_KEY = "mateclaw.chatOrigin"; public static final String CTX_KEY = "mateclaw.chatOrigin";
/** Sentinel used by AgentService default overloads where no origin is supplied. */ /** Sentinel used by AgentService default overloads where no origin is supplied. */
public static final ChatOrigin EMPTY = public static final ChatOrigin EMPTY =
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null); new ChatOrigin(null, null, "", null, null, null, null, false,
null, null, null, null, null, null);
// ---------------- Factories per entry point ---------------- // ---------------- Factories per entry point ----------------
@ -117,7 +131,7 @@ public record ChatOrigin(
return new ChatOrigin(null, conversationId, return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "", requesterId != null ? requesterId : "",
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl, workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
requesterUserId); requesterUserId, null);
} }
public static ChatOrigin cron(@Nullable String conversationId, public static ChatOrigin cron(@Nullable String conversationId,
@ -126,7 +140,8 @@ public record ChatOrigin(
@Nullable Long channelId, @Nullable Long channelId,
@Nullable ChannelTarget target) { @Nullable ChannelTarget target) {
return new ChatOrigin(null, conversationId, "system", return new ChatOrigin(null, conversationId, "system",
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null); workspaceId, workspaceBasePath, channelId, target, true,
null, null, null, null, null, null);
} }
// ---------------- Wither-style updates ---------------- // ---------------- Wither-style updates ----------------
@ -134,27 +149,27 @@ public record ChatOrigin(
public ChatOrigin withAgent(@Nullable Long newAgentId) { public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId, return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId); senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
} }
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) { @Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin, newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId); senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
} }
public ChatOrigin withConversationId(@Nullable String newConversationId) { public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId, return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId); senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
} }
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */ /** Carry a request-derived public base URL (see {@link #baseUrl()}). */
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) { public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, newBaseUrl, requesterUserId); senderName, channelType, chatId, newBaseUrl, requesterUserId, originMessageId);
} }
/** /**
@ -168,7 +183,13 @@ public record ChatOrigin(
@Nullable String newChatId) { @Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId); newSenderName, newChannelType, newChatId, baseUrl, requesterUserId, originMessageId);
}
public ChatOrigin withOriginMessageId(@Nullable Long newOriginMessageId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl, requesterUserId, newOriginMessageId);
} }
// ---------------- Spring AI ToolContext interop ---------------- // ---------------- Spring AI ToolContext interop ----------------

View File

@ -27,6 +27,9 @@ import vip.mate.common.result.R;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.workspace.core.service.WorkspaceService; import vip.mate.workspace.core.service.WorkspaceService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@ -47,6 +50,7 @@ import java.util.concurrent.Executors;
public class AgentController { public class AgentController {
private final AgentService agentService; private final AgentService agentService;
private final ConversationService conversationService;
private final AuditEventService auditEventService; private final AuditEventService auditEventService;
private final AuthService authService; private final AuthService authService;
private final WorkspaceService workspaceService; private final WorkspaceService workspaceService;
@ -211,12 +215,13 @@ public class AgentController {
AgentEntity agent = agentService.getAgent(id); AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId); verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent); verifyAgentEnabled(agent);
ChatOrigin origin = persistOrigin(agent, id, message, conversationId, workspaceId);
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8防止中文 SSE 乱码 // RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8防止中文 SSE 乱码
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L); SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
sseExecutor.execute(() -> { sseExecutor.execute(() -> {
try { try {
agentService.chatStream(id, message, conversationId) agentService.chatStream(id, message, conversationId, origin)
.doOnNext(chunk -> { .doOnNext(chunk -> {
try { try {
emitter.send(SseEmitter.event().name("message").data(chunk)); emitter.send(SseEmitter.event().name("message").data(chunk));
@ -251,7 +256,9 @@ public class AgentController {
AgentEntity agent = agentService.getAgent(id); AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId); verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent); verifyAgentEnabled(agent);
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId())); ChatOrigin origin = persistOrigin(agent, id, request.getMessage(),
request.getConversationId(), workspaceId);
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId(), origin));
} }
@Operation(summary = "执行复杂任务Plan-Execute") @Operation(summary = "执行复杂任务Plan-Execute")
@ -264,7 +271,21 @@ public class AgentController {
AgentEntity agent = agentService.getAgent(id); AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId); verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
verifyAgentEnabled(agent); verifyAgentEnabled(agent);
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId())); ChatOrigin origin = persistOrigin(agent, id, request.getMessage(),
request.getConversationId(), workspaceId);
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId(), origin));
}
private ChatOrigin persistOrigin(AgentEntity agent, Long agentId, String message,
String conversationId, Long requestedWorkspaceId) {
Long resolvedWorkspaceId = agent != null && agent.getWorkspaceId() != null
? agent.getWorkspaceId()
: requestedWorkspaceId != null ? requestedWorkspaceId : 1L;
MessageEntity savedUser = conversationService.saveMessage(
conversationId, "user", message);
return ChatOrigin.web(conversationId, "anonymous", resolvedWorkspaceId, null)
.withAgent(agentId)
.withOriginMessageId(savedUser == null ? null : savedUser.getId());
} }
@Operation(summary = "获取Agent运行状态") @Operation(summary = "获取Agent运行状态")

View File

@ -827,7 +827,8 @@ public class ChannelMessageRouter {
// through unchanged (chatId is null). // through unchanged (chatId is null).
List<MessageContentPart> parts = message.getContentParts(); List<MessageContentPart> parts = message.getContentParts();
String attributedContent = applyGroupTag(message, message.getContent()); String attributedContent = applyGroupTag(message, message.getContent());
conversationService.saveMessage(conversationId, "user", attributedContent, parts); MessageEntity savedUser = conversationService.saveMessage(
conversationId, "user", attributedContent, parts);
// 构建 prompt语音输入时注入场景提示词 // 构建 prompt语音输入时注入场景提示词
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode()); String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
@ -853,7 +854,8 @@ public class ChannelMessageRouter {
// so cron jobs created during this conversation inherit the // so cron jobs created during this conversation inherit the
// channel binding (Issue #25 root path). // channel binding (Issue #25 root path).
ChatOrigin chatOrigin = chatOriginFactory.from( ChatOrigin chatOrigin = chatOriginFactory.from(
channelEntity, message, conversationId, /* workspaceBasePath */ null); channelEntity, message, conversationId, /* workspaceBasePath */ null)
.withOriginMessageId(savedUser == null ? null : savedUser.getId());
if (adapter instanceof StreamingChannelAdapter streamingAdapter) { if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin); savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
@ -1596,14 +1598,16 @@ public class ChannelMessageRouter {
// Mirror processMessage's group attribution for the streaming path // Mirror processMessage's group attribution for the streaming path
// (Web channel today; future streaming IM channels inherit it). // (Web channel today; future streaming IM channels inherit it).
String attributedContent = applyGroupTag(message, message.getContent()); String attributedContent = applyGroupTag(message, message.getContent());
conversationService.saveMessage(conversationId, "user", attributedContent, parts); MessageEntity savedUser = conversationService.saveMessage(
conversationId, "user", attributedContent, parts);
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode()); String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
promptText = applyGroupTag(message, promptText); promptText = applyGroupTag(message, promptText);
// RFC-063r §2.5: forward ChatOrigin so tools created during this // RFC-063r §2.5: forward ChatOrigin so tools created during this
// streaming conversation inherit channel binding. // streaming conversation inherit channel binding.
ChatOrigin origin = chatOriginFactory.from( ChatOrigin origin = chatOriginFactory.from(
channelEntity, message, conversationId, /* workspaceBasePath */ null); channelEntity, message, conversationId, /* workspaceBasePath */ null)
.withOriginMessageId(savedUser == null ? null : savedUser.getId());
return agentService.chatStream(agentId, promptText, conversationId, origin); return agentService.chatStream(agentId, promptText, conversationId, origin);
} }

View File

@ -581,10 +581,15 @@ public class ChatController {
? regenerateSeed.parts() ? regenerateSeed.parts()
: normalizeRequestParts(request); : normalizeRequestParts(request);
String promptText = buildPromptText(message, requestParts); String promptText = buildPromptText(message, requestParts);
Long originMessageId;
if (regenerateSeed == null) { if (regenerateSeed == null) {
// Regenerate reuses the already-persisted seed user row // Regenerate reuses the already-persisted seed user row
// inserting again would duplicate it (issue #547). // inserting again would duplicate it (issue #547).
conversationService.saveMessage(conversationId, "user", message, requestParts); MessageEntity savedUser = conversationService
.saveMessage(conversationId, "user", message, requestParts);
originMessageId = savedUser == null ? null : savedUser.getId();
} else {
originMessageId = regenerateSeed.seedMessageId();
} }
conversationService.updateStreamStatus(conversationId, "running"); conversationService.updateStreamStatus(conversationId, "running");
@ -602,7 +607,8 @@ public class ChatController {
// is enriched with workspaceBasePath in StateGraph buildInitialState). // is enriched with workspaceBasePath in StateGraph buildInitialState).
vip.mate.agent.context.ChatOrigin webOrigin = vip.mate.agent.context.ChatOrigin webOrigin =
memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId()) memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId())
.withBaseUrl(requestBaseUrl); .withBaseUrl(requestBaseUrl)
.withOriginMessageId(originMessageId);
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin) Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
.doOnNext(delta -> { .doOnNext(delta -> {
if (emitterDone.get()) return; if (emitterDone.get()) return;
@ -1105,13 +1111,16 @@ public class ChatController {
return R.fail(401, "未登录,请先登录"); return R.fail(401, "未登录,请先登录");
} }
conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId); conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId);
conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts()); MessageEntity savedUser = conversationService.saveMessage(
request.getConversationId(), "user", request.getMessage(), request.getContentParts());
String promptText = buildPromptText(request.getMessage(), request.getContentParts()); String promptText = buildPromptText(request.getMessage(), request.getContentParts());
// Carry the web origin so per-owner memory recall (read) and the // Carry the web origin so per-owner memory recall (read) and the
// post-conversation memory write below agree on the same owner key. // post-conversation memory write below agree on the same owner key.
vip.mate.agent.context.ChatOrigin webOrigin = vip.mate.agent.context.ChatOrigin webOrigin =
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, request.getEndUserId()); memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId,
request.getEndUserId()).withOriginMessageId(
savedUser == null ? null : savedUser.getId());
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin); AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
String response = result.content(); String response = result.content();
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed", conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
@ -1417,9 +1426,11 @@ public class ChatController {
// 持久化排队的用户消息 contentParts幂等如果 /interrupt 已提前持久化则跳过 // 持久化排队的用户消息 contentParts幂等如果 /interrupt 已提前持久化则跳过
// 这里持久化是为了确保 user 消息在 assistant 消息doOnError/doOnCancel 已写入之后落库 // 这里持久化是为了确保 user 消息在 assistant 消息doOnError/doOnCancel 已写入之后落库
// listMessages ORDER BY create_time ASC 后顺序正确Q1 Asst1 Q2 Asst2 // listMessages ORDER BY create_time ASC 后顺序正确Q1 Asst1 Q2 Asst2
Long queuedOriginMessageId = null;
if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) { if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) {
conversationService.saveMessage(conversationId, "user", queuedMessage, MessageEntity savedUser = conversationService.saveMessage(conversationId, "user", queuedMessage,
preConsumedInput.contentParts(), "queued"); preConsumedInput.contentParts(), "queued");
queuedOriginMessageId = savedUser == null ? null : savedUser.getId();
} }
// 广播 queued_input_started 事件 // 广播 queued_input_started 事件
@ -1444,7 +1455,8 @@ public class ChatController {
// turn keeps a consistent (null-channel) binding. // turn keeps a consistent (null-channel) binding.
vip.mate.agent.context.ChatOrigin queuedOrigin = vip.mate.agent.context.ChatOrigin queuedOrigin =
vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null) vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null)
.withBaseUrl(baseUrl); .withBaseUrl(baseUrl)
.withOriginMessageId(queuedOriginMessageId);
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin) Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin)
.doOnNext(delta -> { .doOnNext(delta -> {
if (emitterDone.get()) return; if (emitterDone.get()) return;

View File

@ -58,6 +58,8 @@ public class ChatStreamTracker {
/** buffer 最大事件数,超出后丢弃最早的 thinking_delta 事件以释放空间 */ /** buffer 最大事件数,超出后丢弃最早的 thinking_delta 事件以释放空间 */
private static final int MAX_BUFFER_SIZE = 16000; private static final int MAX_BUFFER_SIZE = 16000;
private static final SseEventIdGenerator EVENT_IDS =
new SseEventIdGenerator(System::currentTimeMillis);
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -130,10 +132,9 @@ public class ChatStreamTracker {
} }
/** /**
* One buffered SSE event. The {@code id} is a per-conversation monotonic * One buffered SSE event. The {@code id} is process-global and monotonic,
* sequence the SSE protocol's standard {@code id:} line carries this * with a wall-clock floor so a normally restarted process starts above
* value so the client can echo it back via {@code lastEventId} when * ids emitted by its predecessor.
* reconnecting, allowing us to skip already-delivered events on replay.
*/ */
record SseEvent(long id, String name, String json) {} record SseEvent(long id, String name, String json) {}
@ -155,15 +156,6 @@ public class ChatStreamTracker {
volatile boolean done; volatile boolean done;
/** Guarded by lock; once true, cleanup owns this state. */ /** Guarded by lock; once true, cleanup owns this state. */
boolean evicting; boolean evicting;
/**
* Monotonic sequence used as the SSE protocol {@code id:} field.
* Incremented inside {@code state.lock} as each event is buffered,
* so the buffer is always in (id-asc) order. On reconnect, the
* client echoes its last-seen id back via {@code lastEventId} and
* we skip events whose id is &le; that value during replay
* eliminating the duplicate-delivery class of bugs.
*/
long nextEventId = 0L;
/** Flux 订阅的 Disposable用于取消 LLM 流 */ /** Flux 订阅的 Disposable用于取消 LLM 流 */
volatile Disposable disposable; volatile Disposable disposable;
/** 停止标志requestStop() 设为 true各图节点和 LLM 调用检查此标志以提前退出 */ /** 停止标志requestStop() 设为 true各图节点和 LLM 调用检查此标志以提前退出 */
@ -673,7 +665,7 @@ public class ChatStreamTracker {
return; return;
} }
if ((isDone || isAsyncTask) || (!isHeartbeat && !skipBuffer)) { if ((isDone || isAsyncTask) || (!isHeartbeat && !skipBuffer)) {
eventId = ++state.nextEventId; eventId = EVENT_IDS.nextId();
state.buffer.add(new SseEvent(eventId, eventName, jsonData)); state.buffer.add(new SseEvent(eventId, eventName, jsonData));
if (state.buffer.size() > MAX_BUFFER_SIZE) { if (state.buffer.size() > MAX_BUFFER_SIZE) {
trimBuffer(state.buffer); trimBuffer(state.buffer);
@ -747,7 +739,7 @@ public class ChatStreamTracker {
if (isDone || isAsyncTask) { if (isDone || isAsyncTask) {
if (state == null) return; if (state == null) return;
synchronized (state.lock) { synchronized (state.lock) {
long id = ++state.nextEventId; long id = EVENT_IDS.nextId();
SseEvent ev = new SseEvent(id, eventName, jsonData); SseEvent ev = new SseEvent(id, eventName, jsonData);
state.buffer.add(ev); state.buffer.add(ev);
if (state.buffer.size() > MAX_BUFFER_SIZE) { if (state.buffer.size() > MAX_BUFFER_SIZE) {
@ -801,9 +793,10 @@ public class ChatStreamTracker {
} }
synchronized (state.lock) { synchronized (state.lock) {
long eventId = 0L;
if (!skipBuffer) { if (!skipBuffer) {
long id = ++state.nextEventId; eventId = EVENT_IDS.nextId();
SseEvent event = new SseEvent(id, eventName, jsonData); SseEvent event = new SseEvent(eventId, eventName, jsonData);
state.buffer.add(event); state.buffer.add(event);
if (state.buffer.size() > MAX_BUFFER_SIZE) { if (state.buffer.size() > MAX_BUFFER_SIZE) {
trimBuffer(state.buffer); trimBuffer(state.buffer);
@ -816,7 +809,7 @@ public class ChatStreamTracker {
if (skipBuffer) { if (skipBuffer) {
emitter.send(SseEmitter.event().name(eventName).data(jsonData)); emitter.send(SseEmitter.event().name(eventName).data(jsonData));
} else { } else {
emitter.send(SseEmitter.event().id(String.valueOf(state.nextEventId)).name(eventName).data(jsonData)); emitter.send(SseEmitter.event().id(String.valueOf(eventId)).name(eventName).data(jsonData));
} }
} catch (IOException | IllegalStateException e) { } catch (IOException | IllegalStateException e) {
log.debug("Removing dead subscriber for {}: {}", conversationId, e.getMessage()); log.debug("Removing dead subscriber for {}: {}", conversationId, e.getMessage());
@ -1065,8 +1058,8 @@ public class ChatStreamTracker {
* {@code lastEventId}. Pass 0 to replay everything (fresh attach * {@code lastEventId}. Pass 0 to replay everything (fresh attach
* behavior same as the no-arg overload). * behavior same as the no-arg overload).
* *
* <p>The id is the per-conversation monotonic sequence stamped on * <p>The id is the process-global monotonic value stamped on each
* each {@link SseEvent} when it was first emitted. Frontend tracks * {@link SseEvent} when it was first emitted. Frontend tracks
* the last id it processed and echoes it back via the request * the last id it processed and echoes it back via the request
* body's {@code lastEventId} field, eliminating the duplicate- * body's {@code lastEventId} field, eliminating the duplicate-
* delivery class of bugs (the symptom: thinking segments rendered * delivery class of bugs (the symptom: thinking segments rendered

View File

@ -0,0 +1,43 @@
package vip.mate.channel.web;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.LongSupplier;
/** Generates positive event ids from a wall-clock floor and atomic sequence. */
final class SseEventIdGenerator {
static final long MAX_SAFE_INTEGER = 9_007_199_254_740_991L;
private static final int COUNTER_BITS = 10;
private static final long IDS_PER_MILLISECOND = 1L << COUNTER_BITS;
private static final long MAX_EPOCH_MILLIS = MAX_SAFE_INTEGER / IDS_PER_MILLISECOND;
private final LongSupplier clock;
private final AtomicLong lastId;
SseEventIdGenerator(LongSupplier clock) {
this.clock = clock;
this.lastId = new AtomicLong(epochFloor(clock.getAsLong()) - 1);
}
long nextId() {
long floor = epochFloor(clock.getAsLong());
for (;;) {
long current = lastId.get();
if (current >= MAX_SAFE_INTEGER) {
throw new IllegalStateException("SSE event id space exhausted");
}
long next = Math.max(current + 1, floor);
if (lastId.compareAndSet(current, next)) {
return next;
}
}
}
private long epochFloor(long epochMillis) {
if (epochMillis <= 0 || epochMillis > MAX_EPOCH_MILLIS) {
throw new IllegalStateException("clock is outside the SSE event id range");
}
return epochMillis * IDS_PER_MILLISECOND;
}
}

View File

@ -146,13 +146,15 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
Long talkWsId = talkAgent != null ? talkAgent.getWorkspaceId() : 1L; Long talkWsId = talkAgent != null ? talkAgent.getWorkspaceId() : 1L;
conversationService.getOrCreateConversation( conversationService.getOrCreateConversation(
talkSession.conversationId, talkSession.agentId, talkSession.username, talkWsId); talkSession.conversationId, talkSession.agentId, talkSession.username, talkWsId);
conversationService.saveMessage(talkSession.conversationId, "user", transcript, List.of()); var savedUser = conversationService.saveMessage(
talkSession.conversationId, "user", transcript, List.of());
// 5. Agent 对话同步Carry the voice user's identity so per-owner // 5. Agent 对话同步Carry the voice user's identity so per-owner
// memory recall (read) and the post-turn memory write (below) agree // memory recall (read) and the post-turn memory write (below) agree
// on the same owner key. // on the same owner key.
vip.mate.agent.context.ChatOrigin talkOrigin = vip.mate.agent.context.ChatOrigin.web( vip.mate.agent.context.ChatOrigin talkOrigin = vip.mate.agent.context.ChatOrigin.web(
talkSession.conversationId, talkSession.username, talkWsId, null); talkSession.conversationId, talkSession.username, talkWsId, null)
.withOriginMessageId(savedUser == null ? null : savedUser.getId());
AgentService.ChatResult chatResult = agentService.chatWithUsage( AgentService.ChatResult chatResult = agentService.chatWithUsage(
talkSession.agentId, transcript, talkSession.conversationId, talkOrigin); talkSession.agentId, transcript, talkSession.conversationId, talkOrigin);
String reply = chatResult.content(); String reply = chatResult.content();

View File

@ -235,10 +235,13 @@ public class WebChatController {
// 保存用户消息含访客本轮引用的附件附件元数据一律服务端按 fileId 回查 // 保存用户消息含访客本轮引用的附件附件元数据一律服务端按 fileId 回查
// 不信客户端传入path 用于 Agent 侧工具读取对外消息视图会被剥离 // 不信客户端传入path 用于 Agent 侧工具读取对外消息视图会被剥离
List<MessageContentPart> userParts = buildUserParts(conversationId, message, request.getAttachmentIds()); List<MessageContentPart> userParts = buildUserParts(conversationId, message, request.getAttachmentIds());
Long originMessageId = request.getInternalOriginMessageId();
if (!request.isInternalSkipUserPersist()) { if (!request.isInternalSkipUserPersist()) {
// Regenerate reuses the already-persisted seed user row // Regenerate reuses the already-persisted seed user row
// inserting again would duplicate it. // inserting again would duplicate it.
conversationService.saveMessage(conversationId, "user", message, userParts); var savedUser = conversationService
.saveMessage(conversationId, "user", message, userParts);
originMessageId = savedUser == null ? null : savedUser.getId();
} }
// 初始化 SSE 流跟踪 // 初始化 SSE 流跟踪
@ -271,7 +274,8 @@ public class WebChatController {
// (publish) paths below. // (publish) paths below.
vip.mate.agent.context.ChatOrigin webchatOrigin = vip.mate.agent.context.ChatOrigin webchatOrigin =
vip.mate.agent.context.ChatOrigin.web(conversationId, visitorId, webWsId, null) vip.mate.agent.context.ChatOrigin.web(conversationId, visitorId, webWsId, null)
.withSender(null, "api", null); .withSender(null, "api", null)
.withOriginMessageId(originMessageId);
String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin); String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin);
reactor.core.Disposable disposable = agentService.chatStructuredStream(resolvedAgentId, message, conversationId, visitorId, null, webchatOrigin) reactor.core.Disposable disposable = agentService.chatStructuredStream(resolvedAgentId, message, conversationId, visitorId, null, webchatOrigin)
@ -1572,6 +1576,7 @@ public class WebChatController {
req.setVisitorId(visitorId); req.setVisitorId(visitorId);
req.setSessionId(sid); req.setSessionId(sid);
req.setInternalSkipUserPersist(true); req.setInternalSkipUserPersist(true);
req.setInternalOriginMessageId(seed.seedMessageId());
return chatStream(apiKey, req); return chatStream(apiKey, req);
} }
@ -2099,6 +2104,7 @@ public class WebChatController {
*/ */
@JsonIgnore @JsonIgnore
private boolean internalSkipUserPersist; private boolean internalSkipUserPersist;
private Long internalOriginMessageId;
} }
/** Compact view of one of a visitor's conversation threads. */ /** Compact view of one of a visitor's conversation threads. */

View File

@ -29,6 +29,10 @@ public class CronChatOriginFactory {
private final AgentMapper agentMapper; private final AgentMapper agentMapper;
public ChatOrigin from(CronJobEntity job, String conversationId) { public ChatOrigin from(CronJobEntity job, String conversationId) {
return from(job, conversationId, null);
}
public ChatOrigin from(CronJobEntity job, String conversationId, Long originMessageId) {
AgentEntity agent = job.getAgentId() != null ? agentMapper.selectById(job.getAgentId()) : null; AgentEntity agent = job.getAgentId() != null ? agentMapper.selectById(job.getAgentId()) : null;
Long workspaceId = agent != null && agent.getWorkspaceId() != null ? agent.getWorkspaceId() : 1L; Long workspaceId = agent != null && agent.getWorkspaceId() != null ? agent.getWorkspaceId() : 1L;
@ -36,6 +40,6 @@ public class CronChatOriginFactory {
ChannelTarget target = dc != null ? dc.toChannelTarget() : null; ChannelTarget target = dc != null ? dc.toChannelTarget() : null;
return ChatOrigin.cron(conversationId, workspaceId, /* workspaceBasePath */ null, return ChatOrigin.cron(conversationId, workspaceId, /* workspaceBasePath */ null,
job.getChannelId(), target); job.getChannelId(), target).withOriginMessageId(originMessageId);
} }
} }

View File

@ -17,6 +17,7 @@ import vip.mate.dashboard.repository.CronJobRunMapper;
import vip.mate.i18n.I18nService; import vip.mate.i18n.I18nService;
import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -45,6 +46,9 @@ import java.time.LocalDateTime;
@RequiredArgsConstructor @RequiredArgsConstructor
public class CronJobLifecycleService { public class CronJobLifecycleService {
public record StartResult(CronJobRunEntity run, Long originMessageId) {
}
private final CronJobRunMapper runMapper; private final CronJobRunMapper runMapper;
private final ConversationService conversationService; private final ConversationService conversationService;
private final ConversationCompletionPublisher completionPublisher; private final ConversationCompletionPublisher completionPublisher;
@ -60,8 +64,8 @@ public class CronJobLifecycleService {
* @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow) * @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow)
*/ */
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW)
public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType, public StartResult startRun(CronJobEntity job, String userMessage, String triggerType,
String conversationId) { String conversationId) {
CronJobRunEntity run = new CronJobRunEntity(); CronJobRunEntity run = new CronJobRunEntity();
run.setCronJobId(job.getId()); run.setCronJobId(job.getId());
run.setConversationId(conversationId); run.setConversationId(conversationId);
@ -90,10 +94,13 @@ public class CronJobLifecycleService {
// Persist the user message before the LLM call so history reads // Persist the user message before the LLM call so history reads
// see a coherent (user assistant) ordering even if the agent // see a coherent (user assistant) ordering even if the agent
// throws mid-run. // throws mid-run.
Long originMessageId = null;
if (userMessage != null && !userMessage.isBlank()) { if (userMessage != null && !userMessage.isBlank()) {
conversationService.saveMessage(conversationId, "user", userMessage); MessageEntity savedUser = conversationService.saveMessage(
conversationId, "user", userMessage);
originMessageId = savedUser == null ? null : savedUser.getId();
} }
return run; return new StartResult(run, originMessageId);
} }
/** /**

View File

@ -102,13 +102,14 @@ public class CronJobRunner {
String conversationId = conversationResolver.resolve(job); String conversationId = conversationResolver.resolve(job);
// T1 short tx // T1 short tx
CronJobRunEntity run; CronJobLifecycleService.StartResult started;
try { try {
run = lifecycle.startRun(job, userMessage, triggerType, conversationId); started = lifecycle.startRun(job, userMessage, triggerType, conversationId);
} catch (Exception e) { } catch (Exception e) {
log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e); log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e);
return; return;
} }
CronJobRunEntity run = started.run();
// task_type='reminder' pure notification, no LLM call. The user // task_type='reminder' pure notification, no LLM call. The user
// (or the create_reminder tool on their behalf) supplied the exact // (or the create_reminder tool on their behalf) supplied the exact
@ -141,7 +142,8 @@ public class CronJobRunner {
AgentService.ChatResult chatResult; AgentService.ChatResult chatResult;
AssistantMessage result; AssistantMessage result;
try { try {
ChatOrigin origin = originFactory.from(job, conversationId); ChatOrigin origin = originFactory.from(
job, conversationId, started.originMessageId());
chatResult = runAgent(job, userMessage, origin, conversationId); chatResult = runAgent(job, userMessage, origin, conversationId);
result = new AssistantMessage(chatResult.content()); result = new AssistantMessage(chatResult.content());
} catch (Exception e) { } catch (Exception e) {

View File

@ -21,6 +21,7 @@ import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.service.TeamAnnounceService; import vip.mate.team.service.TeamAnnounceService;
import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamDispatchService;
import vip.mate.team.service.TeamEventChannel; import vip.mate.team.service.TeamEventChannel;
import vip.mate.team.service.TeamManualTaskService;
import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamService;
import vip.mate.team.service.TeamTaskService; import vip.mate.team.service.TeamTaskService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
@ -48,6 +49,7 @@ public class TeamController {
private final TeamService teamService; private final TeamService teamService;
private final TeamTaskService taskService; private final TeamTaskService taskService;
private final TeamManualTaskService manualTaskService;
private final TeamDispatchService dispatchService; private final TeamDispatchService dispatchService;
private final TeamAnnounceService announceService; private final TeamAnnounceService announceService;
private final TeamEventChannel eventChannel; private final TeamEventChannel eventChannel;
@ -162,9 +164,10 @@ public class TeamController {
public R<TaskVO> createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req, public R<TaskVO> createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req,
Principal principal) { Principal principal) {
return guarded(() -> { return guarded(() -> {
requireTeam(id); AgentTeamEntity team = requireTeam(id);
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() TeamTaskEntity task = manualTaskService.createTask(team, TeamTaskCreateCommand.builder()
.teamId(id) .teamId(id)
.runId(req.getRunId())
.subject(req.getSubject()) .subject(req.getSubject())
.description(req.getDescription()) .description(req.getDescription())
.assigneeAgentId(req.getAssigneeAgentId()) .assigneeAgentId(req.getAssigneeAgentId())
@ -175,9 +178,6 @@ public class TeamController {
.channel("dashboard") .channel("dashboard")
.build()); .build());
eventChannel.publishTaskEvent(task, "team_task_created", Map.of()); eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
dispatchService.requestDispatch(id);
}
return R.ok(toTaskVO(task)); return R.ok(toTaskVO(task));
}); });
} }
@ -398,7 +398,8 @@ public class TeamController {
private TaskVO toTaskVO(TeamTaskEntity task) { private TaskVO toTaskVO(TeamTaskEntity task) {
return new TaskVO(task, return new TaskVO(task,
agentName(task.getAssigneeAgentId()), agentName(task.getAssigneeAgentId()),
task.getOwnerAgentId() == null ? null : agentName(task.getOwnerAgentId())); task.getOwnerAgentId() == null ? null : agentName(task.getOwnerAgentId()),
task.getRunId());
} }
private String agentName(Long agentId) { private String agentName(Long agentId) {
@ -418,7 +419,7 @@ public class TeamController {
public record MemberVO(Long agentId, String name, String role, String icon) { public record MemberVO(Long agentId, String name, String role, String icon) {
} }
public record TaskVO(TeamTaskEntity task, String assigneeName, String ownerName) { public record TaskVO(TeamTaskEntity task, String assigneeName, String ownerName, Long runId) {
} }
public record TaskDetailVO(TaskVO task, List<TeamTaskCommentEntity> comments) { public record TaskDetailVO(TaskVO task, List<TeamTaskCommentEntity> comments) {
@ -447,6 +448,7 @@ public class TeamController {
@Data @Data
public static class CreateTaskRequest { public static class CreateTaskRequest {
private Long runId;
private String subject; private String subject;
private String description; private String description;
private Long assigneeAgentId; private Long assigneeAgentId;

View File

@ -0,0 +1,87 @@
package vip.mate.team.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
import vip.mate.team.model.TeamRunView;
import vip.mate.team.service.TeamRunApplicationService;
import vip.mate.team.service.TeamRunService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.List;
import java.util.function.Supplier;
/** Workspace-scoped REST API for team run reads and cancellation. */
@Tag(name = "Team Runs")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class TeamRunController {
private final TeamRunService runService;
private final TeamRunApplicationService applicationService;
@Operation(summary = "Get team run")
@GetMapping("/team-runs/{runId}")
@RequireWorkspaceRole("viewer")
public R<TeamRunView> get(@PathVariable Long runId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return guarded(() -> R.ok(runService.getRun(runId, workspaceId(workspaceId))));
}
@Operation(summary = "List team runs")
@GetMapping("/teams/{teamId}/runs")
@RequireWorkspaceRole("viewer")
public R<List<TeamRunView>> listTeamRuns(
@PathVariable Long teamId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return guarded(() -> R.ok(runService.listTeamRuns(teamId, workspaceId(workspaceId))));
}
@Operation(summary = "List conversation team runs")
@GetMapping("/conversations/{conversationId}/team-runs")
@RequireWorkspaceRole("viewer")
public R<List<TeamRunView>> listConversationRuns(
@PathVariable String conversationId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return guarded(() -> R.ok(runService.listConversationRuns(
conversationId, workspaceId(workspaceId))));
}
@Operation(summary = "Cancel team run")
@PostMapping("/team-runs/{runId}/cancel")
@RequireWorkspaceRole("admin")
public R<TeamRunView> cancel(
@PathVariable Long runId,
@RequestBody(required = false) CancelRunRequest request,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return guarded(() -> R.ok(applicationService.cancelRun(runId, workspaceId(workspaceId),
request == null ? null : request.getReason())));
}
private long workspaceId(Long workspaceId) {
return workspaceId == null ? 1L : workspaceId;
}
private <T> R<T> guarded(Supplier<R<T>> action) {
try {
return action.get();
} catch (IllegalArgumentException | IllegalStateException e) {
return R.fail(e.getMessage());
}
}
@Data
public static class CancelRunRequest {
private String reason;
}
}

View File

@ -0,0 +1,19 @@
package vip.mate.team.event;
import vip.mate.team.model.TeamRunView;
import java.util.List;
/** Carries detached cancellation side effects across the transaction boundary. */
public record TeamRunCancelCommittedIntent(
TeamRunView run,
List<WorkerTask> workers
) {
public TeamRunCancelCommittedIntent {
workers = List.copyOf(workers);
}
public record WorkerTask(Long taskId, Integer taskNumber, String conversationId) {
}
}

View File

@ -0,0 +1,5 @@
package vip.mate.team.event;
/** Requests a team dispatch after the run transaction commits. */
public record TeamRunDispatchCommittedIntent(Long teamId) {
}

View File

@ -0,0 +1,26 @@
package vip.mate.team.model;
import lombok.Builder;
import lombok.Data;
/** Input required to create a persistent team run. */
@Data
@Builder
public class TeamRunCreateCommand {
private Long teamId;
private Long workspaceId;
private Long leadAgentId;
private String leadConversationId;
private Long originMessageId;
private String title;
private String objective;
private String metadata;
}

View File

@ -0,0 +1,65 @@
package vip.mate.team.model;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Persistent identity and lifecycle state for one team execution.
*
* @author MateClaw Team
*/
@Data
@TableName("mate_team_run")
public class TeamRunEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long teamId;
private Long workspaceId;
private Long leadAgentId;
private String leadConversationId;
private Long originMessageId;
private String title;
private String objective;
private String status;
@TableField(value = "final_summary", updateStrategy = FieldStrategy.ALWAYS)
private String finalSummary;
@TableField(value = "stop_reason", updateStrategy = FieldStrategy.ALWAYS)
private String stopReason;
@TableField(value = "metadata", updateStrategy = FieldStrategy.ALWAYS)
private String metadata;
@TableField(value = "started_at", updateStrategy = FieldStrategy.ALWAYS)
private LocalDateTime startedAt;
@TableField(value = "completed_at", updateStrategy = FieldStrategy.ALWAYS)
private LocalDateTime completedAt;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,25 @@
package vip.mate.team.model;
import java.util.Set;
/** Team run lifecycle status constants. */
public final class TeamRunStatus {
public static final String PLANNING = "planning";
public static final String RUNNING = "running";
public static final String AWAITING_REVIEW = "awaiting_review";
public static final String FINALIZING = "finalizing";
public static final String COMPLETED = "completed";
public static final String PARTIAL = "partial";
public static final String FAILED = "failed";
public static final String CANCELLED = "cancelled";
public static final Set<String> TERMINAL = Set.of(COMPLETED, PARTIAL, FAILED, CANCELLED);
private TeamRunStatus() {
}
public static boolean isTerminal(String status) {
return status != null && TERMINAL.contains(status);
}
}

View File

@ -0,0 +1,64 @@
package vip.mate.team.model;
import java.time.LocalDateTime;
import java.util.List;
/** Stable read projection for a team run and its tasks. */
public record 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
) {
public record Progress(int total, int done, int failed, int inReview, int percent) {
}
public record Task(
Long id,
Long teamId,
Long runId,
Integer taskNumber,
String subject,
String description,
String status,
Integer priority,
String taskType,
Long assigneeAgentId,
Long ownerAgentId,
String blockedBy,
Boolean requireApproval,
Integer progressPercent,
String progressStep,
String result,
String reason,
String conversationId,
String metadata,
LocalDateTime createTime,
LocalDateTime updateTime
) {
public static Task from(TeamTaskEntity task) {
return new Task(task.getId(), task.getTeamId(), task.getRunId(), task.getTaskNumber(),
task.getSubject(), task.getDescription(), task.getStatus(), task.getPriority(),
task.getTaskType(), task.getAssigneeAgentId(), task.getOwnerAgentId(),
task.getBlockedBy(), task.getRequireApproval(), task.getProgressPercent(),
task.getProgressStep(), task.getResult(), task.getReason(), task.getConversationId(),
task.getMetadata(), task.getCreateTime(), task.getUpdateTime());
}
}
}

View File

@ -18,6 +18,8 @@ public class TeamTaskCreateCommand {
private Long teamId; private Long teamId;
private Long runId;
private String subject; private String subject;
private String description; private String description;

View File

@ -22,6 +22,8 @@ public class TeamTaskEntity {
private Long teamId; private Long teamId;
private Long runId;
/** Human-readable sequential number, unique within the team. */ /** Human-readable sequential number, unique within the team. */
private Integer taskNumber; private Integer taskNumber;

View File

@ -0,0 +1,10 @@
package vip.mate.team.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.team.model.TeamRunEntity;
/** Persistent team run mapper. */
@Mapper
public interface TeamRunMapper extends BaseMapper<TeamRunEntity> {
}

View File

@ -0,0 +1,20 @@
package vip.mate.team.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import vip.mate.team.model.TeamRunView;
import java.util.Map;
/** Publishes run lifecycle events through the unified team event channel. */
@Component
@RequiredArgsConstructor
public class SpringTeamRunEventPublisher implements TeamRunEventPublisher {
private final TeamEventChannel eventChannel;
@Override
public void publishCancelled(TeamRunView run) {
eventChannel.publishRunEvent(run, "team_run_cancelled", Map.of());
}
}

View File

@ -1,5 +1,6 @@
package vip.mate.team.service; package vip.mate.team.service;
import cn.hutool.json.JSONObject;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -14,19 +15,23 @@ import vip.mate.team.model.TeamTaskStatus;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/** /**
* Delivers settled task results back to the team lead. Results arriving close * Delivers settled task results back to the team lead. Results arriving close
* together are debounced per lead conversation and merged into ONE combined * together are debounced per lead conversation and run and merged into ONE
* announcement, so parallel members finishing near-simultaneously wake the * combined announcement, so parallel members in the same run wake the lead
* lead once instead of once per task. * once instead of once per task. Different runs never share a batch.
* *
* Delivery is guaranteed, not opportunistic: when the lead is mid-turn the * Delivery is guaranteed, not opportunistic: when the lead is mid-turn the
* announcement is NOT injected into the running turn (an in-turn notification * announcement is NOT injected into the running turn (an in-turn notification
@ -73,13 +78,36 @@ public class TeamAnnounceService {
private final ChatStreamTracker streamTracker; private final ChatStreamTracker streamTracker;
private final ConversationService conversationService; private final ConversationService conversationService;
/** Pending items per lead conversation; the first item arms the drain timer. */ /** Pending items stay isolated by run while lead wake turns serialize by conversation. */
private final Map<String, List<AnnounceItem>> pending = new ConcurrentHashMap<>(); private final Map<BatchKey, PendingBatch> pending = new ConcurrentHashMap<>();
private final Set<String> drainOwners = ConcurrentHashMap.newKeySet();
private final AtomicLong batchSequence = new AtomicLong();
record AnnounceItem(Long teamId, Integer taskNumber, String subject, String status, record BatchKey(String conversationId, Long runId) {
}
record AnnounceItem(Long taskId, Long teamId, Integer taskNumber, String subject, String status,
String memberName, String detail) { String memberName, String detail) {
} }
private static final class PendingBatch {
private final long sequence;
private final List<AnnounceItem> items;
private long readyAtMillis;
private int retries;
private PendingBatch(long sequence) {
this(sequence, new ArrayList<>(), 0, 0);
}
private PendingBatch(long sequence, List<AnnounceItem> items, long readyAtMillis, int retries) {
this.sequence = sequence;
this.items = items;
this.readyAtMillis = readyAtMillis;
this.retries = retries;
}
}
/** /**
* Queue a settled task for announcement to its lead. Safe to call from any * Queue a settled task for announcement to its lead. Safe to call from any
* thread; no-op when the task has no originating lead conversation. * thread; no-op when the task has no originating lead conversation.
@ -99,76 +127,130 @@ public class TeamAnnounceService {
detailWithFiles.append("\n- ").append(file.name()).append("").append(file.url()); detailWithFiles.append("\n- ").append(file.name()).append("").append(file.url());
} }
} }
AnnounceItem item = new AnnounceItem(task.getTeamId(), task.getTaskNumber(), AnnounceItem item = new AnnounceItem(task.getId(), task.getTeamId(), task.getTaskNumber(),
task.getSubject(), task.getStatus(), task.getSubject(), task.getStatus(),
agentName(task.getAssigneeAgentId()), agentName(task.getAssigneeAgentId()),
detailWithFiles.toString()); detailWithFiles.toString());
String key = task.getLeadConversationId(); BatchKey key = new BatchKey(task.getLeadConversationId(), task.getRunId());
List<AnnounceItem> drainNow = null; boolean drainNow = false;
synchronized (pending) { synchronized (pending) {
List<AnnounceItem> queue = pending.computeIfAbsent(key, k -> new ArrayList<>()); PendingBatch batch = pending.computeIfAbsent(key,
queue.add(item); ignored -> new PendingBatch(batchSequence.incrementAndGet()));
if (queue.size() >= MAX_BATCH) { batch.items.add(item);
drainNow = pending.remove(key); if (batch.items.size() >= MAX_BATCH) {
} else if (queue.size() == 1) { drainNow = true;
} else if (batch.items.size() == 1) {
DEBOUNCE_SCHEDULER.schedule(() -> drain(key), DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS); DEBOUNCE_SCHEDULER.schedule(() -> drain(key), DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS);
} }
} }
if (drainNow != null) { if (drainNow) {
deliver(key, drainNow); drain(key);
} }
} }
/** Timer callback: take whatever accumulated and deliver it. */ /** Acquire the conversation turn, then take and deliver one run-isolated batch. */
void drain(String leadConversationId) { void drain(BatchKey key) {
List<AnnounceItem> items; String conversationId = key.conversationId();
if (!drainOwners.add(conversationId)) {
return;
}
PendingBatch batch;
synchronized (pending) { synchronized (pending) {
items = pending.remove(leadConversationId); batch = pending.get(key);
} if (batch != null && batch.readyAtMillis <= System.currentTimeMillis()) {
if (items != null && !items.isEmpty()) { pending.remove(key);
deliver(leadConversationId, items); } else {
} batch = null;
}
void deliver(String leadConversationId, List<AnnounceItem> items) {
deliver(leadConversationId, items, 0);
}
private void deliver(String leadConversationId, List<AnnounceItem> items, int busyRetries) {
Long teamId = items.get(0).teamId();
AgentTeamEntity team = teamService.getTeam(teamId);
if (team == null) {
log.warn("Announce dropped: team {} vanished", teamId);
return;
}
if (runningConversations.isActive(leadConversationId) && busyRetries < MAX_BUSY_RETRIES) {
// Lead is mid-turn. Late tasks settling meanwhile join this batch
// via the pending map, so re-queue and re-arm instead of injecting
// into the running turn (which can drop the message on turn end).
List<AnnounceItem> merged = items;
synchronized (pending) {
List<AnnounceItem> late = pending.remove(leadConversationId);
if (late != null) {
merged = new ArrayList<>(items);
merged.addAll(late);
}
} }
List<AnnounceItem> retryItems = merged; }
DEBOUNCE_SCHEDULER.schedule(() -> deliver(leadConversationId, retryItems, busyRetries + 1), if (batch == null) {
BUSY_RETRY_MILLIS, TimeUnit.MILLISECONDS); releaseAndScheduleNext(conversationId);
return; return;
} }
String message = buildAnnouncement(items); PendingBatch ownedBatch = batch;
ANNOUNCE_EXECUTOR.submit(() -> wakeLead(team, leadConversationId, message, items.size())); try {
ANNOUNCE_EXECUTOR.submit(() -> deliverOwned(key, ownedBatch));
} catch (RuntimeException e) {
requeue(key, ownedBatch);
releaseAndScheduleNext(conversationId);
throw e;
}
}
private void deliverOwned(BatchKey key, PendingBatch batch) {
try {
List<AnnounceItem> items = batch.items;
Long teamId = items.get(0).teamId();
AgentTeamEntity team = teamService.getTeam(teamId);
if (team == null) {
log.warn("Announce dropped: team {} vanished", teamId);
return;
}
if (runningConversations.isActive(key.conversationId()) && batch.retries < MAX_BUSY_RETRIES) {
batch.retries++;
batch.readyAtMillis = System.currentTimeMillis() + BUSY_RETRY_MILLIS;
requeue(key, batch);
return;
}
wakeLead(team, key, buildAnnouncement(items), List.copyOf(items));
} catch (Exception e) {
batch.retries++;
batch.readyAtMillis = System.currentTimeMillis() + BUSY_RETRY_MILLIS;
requeue(key, batch);
log.warn("Lead wake-up failed for conversation {} run {}: {}",
key.conversationId(), key.runId(), e.getMessage());
} finally {
releaseAndScheduleNext(key.conversationId());
}
}
private void requeue(BatchKey key, PendingBatch batch) {
synchronized (pending) {
PendingBatch late = pending.remove(key);
if (late != null) {
batch.items.addAll(late.items);
}
pending.put(key, batch);
}
}
private void releaseAndScheduleNext(String conversationId) {
drainOwners.remove(conversationId);
BatchKey nextKey;
long delay;
synchronized (pending) {
Map.Entry<BatchKey, PendingBatch> next = pending.entrySet().stream()
.filter(entry -> conversationId.equals(entry.getKey().conversationId()))
.min(Comparator.comparingLong(entry -> entry.getValue().sequence))
.orElse(null);
if (next == null) {
return;
}
nextKey = next.getKey();
delay = Math.max(0, next.getValue().readyAtMillis - System.currentTimeMillis());
}
DEBOUNCE_SCHEDULER.schedule(() -> drain(nextKey), delay, TimeUnit.MILLISECONDS);
} }
/** Start a fresh lead turn carrying the merged results; its reply reaches the user. */ /** Start a fresh lead turn carrying the merged results; its reply reaches the user. */
private void wakeLead(AgentTeamEntity team, String leadConversationId, private void wakeLead(AgentTeamEntity team, BatchKey key,
String message, int taskCount) { String message, List<AnnounceItem> items) {
try { String leadConversationId = key.conversationId();
List<String> taskIds = items.stream().map(item -> String.valueOf(item.taskId())).toList();
Map<String, Object> startPayload = new HashMap<>();
startPayload.put("teamId", String.valueOf(team.getId()));
startPayload.put("tasks", items.size());
if (taskIds.size() == 1) {
startPayload.put("taskId", taskIds.get(0));
} else {
startPayload.put("taskIds", taskIds);
}
if (key.runId() != null) {
startPayload.put("runId", String.valueOf(key.runId()));
}
streamTracker.broadcastObject(leadConversationId, "team_announce_start", streamTracker.broadcastObject(leadConversationId, "team_announce_start",
Map.of("teamId", String.valueOf(team.getId()), "tasks", taskCount)); startPayload);
// Persist the announce turn: message persistence is the caller's // Persist the announce turn: message persistence is the caller's
// contract, and without it the lead's synthesized reply would // contract, and without it the lead's synthesized reply would
// vanish from the conversation history on the next reload. // vanish from the conversation history on the next reload.
@ -178,22 +260,34 @@ public class TeamAnnounceService {
// render a compact system strip instead of a user bubble. // render a compact system strip instead of a user bubble.
conversationService.saveMessage(leadConversationId, "user", message, null, "completed", conversationService.saveMessage(leadConversationId, "user", message, null, "completed",
0, 0, null, null, 0, 0, null, null,
"{\"type\":\"team_announce\",\"taskCount\":" + taskCount + "}"); announceMetadata("team_announce", key, taskIds));
AgentService.ChatResult result = agentService.chatWithUsage( AgentService.ChatResult result = agentService.chatWithUsage(
team.getLeadAgentId(), message, leadConversationId); team.getLeadAgentId(), message, leadConversationId);
String reply = result == null ? null : result.content(); String reply = result == null ? null : result.content();
if (reply != null && !reply.isBlank()) { if (reply != null && !reply.isBlank()) {
conversationService.saveMessage(leadConversationId, "assistant", reply, null, "completed", conversationService.saveMessage(leadConversationId, "assistant", reply, null, "completed",
0, 0, null, null, 0, 0, null, null,
"{\"type\":\"team_announce_reply\"}"); announceMetadata("team_announce_reply", key, taskIds));
} }
streamTracker.broadcastObject(leadConversationId, "team_announce_reply", Map<String, Object> replyPayload = new HashMap<>(startPayload);
Map.of("teamId", String.valueOf(team.getId()), replyPayload.put("content", reply == null ? "" : reply);
"content", reply == null ? "" : reply)); streamTracker.broadcastObject(leadConversationId, "team_announce_reply", replyPayload);
log.info("Team {} lead woken with {} task result(s)", team.getId(), taskCount); log.info("Team {} lead woken with {} task result(s)", team.getId(), items.size());
} catch (Exception e) { }
log.warn("Team {} lead wake-up failed: {}", team.getId(), e.getMessage());
private String announceMetadata(String type, BatchKey key, List<String> taskIds) {
JSONObject metadata = new JSONObject()
.set("type", type)
.set("taskCount", taskIds.size());
if (taskIds.size() == 1) {
metadata.set("taskId", taskIds.get(0));
} else {
metadata.set("taskIds", taskIds);
} }
if (key.runId() != null) {
metadata.set("runId", String.valueOf(key.runId()));
}
return metadata.toString();
} }
/** Merged announcement text; single- and multi-result variants. */ /** Merged announcement text; single- and multi-result variants. */

View File

@ -156,10 +156,12 @@ public class TeamContextBuilder {
return """ return """
### Delegation workflow (mandatory) ### Delegation workflow (mandatory)
- Delegate work by creating tasks on the team board: `team_tasks(action="create", subject=..., description=..., assigneeAgentId=...)`. Every delegation MUST go through the board never pretend a teammate did something without a task backing it. - Start each delegation batch with `team_tasks(action="start_run", title=..., objective=...)` and keep the returned runId.
- Create every task with that explicit run id: `team_tasks(action="create", runId=..., subject=..., description=..., assigneeAgentId=...)`. Every delegation MUST go through the board never pretend a teammate did something without a task backing it.
- After ALL tasks are created, call `team_tasks(action="seal_run", runId=...)` exactly once. The mandatory sequence is start_run -> create* -> seal_run.
- Check the board FIRST: a live board snapshot is injected into your context whenever tasks are in flight; consult it (or call `team_tasks(action="list")`) before creating tasks so you never create duplicates. - Check the board FIRST: a live board snapshot is injected into your context whenever tasks are in flight; consult it (or call `team_tasks(action="list")`) before creating tasks so you never create duplicates.
- When a task's outcome needs a human decision before it counts as done (publishing something, destructive changes), create it with `requireApproval=true`; it will park in review for sign-off instead of completing automatically. - When a task's outcome needs a human decision before it counts as done (publishing something, destructive changes), create it with `requireApproval=true`; it will park in review for sign-off instead of completing automatically.
- Create ALL tasks for the request up front in one batch. Order dependent work with `blockedBy` (ids of prerequisite tasks). Then announce the assignments to the user and STOP do not keep reasoning while members work. - Create ALL tasks for the request up front in one batch. Order dependent work with `blockedBy` (ids of prerequisite tasks). Seal the run, then announce the assignments to the user and STOP do not keep reasoning while members work.
- Delegation is NOT completion. After creating tasks, never say the work is "done" or "finished"; say it has been assigned and results will follow. - Delegation is NOT completion. After creating tasks, never say the work is "done" or "finished"; say it has been assigned and results will follow.
- Never assign a task to yourself the lead orchestrates, members execute. - Never assign a task to yourself the lead orchestrates, members execute.
- Task sizing: one task = one specific action producing one output. Split a task if it needs two different skills; do not over-split mechanical steps. - Task sizing: one task = one specific action producing one output. Split a task if it needs two different skills; do not over-split mechanical steps.

View File

@ -3,9 +3,10 @@ package vip.mate.team.service;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import vip.mate.team.event.TeamTasksDelegatedEvent; import vip.mate.team.event.TeamTasksDelegatedEvent;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
@ -84,7 +85,7 @@ public class TeamDispatchService {
* plan's tasks land. Event-driven because the hand-off bridge cannot * plan's tasks land. Event-driven because the hand-off bridge cannot
* depend on this service directly (bean cycle through the graph builder). * depend on this service directly (bean cycle through the graph builder).
*/ */
@EventListener @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onTeamTasksDelegated(TeamTasksDelegatedEvent event) { public void onTeamTasksDelegated(TeamTasksDelegatedEvent event) {
requestDispatch(event.teamId()); requestDispatch(event.teamId());
} }

View File

@ -5,9 +5,12 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.model.TeamRunView;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@ -38,12 +41,17 @@ public class TeamEventChannel {
return; return;
} }
try { try {
Map<String, Object> payload = new HashMap<>(extra == null ? Map.of() : extra); Map<String, Object> payload = payload(extra);
payload.put("taskId", String.valueOf(task.getId())); payload.put("taskId", String.valueOf(task.getId()));
payload.put("taskNumber", task.getTaskNumber()); payload.put("taskNumber", task.getTaskNumber());
payload.put("subject", task.getSubject()); payload.put("subject", task.getSubject());
payload.put("teamId", String.valueOf(task.getTeamId())); payload.put("teamId", String.valueOf(task.getTeamId()));
payload.put("assigneeAgentId", String.valueOf(task.getAssigneeAgentId())); payload.put("assigneeAgentId", String.valueOf(task.getAssigneeAgentId()));
if (task.getRunId() != null) {
payload.put("runId", String.valueOf(task.getRunId()));
} else {
payload.remove("runId");
}
String channelId = channelId(task.getTeamId()); String channelId = channelId(task.getTeamId());
streamTracker.register(channelId); streamTracker.register(channelId);
@ -52,9 +60,41 @@ public class TeamEventChannel {
if (task.getLeadConversationId() != null) { if (task.getLeadConversationId() != null) {
streamTracker.broadcastObject(task.getLeadConversationId(), event, payload); streamTracker.broadcastObject(task.getLeadConversationId(), event, payload);
} }
log.debug("Team event published runId={} teamId={} conversationId={} taskId={} event={}",
task.getRunId(), task.getTeamId(), task.getLeadConversationId(),
task.getId(), event);
} catch (Exception e) { } catch (Exception e) {
// Events are a side channel never let them affect the task flow. // Events are a side channel never let them affect the task flow.
log.debug("Team event '{}' broadcast skipped: {}", event, e.getMessage()); log.debug("Team event skipped runId={} teamId={} conversationId={} taskId={} event={}: {}",
task.getRunId(), task.getTeamId(), task.getLeadConversationId(),
task.getId(), event, e.getMessage());
}
}
/** Publish a run lifecycle projection to the team channel and lead stream. */
public void publishRunEvent(TeamRunView run, String event, Map<String, Object> extra) {
if (run == null) {
return;
}
try {
Map<String, Object> payload = payload(extra);
payload.put("runId", String.valueOf(run.id()));
payload.put("teamId", String.valueOf(run.teamId()));
payload.put("leadConversationId", run.leadConversationId());
payload.put("status", run.status());
payload.put("progress", run.progress());
String channelId = channelId(run.teamId());
streamTracker.register(channelId);
streamTracker.broadcastObject(channelId, event, payload);
if (run.leadConversationId() != null) {
streamTracker.broadcastObject(run.leadConversationId(), event, payload);
}
log.debug("Team event published runId={} teamId={} conversationId={} taskId={} event={}",
run.id(), run.teamId(), run.leadConversationId(), null, event);
} catch (Exception e) {
log.debug("Team event skipped runId={} teamId={} conversationId={} taskId={} event={}: {}",
run.id(), run.teamId(), run.leadConversationId(), null, event, e.getMessage());
} }
} }
@ -68,4 +108,29 @@ public class TeamEventChannel {
static String channelId(Long teamId) { static String channelId(Long teamId) {
return CHANNEL_PREFIX + teamId; return CHANNEL_PREFIX + teamId;
} }
private Map<String, Object> payload(Map<String, Object> extra) {
Map<String, Object> payload = new HashMap<>();
if (extra != null) {
extra.forEach((key, value) -> payload.put(key, stringifyLongs(value)));
}
return payload;
}
private Object stringifyLongs(Object value) {
if (value instanceof Long longValue) {
return String.valueOf(longValue);
}
if (value instanceof Map<?, ?> map) {
Map<String, Object> normalized = new HashMap<>();
map.forEach((key, nested) -> normalized.put(String.valueOf(key), stringifyLongs(nested)));
return normalized;
}
if (value instanceof Iterable<?> iterable) {
List<Object> normalized = new ArrayList<>();
iterable.forEach(item -> normalized.add(stringifyLongs(item)));
return normalized;
}
return value;
}
} }

View File

@ -0,0 +1,67 @@
package vip.mate.team.service;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.team.event.TeamRunDispatchCommittedIntent;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamRunCreateCommand;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity;
/** Coordinates dashboard task creation with the team run lifecycle. */
@Service
@RequiredArgsConstructor
public class TeamManualTaskService {
private static final String DASHBOARD_CONVERSATION_PREFIX = "dashboard-team-";
private final TeamRunService runService;
private final TeamTaskService taskService;
private final ApplicationEventPublisher events;
@Transactional
public TeamTaskEntity createTask(AgentTeamEntity team, TeamTaskCreateCommand command) {
boolean autoRun = command.getRunId() == null;
TeamRunEntity run = autoRun ? startRun(team, command) : requirePlanningRun(team, command.getRunId());
command.setRunId(run.getId());
command.setLeadConversationId(run.getLeadConversationId());
TeamTaskEntity task = taskService.createTask(command);
if (autoRun) {
TeamRunService.SealResult sealed = runService.sealRunWithResult(
run.getId(), team.getWorkspaceId());
if (sealed.transitioned()) {
events.publishEvent(new TeamRunDispatchCommittedIntent(team.getId()));
}
}
return task;
}
private TeamRunEntity startRun(AgentTeamEntity team, TeamTaskCreateCommand command) {
String objective = command.getDescription() == null || command.getDescription().isBlank()
? command.getSubject() : command.getDescription();
return runService.startRun(TeamRunCreateCommand.builder()
.teamId(team.getId())
.workspaceId(team.getWorkspaceId())
.leadAgentId(team.getLeadAgentId())
.leadConversationId(DASHBOARD_CONVERSATION_PREFIX + team.getId())
.originMessageId(null)
.title(command.getSubject())
.objective(objective)
.build());
}
private TeamRunEntity requirePlanningRun(AgentTeamEntity team, Long runId) {
TeamRunEntity run = runService.requireRun(runId, team.getWorkspaceId());
if (!team.getId().equals(run.getTeamId())) {
throw new IllegalArgumentException("team task and run must belong to the same team");
}
if (!TeamRunStatus.PLANNING.equals(run.getStatus())) {
throw new IllegalStateException("team run must be planning to accept tasks: " + runId);
}
return run;
}
}

View File

@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
import vip.mate.planning.model.PlanEntity; import vip.mate.planning.model.PlanEntity;
@ -15,6 +16,9 @@ import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity; import vip.mate.team.model.AgentTeamMemberEntity;
import vip.mate.team.model.TeamRole; import vip.mate.team.model.TeamRole;
import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamRunCreateCommand;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus; import vip.mate.team.model.TeamTaskStatus;
@ -50,6 +54,7 @@ public class TeamPlanBridge {
private final TeamService teamService; private final TeamService teamService;
private final TeamTaskService taskService; private final TeamTaskService taskService;
private final TeamRunService runService;
private final PlanningService planningService; private final PlanningService planningService;
private final AgentMapper agentMapper; private final AgentMapper agentMapper;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
@ -121,9 +126,30 @@ public class TeamPlanBridge {
* referencing an earlier step); the caller guarantees * referencing an earlier step); the caller guarantees
* validity via its sequential-chain fallback * validity via its sequential-chain fallback
*/ */
@Transactional
public String delegatePlan(AgentTeamEntity team, Long planId, String goal, public String delegatePlan(AgentTeamEntity team, Long planId, String goal,
List<String> steps, List<List<Integer>> stepDeps, List<String> steps, List<List<Integer>> stepDeps,
List<Long> memberIds, String leadConversationId) { List<Long> memberIds, String leadConversationId) {
TeamRunEntity run = runService.startRun(TeamRunCreateCommand.builder()
.teamId(team.getId())
.workspaceId(team.getWorkspaceId())
.leadAgentId(team.getLeadAgentId())
.leadConversationId(leadConversationId)
.originMessageId(-Math.abs(planId))
.title(goal)
.objective(goal)
.metadata(new JSONObject().set("planId", String.valueOf(planId)).toString())
.build());
List<TeamTaskEntity> existing = taskService.listTasksByRun(run.getId());
if (!existing.isEmpty()) {
if (TeamRunStatus.PLANNING.equals(run.getStatus())) {
sealAndPublish(team, planId, run);
}
return buildAnnouncement(existing, stepDeps);
}
if (!TeamRunStatus.PLANNING.equals(run.getStatus())) {
throw new IllegalStateException("sealed team run has no tasks: " + run.getId());
}
List<TeamTaskEntity> created = new ArrayList<>(); List<TeamTaskEntity> created = new ArrayList<>();
for (int i = 0; i < steps.size(); i++) { for (int i = 0; i < steps.size(); i++) {
String step = steps.get(i); String step = steps.get(i);
@ -133,6 +159,7 @@ public class TeamPlanBridge {
} }
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
.teamId(team.getId()) .teamId(team.getId())
.runId(run.getId())
.subject(subjectOf(step)) .subject(subjectOf(step))
.description(step + "\n\n[Plan context]\nOverall request: " + goal) .description(step + "\n\n[Plan context]\nOverall request: " + goal)
.assigneeAgentId(memberIds.get(i)) .assigneeAgentId(memberIds.get(i))
@ -147,13 +174,21 @@ public class TeamPlanBridge {
.build()); .build());
created.add(task); created.add(task);
} }
planningService.markPlanDelegated(planId); sealAndPublish(team, planId, run);
eventPublisher.publishEvent(new TeamTasksDelegatedEvent(team.getId()));
log.info("Plan {} delegated to team {} board as {} task(s)", planId, team.getId(), log.info("Plan {} delegated to team {} board as {} task(s)", planId, team.getId(),
created.size()); created.size());
return buildAnnouncement(created, stepDeps); return buildAnnouncement(created, stepDeps);
} }
private void sealAndPublish(AgentTeamEntity team, Long planId, TeamRunEntity run) {
TeamRunService.SealResult seal = runService.sealRunWithResult(
run.getId(), team.getWorkspaceId());
planningService.markPlanDelegated(planId);
if (seal.transitioned()) {
eventPublisher.publishEvent(new TeamTasksDelegatedEvent(team.getId()));
}
}
// ==================== resume gate ==================== // ==================== resume gate ====================
/** Outcome of the parked-plan check on an inbound message. */ /** Outcome of the parked-plan check on an inbound message. */

View File

@ -0,0 +1,47 @@
package vip.mate.team.service;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.team.event.TeamRunCancelCommittedIntent;
import vip.mate.team.model.TeamRunView;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus;
import java.util.ArrayList;
import java.util.List;
/** Coordinates cancellation side effects around the run domain lifecycle. */
@Service
@RequiredArgsConstructor
public class TeamRunApplicationService {
private final TeamRunService runService;
private final TeamTaskService taskService;
private final ApplicationEventPublisher events;
@Transactional
public TeamRunView cancelRun(Long runId, Long workspaceId, String reason) {
TeamRunService.CancelResult cancelled = runService.cancelRunWithResult(
runId, workspaceId, reason);
List<TeamRunCancelCommittedIntent.WorkerTask> workers = new ArrayList<>();
if (cancelled.transitioned()) {
for (TeamTaskEntity task : taskService.listTasksByRun(runId)) {
if (TeamTaskStatus.isTerminal(task.getStatus())) {
continue;
}
if (TeamTaskStatus.IN_PROGRESS.equals(task.getStatus())) {
workers.add(new TeamRunCancelCommittedIntent.WorkerTask(
task.getId(), task.getTaskNumber(), task.getConversationId()));
}
taskService.cancelTask(task.getId(), reason);
}
}
TeamRunView view = runService.buildView(cancelled.run());
if (cancelled.transitioned()) {
events.publishEvent(new TeamRunCancelCommittedIntent(view, workers));
}
return view;
}
}

View File

@ -0,0 +1,55 @@
package vip.mate.team.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import vip.mate.team.event.TeamRunCancelCommittedIntent;
import vip.mate.team.event.TeamRunDispatchCommittedIntent;
import vip.mate.team.model.TeamTaskEntity;
/** Executes run side effects only after their state transaction commits. */
@Slf4j
@Component
@RequiredArgsConstructor
public class TeamRunCommittedIntentListener {
private final TeamDispatchService dispatchService;
private final TeamRunEventPublisher eventPublisher;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onDispatchCommitted(TeamRunDispatchCommittedIntent intent) {
try {
dispatchService.requestDispatch(intent.teamId());
} catch (Exception e) {
log.warn("Team {} committed dispatch failed: {}", intent.teamId(), e.getMessage());
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onCancelCommitted(TeamRunCancelCommittedIntent intent) {
for (TeamRunCancelCommittedIntent.WorkerTask worker : intent.workers()) {
try {
dispatchService.interruptRun(snapshot(worker));
} catch (Exception e) {
log.warn("Team task {} committed interrupt failed: {}",
worker.taskId(), e.getMessage());
}
}
try {
eventPublisher.publishCancelled(intent.run());
} catch (Exception e) {
log.warn("Team run {} committed cancellation event failed: {}",
intent.run().id(), e.getMessage());
}
}
private TeamTaskEntity snapshot(TeamRunCancelCommittedIntent.WorkerTask worker) {
TeamTaskEntity task = new TeamTaskEntity();
task.setId(worker.taskId());
task.setTaskNumber(worker.taskNumber());
task.setConversationId(worker.conversationId());
return task;
}
}

View File

@ -0,0 +1,9 @@
package vip.mate.team.service;
import vip.mate.team.model.TeamRunView;
/** Stable application boundary for team run lifecycle events. */
public interface TeamRunEventPublisher {
void publishCancelled(TeamRunView run);
}

View File

@ -0,0 +1,30 @@
package vip.mate.team.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.repository.TeamTaskMapper;
/** Executes each run projection in an independent transaction. */
@Service
@RequiredArgsConstructor
public class TeamRunProjectionExecutor {
private final TeamRunProjector runProjector;
private final TeamTaskMapper taskMapper;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void execute(Long runId) {
runProjector.project(runId);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void executeTask(Long taskId) {
TeamTaskEntity task = taskMapper.selectById(taskId);
if (task != null && task.getRunId() != null) {
runProjector.project(task.getRunId());
}
}
}

View File

@ -0,0 +1,66 @@
package vip.mate.team.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** Schedules run projection outside the task mutation transaction. */
@Slf4j
@Service
@RequiredArgsConstructor
public class TeamRunProjectionScheduler {
private final TeamRunProjectionExecutor projectionExecutor;
public void scheduleRun(Long runId) {
if (runId == null) {
return;
}
if (TransactionSynchronizationManager.isActualTransactionActive()
&& TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
projectRun(runId);
}
});
return;
}
projectRun(runId);
}
public void scheduleTask(Long taskId) {
if (taskId == null) {
return;
}
if (TransactionSynchronizationManager.isActualTransactionActive()
&& TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
projectTask(taskId);
}
});
return;
}
projectTask(taskId);
}
private void projectRun(Long runId) {
try {
projectionExecutor.execute(runId);
} catch (RuntimeException error) {
log.warn("Team run {} projection failed: {}", runId, error.getMessage());
}
}
private void projectTask(Long taskId) {
try {
projectionExecutor.executeTask(taskId);
} catch (RuntimeException error) {
log.warn("Team run projection for task {} failed: {}", taskId, error.getMessage());
}
}
}

View File

@ -0,0 +1,112 @@
package vip.mate.team.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
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.repository.TeamRunMapper;
import vip.mate.team.repository.TeamTaskMapper;
import java.util.List;
/** Projects task state into its owning run without exposing failures to task settlement. */
@Slf4j
@Service
public class TeamRunProjector {
private final TeamRunMapper runMapper;
private final TeamTaskMapper taskMapper;
private final TeamRunStateMachine stateMachine;
public TeamRunProjector(TeamRunMapper runMapper, TeamTaskMapper taskMapper) {
this.runMapper = runMapper;
this.taskMapper = taskMapper;
this.stateMachine = new TeamRunStateMachine();
}
public TeamRunView project(Long runId) {
if (runId == null) {
return null;
}
try {
return projectOnce(runId, 1);
} catch (RuntimeException error) {
log.warn("Failed to project team run {}", runId, error);
return null;
}
}
private TeamRunView projectOnce(Long runId, int retryRemaining) {
TeamRunEntity run = runMapper.selectById(runId);
if (run == null) {
return null;
}
List<TeamTaskEntity> tasks = taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getRunId, runId)
.orderByAsc(TeamTaskEntity::getTaskNumber));
TeamRunStateMachine.Projection projection = stateMachine.project(run, tasks);
if (TeamRunStatus.isTerminal(run.getStatus()) || TeamRunStatus.PLANNING.equals(run.getStatus())) {
return view(run, projection, tasks);
}
JSONObject metadata = metadata(run.getMetadata());
boolean metadataChanged;
if (projection.projectedOutcome() == null) {
metadataChanged = metadata.containsKey("projectedOutcome");
metadata.remove("projectedOutcome");
} else {
metadataChanged = !projection.projectedOutcome().equals(metadata.getStr("projectedOutcome"));
metadata.set("projectedOutcome", projection.projectedOutcome());
}
boolean statusChanged = !projection.status().equals(run.getStatus());
if (!statusChanged && !metadataChanged) {
return view(run, projection, tasks);
}
String metadataJson = metadata.toString();
LambdaUpdateWrapper<TeamRunEntity> update = Wrappers.<TeamRunEntity>lambdaUpdate()
.eq(TeamRunEntity::getId, run.getId())
.eq(TeamRunEntity::getStatus, run.getStatus());
if (run.getMetadata() == null) {
update.isNull(TeamRunEntity::getMetadata);
} else {
update.eq(TeamRunEntity::getMetadata, run.getMetadata());
}
update
.set(TeamRunEntity::getStatus, projection.status())
.set(TeamRunEntity::getMetadata, metadataJson);
int changed = runMapper.update(null, update);
if (changed == 1) {
run.setStatus(projection.status());
run.setMetadata(metadataJson);
return view(run, projection, tasks);
}
return retryRemaining > 0 ? projectOnce(runId, retryRemaining - 1) : null;
}
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());
}
private JSONObject metadata(String value) {
if (value == null || value.isBlank()) {
return new JSONObject();
}
try {
return JSONUtil.parseObj(value);
} catch (RuntimeException invalidJson) {
return new JSONObject();
}
}
}

View File

@ -0,0 +1,280 @@
package vip.mate.team.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamRunCreateCommand;
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.repository.TeamRunMapper;
import vip.mate.team.repository.TeamTaskMapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/** Owns team run creation, lifecycle transitions, authorization, and reads. */
@Service
public class TeamRunService {
public record SealResult(TeamRunEntity run, boolean transitioned) {
}
public record CancelResult(TeamRunEntity run, boolean transitioned) {
}
private static final int MAX_TITLE_LENGTH = 255;
private static final Set<String> FINAL_OUTCOMES = Set.of(
TeamRunStatus.COMPLETED, TeamRunStatus.PARTIAL, TeamRunStatus.FAILED);
private final TeamRunMapper runMapper;
private final TeamTaskMapper taskMapper;
private final TeamService teamService;
private final TeamRunStateMachine stateMachine;
public TeamRunService(TeamRunMapper runMapper, TeamTaskMapper taskMapper, TeamService teamService) {
this.runMapper = runMapper;
this.taskMapper = taskMapper;
this.teamService = teamService;
this.stateMachine = new TeamRunStateMachine();
}
public TeamRunEntity startRun(TeamRunCreateCommand command) {
validateCreate(command);
TeamRunEntity existing = findByOrigin(command.getWorkspaceId(), command.getLeadConversationId(),
command.getOriginMessageId());
if (existing != null) {
return existing;
}
TeamRunEntity run = new TeamRunEntity();
run.setTeamId(command.getTeamId());
run.setWorkspaceId(command.getWorkspaceId());
run.setLeadAgentId(command.getLeadAgentId());
run.setLeadConversationId(command.getLeadConversationId());
run.setOriginMessageId(command.getOriginMessageId());
run.setTitle(deriveTitle(command));
run.setObjective(command.getObjective().trim());
run.setStatus(TeamRunStatus.PLANNING);
run.setMetadata(command.getMetadata());
try {
runMapper.insert(run);
return run;
} catch (DuplicateKeyException duplicate) {
TeamRunEntity winner = findByOrigin(command.getWorkspaceId(), command.getLeadConversationId(),
command.getOriginMessageId());
if (winner != null) {
return winner;
}
throw duplicate;
}
}
public TeamRunEntity requireRun(Long runId, Long workspaceId) {
TeamRunEntity run = runMapper.selectById(runId);
if (run == null || workspaceId == null || !workspaceId.equals(run.getWorkspaceId())) {
throw new IllegalArgumentException("team run not found in workspace: " + runId);
}
return run;
}
public Set<Long> findPlanningRunIds(Collection<Long> runIds) {
if (runIds == null || runIds.isEmpty()) {
return Set.of();
}
return runMapper.selectBatchIds(runIds).stream()
.filter(run -> TeamRunStatus.PLANNING.equals(run.getStatus()))
.map(TeamRunEntity::getId)
.collect(Collectors.toSet());
}
public TeamRunView getRun(Long runId, Long workspaceId) {
return buildView(requireRun(runId, workspaceId));
}
public List<TeamRunView> listTeamRuns(Long teamId, Long workspaceId) {
return runMapper.selectList(Wrappers.<TeamRunEntity>lambdaQuery()
.eq(TeamRunEntity::getTeamId, teamId)
.eq(TeamRunEntity::getWorkspaceId, workspaceId)
.orderByDesc(TeamRunEntity::getCreateTime))
.stream().map(this::buildView).toList();
}
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();
}
@Transactional
public TeamRunEntity sealRun(Long runId, Long workspaceId) {
return sealRunWithResult(runId, workspaceId).run();
}
@Transactional
public SealResult sealRunWithResult(Long runId, Long workspaceId) {
TeamRunEntity run = requireRun(runId, workspaceId);
if (!TeamRunStatus.PLANNING.equals(run.getStatus())) {
return new SealResult(run, false);
}
long taskCount = taskMapper.selectCount(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getRunId, runId));
if (taskCount == 0) {
throw new IllegalStateException("cannot seal a team run without tasks");
}
LocalDateTime startedAt = LocalDateTime.now();
int changed = runMapper.update(null, Wrappers.<TeamRunEntity>lambdaUpdate()
.eq(TeamRunEntity::getId, runId)
.eq(TeamRunEntity::getStatus, TeamRunStatus.PLANNING)
.set(TeamRunEntity::getStatus, TeamRunStatus.RUNNING)
.set(TeamRunEntity::getStartedAt, startedAt));
if (changed == 1) {
run.setStatus(TeamRunStatus.RUNNING);
run.setStartedAt(startedAt);
return new SealResult(run, true);
}
TeamRunEntity current = requireRun(runId, workspaceId);
if (!TeamRunStatus.PLANNING.equals(current.getStatus())) {
return new SealResult(current, false);
}
throw new IllegalStateException("failed to seal team run: " + runId);
}
@Transactional
public TeamRunEntity markFinalized(Long runId, Long workspaceId, String finalSummary) {
TeamRunEntity run = requireRun(runId, workspaceId);
if (TeamRunStatus.isTerminal(run.getStatus())) {
return run;
}
if (!TeamRunStatus.FINALIZING.equals(run.getStatus())) {
throw new IllegalStateException("team run is not finalizing: " + runId);
}
String outcome = metadata(run.getMetadata()).getStr("projectedOutcome");
if (!FINAL_OUTCOMES.contains(outcome)) {
throw new IllegalStateException("team run has no valid projected outcome: " + runId);
}
LocalDateTime completedAt = LocalDateTime.now();
int changed = runMapper.update(null, Wrappers.<TeamRunEntity>lambdaUpdate()
.eq(TeamRunEntity::getId, runId)
.eq(TeamRunEntity::getStatus, TeamRunStatus.FINALIZING)
.set(TeamRunEntity::getStatus, outcome)
.set(TeamRunEntity::getFinalSummary, finalSummary)
.set(TeamRunEntity::getCompletedAt, completedAt));
if (changed == 1) {
run.setStatus(outcome);
run.setFinalSummary(finalSummary);
run.setCompletedAt(completedAt);
return run;
}
TeamRunEntity current = requireRun(runId, workspaceId);
if (TeamRunStatus.isTerminal(current.getStatus())) {
return current;
}
throw new IllegalStateException("failed to finalize team run: " + runId);
}
@Transactional
public TeamRunEntity cancelRun(Long runId, Long workspaceId, String reason) {
return cancelRunWithResult(runId, workspaceId, reason).run();
}
@Transactional
public CancelResult cancelRunWithResult(Long runId, Long workspaceId, String reason) {
TeamRunEntity run = requireRun(runId, workspaceId);
if (TeamRunStatus.isTerminal(run.getStatus())) {
return new CancelResult(run, false);
}
LocalDateTime completedAt = LocalDateTime.now();
int changed = runMapper.update(null, Wrappers.<TeamRunEntity>lambdaUpdate()
.eq(TeamRunEntity::getId, runId)
.notIn(TeamRunEntity::getStatus, TeamRunStatus.TERMINAL)
.set(TeamRunEntity::getStatus, TeamRunStatus.CANCELLED)
.set(TeamRunEntity::getStopReason, reason)
.set(TeamRunEntity::getCompletedAt, completedAt));
if (changed == 1) {
run.setStatus(TeamRunStatus.CANCELLED);
run.setStopReason(reason);
run.setCompletedAt(completedAt);
return new CancelResult(run, true);
}
return new CancelResult(requireRun(runId, workspaceId), false);
}
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());
}
private List<TeamTaskEntity> tasksForRun(Long runId) {
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getRunId, runId)
.orderByAsc(TeamTaskEntity::getTaskNumber));
}
private void validateCreate(TeamRunCreateCommand command) {
if (command == null || command.getTeamId() == null || command.getWorkspaceId() == null
|| command.getLeadAgentId() == null) {
throw new IllegalArgumentException("team, workspace, and lead are required");
}
AgentTeamEntity team = teamService.getTeam(command.getTeamId());
if (team == null || !TeamService.STATUS_ACTIVE.equals(team.getStatus())) {
throw new IllegalArgumentException("team not found or not active: " + command.getTeamId());
}
if (!command.getWorkspaceId().equals(team.getWorkspaceId())) {
throw new IllegalArgumentException("team is not in workspace: " + command.getWorkspaceId());
}
if (!command.getLeadAgentId().equals(team.getLeadAgentId())) {
throw new IllegalArgumentException("agent is not the team lead: " + command.getLeadAgentId());
}
if (command.getLeadConversationId() == null || command.getLeadConversationId().isBlank()) {
throw new IllegalArgumentException("lead conversation is required");
}
if (command.getObjective() == null || command.getObjective().isBlank()) {
throw new IllegalArgumentException("objective is required");
}
}
private TeamRunEntity findByOrigin(Long workspaceId, String conversationId, Long originMessageId) {
if (originMessageId == null) {
return null;
}
return runMapper.selectOne(Wrappers.<TeamRunEntity>lambdaQuery()
.eq(TeamRunEntity::getWorkspaceId, workspaceId)
.eq(TeamRunEntity::getLeadConversationId, conversationId)
.eq(TeamRunEntity::getOriginMessageId, originMessageId));
}
private String deriveTitle(TeamRunCreateCommand command) {
String title = command.getTitle() == null || command.getTitle().isBlank()
? command.getObjective().trim() : command.getTitle().trim();
return title.length() <= MAX_TITLE_LENGTH ? title : title.substring(0, MAX_TITLE_LENGTH);
}
private JSONObject metadata(String value) {
if (value == null || value.isBlank()) {
return new JSONObject();
}
try {
return JSONUtil.parseObj(value);
} catch (RuntimeException invalidJson) {
return new JSONObject();
}
}
}

View File

@ -0,0 +1,78 @@
package vip.mate.team.service;
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 java.util.Set;
/** Pure task-to-run lifecycle projection. */
public final class TeamRunStateMachine {
private static final Set<String> ACTIVE_TASK_STATUSES = Set.of(
TeamTaskStatus.PENDING,
TeamTaskStatus.BLOCKED,
TeamTaskStatus.IN_PROGRESS,
TeamTaskStatus.STALE
);
private static final Set<String> KNOWN_TASK_STATUSES = Set.of(
TeamTaskStatus.PENDING,
TeamTaskStatus.BLOCKED,
TeamTaskStatus.IN_PROGRESS,
TeamTaskStatus.IN_REVIEW,
TeamTaskStatus.COMPLETED,
TeamTaskStatus.FAILED,
TeamTaskStatus.CANCELLED,
TeamTaskStatus.STALE
);
public Projection project(TeamRunEntity run, List<TeamTaskEntity> tasks) {
List<TeamTaskEntity> safeTasks = tasks == null ? List.of() : tasks;
TeamRunView.Progress progress = progress(safeTasks);
String currentStatus = run.getStatus();
if (TeamRunStatus.isTerminal(currentStatus) || TeamRunStatus.PLANNING.equals(currentStatus)) {
return new Projection(currentStatus, null, progress);
}
if (safeTasks.isEmpty()
|| safeTasks.stream().anyMatch(task -> !KNOWN_TASK_STATUSES.contains(task.getStatus()))) {
return new Projection(currentStatus, null, progress);
}
if (safeTasks.stream().anyMatch(task -> ACTIVE_TASK_STATUSES.contains(task.getStatus()))) {
return new Projection(TeamRunStatus.RUNNING, null, progress);
}
if (safeTasks.stream().anyMatch(task -> TeamTaskStatus.IN_REVIEW.equals(task.getStatus()))) {
return new Projection(TeamRunStatus.AWAITING_REVIEW, null, progress);
}
String outcome = progress.done() == progress.total()
? TeamRunStatus.COMPLETED
: progress.done() > 0 ? TeamRunStatus.PARTIAL : TeamRunStatus.FAILED;
return new Projection(TeamRunStatus.FINALIZING, outcome, progress);
}
private TeamRunView.Progress progress(List<TeamTaskEntity> tasks) {
int done = 0;
int failed = 0;
int inReview = 0;
for (TeamTaskEntity task : tasks) {
if (TeamTaskStatus.COMPLETED.equals(task.getStatus())) {
done++;
} else if (TeamTaskStatus.FAILED.equals(task.getStatus())
|| TeamTaskStatus.CANCELLED.equals(task.getStatus())) {
failed++;
} else if (TeamTaskStatus.IN_REVIEW.equals(task.getStatus())) {
inReview++;
}
}
int total = tasks.size();
int percent = total == 0 ? 0 : done * 100 / total;
return new TeamRunView.Progress(total, done, failed, inReview, percent);
}
public record Projection(String status, String projectedOutcome, TeamRunView.Progress progress) {
}
}

View File

@ -13,6 +13,8 @@ import vip.mate.team.model.TeamTaskCommentEntity;
import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus; import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamTaskEventEntity; import vip.mate.team.model.TeamTaskEventEntity;
import vip.mate.team.repository.TeamTaskCommentMapper; import vip.mate.team.repository.TeamTaskCommentMapper;
import vip.mate.team.repository.TeamTaskEventMapper; import vip.mate.team.repository.TeamTaskEventMapper;
@ -26,6 +28,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
/** /**
* Shared task board service. All status transitions are guarded conditional * Shared task board service. All status transitions are guarded conditional
@ -57,6 +60,8 @@ public class TeamTaskService {
private final TeamTaskCommentMapper commentMapper; private final TeamTaskCommentMapper commentMapper;
private final TeamTaskEventMapper eventMapper; private final TeamTaskEventMapper eventMapper;
private final TeamService teamService; private final TeamService teamService;
private final TeamRunProjectionScheduler projectionScheduler;
private final TeamRunService runService;
// ==================== creation ==================== // ==================== creation ====================
@ -66,6 +71,15 @@ public class TeamTaskService {
if (team == null || !TeamService.STATUS_ACTIVE.equals(team.getStatus())) { if (team == null || !TeamService.STATUS_ACTIVE.equals(team.getStatus())) {
throw new IllegalArgumentException("team not found or not active: " + cmd.getTeamId()); throw new IllegalArgumentException("team not found or not active: " + cmd.getTeamId());
} }
if (cmd.getRunId() != null) {
TeamRunEntity run = runService.requireRun(cmd.getRunId(), team.getWorkspaceId());
if (!cmd.getTeamId().equals(run.getTeamId())) {
throw new IllegalArgumentException("team task and run must belong to the same team");
}
if (!TeamRunStatus.PLANNING.equals(run.getStatus())) {
throw new IllegalStateException("team run must be planning to accept tasks: " + cmd.getRunId());
}
}
if (cmd.getSubject() == null || cmd.getSubject().isBlank()) { if (cmd.getSubject() == null || cmd.getSubject().isBlank()) {
throw new IllegalArgumentException("subject is required"); throw new IllegalArgumentException("subject is required");
} }
@ -92,6 +106,9 @@ public class TeamTaskService {
if (blocker == null || !blocker.getTeamId().equals(cmd.getTeamId())) { if (blocker == null || !blocker.getTeamId().equals(cmd.getTeamId())) {
throw new IllegalArgumentException("blocking task not found in this team: " + blockerId); throw new IllegalArgumentException("blocking task not found in this team: " + blockerId);
} }
if (!Objects.equals(blocker.getRunId(), cmd.getRunId())) {
throw new IllegalArgumentException("blocking task must belong to the same run: " + blockerId);
}
if (TeamTaskStatus.isTerminal(blocker.getStatus())) { if (TeamTaskStatus.isTerminal(blocker.getStatus())) {
throw new IllegalArgumentException("blocking task " + blockerId throw new IllegalArgumentException("blocking task " + blockerId
+ " is already " + blocker.getStatus() + " is already " + blocker.getStatus()
@ -101,6 +118,7 @@ public class TeamTaskService {
TeamTaskEntity task = new TeamTaskEntity(); TeamTaskEntity task = new TeamTaskEntity();
task.setTeamId(cmd.getTeamId()); task.setTeamId(cmd.getTeamId());
task.setRunId(cmd.getRunId());
task.setTaskNumber(teamService.nextTaskNumber(cmd.getTeamId())); task.setTaskNumber(teamService.nextTaskNumber(cmd.getTeamId()));
task.setSubject(cmd.getSubject()); task.setSubject(cmd.getSubject());
task.setDescription(cmd.getDescription()); task.setDescription(cmd.getDescription());
@ -125,6 +143,7 @@ public class TeamTaskService {
"assignee: agent " + assignee); "assignee: agent " + assignee);
log.info("Team {} task #{} created ({}), assignee={} status={}", log.info("Team {} task #{} created ({}), assignee={} status={}",
cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus()); cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus());
projectTask(task);
return task; return task;
} }
@ -135,13 +154,17 @@ public class TeamTaskService {
* get false. The WHERE clause is the mutex. * get false. The WHERE clause is the mutex.
*/ */
public boolean claimTask(Long taskId, Long agentId) { public boolean claimTask(Long taskId, Long agentId) {
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate() boolean claimed = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
.eq(TeamTaskEntity::getId, taskId) .eq(TeamTaskEntity::getId, taskId)
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)
.isNull(TeamTaskEntity::getOwnerAgentId) .isNull(TeamTaskEntity::getOwnerAgentId)
.set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) .set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
.set(TeamTaskEntity::getOwnerAgentId, agentId) .set(TeamTaskEntity::getOwnerAgentId, agentId)
.set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1;
if (claimed) {
projectTask(taskId);
}
return claimed;
} }
/** /**
@ -149,12 +172,16 @@ public class TeamTaskService {
* this overrides a previously set owner but still requires pending status. * this overrides a previously set owner but still requires pending status.
*/ */
public boolean assignTask(Long taskId, Long agentId) { public boolean assignTask(Long taskId, Long agentId) {
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate() boolean assigned = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
.eq(TeamTaskEntity::getId, taskId) .eq(TeamTaskEntity::getId, taskId)
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)
.set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) .set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
.set(TeamTaskEntity::getOwnerAgentId, agentId) .set(TeamTaskEntity::getOwnerAgentId, agentId)
.set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1;
if (assigned) {
projectTask(taskId);
}
return assigned;
} }
/** Record the member conversation executing the task. */ /** Record the member conversation executing the task. */
@ -207,7 +234,9 @@ public class TeamTaskService {
toReview ? TeamTaskEventEntity.IN_REVIEW : TeamTaskEventEntity.COMPLETED, toReview ? TeamTaskEventEntity.IN_REVIEW : TeamTaskEventEntity.COMPLETED,
agentId != null ? AUTHOR_AGENT : AUTHOR_SYSTEM, agentId != null ? AUTHOR_AGENT : AUTHOR_SYSTEM,
agentId != null ? String.valueOf(agentId) : null, null); agentId != null ? String.valueOf(agentId) : null, null);
return toReview ? List.of() : releaseDependents(task); List<Long> released = toReview ? List.of() : releaseDependents(task);
projectTask(task);
return released;
} }
/** Human approval of an in_review task; releases dependents. */ /** Human approval of an in_review task; releases dependents. */
@ -221,7 +250,9 @@ public class TeamTaskService {
if (rows != 1) { if (rows != 1) {
throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review"); throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review");
} }
return releaseDependents(task); List<Long> released = releaseDependents(task);
projectTask(task);
return released;
} }
/** Human rejection of an in_review task; cancels it and releases dependents. */ /** Human rejection of an in_review task; cancels it and releases dependents. */
@ -236,11 +267,14 @@ public class TeamTaskService {
if (rows != 1) { if (rows != 1) {
throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review"); throw new IllegalStateException("task #" + task.getTaskNumber() + " is not awaiting review");
} }
return releaseDependents(task); List<Long> released = releaseDependents(task);
projectTask(task);
return released;
} }
/** Fail a task (blocker escalation, runner error, circuit breaker). Does NOT release dependents. */ /** Fail a task (blocker escalation, runner error, circuit breaker). Does NOT release dependents. */
public boolean failTask(Long taskId, String reason) { public boolean failTask(Long taskId, String reason) {
TeamTaskEntity task = taskMapper.selectById(taskId);
boolean failed = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate() boolean failed = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
.eq(TeamTaskEntity::getId, taskId) .eq(TeamTaskEntity::getId, taskId)
.in(TeamTaskEntity::getStatus, .in(TeamTaskEntity::getStatus,
@ -249,9 +283,9 @@ public class TeamTaskService {
.set(TeamTaskEntity::getReason, reason) .set(TeamTaskEntity::getReason, reason)
.set(TeamTaskEntity::getLockExpiresAt, null)) == 1; .set(TeamTaskEntity::getLockExpiresAt, null)) == 1;
if (failed) { if (failed) {
TeamTaskEntity task = taskMapper.selectById(taskId);
recordEvent(task == null ? null : task.getTeamId(), taskId, recordEvent(task == null ? null : task.getTeamId(), taskId,
TeamTaskEventEntity.FAILED, AUTHOR_SYSTEM, null, reason); TeamTaskEventEntity.FAILED, AUTHOR_SYSTEM, null, reason);
projectTask(taskId);
} }
return failed; return failed;
} }
@ -270,12 +304,14 @@ public class TeamTaskService {
if (rows != 1) { if (rows != 1) {
throw new IllegalStateException("task #" + task.getTaskNumber() + " is already terminal"); throw new IllegalStateException("task #" + task.getTaskNumber() + " is already terminal");
} }
return releaseDependents(task); List<Long> released = releaseDependents(task);
projectTask(task);
return released;
} }
/** Manual retry of a failed/stale task: back to pending, owner and breaker reset. */ /** Manual retry of a failed/stale task: back to pending, owner and breaker reset. */
public boolean retryTask(Long taskId) { public boolean retryTask(Long taskId) {
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate() boolean retried = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
.eq(TeamTaskEntity::getId, taskId) .eq(TeamTaskEntity::getId, taskId)
.in(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED, TeamTaskStatus.STALE) .in(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED, TeamTaskStatus.STALE)
.set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) .set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)
@ -283,12 +319,17 @@ public class TeamTaskService {
.set(TeamTaskEntity::getLockExpiresAt, null) .set(TeamTaskEntity::getLockExpiresAt, null)
.set(TeamTaskEntity::getReason, null) .set(TeamTaskEntity::getReason, null)
.set(TeamTaskEntity::getDispatchCount, 0)) == 1; .set(TeamTaskEntity::getDispatchCount, 0)) == 1;
if (retried) {
projectTask(taskId);
}
return retried;
} }
// ==================== progress / comments ==================== // ==================== progress / comments ====================
/** Update progress and renew the execution lease in one shot. */ /** Update progress and renew the execution lease in one shot. */
public boolean updateProgress(Long taskId, Long agentId, Integer percent, String step) { public boolean updateProgress(Long taskId, Long agentId, Integer percent, String step) {
TeamTaskEntity task = taskMapper.selectById(taskId);
boolean updated = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate() boolean updated = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
.eq(TeamTaskEntity::getId, taskId) .eq(TeamTaskEntity::getId, taskId)
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
@ -297,11 +338,11 @@ public class TeamTaskService {
.set(step != null, TeamTaskEntity::getProgressStep, step) .set(step != null, TeamTaskEntity::getProgressStep, step)
.set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1; .set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1;
if (updated) { if (updated) {
TeamTaskEntity task = taskMapper.selectById(taskId);
recordEvent(task == null ? null : task.getTeamId(), taskId, recordEvent(task == null ? null : task.getTeamId(), taskId,
TeamTaskEventEntity.PROGRESS, AUTHOR_AGENT, TeamTaskEventEntity.PROGRESS, AUTHOR_AGENT,
agentId != null ? String.valueOf(agentId) : null, agentId != null ? String.valueOf(agentId) : null,
(percent != null ? percent + "%" : "") + (step != null ? "" + step : "")); (percent != null ? percent + "%" : "") + (step != null ? "" + step : ""));
projectTask(taskId);
} }
return updated; return updated;
} }
@ -524,12 +565,20 @@ public class TeamTaskService {
* picks at most one per assignee so a member never runs two tasks at once. * picks at most one per assignee so a member never runs two tasks at once.
*/ */
public List<TeamTaskEntity> findDispatchable(Long teamId) { public List<TeamTaskEntity> findDispatchable(Long teamId) {
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery() List<TeamTaskEntity> candidates = taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getTeamId, teamId) .eq(TeamTaskEntity::getTeamId, teamId)
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)
.isNotNull(TeamTaskEntity::getAssigneeAgentId) .isNotNull(TeamTaskEntity::getAssigneeAgentId)
.orderByDesc(TeamTaskEntity::getPriority) .orderByDesc(TeamTaskEntity::getPriority)
.orderByAsc(TeamTaskEntity::getCreateTime)); .orderByAsc(TeamTaskEntity::getCreateTime));
Set<Long> runIds = candidates.stream()
.map(TeamTaskEntity::getRunId)
.filter(Objects::nonNull)
.collect(java.util.stream.Collectors.toSet());
Set<Long> planningRunIds = runService.findPlanningRunIds(runIds);
return candidates.stream()
.filter(task -> task.getRunId() == null || !planningRunIds.contains(task.getRunId()))
.toList();
} }
/** Whether the agent is already executing a task in this team. */ /** Whether the agent is already executing a task in this team. */
@ -551,13 +600,16 @@ public class TeamTaskService {
.isNotNull(TeamTaskEntity::getLockExpiresAt) .isNotNull(TeamTaskEntity::getLockExpiresAt)
.lt(TeamTaskEntity::getLockExpiresAt, LocalDateTime.now())); .lt(TeamTaskEntity::getLockExpiresAt, LocalDateTime.now()));
for (TeamTaskEntity task : expired) { for (TeamTaskEntity task : expired) {
taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate() int rows = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
.eq(TeamTaskEntity::getId, task.getId()) .eq(TeamTaskEntity::getId, task.getId())
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS) .eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
.set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE) .set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE)
.set(TeamTaskEntity::getReason, "execution lease expired")); .set(TeamTaskEntity::getReason, "execution lease expired"));
recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE, if (rows == 1) {
AUTHOR_SYSTEM, null, "execution lease expired"); recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE,
AUTHOR_SYSTEM, null, "execution lease expired");
projectTask(task);
}
} }
if (!expired.isEmpty()) { if (!expired.isEmpty()) {
log.warn("Marked {} team task(s) stale after lease expiry", expired.size()); log.warn("Marked {} team task(s) stale after lease expiry", expired.size());
@ -571,6 +623,12 @@ public class TeamTaskService {
return taskMapper.selectById(taskId); return taskMapper.selectById(taskId);
} }
public List<TeamTaskEntity> listTasksByRun(Long runId) {
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getRunId, runId)
.orderByAsc(TeamTaskEntity::getTaskNumber));
}
/** /**
* Tasks created from a delegated plan's steps, ordered by creation. The * Tasks created from a delegated plan's steps, ordered by creation. The
* plan linkage lives in the task metadata JSON ({@code "planId"} written * plan linkage lives in the task metadata JSON ({@code "planId"} written
@ -659,6 +717,7 @@ public class TeamTaskService {
.set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)); .set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING));
if (rows == 1) { if (rows == 1) {
released.add(candidate.getId()); released.add(candidate.getId());
projectTask(candidate);
} }
} }
if (!released.isEmpty()) { if (!released.isEmpty()) {
@ -678,6 +737,26 @@ public class TeamTaskService {
return task; return task;
} }
private void projectTask(Long taskId) {
try {
projectionScheduler.scheduleTask(taskId);
} catch (RuntimeException error) {
log.warn("Team run projection failed after task {} changed: {}", taskId, error.getMessage());
}
}
private void projectTask(TeamTaskEntity task) {
if (task == null || task.getRunId() == null) {
return;
}
try {
projectionScheduler.scheduleRun(task.getRunId());
} catch (RuntimeException error) {
log.warn("Team run {} projection failed after task {} changed: {}",
task.getRunId(), task.getId(), error.getMessage());
}
}
private static LocalDateTime newLease() { private static LocalDateTime newLease() {
return LocalDateTime.now().plusMinutes(LOCK_MINUTES); return LocalDateTime.now().plusMinutes(LOCK_MINUTES);
} }

View File

@ -11,6 +11,8 @@ import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamTaskCommentEntity; import vip.mate.team.model.TeamTaskCommentEntity;
import vip.mate.team.model.TeamRunCreateCommand;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskEventEntity; import vip.mate.team.model.TeamTaskEventEntity;
@ -18,6 +20,7 @@ import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamDispatchService;
import vip.mate.team.service.TeamEventChannel; import vip.mate.team.service.TeamEventChannel;
import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamService;
import vip.mate.team.service.TeamRunService;
import vip.mate.team.service.TeamTaskService; import vip.mate.team.service.TeamTaskService;
import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
@ -47,6 +50,7 @@ public class TeamTasksTool {
private final TeamService teamService; private final TeamService teamService;
private final TeamTaskService taskService; private final TeamTaskService taskService;
private final TeamRunService runService;
private final TeamDispatchService dispatchService; private final TeamDispatchService dispatchService;
private final TeamEventChannel eventChannel; private final TeamEventChannel eventChannel;
private final ConversationService conversationService; private final ConversationService conversationService;
@ -54,9 +58,11 @@ public class TeamTasksTool {
@Tool(description = "Operate your team's shared task board. Actions: " @Tool(description = "Operate your team's shared task board. Actions: "
+ "'list' all tasks; 'get' one task with comments (taskId); " + "'list' all tasks; 'get' one task with comments (taskId); "
+ "'create' a task (lead only; subject, description, assigneeAgentId required, " + "'start_run' (lead only; objective required, optional title) returns a runId; "
+ "'create' stages a task (lead only; runId, subject, description, assigneeAgentId required, "
+ "optional blockedBy comma-separated prerequisite task ids, priority, higher first, " + "optional blockedBy comma-separated prerequisite task ids, priority, higher first, "
+ "requireApproval=true to park the finished task for human sign-off); " + "requireApproval=true to park the finished task for human sign-off); "
+ "'seal_run' (lead only; runId) seals the batch and starts dispatch; "
+ "'complete' a task with its result summary (taskId, result); " + "'complete' a task with its result summary (taskId, result); "
+ "'progress' to report execution progress (taskId, percent 0-100, step); " + "'progress' to report execution progress (taskId, percent 0-100, step); "
+ "'comment' to leave a note, or type='blocker' when you are stuck and need the lead " + "'comment' to leave a note, or type='blocker' when you are stuck and need the lead "
@ -66,10 +72,16 @@ public class TeamTasksTool {
+ "'retry' a failed/stale task back to pending (lead only; taskId). " + "'retry' a failed/stale task back to pending (lead only; taskId). "
+ "Only usable when you belong to an agent team.") + "Only usable when you belong to an agent team.")
public String team_tasks( public String team_tasks(
@ToolParam(description = "One of: list, get, create, complete, progress, comment, attach, cancel, retry") @ToolParam(description = "One of: start_run, create, seal_run, list, get, complete, progress, comment, attach, cancel, retry")
String action, String action,
@ToolParam(description = "Task id (string form is fine) — required by every action except list/create", required = false) @ToolParam(description = "Task id (string form is fine) — required by get/complete/progress/comment/attach/cancel/retry", required = false)
String taskId, String taskId,
@ToolParam(description = "create/seal_run: explicit team run id", required = false)
String runId,
@ToolParam(description = "start_run: concise run title", required = false)
String title,
@ToolParam(description = "start_run: objective for the delegated work", required = false)
String objective,
@ToolParam(description = "create: short task title", required = false) @ToolParam(description = "create: short task title", required = false)
String subject, String subject,
@ToolParam(description = "create: full task instructions; include every input the member needs — members do not see this conversation", required = false) @ToolParam(description = "create: full task instructions; include every input the member needs — members do not see this conversation", required = false)
@ -106,7 +118,11 @@ public class TeamTasksTool {
if (conversation == null || conversation.getAgentId() == null) { if (conversation == null || conversation.getAgentId() == null) {
return "Error: cannot resolve the calling agent for this conversation."; return "Error: cannot resolve the calling agent for this conversation.";
} }
if (conversation.getWorkspaceId() == null) {
return "Error: workspaceId is missing from conversation context.";
}
Long agentId = conversation.getAgentId(); Long agentId = conversation.getAgentId();
Long workspaceId = conversation.getWorkspaceId();
Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId); Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
if (teamOpt.isEmpty()) { if (teamOpt.isEmpty()) {
return "Error: you are not part of any agent team; team_tasks is unavailable."; return "Error: you are not part of any agent team; team_tasks is unavailable.";
@ -116,10 +132,14 @@ public class TeamTasksTool {
try { try {
return switch (action == null ? "" : action) { return switch (action == null ? "" : action) {
case "start_run" -> startRun(team, agentId, isLead, workspaceId,
conversationId, title, objective, ctx);
case "list" -> renderBoard(team); case "list" -> renderBoard(team);
case "get" -> renderDetail(team, parseId(taskId, "taskId")); case "get" -> renderDetail(team, parseId(taskId, "taskId"));
case "create" -> createTask(team, agentId, isLead, subject, description, case "create" -> createTask(team, agentId, isLead, workspaceId, runId,
assigneeAgentId, blockedBy, priority, requireApproval, conversationId); subject, description, assigneeAgentId, blockedBy, priority,
requireApproval, conversationId);
case "seal_run" -> sealRun(team, isLead, workspaceId, conversationId, runId);
case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result); case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result);
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step); case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text); case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
@ -127,7 +147,8 @@ public class TeamTasksTool {
case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text); case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text);
case "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId")); case "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId"));
default -> "Error: unknown action '" + action default -> "Error: unknown action '" + action
+ "'. Use one of: list, get, create, complete, progress, comment, attach, cancel, retry."; + "'. Use one of: start_run, create, seal_run, list, get, complete, progress, "
+ "comment, attach, cancel, retry.";
}; };
} catch (IllegalArgumentException | IllegalStateException e) { } catch (IllegalArgumentException | IllegalStateException e) {
return "Error: " + e.getMessage(); return "Error: " + e.getMessage();
@ -140,7 +161,26 @@ public class TeamTasksTool {
// ==================== actions ==================== // ==================== actions ====================
private String startRun(AgentTeamEntity team, Long agentId, boolean isLead,
Long workspaceId, String conversationId, String title,
String objective, @Nullable ToolContext ctx) {
if (!isLead) {
return "Error: only the team lead can start runs.";
}
TeamRunEntity run = runService.startRun(TeamRunCreateCommand.builder()
.teamId(team.getId())
.workspaceId(workspaceId)
.leadAgentId(agentId)
.leadConversationId(conversationId)
.originMessageId(ToolExecutionContext.originMessageId(ctx))
.title(title)
.objective(objective)
.build());
return String.valueOf(run.getId());
}
private String createTask(AgentTeamEntity team, Long agentId, boolean isLead, private String createTask(AgentTeamEntity team, Long agentId, boolean isLead,
Long workspaceId, String runId,
String subject, String description, String assigneeAgentId, String subject, String description, String assigneeAgentId,
String blockedBy, Integer priority, Boolean requireApproval, String blockedBy, Integer priority, Boolean requireApproval,
String conversationId) { String conversationId) {
@ -148,8 +188,11 @@ public class TeamTasksTool {
return "Error: only the team lead can create tasks. Report blockers or ask the " return "Error: only the team lead can create tasks. Report blockers or ask the "
+ "lead via a comment on your current task instead."; + "lead via a comment on your current task instead.";
} }
Long parsedRunId = parseId(runId, "runId");
requireRun(team, workspaceId, conversationId, parsedRunId);
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
.teamId(team.getId()) .teamId(team.getId())
.runId(parsedRunId)
.subject(subject) .subject(subject)
.description(description) .description(description)
.assigneeAgentId(parseId(assigneeAgentId, "assigneeAgentId")) .assigneeAgentId(parseId(assigneeAgentId, "assigneeAgentId"))
@ -160,15 +203,27 @@ public class TeamTasksTool {
.leadConversationId(conversationId) .leadConversationId(conversationId)
.build()); .build());
eventChannel.publishTaskEvent(task, "team_task_created", Map.of()); eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
dispatchService.requestDispatch(team.getId());
}
return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId() return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId()
+ ") \"" + task.getSubject() + "\" assigned to " + agentName(task.getAssigneeAgentId()) + ") \"" + task.getSubject() + "\" assigned to " + agentName(task.getAssigneeAgentId())
+ ". Status: " + task.getStatus() + ". Status: " + task.getStatus()
+ (TeamTaskStatus.BLOCKED.equals(task.getStatus()) + (TeamTaskStatus.BLOCKED.equals(task.getStatus())
? " (starts automatically once its prerequisites finish)." : ".") ? " (starts automatically once its prerequisites finish)." : ".")
+ " Members are dispatched automatically — do not wait in this turn."; + " Seal the run after all tasks are staged.";
}
private String sealRun(AgentTeamEntity team, boolean isLead, Long workspaceId,
String conversationId, String runId) {
if (!isLead) {
return "Error: only the team lead can seal runs.";
}
Long parsedRunId = parseId(runId, "runId");
requireRun(team, workspaceId, conversationId, parsedRunId);
TeamRunService.SealResult result = runService.sealRunWithResult(parsedRunId, workspaceId);
if (result.transitioned()) {
dispatchService.requestDispatch(team.getId());
return "✓ Team run " + parsedRunId + " sealed; dispatch started.";
}
return "Team run " + parsedRunId + " was already sealed; dispatch unchanged.";
} }
private String completeTask(AgentTeamEntity team, Long agentId, Long taskId, String result) { private String completeTask(AgentTeamEntity team, Long agentId, Long taskId, String result) {
@ -333,6 +388,17 @@ public class TeamTasksTool {
return task; return task;
} }
private TeamRunEntity requireRun(AgentTeamEntity team, Long workspaceId,
String conversationId, Long runId) {
TeamRunEntity run = runService.requireRun(runId, workspaceId);
if (!team.getId().equals(run.getTeamId())
|| !conversationId.equals(run.getLeadConversationId())) {
throw new IllegalArgumentException(
"runId does not belong to this team and lead conversation: " + runId);
}
return run;
}
private String agentName(Long agentId) { private String agentName(Long agentId) {
if (agentId == null) { if (agentId == null) {
return "-"; return "-";

View File

@ -87,4 +87,8 @@ public final class ToolExecutionContext {
} }
return WORKSPACE_BASE_PATH.get(); return WORKSPACE_BASE_PATH.get();
} }
public static Long originMessageId(@Nullable ToolContext ctx) {
return ctx == null ? null : ChatOrigin.from(ctx).originMessageId();
}
} }

View File

@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS mate_team_run (
id BIGINT NOT NULL PRIMARY KEY,
team_id BIGINT NOT NULL,
workspace_id BIGINT NOT NULL,
lead_agent_id BIGINT NOT NULL,
lead_conversation_id VARCHAR(64) NOT NULL,
origin_message_id BIGINT NULL,
title VARCHAR(255) NOT NULL,
objective TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'planning',
final_summary TEXT,
stop_reason VARCHAR(1000),
metadata TEXT,
started_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
deleted INT DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_team_run_team_history
ON mate_team_run (team_id, create_time);
CREATE INDEX IF NOT EXISTS idx_team_run_conversation_history
ON mate_team_run (lead_conversation_id, create_time);
CREATE INDEX IF NOT EXISTS idx_team_run_status
ON mate_team_run (status, update_time);
CREATE UNIQUE INDEX IF NOT EXISTS uk_team_run_origin_message
ON mate_team_run (workspace_id, lead_conversation_id, origin_message_id);
ALTER TABLE mate_team_task
ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
CREATE INDEX IF NOT EXISTS idx_team_task_run_number
ON mate_team_task (run_id, task_number);
CREATE INDEX IF NOT EXISTS idx_team_task_run_status
ON mate_team_task (run_id, status);

View File

@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS mate_team_run (
id BIGINT NOT NULL PRIMARY KEY,
team_id BIGINT NOT NULL,
workspace_id BIGINT NOT NULL,
lead_agent_id BIGINT NOT NULL,
lead_conversation_id VARCHAR(64) NOT NULL,
origin_message_id BIGINT NULL,
title VARCHAR(255) NOT NULL,
objective TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'planning',
final_summary TEXT,
stop_reason VARCHAR(1000),
metadata TEXT,
started_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
deleted INT DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_team_run_team_history
ON mate_team_run (team_id, create_time);
CREATE INDEX IF NOT EXISTS idx_team_run_conversation_history
ON mate_team_run (lead_conversation_id, create_time);
CREATE INDEX IF NOT EXISTS idx_team_run_status
ON mate_team_run (status, update_time);
CREATE UNIQUE INDEX IF NOT EXISTS uk_team_run_origin_message
ON mate_team_run (workspace_id, lead_conversation_id, origin_message_id);
ALTER TABLE mate_team_task
ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
CREATE INDEX IF NOT EXISTS idx_team_task_run_number
ON mate_team_task (run_id, task_number);
CREATE INDEX IF NOT EXISTS idx_team_task_run_status
ON mate_team_task (run_id, status);

View File

@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS mate_team_run (
id BIGINT NOT NULL PRIMARY KEY,
team_id BIGINT NOT NULL,
workspace_id BIGINT NOT NULL,
lead_agent_id BIGINT NOT NULL,
lead_conversation_id VARCHAR(64) NOT NULL,
origin_message_id BIGINT NULL,
title VARCHAR(255) NOT NULL,
objective TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'planning',
final_summary TEXT,
stop_reason VARCHAR(1000),
metadata TEXT,
started_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted INT DEFAULT 0,
KEY idx_team_run_team_history (team_id, create_time),
KEY idx_team_run_conversation_history (lead_conversation_id, create_time),
KEY idx_team_run_status (status, update_time),
UNIQUE KEY uk_team_run_origin_message (workspace_id, lead_conversation_id, origin_message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_team_task'
AND COLUMN_NAME = 'run_id');
SET @s := IF(@c = 0,
'ALTER TABLE mate_team_task ADD COLUMN run_id BIGINT NULL AFTER team_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_task'
AND INDEX_NAME = 'idx_team_task_run_number');
SET @s := IF(@c = 0,
'CREATE INDEX idx_team_task_run_number ON mate_team_task (run_id, task_number)',
'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_task'
AND INDEX_NAME = 'idx_team_task_run_status');
SET @s := IF(@c = 0,
'CREATE INDEX idx_team_task_run_status ON mate_team_task (run_id, status)',
'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -3,6 +3,7 @@ package vip.mate.agent.context;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.chat.model.ToolContext;
import vip.mate.tool.builtin.ToolExecutionContext;
import java.util.Map; import java.util.Map;
@ -49,6 +50,20 @@ class ChatOriginTest {
"channelTarget must be preserved"); "channelTarget must be preserved");
} }
@Test
void originMessageId_isExplicitAndPreservedByWithersAndToolContext() {
ChatOrigin origin = ChatOrigin.web("conv-1", "user", 5L, null)
.withOriginMessageId(99L);
assertNull(ChatOrigin.EMPTY.originMessageId());
assertEquals(99L, origin.withAgent(7L).originMessageId());
assertEquals(99L, origin.withWorkspace(6L, "/ws").originMessageId());
assertEquals(99L, origin.withConversationId("conv-2").originMessageId());
assertEquals(99L, origin.withBaseUrl("https://example.test").originMessageId());
assertEquals(99L, origin.withSender("Alice", "web", null).originMessageId());
assertEquals(99L, ToolExecutionContext.originMessageId(origin.toToolContext()));
}
@Test @Test
void cronFactory_setsRequesterToSystem() { void cronFactory_setsRequesterToSystem() {
ChatOrigin origin = ChatOrigin.cron("cron_7", 1L, null, 3L, null); ChatOrigin origin = ChatOrigin.cron("cron_7", 1L, null, 3L, null);

View File

@ -0,0 +1,116 @@
package vip.mate.agent.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.service.AgentGenerationService;
import vip.mate.audit.service.AuditEventService;
import vip.mate.auth.service.AuthService;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.system.service.SystemSettingService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity;
import vip.mate.workspace.core.service.WorkspaceService;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AgentControllerOriginTest {
private static final Long AGENT_ID = 10L;
private static final Long WORKSPACE_ID = 30L;
private static final Long MESSAGE_ID = 99L;
private static final String CONVERSATION_ID = "agent-entry";
private static final String MESSAGE = "do work";
private AgentService agentService;
private ConversationService conversations;
private AgentController controller;
@BeforeEach
void setUp() {
agentService = mock(AgentService.class);
conversations = mock(ConversationService.class);
controller = new AgentController(agentService, conversations,
mock(AuditEventService.class), mock(AuthService.class), mock(WorkspaceService.class),
mock(ModelConfigService.class), mock(ModelCapabilityService.class),
mock(SystemSettingService.class), mock(AgentGenerationService.class),
new ObjectMapper());
AgentEntity agent = new AgentEntity();
agent.setId(AGENT_ID);
agent.setWorkspaceId(WORKSPACE_ID);
agent.setEnabled(true);
when(agentService.getAgent(AGENT_ID)).thenReturn(agent);
MessageEntity saved = new MessageEntity();
saved.setId(MESSAGE_ID);
when(conversations.saveMessage(CONVERSATION_ID, "user", MESSAGE)).thenReturn(saved);
}
@Test
void sseEntryPersistsOnceAndUsesExplicitOrigin() {
when(agentService.chatStream(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), any()))
.thenReturn(Flux.empty());
controller.chatStream(AGENT_ID, MESSAGE, CONVERSATION_ID, WORKSPACE_ID);
ArgumentCaptor<ChatOrigin> origin = ArgumentCaptor.forClass(ChatOrigin.class);
verify(agentService, org.mockito.Mockito.timeout(1000))
.chatStream(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), origin.capture());
assertEquals(MESSAGE_ID, origin.getValue().originMessageId());
verifySingleUserSave();
verify(agentService, never()).chatStream(AGENT_ID, MESSAGE, CONVERSATION_ID);
}
@Test
void syncChatEntryPersistsOnceAndUsesExplicitOrigin() {
AgentController.ChatRequest request = request();
when(agentService.chat(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), any()))
.thenReturn("done");
controller.chat(AGENT_ID, request, WORKSPACE_ID);
ArgumentCaptor<ChatOrigin> origin = ArgumentCaptor.forClass(ChatOrigin.class);
verify(agentService).chat(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), origin.capture());
assertEquals(MESSAGE_ID, origin.getValue().originMessageId());
verifySingleUserSave();
verify(agentService, never()).chat(AGENT_ID, MESSAGE, CONVERSATION_ID);
}
@Test
void executeEntryPersistsOnceAndUsesExplicitOrigin() {
AgentController.ChatRequest request = request();
when(agentService.execute(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), any()))
.thenReturn("done");
controller.execute(AGENT_ID, request, WORKSPACE_ID);
ArgumentCaptor<ChatOrigin> origin = ArgumentCaptor.forClass(ChatOrigin.class);
verify(agentService).execute(eq(AGENT_ID), eq(MESSAGE), eq(CONVERSATION_ID), origin.capture());
assertEquals(MESSAGE_ID, origin.getValue().originMessageId());
verifySingleUserSave();
verify(agentService, never()).execute(AGENT_ID, MESSAGE, CONVERSATION_ID);
}
private void verifySingleUserSave() {
verify(conversations, times(1)).saveMessage(CONVERSATION_ID, "user", MESSAGE);
}
private static AgentController.ChatRequest request() {
AgentController.ChatRequest request = new AgentController.ChatRequest();
request.setMessage(MESSAGE);
request.setConversationId(CONVERSATION_ID);
return request;
}
}

View File

@ -0,0 +1,122 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatStreamTrackerEventIdTest {
@Test
void eventIdsIncreaseAcrossChannelsAndRecreatedRunState() {
ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper());
CapturingEmitter firstChannel = new CapturingEmitter();
CapturingEmitter secondChannel = new CapturingEmitter();
CapturingEmitter recreatedChannel = new CapturingEmitter();
tracker.register("channel-a");
tracker.attach("channel-a", firstChannel);
tracker.broadcast("channel-a", "progress", "{}");
tracker.register("channel-b");
tracker.attach("channel-b", secondChannel);
tracker.broadcast("channel-b", "progress", "{}");
tracker.broadcast("channel-a", "done", "{}");
tracker.register("channel-a");
tracker.attach("channel-a", recreatedChannel, Long.MAX_VALUE);
tracker.broadcast("channel-a", "progress", "{}");
long first = firstChannel.ids.getFirst();
long second = secondChannel.ids.getFirst();
long recreated = recreatedChannel.ids.getFirst();
assertTrue(first < second);
assertTrue(second < recreated);
}
@Test
void laterClockFloorStartsAboveIdsFromAnEarlierGeneratorInstance() {
SseEventIdGenerator firstProcess = new SseEventIdGenerator(() -> 1_000L);
long first = firstProcess.nextId();
long second = firstProcess.nextId();
SseEventIdGenerator restartedProcess = new SseEventIdGenerator(() -> 1_001L);
long afterRestart = restartedProcess.nextId();
assertEquals(first + 1, second);
assertTrue(afterRestart > second);
}
@Test
void concurrentAllocationIsUnique() {
SseEventIdGenerator generator = new SseEventIdGenerator(() -> 1_000L);
Set<Long> ids = ConcurrentHashMap.newKeySet();
IntStream.range(0, 10_000).parallel().forEach(ignored -> ids.add(generator.nextId()));
assertEquals(10_000, ids.size());
}
@Test
void currentEventIdsStayWithinTheJavaScriptSafeIntegerRange() {
SseEventIdGenerator generator = new SseEventIdGenerator(System::currentTimeMillis);
assertTrue(generator.nextId() <= SseEventIdGenerator.MAX_SAFE_INTEGER);
}
@Test
void fixedClockCanBorrowFutureSlotsBeyondOneMillisecondCapacity() {
SseEventIdGenerator generator = new SseEventIdGenerator(() -> 1_000L);
long first = generator.nextId();
long last = IntStream.range(0, 2_048)
.mapToLong(ignored -> generator.nextId())
.reduce(first, (ignored, id) -> id);
assertEquals(first + 2_048, last);
assertTrue(last <= SseEventIdGenerator.MAX_SAFE_INTEGER);
}
@Test
void exhaustsAtTheJavaScriptSafeIntegerBoundary() {
long maxEpochMillis = SseEventIdGenerator.MAX_SAFE_INTEGER / 1_024L;
SseEventIdGenerator generator = new SseEventIdGenerator(() -> maxEpochMillis);
long last = 0L;
for (int i = 0; i < 1_024; i++) {
last = generator.nextId();
}
assertEquals(SseEventIdGenerator.MAX_SAFE_INTEGER, last);
assertThrows(IllegalStateException.class, generator::nextId);
assertThrows(IllegalStateException.class,
() -> new SseEventIdGenerator(() -> maxEpochMillis + 1));
}
private static final class CapturingEmitter extends SseEmitter {
private final List<Long> ids = new ArrayList<>();
@Override
public void send(SseEventBuilder builder) throws IOException {
Set<ResponseBodyEmitter.DataWithMediaType> entries = builder.build();
for (ResponseBodyEmitter.DataWithMediaType entry : entries) {
if (entry.getData() instanceof String text && text.startsWith("id:")) {
int end = text.indexOf('\n');
ids.add(Long.parseLong(text.substring(3, end).trim()));
}
}
}
}
}

View File

@ -0,0 +1,28 @@
package vip.mate.cron;
import org.junit.jupiter.api.Test;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.cron.model.CronJobEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class CronChatOriginFactoryTest {
@Test
void explicitMessageIdIsCarriedByTheCronOrigin() {
AgentMapper agents = mock(AgentMapper.class);
AgentEntity agent = new AgentEntity();
agent.setWorkspaceId(30L);
CronJobEntity job = new CronJobEntity();
job.setAgentId(20L);
when(agents.selectById(20L)).thenReturn(agent);
ChatOrigin origin = new CronChatOriginFactory(agents).from(job, "tasks_30", 99L);
assertEquals(99L, origin.originMessageId());
}
}

View File

@ -0,0 +1,92 @@
package vip.mate.cron.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.cron.CronChatOriginFactory;
import vip.mate.cron.CronConversationResolver;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.dashboard.repository.CronJobRunMapper;
import vip.mate.i18n.I18nService;
import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.wiki.service.WikiProcessingService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class CronJobOriginPropagationTest {
private static final Long JOB_ID = 11L;
private static final Long AGENT_ID = 22L;
private static final Long WORKSPACE_ID = 33L;
private static final Long MESSAGE_ID = 44L;
private static final String CONVERSATION_ID = "tasks_33";
@Test
void lifecycleReturnsThePersistedUserMessageIdWithoutSavingTwice() {
ConversationService conversations = mock(ConversationService.class);
CronJobLifecycleService lifecycle = new CronJobLifecycleService(
mock(CronJobRunMapper.class), conversations,
mock(ConversationCompletionPublisher.class),
mock(ApplicationEventPublisher.class), mock(I18nService.class));
CronJobEntity job = job();
MessageEntity saved = new MessageEntity();
saved.setId(MESSAGE_ID);
when(conversations.saveMessage(CONVERSATION_ID, "user", "do work"))
.thenReturn(saved);
CronJobLifecycleService.StartResult result = lifecycle.startRun(
job, "do work", "scheduled", CONVERSATION_ID);
assertEquals(MESSAGE_ID, result.originMessageId());
verify(conversations, times(1)).saveMessage(CONVERSATION_ID, "user", "do work");
}
@Test
void runnerPassesTheLifecycleMessageIdIntoTheAgentOrigin() {
CronJobLifecycleService lifecycle = mock(CronJobLifecycleService.class);
AgentService agentService = mock(AgentService.class);
CronChatOriginFactory originFactory = mock(CronChatOriginFactory.class);
CronConversationResolver resolver = mock(CronConversationResolver.class);
CronJobEntity job = job();
CronJobRunEntity run = new CronJobRunEntity();
run.setId(55L);
ChatOrigin origin = ChatOrigin.cron(CONVERSATION_ID, WORKSPACE_ID, null, null, null)
.withOriginMessageId(MESSAGE_ID);
when(resolver.resolve(job)).thenReturn(CONVERSATION_ID);
when(lifecycle.startRun(job, "do work", "scheduled", CONVERSATION_ID))
.thenReturn(new CronJobLifecycleService.StartResult(run, MESSAGE_ID));
when(originFactory.from(job, CONVERSATION_ID, MESSAGE_ID)).thenReturn(origin);
when(agentService.chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin)))
.thenReturn(AgentService.ChatResult.contentOnly("done"));
CronJobRunner runner = new CronJobRunner(lifecycle, agentService, originFactory, resolver,
mock(WikiProcessingService.class), new ObjectMapper());
runner.executeJob(job);
verify(originFactory).from(job, CONVERSATION_ID, MESSAGE_ID);
verify(agentService).chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID), eq(origin));
verify(agentService, never()).chatWithUsage(eq(AGENT_ID), anyString(), eq(CONVERSATION_ID));
}
private static CronJobEntity job() {
CronJobEntity job = new CronJobEntity();
job.setId(JOB_ID);
job.setAgentId(AGENT_ID);
job.setWorkspaceId(WORKSPACE_ID);
job.setTaskType("text");
job.setTriggerMessage("do work");
return job;
}
}

View File

@ -0,0 +1,181 @@
package vip.mate.team;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.repository.TeamRunMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:team_run_migration_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none"
})
class MigrationSmokeTest {
private static final Path MIGRATIONS = Path.of("src/main/resources/db/migration");
@Autowired
private JdbcTemplate jdbc;
@Autowired
private TeamRunMapper runMapper;
@Test
@DisplayName("team run migration creates the run table with a BIGINT workspace")
void teamRunTableExists() {
assertEquals(1L, countTables("mate_team_run"));
assertEquals("bigint", columnType("mate_team_run", "workspace_id").toLowerCase(Locale.ROOT));
}
@Test
@DisplayName("team run migration adds the nullable task binding")
void teamTaskRunBindingExists() {
assertEquals(1L, countColumns("mate_team_task", "run_id"));
assertEquals("YES", columnNullable("mate_team_task", "run_id"));
}
@Test
@DisplayName("all database dialects contain the complete team run contract")
void allDialectsContainVersion181() throws Exception {
for (String dialect : List.of("h2", "mysql", "kingbase")) {
Path migration = MIGRATIONS.resolve(dialect).resolve("V181__team_run_foundation.sql");
assertTrue(Files.exists(migration), dialect + " migration must contain version 181");
String sql = Files.readString(migration).toLowerCase(Locale.ROOT);
assertTrue(sql.contains("mate_team_run"), dialect + " migration must create the run table");
assertTrue(sql.matches("(?s).*workspace_id\\s+bigint.*"),
dialect + " migration must use BIGINT workspace ids");
assertTrue(sql.matches("(?s).*run_id\\s+bigint\\s+null.*"),
dialect + " migration must add a nullable task run id");
assertTrue(sql.matches("(?s).*unique\\s+(?:index(?:\\s+if\\s+not\\s+exists)?|key)"
+ "\\s+uk_team_run_origin_message.*"),
dialect + " migration must enforce origin-message idempotency");
assertTrue(sql.matches("(?s).*uk_team_run_origin_message.*?"
+ "\\(workspace_id,\\s*lead_conversation_id,\\s*origin_message_id\\).*"),
dialect + " migration must scope origin-message idempotency by workspace");
assertTrue(sql.contains("idx_team_task_run_number"),
dialect + " migration must index run task numbers");
assertTrue(sql.contains("idx_team_task_run_status"),
dialect + " migration must index run task statuses");
}
}
@Test
@DisplayName("origin message identity is unique while manual runs allow null origins")
void originMessageUniquenessAllowsManualRuns() {
runMapper.insert(newRun(9_811_001L, "lead-unique", 7_001L));
assertThrows(DuplicateKeyException.class,
() -> runMapper.insert(newRun(9_811_002L, "lead-unique", 7_001L)));
TeamRunEntity otherWorkspace = newRun(9_811_005L, "lead-unique", 7_001L);
otherWorkspace.setWorkspaceId(42L);
runMapper.insert(otherWorkspace);
assertNotNull(runMapper.selectById(9_811_005L));
runMapper.insert(newRun(9_811_003L, "lead-manual", null));
runMapper.insert(newRun(9_811_004L, "lead-manual", null));
assertNotNull(runMapper.selectById(9_811_003L));
assertNotNull(runMapper.selectById(9_811_004L));
}
@Test
@DisplayName("team run mapper round-trips fields and clears nullable final state")
void teamRunMapperRoundTripAndClear() {
TeamRunEntity run = newRun(9_812_001L, "lead-round-trip", 7_002L);
LocalDateTime now = LocalDateTime.now().withNano(0);
run.setFinalSummary("delivered");
run.setStopReason("finished");
run.setMetadata("{\"outcome\":\"completed\"}");
run.setStartedAt(now);
run.setCompletedAt(now.plusMinutes(1));
assertEquals(1, runMapper.insert(run));
TeamRunEntity inserted = runMapper.selectById(run.getId());
assertNotNull(inserted);
assertEquals(41L, inserted.getWorkspaceId());
assertEquals("delivered", inserted.getFinalSummary());
assertNotNull(inserted.getCreateTime());
inserted.setFinalSummary(null);
inserted.setStopReason(null);
inserted.setMetadata(null);
inserted.setStartedAt(null);
inserted.setCompletedAt(null);
assertEquals(1, runMapper.updateById(inserted));
TeamRunEntity cleared = runMapper.selectById(run.getId());
assertNull(cleared.getFinalSummary());
assertNull(cleared.getStopReason());
assertNull(cleared.getMetadata());
assertNull(cleared.getStartedAt());
assertNull(cleared.getCompletedAt());
}
private TeamRunEntity newRun(long id, String leadConversationId, Long originMessageId) {
TeamRunEntity run = new TeamRunEntity();
run.setId(id);
run.setTeamId(31L);
run.setWorkspaceId(41L);
run.setLeadAgentId(51L);
run.setLeadConversationId(leadConversationId);
run.setOriginMessageId(originMessageId);
run.setTitle("Persistence contract");
run.setObjective("Verify the team run persistence mapping");
run.setStatus(TeamRunStatus.PLANNING);
return run;
}
private Long countTables(String tableName) {
return jdbc.queryForObject(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ?",
Long.class,
tableName);
}
private Long countColumns(String tableName, String columnName) {
return jdbc.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
Long.class,
tableName,
columnName);
}
private String columnType(String tableName, String columnName) {
return jdbc.queryForObject(
"SELECT data_type FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
String.class,
tableName,
columnName);
}
private String columnNullable(String tableName, String columnName) {
return jdbc.queryForObject(
"SELECT is_nullable FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
String.class,
tableName,
columnName);
}
}

View File

@ -5,7 +5,9 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
import vip.mate.auth.model.UserEntity; import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService; import vip.mate.auth.service.AuthService;
@ -13,10 +15,17 @@ import vip.mate.common.result.R;
import vip.mate.config.WorkspaceAccessInterceptor; import vip.mate.config.WorkspaceAccessInterceptor;
import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamRunCreateCommand;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.event.TeamRunDispatchCommittedIntent;
import vip.mate.team.service.TeamAnnounceService; import vip.mate.team.service.TeamAnnounceService;
import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamDispatchService;
import vip.mate.team.service.TeamEventChannel; import vip.mate.team.service.TeamEventChannel;
import vip.mate.team.service.TeamManualTaskService;
import vip.mate.team.service.TeamRunService;
import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamService;
import vip.mate.team.service.TeamTaskService; import vip.mate.team.service.TeamTaskService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
@ -31,9 +40,11 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -47,9 +58,12 @@ class TeamControllerTest {
private static final Long TEAM_ID = 1L; private static final Long TEAM_ID = 1L;
private static final Long TASK_ID = 100L; private static final Long TASK_ID = 100L;
private static final Long RUN_ID = 200L;
@Mock private TeamService teamService; @Mock private TeamService teamService;
@Mock private TeamTaskService taskService; @Mock private TeamTaskService taskService;
@Mock private TeamRunService runService;
@Mock private ApplicationEventPublisher events;
@Mock private TeamDispatchService dispatchService; @Mock private TeamDispatchService dispatchService;
@Mock private TeamAnnounceService announceService; @Mock private TeamAnnounceService announceService;
@Mock private TeamEventChannel eventChannel; @Mock private TeamEventChannel eventChannel;
@ -58,15 +72,18 @@ class TeamControllerTest {
@Mock private AuthService authService; @Mock private AuthService authService;
private TeamController controller; private TeamController controller;
private TeamManualTaskService manualTaskService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
controller = new TeamController(teamService, taskService, dispatchService, manualTaskService = new TeamManualTaskService(runService, taskService, events);
controller = new TeamController(teamService, taskService, manualTaskService, dispatchService,
announceService, eventChannel, agentMapper); announceService, eventChannel, agentMapper);
AgentTeamEntity team = new AgentTeamEntity(); AgentTeamEntity team = new AgentTeamEntity();
team.setId(TEAM_ID); team.setId(TEAM_ID);
team.setWorkspaceId(1L); team.setWorkspaceId(1L);
org.mockito.Mockito.lenient().when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(team); team.setLeadAgentId(10L);
lenient().when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(team);
} }
@AfterEach @AfterEach
@ -84,6 +101,15 @@ class TeamControllerTest {
return task; return task;
} }
private TeamRunEntity run(Long teamId, Long workspaceId, String status) {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setTeamId(teamId);
run.setWorkspaceId(workspaceId);
run.setStatus(status);
return run;
}
// ==================== team / membership ==================== // ==================== team / membership ====================
@Test @Test
@ -141,9 +167,12 @@ class TeamControllerTest {
@Test @Test
void createTaskSurfacesUnknownAssigneeAsReadableFailure() { void createTaskSurfacesUnknownAssigneeAsReadableFailure() {
TeamRunEntity planning = run(TEAM_ID, 1L, TeamRunStatus.PLANNING);
when(runService.requireRun(RUN_ID, 1L)).thenReturn(planning);
when(taskService.createTask(any(TeamTaskCreateCommand.class))) when(taskService.createTask(any(TeamTaskCreateCommand.class)))
.thenThrow(new IllegalArgumentException("assignee 9 is not a member of this team")); .thenThrow(new IllegalArgumentException("assignee 9 is not a member of this team"));
TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest(); TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest();
req.setRunId(RUN_ID);
req.setSubject("do the thing"); req.setSubject("do the thing");
req.setAssigneeAgentId(9L); req.setAssigneeAgentId(9L);
@ -155,6 +184,95 @@ class TeamControllerTest {
verify(dispatchService, never()).requestDispatch(anyLong()); verify(dispatchService, never()).requestDispatch(anyLong());
} }
@Test
void createTaskWithoutRunCreatesSealsAndPublishesOneDispatchIntent() {
TeamRunEntity planning = run(TEAM_ID, 1L, TeamRunStatus.PLANNING);
TeamRunEntity running = run(TEAM_ID, 1L, TeamRunStatus.RUNNING);
when(runService.startRun(any())).thenReturn(planning);
TeamTaskEntity created = task(TEAM_ID, TeamTaskStatus.PENDING);
created.setRunId(RUN_ID);
when(taskService.createTask(any())).thenReturn(created);
when(runService.sealRunWithResult(RUN_ID, 1L))
.thenReturn(new TeamRunService.SealResult(running, true));
TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest();
req.setSubject("dashboard task");
req.setDescription("details");
req.setAssigneeAgentId(9L);
R<TeamController.TaskVO> result = controller.createTask(TEAM_ID, req, null);
assertEquals(RUN_ID, result.getData().runId());
ArgumentCaptor<TeamRunCreateCommand> runCommand =
ArgumentCaptor.forClass(TeamRunCreateCommand.class);
verify(runService).startRun(runCommand.capture());
assertEquals("dashboard-team-1", runCommand.getValue().getLeadConversationId());
assertEquals("dashboard task", runCommand.getValue().getTitle());
assertEquals("details", runCommand.getValue().getObjective());
assertEquals(null, runCommand.getValue().getOriginMessageId());
ArgumentCaptor<TeamTaskCreateCommand> taskCommand =
ArgumentCaptor.forClass(TeamTaskCreateCommand.class);
verify(taskService).createTask(taskCommand.capture());
assertEquals(RUN_ID, taskCommand.getValue().getRunId());
verify(runService).sealRunWithResult(RUN_ID, 1L);
verify(events, times(1)).publishEvent(new TeamRunDispatchCommittedIntent(TEAM_ID));
}
@Test
void createTaskWithoutRunDoesNotPublishWhenSealAlreadyTransitioned() {
TeamRunEntity planning = run(TEAM_ID, 1L, TeamRunStatus.PLANNING);
TeamRunEntity running = run(TEAM_ID, 1L, TeamRunStatus.RUNNING);
when(runService.startRun(any())).thenReturn(planning);
TeamTaskEntity created = task(TEAM_ID, TeamTaskStatus.PENDING);
created.setRunId(RUN_ID);
when(taskService.createTask(any())).thenReturn(created);
when(runService.sealRunWithResult(RUN_ID, 1L))
.thenReturn(new TeamRunService.SealResult(running, false));
TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest();
req.setSubject("dashboard task");
req.setAssigneeAgentId(9L);
controller.createTask(TEAM_ID, req, null);
verify(events, never()).publishEvent(any());
}
@Test
void createTaskWithExplicitRunDoesNotSealOrDispatch() {
when(runService.requireRun(RUN_ID, 1L))
.thenReturn(run(TEAM_ID, 1L, TeamRunStatus.PLANNING));
TeamTaskEntity created = task(TEAM_ID, TeamTaskStatus.PENDING);
created.setRunId(RUN_ID);
when(taskService.createTask(any())).thenReturn(created);
TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest();
req.setRunId(RUN_ID);
req.setSubject("another task");
req.setAssigneeAgentId(9L);
R<TeamController.TaskVO> result = controller.createTask(TEAM_ID, req, null);
assertEquals(RUN_ID, result.getData().runId());
verify(runService, never()).startRun(any());
verify(runService, never()).sealRunWithResult(anyLong(), anyLong());
verify(events, never()).publishEvent(any());
verify(dispatchService, never()).requestDispatch(anyLong());
}
@Test
void createTaskRejectsExplicitRunFromAnotherTeam() {
when(runService.requireRun(RUN_ID, 1L))
.thenReturn(run(99L, 1L, TeamRunStatus.PLANNING));
TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest();
req.setRunId(RUN_ID);
req.setSubject("another task");
req.setAssigneeAgentId(9L);
R<TeamController.TaskVO> result = controller.createTask(TEAM_ID, req, null);
assertEquals(500, result.getCode());
assertEquals("team task and run must belong to the same team", result.getMsg());
verify(taskService, never()).createTask(any());
}
@Test @Test
void approveRejectsTaskFromAnotherTeamsBoard() { void approveRejectsTaskFromAnotherTeamsBoard() {
when(taskService.getTask(TASK_ID)).thenReturn(task(2L, "in_review")); when(taskService.getTask(TASK_ID)).thenReturn(task(2L, "in_review"));

View File

@ -0,0 +1,147 @@
package vip.mate.team.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import vip.mate.common.result.R;
import vip.mate.config.JacksonConfig;
import vip.mate.team.model.TeamRunView;
import vip.mate.team.service.TeamRunApplicationService;
import vip.mate.team.service.TeamRunService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class TeamRunControllerTest {
private static final Long RUN_ID = 9007199254740993L;
private static final Long TEAM_ID = 9007199254740995L;
private static final Long TASK_ID = 9007199254740997L;
private static final Long BLOCKER_ID = 9007199254740999L;
private static final Long WORKSPACE_ID = 30L;
private static final String CONVERSATION_ID = "lead-conversation";
private TeamRunService runService;
private TeamRunApplicationService applicationService;
private TeamRunController controller;
@BeforeEach
void setUp() {
runService = mock(TeamRunService.class);
applicationService = mock(TeamRunApplicationService.class);
controller = new TeamRunController(runService, applicationService);
}
@Test
void detailAndListsAreScopedToTheRequestedWorkspace() {
TeamRunView view = view();
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));
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());
}
@Test
void crossWorkspaceReadReturnsAReadableFailure() {
when(runService.getRun(RUN_ID, WORKSPACE_ID))
.thenThrow(new IllegalArgumentException("team run not found in workspace: " + RUN_ID));
R<TeamRunView> result = controller.get(RUN_ID, WORKSPACE_ID);
assertEquals(500, result.getCode());
assertEquals("team run not found in workspace: " + RUN_ID, result.getMsg());
}
@Test
void cancelDelegatesToTheApplicationServiceInTheCurrentWorkspace() {
TeamRunView view = view();
when(applicationService.cancelRun(RUN_ID, WORKSPACE_ID, "stop")).thenReturn(view);
TeamRunController.CancelRunRequest request = new TeamRunController.CancelRunRequest();
request.setReason("stop");
assertEquals(view, controller.cancel(RUN_ID, request, WORKSPACE_ID).getData());
verify(applicationService).cancelRun(RUN_ID, WORKSPACE_ID, "stop");
verify(runService, never()).cancelRun(RUN_ID, WORKSPACE_ID, "stop");
}
@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("listConversationRuns", "viewer", "/conversations/{conversationId}/team-runs",
String.class, Long.class);
assertEndpoint("cancel", "admin", "/team-runs/{runId}/cancel",
Long.class, TeamRunController.CancelRunRequest.class, Long.class);
}
@Test
void configuredJsonSerializesRunAndTeamLongIdsAsStrings() throws Exception {
TeamRunView view = view();
when(runService.getRun(RUN_ID, WORKSPACE_ID)).thenReturn(view);
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
new JacksonConfig().longToStringCustomizer().customize(builder);
ObjectMapper mapper = builder.build();
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller)
.setMessageConverters(new MappingJackson2HttpMessageConverter(mapper))
.build();
mvc.perform(get("/api/v1/team-runs/{runId}", RUN_ID)
.header("X-Workspace-Id", WORKSPACE_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(String.valueOf(RUN_ID)))
.andExpect(jsonPath("$.data.teamId").value(String.valueOf(TEAM_ID)))
.andExpect(jsonPath("$.data.tasks[0].id").value(String.valueOf(TASK_ID)))
.andExpect(jsonPath("$.data.tasks[0].blockedBy")
.value("[\"" + BLOCKER_ID + "\"]"))
.andExpect(jsonPath("$.data.tasks[0].metadata")
.value("{\"planId\":\"" + RUN_ID + "\"}"));
}
private static TeamRunView view() {
return new TeamRunView(RUN_ID, TEAM_ID, WORKSPACE_ID, 1L, CONVERSATION_ID,
null, "Run", "Objective", "running", null, null, null,
null, null, null, null,
new TeamRunView.Progress(1, 0, 0, 0, 0), List.of(task()));
}
private static TeamRunView.Task task() {
return new TeamRunView.Task(TASK_ID, TEAM_ID, RUN_ID, 1, "Task", null,
"blocked", 0, "general", 1L, null,
"[\"" + BLOCKER_ID + "\"]", false, null, null,
null, null, null, "{\"planId\":\"" + RUN_ID + "\"}", null, null);
}
private static void assertEndpoint(String method, String role, String path,
Class<?>... parameterTypes) throws Exception {
var reflected = TeamRunController.class.getDeclaredMethod(method, parameterTypes);
RequireWorkspaceRole permission = reflected.getAnnotation(RequireWorkspaceRole.class);
assertNotNull(permission);
assertEquals(role, permission.value());
var get = reflected.getAnnotation(GetMapping.class);
var post = reflected.getAnnotation(PostMapping.class);
String actual = get != null ? get.value()[0] : post.value()[0];
assertEquals(path, actual);
}
}

View File

@ -0,0 +1,28 @@
package vip.mate.team.service;
import org.junit.jupiter.api.Test;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamRunView;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class SpringTeamRunEventPublisherTest {
@Test
void cancellationPublishesUnifiedRunProjection() {
TeamEventChannel channel = mock(TeamEventChannel.class);
SpringTeamRunEventPublisher publisher = new SpringTeamRunEventPublisher(channel);
TeamRunView run = new TeamRunView(20L, 10L, 30L, 1L, "lead-conversation",
null, "Run", "Objective", TeamRunStatus.CANCELLED, null, "stop", null,
null, null, null, null,
new TeamRunView.Progress(1, 0, 0, 0, 0), List.of());
publisher.publishCancelled(run);
verify(channel).publishRunEvent(run, "team_run_cancelled", Map.of());
}
}

View File

@ -1,5 +1,7 @@
package vip.mate.team.service; package vip.mate.team.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -15,6 +17,12 @@ import vip.mate.team.model.TeamTaskStatus;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@ -25,8 +33,8 @@ import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
* Pins the announce contract: settled results are batched per lead * Pins the announce contract: settled results are batched per lead conversation
* conversation and delivered as ONE merged wake-up message; a busy lead defers * and run and delivered as ONE merged wake-up message; a busy lead defers
* delivery instead of risking an in-turn drop; the merged text carries the * delivery instead of risking an in-turn drop; the merged text carries the
* synthesis / retry instructions the lead acts on. * synthesis / retry instructions the lead acts on.
*/ */
@ -34,6 +42,7 @@ class TeamAnnounceServiceTest {
private static final Long TEAM_ID = 10L; private static final Long TEAM_ID = 10L;
private static final Long LEAD_ID = 1L; private static final Long LEAD_ID = 1L;
private static final Long RUN_ID = 90L;
private static final String LEAD_CONV = "lead-conv"; private static final String LEAD_CONV = "lead-conv";
private TeamService teamService; private TeamService teamService;
@ -71,6 +80,7 @@ class TeamAnnounceServiceTest {
TeamTaskEntity t = new TeamTaskEntity(); TeamTaskEntity t = new TeamTaskEntity();
t.setId(id); t.setId(id);
t.setTeamId(TEAM_ID); t.setTeamId(TEAM_ID);
t.setRunId(RUN_ID);
t.setTaskNumber(id.intValue()); t.setTaskNumber(id.intValue());
t.setSubject("task " + id); t.setSubject("task " + id);
t.setStatus(status); t.setStatus(status);
@ -91,7 +101,7 @@ class TeamAnnounceServiceTest {
service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "report done")); service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "report done"));
service.announceTaskSettled(settled(2L, TeamTaskStatus.FAILED, "blocked: no docs")); service.announceTaskSettled(settled(2L, TeamTaskStatus.FAILED, "blocked: no docs"));
service.drain(LEAD_CONV); service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID));
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class); ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
verify(agentService, timeout(3000)).chatWithUsage(eq(LEAD_ID), captor.capture(), eq(LEAD_CONV)); verify(agentService, timeout(3000)).chatWithUsage(eq(LEAD_ID), captor.capture(), eq(LEAD_CONV));
@ -102,8 +112,17 @@ class TeamAnnounceServiceTest {
assertTrue(message.contains("Task #2")); assertTrue(message.contains("Task #2"));
assertTrue(message.contains("blocked: no docs")); assertTrue(message.contains("blocked: no docs"));
// Drained means a later timer fire must not wake the lead again. // Drained means a later timer fire must not wake the lead again.
service.drain(LEAD_CONV); service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID));
verify(agentService, after(300).times(1)).chatWithUsage(any(), anyString(), anyString()); verify(agentService, after(300).times(1)).chatWithUsage(any(), anyString(), anyString());
ArgumentCaptor<String> metadata = ArgumentCaptor.forClass(String.class);
verify(conversationService, timeout(3000))
.saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"),
eq(0), eq(0), isNull(), isNull(), metadata.capture());
JSONObject json = JSONUtil.parseObj(metadata.getValue());
assertEquals(String.valueOf(RUN_ID), json.getStr("runId"));
assertFalse(json.containsKey("taskId"));
assertEquals(List.of("1", "2"), json.getJSONArray("taskIds").toList(String.class));
} }
@Test @Test
@ -112,7 +131,7 @@ class TeamAnnounceServiceTest {
when(runningConversations.isActive(LEAD_CONV)).thenReturn(true); when(runningConversations.isActive(LEAD_CONV)).thenReturn(true);
service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "done")); service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "done"));
service.drain(LEAD_CONV); service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID));
verify(agentService, after(500).never()).chatWithUsage(any(), anyString(), anyString()); verify(agentService, after(500).never()).chatWithUsage(any(), anyString(), anyString());
} }
@ -124,7 +143,7 @@ class TeamAnnounceServiceTest {
orphan.setLeadConversationId(null); orphan.setLeadConversationId(null);
service.announceTaskSettled(orphan); service.announceTaskSettled(orphan);
service.drain(LEAD_CONV); service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID));
verify(agentService, after(300).never()).chatWithUsage(any(), anyString(), anyString()); verify(agentService, after(300).never()).chatWithUsage(any(), anyString(), anyString());
} }
@ -137,7 +156,7 @@ class TeamAnnounceServiceTest {
.thenReturn(AgentService.ChatResult.contentOnly("综合汇报")); .thenReturn(AgentService.ChatResult.contentOnly("综合汇报"));
service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "done")); service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "done"));
service.drain(LEAD_CONV); service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID));
verify(streamTracker, timeout(3000)) verify(streamTracker, timeout(3000))
.broadcastObject(eq(LEAD_CONV), eq("team_announce_start"), any()); .broadcastObject(eq(LEAD_CONV), eq("team_announce_start"), any());
@ -147,23 +166,149 @@ class TeamAnnounceServiceTest {
// stays in the lead's conversation window for later turns. Both rows // stays in the lead's conversation window for later turns. Both rows
// carry an internal-note metadata type so the chat UI renders them as // carry an internal-note metadata type so the chat UI renders them as
// a collapsed system strip instead of a user bubble. // a collapsed system strip instead of a user bubble.
ArgumentCaptor<String> userMetadata = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> replyMetadata = ArgumentCaptor.forClass(String.class);
verify(conversationService, timeout(3000)) verify(conversationService, timeout(3000))
.saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"), .saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"),
eq(0), eq(0), isNull(), isNull(), contains("\"team_announce\"")); eq(0), eq(0), isNull(), isNull(), userMetadata.capture());
verify(conversationService, timeout(3000)) verify(conversationService, timeout(3000))
.saveMessage(eq(LEAD_CONV), eq("assistant"), eq("综合汇报"), isNull(), eq("completed"), .saveMessage(eq(LEAD_CONV), eq("assistant"), eq("综合汇报"), isNull(), eq("completed"),
eq(0), eq(0), isNull(), isNull(), contains("\"team_announce_reply\"")); eq(0), eq(0), isNull(), isNull(), replyMetadata.capture());
assertAnnounceMetadata(userMetadata.getValue(), "team_announce", "1");
assertAnnounceMetadata(replyMetadata.getValue(), "team_announce_reply", "1");
}
@Test
@DisplayName("legacy null-run announcements omit runId but retain taskId")
void legacyAnnouncementOmitsNullRunId() {
when(runningConversations.isActive(LEAD_CONV)).thenReturn(false);
TeamTaskEntity task = settled(7L, TeamTaskStatus.COMPLETED, "done");
task.setRunId(null);
service.announceTaskSettled(task);
service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, null));
ArgumentCaptor<String> metadata = ArgumentCaptor.forClass(String.class);
verify(conversationService, timeout(3000))
.saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"),
eq(0), eq(0), isNull(), isNull(), metadata.capture());
JSONObject json = JSONUtil.parseObj(metadata.getValue());
assertEquals("team_announce", json.getStr("type"));
assertEquals("7", json.getStr("taskId"));
assertFalse(json.containsKey("runId"));
}
@Test
@DisplayName("concurrent run drains serialize lead wake turns and preserve metadata")
void concurrentRunDrainsSerializeLeadWakeTurns() throws Exception {
when(runningConversations.isActive(LEAD_CONV)).thenReturn(false);
CountDownLatch firstStarted = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
CountDownLatch secondStarted = new CountDownLatch(1);
AtomicInteger wakeCount = new AtomicInteger();
when(agentService.chatWithUsage(eq(LEAD_ID), anyString(), eq(LEAD_CONV)))
.thenAnswer(invocation -> {
if (wakeCount.incrementAndGet() == 1) {
firstStarted.countDown();
assertTrue(releaseFirst.await(3, TimeUnit.SECONDS));
} else {
secondStarted.countDown();
}
return AgentService.ChatResult.contentOnly("reply");
});
TeamTaskEntity first = settled(11L, TeamTaskStatus.COMPLETED, "first");
first.setRunId(101L);
TeamTaskEntity second = settled(22L, TeamTaskStatus.COMPLETED, "second");
second.setRunId(202L);
service.announceTaskSettled(first);
service.announceTaskSettled(second);
CompletableFuture.allOf(
CompletableFuture.runAsync(() -> service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, 101L))),
CompletableFuture.runAsync(() -> service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, 202L)))
).join();
assertTrue(firstStarted.await(3, TimeUnit.SECONDS));
assertFalse(secondStarted.await(300, TimeUnit.MILLISECONDS));
releaseFirst.countDown();
assertTrue(secondStarted.await(3, TimeUnit.SECONDS));
ArgumentCaptor<String> userMetadata = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> replyMetadata = ArgumentCaptor.forClass(String.class);
verify(conversationService, timeout(3000).times(2))
.saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"),
eq(0), eq(0), isNull(), isNull(), userMetadata.capture());
verify(conversationService, timeout(3000).times(2))
.saveMessage(eq(LEAD_CONV), eq("assistant"), eq("reply"), isNull(), eq("completed"),
eq(0), eq(0), isNull(), isNull(), replyMetadata.capture());
Map<String, String> expected = Map.of("101", "11", "202", "22");
assertRunTaskMetadata(userMetadata.getAllValues(), expected);
assertRunTaskMetadata(replyMetadata.getAllValues(), expected);
}
@Test
@DisplayName("busy retry releases ownership and later serializes pending runs")
void busyRetrySerializesPendingRuns() throws Exception {
AtomicBoolean busy = new AtomicBoolean(true);
when(runningConversations.isActive(LEAD_CONV)).thenAnswer(invocation -> busy.get());
CountDownLatch firstStarted = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
CountDownLatch secondStarted = new CountDownLatch(1);
AtomicInteger wakeCount = new AtomicInteger();
when(agentService.chatWithUsage(eq(LEAD_ID), anyString(), eq(LEAD_CONV)))
.thenAnswer(invocation -> {
if (wakeCount.incrementAndGet() == 1) {
firstStarted.countDown();
assertTrue(releaseFirst.await(3, TimeUnit.SECONDS));
} else {
secondStarted.countDown();
}
return AgentService.ChatResult.contentOnly("reply");
});
TeamTaskEntity first = settled(31L, TeamTaskStatus.COMPLETED, "first");
first.setRunId(301L);
TeamTaskEntity second = settled(32L, TeamTaskStatus.COMPLETED, "second");
second.setRunId(302L);
service.announceTaskSettled(first);
service.announceTaskSettled(second);
service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, 301L));
service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, 302L));
verify(agentService, after(300).never()).chatWithUsage(any(), anyString(), anyString());
busy.set(false);
assertTrue(firstStarted.await(4, TimeUnit.SECONDS));
assertFalse(secondStarted.await(300, TimeUnit.MILLISECONDS));
releaseFirst.countDown();
assertTrue(secondStarted.await(3, TimeUnit.SECONDS));
verify(agentService, timeout(3000).times(2)).chatWithUsage(eq(LEAD_ID), anyString(), eq(LEAD_CONV));
} }
@Test @Test
@DisplayName("announcement text: single result keeps the singular form and the playbook") @DisplayName("announcement text: single result keeps the singular form and the playbook")
void announcementText() { void announcementText() {
String single = TeamAnnounceService.buildAnnouncement(List.of( String single = TeamAnnounceService.buildAnnouncement(List.of(
new TeamAnnounceService.AnnounceItem(TEAM_ID, 1, "collect", TeamTaskStatus.COMPLETED, new TeamAnnounceService.AnnounceItem(1L, TEAM_ID, 1, "collect", TeamTaskStatus.COMPLETED,
"写手", "all collected"))); "写手", "all collected")));
assertTrue(single.contains("A delegated team task has settled")); assertTrue(single.contains("A delegated team task has settled"));
assertTrue(single.contains("member: 写手")); assertTrue(single.contains("member: 写手"));
assertTrue(single.contains("ONE synthesized answer")); assertTrue(single.contains("ONE synthesized answer"));
assertTrue(single.contains("action=\"retry\"")); assertTrue(single.contains("action=\"retry\""));
} }
private void assertAnnounceMetadata(String metadata, String type, String taskId) {
JSONObject json = JSONUtil.parseObj(metadata);
assertEquals(type, json.getStr("type"));
assertEquals(String.valueOf(RUN_ID), json.getStr("runId"));
assertEquals(taskId, json.getStr("taskId"));
}
private void assertRunTaskMetadata(List<String> metadata, Map<String, String> expected) {
assertEquals(expected, metadata.stream()
.map(JSONUtil::parseObj)
.collect(java.util.stream.Collectors.toMap(
json -> json.getStr("runId"),
json -> json.getStr("taskId"))));
}
} }

View File

@ -93,6 +93,11 @@ class TeamContextBuilderTest {
assertTrue(ctx.contains("LEAD — you orchestrate")); assertTrue(ctx.contains("LEAD — you orchestrate"));
assertTrue(ctx.contains("Delegation workflow (mandatory)")); assertTrue(ctx.contains("Delegation workflow (mandatory)"));
assertTrue(ctx.contains("Delegation is NOT completion")); assertTrue(ctx.contains("Delegation is NOT completion"));
int start = ctx.indexOf("team_tasks(action=\"start_run\"");
int create = ctx.indexOf("team_tasks(action=\"create\"");
int seal = ctx.indexOf("team_tasks(action=\"seal_run\"");
assertTrue(start >= 0 && start < create && create < seal,
"lead playbook must require start_run -> create* -> seal_run");
assertTrue(ctx.contains("写手")); assertTrue(ctx.contains("写手"));
assertTrue(ctx.contains("agentId: " + MEMBER_ID)); assertTrue(ctx.contains("agentId: " + MEMBER_ID));
// The lead must not receive member execution instructions. // The lead must not receive member execution instructions.

View File

@ -0,0 +1,91 @@
package vip.mate.team.service;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.agent.AgentService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.event.TeamTasksDelegatedEvent;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import static org.mockito.Mockito.after;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TeamDispatchServiceEventTest {
private static final Long TEAM_ID = 10L;
@Test
void delegatedEventDispatchesOnlyAfterCommit() {
try (AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(TestConfig.class)) {
TeamTaskService taskService = context.getBean(TeamTaskService.class);
when(taskService.findDispatchable(TEAM_ID)).thenReturn(List.of());
ApplicationEventPublisher publisher = context;
TransactionTemplate transactions = new TransactionTemplate(
context.getBean(PlatformTransactionManager.class));
transactions.executeWithoutResult(status -> {
publisher.publishEvent(new TeamTasksDelegatedEvent(TEAM_ID));
verify(taskService, after(200).never()).findDispatchable(TEAM_ID);
});
verify(taskService, after(1000)).findDispatchable(TEAM_ID);
}
}
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement
static class TestConfig {
@Bean
PlatformTransactionManager transactionManager() {
return new TestTransactionManager();
}
@Bean
TeamTaskService taskService() {
return mock(TeamTaskService.class);
}
@Bean
TeamDispatchService dispatchService(TeamTaskService taskService) {
return new TeamDispatchService(
mock(TeamService.class), taskService, mock(AgentService.class),
mock(ConversationService.class), mock(ChatStreamTracker.class),
mock(TeamAnnounceService.class), mock(TeamEventChannel.class));
}
}
static class TestTransactionManager extends AbstractPlatformTransactionManager {
@Override
protected Object doGetTransaction() {
return new Object();
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
}
@Override
protected void doRollback(DefaultTransactionStatus status) {
}
}
}

View File

@ -0,0 +1,107 @@
package vip.mate.team.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamRunView;
import vip.mate.team.model.TeamTaskEntity;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class TeamEventChannelTest {
private static final Long RUN_ID = 9007199254740993L;
private static final Long TEAM_ID = 9007199254740995L;
private static final String LEAD_CONVERSATION_ID = "lead-conversation";
private ChatStreamTracker streamTracker;
private TeamEventChannel channel;
@BeforeEach
void setUp() {
streamTracker = mock(ChatStreamTracker.class);
channel = new TeamEventChannel(streamTracker);
}
@Test
void taskEventIncludesStringRunIdWhenPresent() {
TeamTaskEntity task = task();
task.setRunId(RUN_ID);
ArgumentCaptor<Object> payload = ArgumentCaptor.forClass(Object.class);
channel.publishTaskEvent(task, "team_task_created", Map.of("status", "pending"));
verify(streamTracker).broadcastObject(
eq("team-events-" + TEAM_ID), eq("team_task_created"), payload.capture());
Map<?, ?> data = (Map<?, ?>) payload.getValue();
assertEquals(String.valueOf(RUN_ID), data.get("runId"));
assertEquals(String.valueOf(TEAM_ID), data.get("teamId"));
}
@Test
void legacyTaskEventOmitsRunId() {
TeamTaskEntity task = task();
ArgumentCaptor<Object> payload = ArgumentCaptor.forClass(Object.class);
channel.publishTaskEvent(task, "team_task_created", Map.of());
verify(streamTracker).broadcastObject(
eq("team-events-" + TEAM_ID), eq("team_task_created"), payload.capture());
assertFalse(((Map<?, ?>) payload.getValue()).containsKey("runId"));
}
@Test
void runEventPublishesStringIdsAndProgressToTeamAndLeadConversation() {
TeamRunView run = run();
ArgumentCaptor<Object> teamPayload = ArgumentCaptor.forClass(Object.class);
ArgumentCaptor<Object> leadPayload = ArgumentCaptor.forClass(Object.class);
channel.publishRunEvent(run, "team_run_cancelled", Map.of(
"reason", "stop",
"taskId", 9007199254740997L,
"blockedBy", List.of(9007199254740999L)));
verify(streamTracker).register("team-events-" + TEAM_ID);
verify(streamTracker).broadcastObject(
eq("team-events-" + TEAM_ID), eq("team_run_cancelled"), teamPayload.capture());
verify(streamTracker).broadcastObject(
eq(LEAD_CONVERSATION_ID), eq("team_run_cancelled"), leadPayload.capture());
Map<?, ?> data = (Map<?, ?>) teamPayload.getValue();
assertEquals(data, leadPayload.getValue());
assertEquals(String.valueOf(RUN_ID), data.get("runId"));
assertEquals(String.valueOf(TEAM_ID), data.get("teamId"));
assertEquals(LEAD_CONVERSATION_ID, data.get("leadConversationId"));
assertEquals(TeamRunStatus.CANCELLED, data.get("status"));
assertEquals(run.progress(), data.get("progress"));
assertEquals("stop", data.get("reason"));
assertEquals("9007199254740997", data.get("taskId"));
assertEquals(List.of("9007199254740999"), data.get("blockedBy"));
}
private static TeamTaskEntity task() {
TeamTaskEntity task = new TeamTaskEntity();
task.setId(101L);
task.setTeamId(TEAM_ID);
task.setTaskNumber(1);
task.setSubject("Task");
task.setAssigneeAgentId(2L);
task.setLeadConversationId(LEAD_CONVERSATION_ID);
return task;
}
private static TeamRunView run() {
return new TeamRunView(RUN_ID, TEAM_ID, 30L, 1L, LEAD_CONVERSATION_ID,
null, "Run", "Objective", TeamRunStatus.CANCELLED, null, "stop", null,
null, null, null, null,
new TeamRunView.Progress(2, 0, 0, 0, 0), List.of());
}
}

View File

@ -4,7 +4,9 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
import vip.mate.planning.model.PlanEntity; import vip.mate.planning.model.PlanEntity;
@ -13,6 +15,9 @@ import vip.mate.planning.service.PlanningService;
import vip.mate.team.event.TeamTasksDelegatedEvent; import vip.mate.team.event.TeamTasksDelegatedEvent;
import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.AgentTeamMemberEntity; import vip.mate.team.model.AgentTeamMemberEntity;
import vip.mate.team.model.TeamRunCreateCommand;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamRole; import vip.mate.team.model.TeamRole;
import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
@ -40,10 +45,13 @@ class TeamPlanBridgeTest {
private static final Long ANALYST_ID = 3L; private static final Long ANALYST_ID = 3L;
private static final Long PLAN_ID = 77L; private static final Long PLAN_ID = 77L;
private static final String CONV = "lead-conv"; private static final String CONV = "lead-conv";
private static final Long WORKSPACE_ID = 30L;
private static final Long RUN_ID = 20L;
private TeamService teamService; private TeamService teamService;
private TeamTaskService taskService; private TeamTaskService taskService;
private PlanningService planningService; private PlanningService planningService;
private TeamRunService runService;
private AgentMapper agentMapper; private AgentMapper agentMapper;
private ApplicationEventPublisher eventPublisher; private ApplicationEventPublisher eventPublisher;
private TeamPlanBridge bridge; private TeamPlanBridge bridge;
@ -54,15 +62,17 @@ class TeamPlanBridgeTest {
teamService = mock(TeamService.class); teamService = mock(TeamService.class);
taskService = mock(TeamTaskService.class); taskService = mock(TeamTaskService.class);
planningService = mock(PlanningService.class); planningService = mock(PlanningService.class);
runService = mock(TeamRunService.class);
agentMapper = mock(AgentMapper.class); agentMapper = mock(AgentMapper.class);
eventPublisher = mock(ApplicationEventPublisher.class); eventPublisher = mock(ApplicationEventPublisher.class);
bridge = new TeamPlanBridge(teamService, taskService, planningService, bridge = new TeamPlanBridge(teamService, taskService, runService, planningService,
agentMapper, eventPublisher); agentMapper, eventPublisher);
team = new AgentTeamEntity(); team = new AgentTeamEntity();
team.setId(TEAM_ID); team.setId(TEAM_ID);
team.setName("编队"); team.setName("编队");
team.setLeadAgentId(LEAD_ID); team.setLeadAgentId(LEAD_ID);
team.setWorkspaceId(WORKSPACE_ID);
when(teamService.listMembers(TEAM_ID)).thenReturn(List.of( when(teamService.listMembers(TEAM_ID)).thenReturn(List.of(
member(LEAD_ID, TeamRole.LEAD), member(LEAD_ID, TeamRole.LEAD),
@ -91,6 +101,7 @@ class TeamPlanBridgeTest {
TeamTaskEntity t = new TeamTaskEntity(); TeamTaskEntity t = new TeamTaskEntity();
t.setId(id); t.setId(id);
t.setTeamId(TEAM_ID); t.setTeamId(TEAM_ID);
t.setRunId(RUN_ID);
t.setTaskNumber(number); t.setTaskNumber(number);
t.setSubject("task " + number); t.setSubject("task " + number);
t.setStatus(status); t.setStatus(status);
@ -116,6 +127,13 @@ class TeamPlanBridgeTest {
@Test @Test
@DisplayName("delegatePlan maps deps to blockedBy, stamps plan linkage, parks and nudges dispatch") @DisplayName("delegatePlan maps deps to blockedBy, stamps plan linkage, parks and nudges dispatch")
void delegatePlanCreatesLinkedTasks() { void delegatePlanCreatesLinkedTasks() {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setStatus(TeamRunStatus.PLANNING);
when(runService.startRun(any())).thenReturn(run);
when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of());
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
.thenReturn(new TeamRunService.SealResult(run, true));
when(taskService.createTask(any())).thenAnswer(inv -> { when(taskService.createTask(any())).thenAnswer(inv -> {
TeamTaskCreateCommand cmd = inv.getArgument(0); TeamTaskCreateCommand cmd = inv.getArgument(0);
TeamTaskEntity created = new TeamTaskEntity(); TeamTaskEntity created = new TeamTaskEntity();
@ -143,14 +161,81 @@ class TeamPlanBridgeTest {
assertTrue(second.getMetadata().contains("\"stepIndex\":1")); assertTrue(second.getMetadata().contains("\"stepIndex\":1"));
assertEquals(CONV, first.getLeadConversationId()); assertEquals(CONV, first.getLeadConversationId());
assertEquals(LEAD_ID, first.getCreatedByAgentId()); assertEquals(LEAD_ID, first.getCreatedByAgentId());
assertEquals(RUN_ID, first.getRunId());
assertEquals(RUN_ID, second.getRunId());
assertTrue(first.getDescription().contains("整体请求")); assertTrue(first.getDescription().contains("整体请求"));
verify(planningService).markPlanDelegated(PLAN_ID); ArgumentCaptor<TeamRunCreateCommand> runCaptor = ArgumentCaptor.forClass(TeamRunCreateCommand.class);
verify(eventPublisher).publishEvent(new TeamTasksDelegatedEvent(TEAM_ID)); verify(runService).startRun(runCaptor.capture());
assertEquals(WORKSPACE_ID, runCaptor.getValue().getWorkspaceId());
assertEquals(-PLAN_ID, runCaptor.getValue().getOriginMessageId());
assertTrue(runCaptor.getValue().getMetadata().contains("\"planId\":\"" + PLAN_ID + "\""));
InOrder order = inOrder(runService, taskService, planningService, eventPublisher);
order.verify(runService).startRun(any());
order.verify(taskService, times(2)).createTask(any());
order.verify(runService).sealRunWithResult(RUN_ID, WORKSPACE_ID);
order.verify(planningService).markPlanDelegated(PLAN_ID);
order.verify(eventPublisher).publishEvent(new TeamTasksDelegatedEvent(TEAM_ID));
assertTrue(announcement.contains("并行")); assertTrue(announcement.contains("并行"));
assertTrue(announcement.contains("前置")); assertTrue(announcement.contains("前置"));
} }
@Test
@DisplayName("a repeated delegation for a sealed run returns existing tasks without side effects")
void sealedRunDelegationIsIdempotent() {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setStatus(TeamRunStatus.RUNNING);
List<TeamTaskEntity> existing = List.of(
task(101L, 1, 0, TeamTaskStatus.PENDING),
task(102L, 2, 1, TeamTaskStatus.PENDING));
when(runService.startRun(any())).thenReturn(run);
when(taskService.listTasksByRun(RUN_ID)).thenReturn(existing);
String announcement = bridge.delegatePlan(team, PLAN_ID, "整体请求",
List.of("第一步", "第二步"), List.of(List.of(), List.of(0)),
List.of(WRITER_ID, ANALYST_ID), CONV);
assertTrue(announcement.contains("task 1"));
verify(taskService, never()).createTask(any());
verify(runService, never()).sealRunWithResult(any(), any());
verify(planningService, never()).markPlanDelegated(any());
verifyNoInteractions(eventPublisher);
}
@Test
@DisplayName("a retry with existing planning tasks seals once without recreating tasks")
void existingPlanningTasksAreSealedWithoutDuplication() {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setStatus(TeamRunStatus.PLANNING);
List<TeamTaskEntity> existing = List.of(
task(101L, 1, 0, TeamTaskStatus.PENDING),
task(102L, 2, 1, TeamTaskStatus.PENDING));
when(runService.startRun(any())).thenReturn(run);
when(taskService.listTasksByRun(RUN_ID)).thenReturn(existing);
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
.thenReturn(new TeamRunService.SealResult(run, true));
bridge.delegatePlan(team, PLAN_ID, "整体请求",
List.of("第一步", "第二步"), List.of(List.of(), List.of(0)),
List.of(WRITER_ID, ANALYST_ID), CONV);
verify(taskService, never()).createTask(any());
verify(runService).sealRunWithResult(RUN_ID, WORKSPACE_ID);
verify(planningService).markPlanDelegated(PLAN_ID);
verify(eventPublisher).publishEvent(new TeamTasksDelegatedEvent(TEAM_ID));
}
@Test
@DisplayName("delegatePlan is transactional")
void delegatePlanIsTransactional() throws NoSuchMethodException {
assertNotNull(TeamPlanBridge.class.getMethod("delegatePlan", AgentTeamEntity.class,
Long.class, String.class, List.class, List.class, List.class, String.class)
.getAnnotation(Transactional.class));
}
// ==================== resume gate ==================== // ==================== resume gate ====================
private void parkedPlan() { private void parkedPlan() {

View File

@ -0,0 +1,98 @@
package vip.mate.team.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.team.event.TeamRunCancelCommittedIntent;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class TeamRunApplicationServiceTest {
private static final Long RUN_ID = 20L;
private static final Long WORKSPACE_ID = 30L;
private TeamRunService runService;
private TeamTaskService taskService;
private ApplicationEventPublisher events;
private TeamRunApplicationService service;
@BeforeEach
void setUp() {
runService = mock(TeamRunService.class);
taskService = mock(TeamTaskService.class);
events = mock(ApplicationEventPublisher.class);
service = new TeamRunApplicationService(runService, taskService, events);
}
@Test
void firstCancellationCancelsActiveTasksAndPublishesDetachedIntentOnce() {
TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED);
TeamTaskEntity pending = task(1L, TeamTaskStatus.PENDING, null);
TeamTaskEntity running = task(2L, TeamTaskStatus.IN_PROGRESS, "worker-conversation");
TeamTaskEntity completed = task(3L, TeamTaskStatus.COMPLETED, "old-conversation");
TeamRunView view = mock(TeamRunView.class);
when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, "stop"))
.thenReturn(new TeamRunService.CancelResult(cancelled, true));
when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of(pending, running, completed));
when(runService.buildView(cancelled)).thenReturn(view);
TeamRunView result = service.cancelRun(RUN_ID, WORKSPACE_ID, "stop");
assertSame(view, result);
verify(taskService).cancelTask(1L, "stop");
verify(taskService).cancelTask(2L, "stop");
verify(taskService, never()).cancelTask(3L, "stop");
ArgumentCaptor<TeamRunCancelCommittedIntent> intent =
ArgumentCaptor.forClass(TeamRunCancelCommittedIntent.class);
verify(events).publishEvent(intent.capture());
assertSame(view, intent.getValue().run());
assertEquals(List.of(new TeamRunCancelCommittedIntent.WorkerTask(
2L, null, "worker-conversation")), intent.getValue().workers());
}
@Test
void repeatedCancellationHasNoTaskOrEventSideEffects() {
TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED);
TeamRunView view = mock(TeamRunView.class);
when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, null))
.thenReturn(new TeamRunService.CancelResult(cancelled, false));
when(runService.buildView(cancelled)).thenReturn(view);
assertSame(view, service.cancelRun(RUN_ID, WORKSPACE_ID, null));
verify(taskService, never()).listTasksByRun(RUN_ID);
verifyNoInteractions(events);
}
private static TeamRunEntity run(String status) {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setWorkspaceId(WORKSPACE_ID);
run.setStatus(status);
return run;
}
private static TeamTaskEntity task(Long id, String status, String conversationId) {
TeamTaskEntity task = new TeamTaskEntity();
task.setId(id);
task.setRunId(RUN_ID);
task.setStatus(status);
task.setConversationId(conversationId);
return task;
}
}

View File

@ -0,0 +1,321 @@
package vip.mate.team.service;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamRunView;
import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.event.TeamRunDispatchCommittedIntent;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TeamRunCommittedIntentEventTest {
private static final Long TEAM_ID = 10L;
private static final Long RUN_ID = 20L;
private static final Long WORKSPACE_ID = 30L;
@Test
void manualRunDispatchesOnlyAfterTheRealTransactionCommits() {
try (AnnotationConfigApplicationContext context = context()) {
TeamRunService runService = context.getBean(TeamRunService.class);
TeamTaskService taskService = context.getBean(TeamTaskService.class);
TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class);
TeamManualTaskService service = context.getBean(TeamManualTaskService.class);
when(runService.startRun(any())).thenReturn(run(TeamRunStatus.PLANNING));
when(taskService.createTask(any())).thenReturn(task(1L, TeamTaskStatus.PENDING, null));
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
.thenReturn(new TeamRunService.SealResult(run(TeamRunStatus.RUNNING), true));
transactions(context).executeWithoutResult(status -> {
service.createTask(team(), TeamTaskCreateCommand.builder()
.subject("dashboard task")
.assigneeAgentId(2L)
.build());
verify(dispatchService, never()).requestDispatch(TEAM_ID);
});
verify(dispatchService).requestDispatch(TEAM_ID);
}
}
@Test
void cancellationInterruptsAndPublishesOnlyAfterTheRealTransactionCommits() {
try (AnnotationConfigApplicationContext context = context()) {
TeamRunService runService = context.getBean(TeamRunService.class);
TeamTaskService taskService = context.getBean(TeamTaskService.class);
TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class);
TeamRunEventPublisher eventPublisher = context.getBean(TeamRunEventPublisher.class);
TeamRunApplicationService service = context.getBean(TeamRunApplicationService.class);
TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED);
TeamRunView view = view();
when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, "stop"))
.thenReturn(new TeamRunService.CancelResult(cancelled, true));
when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of(
task(1L, TeamTaskStatus.PENDING, null),
task(2L, TeamTaskStatus.IN_PROGRESS, "worker-conversation")));
when(runService.buildView(cancelled)).thenReturn(view);
transactions(context).executeWithoutResult(status -> {
service.cancelRun(RUN_ID, WORKSPACE_ID, "stop");
verify(dispatchService, never()).interruptRun(any());
verify(eventPublisher, never()).publishCancelled(any());
});
TeamTaskEntity expectedSnapshot = new TeamTaskEntity();
expectedSnapshot.setId(2L);
expectedSnapshot.setTaskNumber(2);
expectedSnapshot.setConversationId("worker-conversation");
verify(dispatchService).interruptRun(expectedSnapshot);
verify(eventPublisher).publishCancelled(view);
}
}
@Test
void listenerFailureDoesNotEscapeTheCommittedTransaction() {
try (AnnotationConfigApplicationContext context = context()) {
TeamRunService runService = context.getBean(TeamRunService.class);
TeamTaskService taskService = context.getBean(TeamTaskService.class);
TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class);
TeamRunEventPublisher eventPublisher = context.getBean(TeamRunEventPublisher.class);
TeamRunApplicationService service = context.getBean(TeamRunApplicationService.class);
TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED);
TeamRunView view = view();
when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, null))
.thenReturn(new TeamRunService.CancelResult(cancelled, true));
when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of(
task(2L, TeamTaskStatus.IN_PROGRESS, "worker-conversation")));
when(runService.buildView(cancelled)).thenReturn(view);
doThrow(new IllegalStateException("interrupt failed"))
.when(dispatchService).interruptRun(any());
assertDoesNotThrow(() -> transactions(context).executeWithoutResult(
status -> service.cancelRun(RUN_ID, WORKSPACE_ID, null)));
verify(eventPublisher).publishCancelled(view);
}
}
@Test
void dispatchIntentFallsBackToImmediateExecutionWithoutATransaction() {
try (AnnotationConfigApplicationContext context = context()) {
TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class);
context.publishEvent(new TeamRunDispatchCommittedIntent(TEAM_ID));
verify(dispatchService).requestDispatch(TEAM_ID);
}
}
@Test
void rolledBackManualRunNeverDispatches() {
try (AnnotationConfigApplicationContext context = context()) {
TeamRunService runService = context.getBean(TeamRunService.class);
TeamTaskService taskService = context.getBean(TeamTaskService.class);
TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class);
TeamRunEventPublisher eventPublisher = context.getBean(TeamRunEventPublisher.class);
TeamManualTaskService service = context.getBean(TeamManualTaskService.class);
when(runService.startRun(any())).thenReturn(run(TeamRunStatus.PLANNING));
when(taskService.createTask(any())).thenReturn(task(1L, TeamTaskStatus.PENDING, null));
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
.thenReturn(new TeamRunService.SealResult(run(TeamRunStatus.RUNNING), true));
transactions(context).executeWithoutResult(status -> {
service.createTask(team(), TeamTaskCreateCommand.builder()
.subject("dashboard task")
.assigneeAgentId(2L)
.build());
status.setRollbackOnly();
});
verify(dispatchService, never()).requestDispatch(any());
verify(dispatchService, never()).interruptRun(any());
verify(eventPublisher, never()).publishCancelled(any());
}
}
@Test
void rolledBackCancellationNeverInterruptsOrPublishes() {
try (AnnotationConfigApplicationContext context = context()) {
TeamRunService runService = context.getBean(TeamRunService.class);
TeamTaskService taskService = context.getBean(TeamTaskService.class);
TeamDispatchService dispatchService = context.getBean(TeamDispatchService.class);
TeamRunEventPublisher eventPublisher = context.getBean(TeamRunEventPublisher.class);
TeamRunApplicationService service = context.getBean(TeamRunApplicationService.class);
TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED);
when(runService.cancelRunWithResult(RUN_ID, WORKSPACE_ID, "stop"))
.thenReturn(new TeamRunService.CancelResult(cancelled, true));
when(taskService.listTasksByRun(RUN_ID)).thenReturn(List.of(
task(2L, TeamTaskStatus.IN_PROGRESS, "worker-conversation")));
when(runService.buildView(cancelled)).thenReturn(view());
assertThrows(IllegalStateException.class,
() -> transactions(context).executeWithoutResult(status -> {
service.cancelRun(RUN_ID, WORKSPACE_ID, "stop");
throw new IllegalStateException("roll back");
}));
verify(dispatchService, never()).requestDispatch(any());
verify(dispatchService, never()).interruptRun(any());
verify(eventPublisher, never()).publishCancelled(any());
}
}
private static AnnotationConfigApplicationContext context() {
return new AnnotationConfigApplicationContext(TestConfig.class);
}
private static TransactionTemplate transactions(AnnotationConfigApplicationContext context) {
return new TransactionTemplate(context.getBean(PlatformTransactionManager.class));
}
private static AgentTeamEntity team() {
AgentTeamEntity team = new AgentTeamEntity();
team.setId(TEAM_ID);
team.setWorkspaceId(WORKSPACE_ID);
team.setLeadAgentId(1L);
return team;
}
private static TeamRunEntity run(String status) {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setTeamId(TEAM_ID);
run.setWorkspaceId(WORKSPACE_ID);
run.setLeadConversationId("dashboard-team-10");
run.setStatus(status);
return run;
}
private static TeamTaskEntity task(Long id, String status, String conversationId) {
TeamTaskEntity task = new TeamTaskEntity();
task.setId(id);
task.setTaskNumber(id.intValue());
task.setRunId(RUN_ID);
task.setStatus(status);
task.setConversationId(conversationId);
return task;
}
private static TeamRunView view() {
return new TeamRunView(RUN_ID, TEAM_ID, WORKSPACE_ID, 1L, "dashboard-team-10",
null, "Run", "Objective", TeamRunStatus.CANCELLED, null, "stop", null,
null, null, null, null,
new TeamRunView.Progress(2, 0, 0, 0, 2), List.of());
}
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement
static class TestConfig {
@Bean
PlatformTransactionManager transactionManager() {
return new TestTransactionManager();
}
@Bean
TeamRunService runService() {
return mock(TeamRunService.class);
}
@Bean
TeamTaskService taskService() {
return mock(TeamTaskService.class);
}
@Bean
TeamDispatchService dispatchService() {
return mock(TeamDispatchService.class);
}
@Bean
TeamRunEventPublisher teamRunEventPublisher() {
return mock(TeamRunEventPublisher.class);
}
@Bean
TeamManualTaskService manualTaskService(TeamRunService runService,
TeamTaskService taskService,
ApplicationEventPublisher events) {
return new TeamManualTaskService(runService, taskService, events);
}
@Bean
TeamRunApplicationService teamRunApplicationService(TeamRunService runService,
TeamTaskService taskService,
ApplicationEventPublisher events) {
return new TeamRunApplicationService(runService, taskService, events);
}
@Bean
TeamRunCommittedIntentListener teamRunCommittedIntentListener(
TeamDispatchService dispatchService, TeamRunEventPublisher eventPublisher) {
return new TeamRunCommittedIntentListener(dispatchService, eventPublisher);
}
}
static class TestTransactionManager extends AbstractPlatformTransactionManager {
private final ThreadLocal<TestTransaction> current = new ThreadLocal<>();
@Override
protected Object doGetTransaction() {
TestTransaction transaction = current.get();
return transaction == null ? new TestTransaction() : transaction;
}
@Override
protected boolean isExistingTransaction(Object transaction) {
return ((TestTransaction) transaction).active;
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
TestTransaction testTransaction = (TestTransaction) transaction;
testTransaction.active = true;
current.set(testTransaction);
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
((TestTransaction) status.getTransaction()).active = false;
}
@Override
protected void doRollback(DefaultTransactionStatus status) {
((TestTransaction) status.getTransaction()).active = false;
}
@Override
protected void doCleanupAfterCompletion(Object transaction) {
current.remove();
}
private static final class TestTransaction {
private boolean active;
}
}
}

View File

@ -0,0 +1,65 @@
package vip.mate.team.service;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.repository.TeamTaskMapper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TeamRunProjectionExecutorTest {
@Test
void executeDelegatesInANewTransaction() throws Exception {
TeamRunProjector projector = mock(TeamRunProjector.class);
TeamTaskMapper taskMapper = mock(TeamTaskMapper.class);
TeamRunProjectionExecutor executor = new TeamRunProjectionExecutor(projector, taskMapper);
executor.execute(20L);
verify(projector).project(20L);
Transactional transactional = TeamRunProjectionExecutor.class
.getDeclaredMethod("execute", Long.class)
.getAnnotation(Transactional.class);
assertNotNull(transactional);
assertEquals(Propagation.REQUIRES_NEW, transactional.propagation());
}
@Test
void executeTaskLooksUpRunAndProjectsInANewTransaction() throws Exception {
TeamRunProjector projector = mock(TeamRunProjector.class);
TeamTaskMapper taskMapper = mock(TeamTaskMapper.class);
TeamTaskEntity task = new TeamTaskEntity();
task.setRunId(20L);
when(taskMapper.selectById(5L)).thenReturn(task);
TeamRunProjectionExecutor executor = new TeamRunProjectionExecutor(projector, taskMapper);
executor.executeTask(5L);
verify(taskMapper).selectById(5L);
verify(projector).project(20L);
Transactional transactional = TeamRunProjectionExecutor.class
.getDeclaredMethod("executeTask", Long.class)
.getAnnotation(Transactional.class);
assertNotNull(transactional);
assertEquals(Propagation.REQUIRES_NEW, transactional.propagation());
}
@Test
void executeTaskSkipsTasksWithoutRuns() {
TeamRunProjector projector = mock(TeamRunProjector.class);
TeamTaskMapper taskMapper = mock(TeamTaskMapper.class);
when(taskMapper.selectById(5L)).thenReturn(new TeamTaskEntity());
TeamRunProjectionExecutor executor = new TeamRunProjectionExecutor(projector, taskMapper);
executor.executeTask(5L);
verify(projector, never()).project(20L);
}
}

View File

@ -0,0 +1,106 @@
package vip.mate.team.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionSynchronizationUtils;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.repository.TeamTaskMapper;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TeamRunProjectionSchedulerTest {
private static final Long RUN_ID = 20L;
private static final Long TASK_ID = 5L;
private TeamRunProjectionExecutor executor;
private TeamRunProjectionScheduler scheduler;
@BeforeEach
void setUp() {
clearTransactionState();
executor = mock(TeamRunProjectionExecutor.class);
scheduler = new TeamRunProjectionScheduler(executor);
}
@AfterEach
void tearDown() {
clearTransactionState();
}
@Test
void activeTransactionProjectsOnlyAfterCommit() {
beginTransactionSynchronization();
scheduler.scheduleRun(RUN_ID);
verify(executor, never()).execute(RUN_ID);
TransactionSynchronizationUtils.triggerAfterCommit();
verify(executor).execute(RUN_ID);
TransactionSynchronizationUtils.triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
}
@Test
void rolledBackTransactionDoesNotProject() {
beginTransactionSynchronization();
scheduler.scheduleRun(RUN_ID);
TransactionSynchronizationUtils.triggerAfterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
verify(executor, never()).execute(RUN_ID);
}
@Test
void noTransactionProjectsImmediately() {
scheduler.scheduleRun(RUN_ID);
verify(executor).execute(RUN_ID);
}
@Test
void projectionFailureIsSwallowed() {
doThrow(new IllegalStateException("projection unavailable")).when(executor).execute(RUN_ID);
assertDoesNotThrow(() -> scheduler.scheduleRun(RUN_ID));
}
@Test
void taskLookupIsDeferredUntilAfterCommit() {
TeamTaskMapper taskMapper = mock(TeamTaskMapper.class);
TeamRunProjector projector = mock(TeamRunProjector.class);
TeamTaskEntity task = new TeamTaskEntity();
task.setRunId(RUN_ID);
when(taskMapper.selectById(TASK_ID)).thenReturn(task);
TeamRunProjectionScheduler taskScheduler = new TeamRunProjectionScheduler(
new TeamRunProjectionExecutor(projector, taskMapper));
beginTransactionSynchronization();
taskScheduler.scheduleTask(TASK_ID);
verify(taskMapper, never()).selectById(TASK_ID);
TransactionSynchronizationUtils.triggerAfterCommit();
verify(taskMapper).selectById(TASK_ID);
verify(projector).project(RUN_ID);
TransactionSynchronizationUtils.triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
}
private void beginTransactionSynchronization() {
TransactionSynchronizationManager.setActualTransactionActive(true);
TransactionSynchronizationManager.initSynchronization();
}
private void clearTransactionState() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
TransactionSynchronizationManager.setActualTransactionActive(false);
}
}

View File

@ -0,0 +1,205 @@
package vip.mate.team.service;
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;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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 vip.mate.team.repository.TeamRunMapper;
import vip.mate.team.repository.TeamTaskMapper;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TeamRunProjectorTest {
private static final Long RUN_ID = 20L;
private TeamRunMapper runMapper;
private TeamTaskMapper taskMapper;
private TeamRunProjector projector;
@BeforeAll
static void initTableInfo() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
TableInfoHelper.initTableInfo(assistant, TeamRunEntity.class);
TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class);
}
@BeforeEach
void setUp() {
runMapper = mock(TeamRunMapper.class);
taskMapper = mock(TeamTaskMapper.class);
projector = new TeamRunProjector(runMapper, taskMapper);
}
@Test
void projectsStatusAndReturnsComputedProgress() {
when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.AWAITING_REVIEW, null));
when(taskMapper.selectList(any())).thenReturn(List.of(
task(TeamTaskStatus.COMPLETED), task(TeamTaskStatus.PENDING)));
when(runMapper.update(isNull(), any())).thenReturn(1);
TeamRunView view = projector.project(RUN_ID);
assertEquals(TeamRunStatus.RUNNING, view.status());
assertEquals(new TeamRunView.Progress(2, 1, 0, 0, 50), view.progress());
ArgumentCaptor<LambdaUpdateWrapper<TeamRunEntity>> captor = updateCaptor();
verify(runMapper).update(isNull(), captor.capture());
assertTrue(captor.getValue().getSqlSegment().toUpperCase().contains("METADATA IS NULL"));
}
@Test
void projectsTaskDependenciesAndMetadataWithoutReencodingIds() {
TeamTaskEntity task = task(TeamTaskStatus.BLOCKED);
task.setBlockedBy("[\"9007199254740993\"]");
task.setMetadata("{\"deliverables\":[],\"planId\":\"9007199254740995\"}");
when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.PLANNING, null));
when(taskMapper.selectList(any())).thenReturn(List.of(task));
TeamRunView.Task projected = projector.project(RUN_ID).tasks().getFirst();
assertEquals("[\"9007199254740993\"]", projected.blockedBy());
assertEquals("{\"deliverables\":[],\"planId\":\"9007199254740995\"}", projected.metadata());
}
@Test
void terminalRunCannotBeMovedByLateTaskEvents() {
when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.CANCELLED, "{\"traceId\":\"a\"}"));
when(taskMapper.selectList(any())).thenReturn(List.of(task(TeamTaskStatus.PENDING)));
TeamRunView view = projector.project(RUN_ID);
assertEquals(TeamRunStatus.CANCELLED, view.status());
verify(runMapper, never()).update(isNull(), any());
}
@Test
void concurrentCancellationWinsProjectionCompareAndSet() {
TeamRunEntity running = run(TeamRunStatus.RUNNING, null);
TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED, null);
when(runMapper.selectById(RUN_ID)).thenReturn(running, cancelled);
when(taskMapper.selectList(any())).thenReturn(List.of(task(TeamTaskStatus.COMPLETED)));
when(runMapper.update(isNull(), any())).thenReturn(0);
TeamRunView view = projector.project(RUN_ID);
assertEquals(TeamRunStatus.CANCELLED, view.status());
}
@Test
void failedCompareAndSetReloadsRunAndTasksBeforeRecomputing() {
TeamRunEntity firstRun = run(TeamRunStatus.RUNNING, "{\"revision\":1}");
TeamRunEntity secondRun = run(TeamRunStatus.RUNNING, "{\"revision\":2}");
TeamTaskEntity completed = task(TeamTaskStatus.COMPLETED);
TeamTaskEntity pending = task(TeamTaskStatus.PENDING);
when(runMapper.selectById(RUN_ID)).thenReturn(firstRun, secondRun);
when(taskMapper.selectList(any())).thenReturn(List.of(completed), List.of(pending));
when(runMapper.update(isNull(), any())).thenReturn(0);
TeamRunView view = projector.project(RUN_ID);
assertEquals(TeamRunStatus.RUNNING, view.status());
assertEquals("{\"revision\":2}", view.metadata());
assertEquals(TeamTaskStatus.PENDING, view.tasks().getFirst().status());
assertEquals(new TeamRunView.Progress(1, 0, 0, 0, 0), view.progress());
verify(taskMapper, times(2)).selectList(any());
verify(runMapper, times(1)).update(isNull(), any());
}
@Test
void finalizingProjectionMergesOutcomeIntoMetadataObject() {
String originalMetadata = "{\"traceId\":\"a\",\"nested\":{\"kept\":true}}";
when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.RUNNING, originalMetadata));
when(taskMapper.selectList(any())).thenReturn(List.of(
task(TeamTaskStatus.COMPLETED), task(TeamTaskStatus.FAILED)));
when(runMapper.update(isNull(), any())).thenReturn(1);
TeamRunView view = projector.project(RUN_ID);
assertEquals(TeamRunStatus.FINALIZING, view.status());
assertTrue(view.metadata().contains("\"traceId\":\"a\""));
assertTrue(view.metadata().contains("\"nested\""));
assertTrue(view.metadata().contains("\"projectedOutcome\":\"partial\""));
ArgumentCaptor<LambdaUpdateWrapper<TeamRunEntity>> captor = updateCaptor();
verify(runMapper).update(isNull(), captor.capture());
captor.getValue().getSqlSegment();
assertTrue(captor.getValue().getParamNameValuePairs().values().stream()
.map(String::valueOf).anyMatch(value -> value.contains("projectedOutcome")));
assertTrue(captor.getValue().getParamNameValuePairs().containsValue(originalMetadata));
}
@Test
void planningRunKeepsPlanningWhenTaskIsCreated() {
when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.PLANNING, null));
when(taskMapper.selectList(any())).thenReturn(List.of(task(TeamTaskStatus.PENDING)));
TeamRunView view = projector.project(RUN_ID);
assertEquals(TeamRunStatus.PLANNING, view.status());
verify(runMapper, never()).update(isNull(), any());
}
@Test
void nullAndMissingRunsAreSafe() {
assertNull(projector.project(null));
verify(runMapper, never()).selectById(any());
when(runMapper.selectById(RUN_ID)).thenReturn(null);
assertNull(projector.project(RUN_ID));
verify(taskMapper, never()).selectList(any());
}
@Test
void projectionFailureIsLoggedAndSwallowed() {
when(runMapper.selectById(RUN_ID)).thenThrow(new IllegalStateException("database unavailable"));
assertDoesNotThrow(() -> assertNull(projector.project(RUN_ID)));
}
@SuppressWarnings({"unchecked", "rawtypes"})
private ArgumentCaptor<LambdaUpdateWrapper<TeamRunEntity>> updateCaptor() {
return ArgumentCaptor.forClass((Class) LambdaUpdateWrapper.class);
}
private TeamRunEntity run(String status, String metadata) {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setTeamId(10L);
run.setWorkspaceId(30L);
run.setLeadAgentId(40L);
run.setLeadConversationId("conversation");
run.setTitle("Research");
run.setObjective("Research the topic");
run.setStatus(status);
run.setMetadata(metadata);
return run;
}
private TeamTaskEntity task(String status) {
TeamTaskEntity task = new TeamTaskEntity();
task.setRunId(RUN_ID);
task.setStatus(status);
return task;
}
}

View File

@ -0,0 +1,360 @@
package vip.mate.team.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamRunCreateCommand;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.repository.TeamRunMapper;
import vip.mate.team.repository.TeamTaskMapper;
import java.util.List;
import java.util.Set;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TeamRunServiceTest {
private static final Long RUN_ID = 20L;
private static final Long TEAM_ID = 10L;
private static final Long WORKSPACE_ID = 30L;
private static final Long LEAD_ID = 40L;
private TeamRunMapper runMapper;
private TeamTaskMapper taskMapper;
private TeamService teamService;
private TeamRunService service;
@BeforeAll
static void initTableInfo() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
TableInfoHelper.initTableInfo(assistant, TeamRunEntity.class);
TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class);
}
@BeforeEach
void setUp() {
runMapper = mock(TeamRunMapper.class);
taskMapper = mock(TeamTaskMapper.class);
teamService = mock(TeamService.class);
service = new TeamRunService(runMapper, taskMapper, teamService);
when(teamService.getTeam(TEAM_ID)).thenReturn(team(TeamService.STATUS_ACTIVE, WORKSPACE_ID, LEAD_ID));
}
@Test
void startRunValidatesActiveTeamWorkspaceAndLead() {
when(teamService.getTeam(TEAM_ID)).thenReturn(team(TeamService.STATUS_PAUSED, WORKSPACE_ID, LEAD_ID));
assertThrows(IllegalArgumentException.class, () -> service.startRun(command().build()));
when(teamService.getTeam(TEAM_ID)).thenReturn(team(TeamService.STATUS_ACTIVE, 999L, LEAD_ID));
assertThrows(IllegalArgumentException.class, () -> service.startRun(command().build()));
when(teamService.getTeam(TEAM_ID)).thenReturn(team(TeamService.STATUS_ACTIVE, WORKSPACE_ID, 999L));
assertThrows(IllegalArgumentException.class, () -> service.startRun(command().build()));
verify(runMapper, never()).insert(any(TeamRunEntity.class));
}
@Test
void startRunValidatesConversationAndObjective() {
assertThrows(IllegalArgumentException.class,
() -> service.startRun(command().leadConversationId(" ").build()));
assertThrows(IllegalArgumentException.class,
() -> service.startRun(command().objective(null).build()));
}
@Test
void startRunCreatesPlanningRunAndDerivesBoundedTitle() {
String objective = "x".repeat(300);
TeamRunCreateCommand command = command().title(" ").objective(objective).build();
TeamRunEntity created = service.startRun(command);
assertEquals(TeamRunStatus.PLANNING, created.getStatus());
assertEquals(255, created.getTitle().length());
assertTrue(objective.startsWith(created.getTitle()));
assertEquals(WORKSPACE_ID, created.getWorkspaceId());
assertEquals(LEAD_ID, created.getLeadAgentId());
verify(runMapper).insert(created);
}
@Test
void startRunReturnsExistingIdempotentRun() {
TeamRunEntity existing = run(TeamRunStatus.RUNNING);
when(runMapper.selectOne(any())).thenReturn(existing);
assertSame(existing, service.startRun(command().build()));
verify(runMapper, never()).insert(any(TeamRunEntity.class));
}
@Test
void startRunScopesIdempotencyByWorkspaceAndDoesNotOwnATransaction() throws Exception {
TeamRunEntity existing = run(TeamRunStatus.RUNNING);
when(runMapper.selectOne(any())).thenReturn(existing);
service.startRun(command().build());
ArgumentCaptor<LambdaQueryWrapper<TeamRunEntity>> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(runMapper).selectOne(query.capture());
query.getValue().getSqlSegment();
assertTrue(query.getValue().getParamNameValuePairs().containsValue(WORKSPACE_ID));
assertFalse(TeamRunService.class
.getDeclaredMethod("startRun", TeamRunCreateCommand.class)
.isAnnotationPresent(Transactional.class));
}
@Test
void startRunRecoversDuplicateKeyRaceByReadingWinner() {
TeamRunEntity winner = run(TeamRunStatus.PLANNING);
when(runMapper.selectOne(any())).thenReturn(null, winner);
when(runMapper.insert(any(TeamRunEntity.class)))
.thenThrow(new DuplicateKeyException("duplicate origin"));
assertSame(winner, service.startRun(command().build()));
}
@Test
void requireRunRejectsCrossWorkspaceAccess() {
TeamRunEntity foreign = run(TeamRunStatus.RUNNING);
foreign.setWorkspaceId(999L);
when(runMapper.selectById(RUN_ID)).thenReturn(foreign);
assertThrows(IllegalArgumentException.class, () -> service.requireRun(RUN_ID, WORKSPACE_ID));
}
@Test
void sealRunRejectsEmptyPlanningRun() {
when(runMapper.selectById(RUN_ID)).thenReturn(run(TeamRunStatus.PLANNING));
when(taskMapper.selectCount(any())).thenReturn(0L);
assertThrows(IllegalStateException.class, () -> service.sealRun(RUN_ID, WORKSPACE_ID));
verify(runMapper, never()).update(isNull(), any());
}
@Test
void sealRunStartsPopulatedPlanningRun() {
TeamRunEntity planning = run(TeamRunStatus.PLANNING);
when(runMapper.selectById(RUN_ID)).thenReturn(planning);
when(taskMapper.selectCount(any())).thenReturn(2L);
when(runMapper.update(isNull(), any())).thenReturn(1);
TeamRunEntity sealed = service.sealRun(RUN_ID, WORKSPACE_ID);
assertEquals(TeamRunStatus.RUNNING, sealed.getStatus());
assertNotNull(sealed.getStartedAt());
verify(runMapper).update(isNull(), any());
}
@Test
void sealRunWithResultReportsFirstTransition() {
TeamRunEntity planning = run(TeamRunStatus.PLANNING);
when(runMapper.selectById(RUN_ID)).thenReturn(planning);
when(taskMapper.selectCount(any())).thenReturn(2L);
when(runMapper.update(isNull(), any())).thenReturn(1);
TeamRunService.SealResult result = service.sealRunWithResult(RUN_ID, WORKSPACE_ID);
assertSame(planning, result.run());
assertTrue(result.transitioned());
assertEquals(TeamRunStatus.RUNNING, result.run().getStatus());
}
@Test
void sealRunWithResultReportsRepeatedSealWithoutTransition() {
TeamRunEntity running = run(TeamRunStatus.RUNNING);
when(runMapper.selectById(RUN_ID)).thenReturn(running);
TeamRunService.SealResult result = service.sealRunWithResult(RUN_ID, WORKSPACE_ID);
assertSame(running, result.run());
assertFalse(result.transitioned());
verify(runMapper, never()).update(isNull(), any());
}
@Test
void sealRunWithResultReportsConcurrentWinnerWithoutTransition() {
TeamRunEntity planning = run(TeamRunStatus.PLANNING);
TeamRunEntity winner = run(TeamRunStatus.RUNNING);
when(runMapper.selectById(RUN_ID)).thenReturn(planning, winner);
when(taskMapper.selectCount(any())).thenReturn(2L);
when(runMapper.update(isNull(), any())).thenReturn(0);
TeamRunService.SealResult result = service.sealRunWithResult(RUN_ID, WORKSPACE_ID);
assertSame(winner, result.run());
assertFalse(result.transitioned());
}
@Test
void sealRunReturnsRunsThatAlreadyLeftPlanning() {
for (String status : List.of(TeamRunStatus.RUNNING, TeamRunStatus.FINALIZING,
TeamRunStatus.COMPLETED, TeamRunStatus.CANCELLED)) {
TeamRunEntity current = run(status);
when(runMapper.selectById(RUN_ID)).thenReturn(current);
assertSame(current, service.sealRun(RUN_ID, WORKSPACE_ID));
}
verify(taskMapper, never()).selectCount(any());
verify(runMapper, never()).update(isNull(), any());
}
@Test
void cancelRunWithResultReportsOnlyTheFirstTransition() {
TeamRunEntity running = run(TeamRunStatus.RUNNING);
when(runMapper.selectById(RUN_ID)).thenReturn(running);
when(runMapper.update(isNull(), any())).thenReturn(1);
TeamRunService.CancelResult result = service.cancelRunWithResult(
RUN_ID, WORKSPACE_ID, "stop");
assertTrue(result.transitioned());
assertEquals(TeamRunStatus.CANCELLED, result.run().getStatus());
assertEquals("stop", result.run().getStopReason());
}
@Test
void cancelRunWithResultIsIdempotentAfterCancellation() {
TeamRunEntity cancelled = run(TeamRunStatus.CANCELLED);
when(runMapper.selectById(RUN_ID)).thenReturn(cancelled);
TeamRunService.CancelResult result = service.cancelRunWithResult(
RUN_ID, WORKSPACE_ID, null);
assertFalse(result.transitioned());
assertSame(cancelled, result.run());
verify(runMapper, never()).update(isNull(), any());
}
@Test
void cancelRunWithResultReportsConcurrentWinnerWithoutTransition() {
TeamRunEntity running = run(TeamRunStatus.RUNNING);
TeamRunEntity winner = run(TeamRunStatus.CANCELLED);
when(runMapper.selectById(RUN_ID)).thenReturn(running, winner);
when(runMapper.update(isNull(), any())).thenReturn(0);
TeamRunService.CancelResult result = service.cancelRunWithResult(
RUN_ID, WORKSPACE_ID, null);
assertFalse(result.transitioned());
assertSame(winner, result.run());
}
@Test
void markFinalizedUsesProjectedOutcomeAndWritesSummary() {
TeamRunEntity finalizing = run(TeamRunStatus.FINALIZING);
finalizing.setMetadata("{\"traceId\":\"abc\",\"projectedOutcome\":\"partial\"}");
when(runMapper.selectById(RUN_ID)).thenReturn(finalizing);
when(runMapper.update(isNull(), any())).thenReturn(1);
TeamRunEntity finalized = service.markFinalized(RUN_ID, WORKSPACE_ID, "usable result");
assertEquals(TeamRunStatus.PARTIAL, finalized.getStatus());
assertEquals("usable result", finalized.getFinalSummary());
assertNotNull(finalized.getCompletedAt());
}
@Test
void markFinalizedRejectsInvalidOutcome() {
TeamRunEntity finalizing = run(TeamRunStatus.FINALIZING);
finalizing.setMetadata("{\"projectedOutcome\":\"running\"}");
when(runMapper.selectById(RUN_ID)).thenReturn(finalizing);
assertThrows(IllegalStateException.class,
() -> service.markFinalized(RUN_ID, WORKSPACE_ID, "summary"));
}
@Test
void markFinalizedReturnsAlreadyTerminalRuns() {
for (String status : Set.of(TeamRunStatus.COMPLETED, TeamRunStatus.PARTIAL,
TeamRunStatus.FAILED, TeamRunStatus.CANCELLED)) {
TeamRunEntity current = run(status);
when(runMapper.selectById(RUN_ID)).thenReturn(current);
assertSame(current, service.markFinalized(RUN_ID, WORKSPACE_ID, "summary"));
}
verify(runMapper, never()).update(isNull(), any());
}
@Test
void getRunBuildsStableViewWithTasksAndProgress() {
TeamRunEntity running = run(TeamRunStatus.RUNNING);
TeamTaskEntity completed = task(1L, TeamTaskStatus.COMPLETED);
TeamTaskEntity pending = task(2L, TeamTaskStatus.PENDING);
when(runMapper.selectById(RUN_ID)).thenReturn(running);
when(taskMapper.selectList(any())).thenReturn(List.of(completed, pending));
var view = service.getRun(RUN_ID, WORKSPACE_ID);
assertEquals(RUN_ID, view.id());
assertEquals(2, view.tasks().size());
assertEquals(50, view.progress().percent());
}
private TeamRunCreateCommand.TeamRunCreateCommandBuilder command() {
return TeamRunCreateCommand.builder()
.teamId(TEAM_ID)
.workspaceId(WORKSPACE_ID)
.leadAgentId(LEAD_ID)
.leadConversationId("conversation")
.originMessageId(50L)
.title("Research")
.objective("Research the topic");
}
private AgentTeamEntity team(String status, Long workspaceId, Long leadId) {
AgentTeamEntity team = new AgentTeamEntity();
team.setId(TEAM_ID);
team.setStatus(status);
team.setWorkspaceId(workspaceId);
team.setLeadAgentId(leadId);
return team;
}
private TeamRunEntity run(String status) {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setTeamId(TEAM_ID);
run.setWorkspaceId(WORKSPACE_ID);
run.setLeadAgentId(LEAD_ID);
run.setLeadConversationId("conversation");
run.setTitle("Research");
run.setObjective("Research the topic");
run.setStatus(status);
return run;
}
private TeamTaskEntity task(Long id, String status) {
TeamTaskEntity task = new TeamTaskEntity();
task.setId(id);
task.setTeamId(TEAM_ID);
task.setRunId(RUN_ID);
task.setStatus(status);
return task;
}
}

View File

@ -0,0 +1,118 @@
package vip.mate.team.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
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.Arrays;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class TeamRunStateMachineTest {
private final TeamRunStateMachine stateMachine = new TeamRunStateMachine();
@ParameterizedTest(name = "{0}")
@MethodSource("projections")
void projectsTaskState(String name, String runStatus, List<TeamTaskEntity> tasks,
String expectedStatus, String expectedOutcome,
int done, int failed, int inReview, int percent) {
TeamRunEntity run = run(runStatus);
TeamRunStateMachine.Projection projection = stateMachine.project(run, tasks);
assertEquals(expectedStatus, projection.status());
assertEquals(expectedOutcome, projection.projectedOutcome());
assertEquals(new TeamRunView.Progress(tasks.size(), done, failed, inReview, percent),
projection.progress());
}
static Stream<Arguments> projections() {
return Stream.of(
Arguments.of("empty planning run", TeamRunStatus.PLANNING, tasks(),
TeamRunStatus.PLANNING, null, 0, 0, 0, 0),
Arguments.of("planning run with tasks", TeamRunStatus.PLANNING,
tasks(TeamTaskStatus.PENDING), TeamRunStatus.PLANNING, null, 0, 0, 0, 0),
Arguments.of("active tasks", TeamRunStatus.RUNNING,
tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.IN_PROGRESS),
TeamRunStatus.RUNNING, null, 1, 0, 0, 50),
Arguments.of("blocked tasks are active", TeamRunStatus.AWAITING_REVIEW,
tasks(TeamTaskStatus.BLOCKED), TeamRunStatus.RUNNING, null, 0, 0, 0, 0),
Arguments.of("review only", TeamRunStatus.RUNNING,
tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.IN_REVIEW),
TeamRunStatus.AWAITING_REVIEW, null, 1, 0, 1, 50),
Arguments.of("all completed", TeamRunStatus.RUNNING,
tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.COMPLETED),
TeamRunStatus.FINALIZING, TeamRunStatus.COMPLETED, 2, 0, 0, 100),
Arguments.of("mixed completed and failed", TeamRunStatus.RUNNING,
tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.FAILED),
TeamRunStatus.FINALIZING, TeamRunStatus.PARTIAL, 1, 1, 0, 50),
Arguments.of("mixed completed and cancelled", TeamRunStatus.RUNNING,
tasks(TeamTaskStatus.COMPLETED, TeamTaskStatus.CANCELLED),
TeamRunStatus.FINALIZING, TeamRunStatus.PARTIAL, 1, 1, 0, 50),
Arguments.of("no successful tasks", TeamRunStatus.RUNNING,
tasks(TeamTaskStatus.FAILED, TeamTaskStatus.CANCELLED),
TeamRunStatus.FINALIZING, TeamRunStatus.FAILED, 0, 2, 0, 0)
);
}
@Test
void cancelledRunIsImmutable() {
TeamRunStateMachine.Projection projection = stateMachine.project(
run(TeamRunStatus.CANCELLED), tasks(TeamTaskStatus.PENDING));
assertEquals(TeamRunStatus.CANCELLED, projection.status());
assertNull(projection.projectedOutcome());
}
@Test
void otherTerminalRunsAreImmutable() {
for (String status : List.of(TeamRunStatus.COMPLETED, TeamRunStatus.PARTIAL, TeamRunStatus.FAILED)) {
assertEquals(status, stateMachine.project(run(status), tasks(TeamTaskStatus.PENDING)).status());
}
}
@Test
void emptyNonPlanningRunKeepsItsCurrentNonTerminalStatus() {
for (String status : List.of(
TeamRunStatus.RUNNING, TeamRunStatus.AWAITING_REVIEW, TeamRunStatus.FINALIZING)) {
TeamRunStateMachine.Projection projection = stateMachine.project(run(status), tasks());
assertEquals(status, projection.status());
assertNull(projection.projectedOutcome());
}
}
@Test
void unknownTaskStatusKeepsCurrentNonTerminalStatus() {
TeamRunStateMachine.Projection projection = stateMachine.project(
run(TeamRunStatus.RUNNING), tasks("custom_status"));
assertEquals(TeamRunStatus.RUNNING, projection.status());
assertNull(projection.projectedOutcome());
assertEquals(new TeamRunView.Progress(1, 0, 0, 0, 0), projection.progress());
}
private static TeamRunEntity run(String status) {
TeamRunEntity run = new TeamRunEntity();
run.setStatus(status);
return run;
}
private static List<TeamTaskEntity> tasks(String... statuses) {
return Arrays.stream(statuses).map(status -> {
TeamTaskEntity task = new TeamTaskEntity();
task.setStatus(status);
return task;
}).toList();
}
}

View File

@ -1,6 +1,7 @@
package vip.mate.team.service; package vip.mate.team.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration; import org.apache.ibatis.session.Configuration;
@ -8,8 +9,11 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamTaskCommentEntity; import vip.mate.team.model.TeamTaskCommentEntity;
import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
@ -20,6 +24,7 @@ import vip.mate.team.repository.TeamTaskEventMapper;
import vip.mate.team.repository.TeamTaskMapper; import vip.mate.team.repository.TeamTaskMapper;
import java.util.List; import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@ -36,11 +41,15 @@ class TeamTaskServiceTest {
private static final Long TEAM_ID = 10L; private static final Long TEAM_ID = 10L;
private static final Long LEAD_ID = 1L; private static final Long LEAD_ID = 1L;
private static final Long MEMBER_ID = 2L; private static final Long MEMBER_ID = 2L;
private static final Long RUN_ID = 20L;
private static final Long WORKSPACE_ID = 30L;
private TeamTaskMapper taskMapper; private TeamTaskMapper taskMapper;
private TeamTaskCommentMapper commentMapper; private TeamTaskCommentMapper commentMapper;
private TeamTaskEventMapper eventMapper; private TeamTaskEventMapper eventMapper;
private TeamService teamService; private TeamService teamService;
private TeamRunProjectionScheduler projectionScheduler;
private TeamRunService runService;
private TeamTaskService service; private TeamTaskService service;
@BeforeAll @BeforeAll
@ -60,12 +69,16 @@ class TeamTaskServiceTest {
commentMapper = mock(TeamTaskCommentMapper.class); commentMapper = mock(TeamTaskCommentMapper.class);
eventMapper = mock(TeamTaskEventMapper.class); eventMapper = mock(TeamTaskEventMapper.class);
teamService = mock(TeamService.class); teamService = mock(TeamService.class);
service = new TeamTaskService(taskMapper, commentMapper, eventMapper, teamService); projectionScheduler = mock(TeamRunProjectionScheduler.class);
runService = mock(TeamRunService.class);
service = new TeamTaskService(taskMapper, commentMapper, eventMapper, teamService,
projectionScheduler, runService);
AgentTeamEntity team = new AgentTeamEntity(); AgentTeamEntity team = new AgentTeamEntity();
team.setId(TEAM_ID); team.setId(TEAM_ID);
team.setLeadAgentId(LEAD_ID); team.setLeadAgentId(LEAD_ID);
team.setStatus(TeamService.STATUS_ACTIVE); team.setStatus(TeamService.STATUS_ACTIVE);
team.setWorkspaceId(WORKSPACE_ID);
when(teamService.getTeam(TEAM_ID)).thenReturn(team); when(teamService.getTeam(TEAM_ID)).thenReturn(team);
when(teamService.isMember(TEAM_ID, MEMBER_ID)).thenReturn(true); when(teamService.isMember(TEAM_ID, MEMBER_ID)).thenReturn(true);
when(teamService.nextTaskNumber(TEAM_ID)).thenReturn(1); when(teamService.nextTaskNumber(TEAM_ID)).thenReturn(1);
@ -87,6 +100,21 @@ class TeamTaskServiceTest {
return t; return t;
} }
private TeamTaskEntity runTask(Long id, String status) {
TeamTaskEntity task = task(id, status);
task.setRunId(RUN_ID);
return task;
}
private TeamRunEntity planningRun(Long teamId) {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setTeamId(teamId);
run.setWorkspaceId(WORKSPACE_ID);
run.setStatus(TeamRunStatus.PLANNING);
return run;
}
// ==================== creation guards ==================== // ==================== creation guards ====================
@Test @Test
@ -137,6 +165,60 @@ class TeamTaskServiceTest {
assertEquals(0, created.getDispatchCount()); assertEquals(0, created.getDispatchCount());
} }
@Test
@DisplayName("create copies an optional run id onto the persisted task")
void createCopiesRunId() {
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planningRun(TEAM_ID));
service.createTask(baseCreate().runId(RUN_ID).build());
ArgumentCaptor<TeamTaskEntity> captor = ArgumentCaptor.forClass(TeamTaskEntity.class);
verify(taskMapper).insert(captor.capture());
assertEquals(RUN_ID, captor.getValue().getRunId());
verify(projectionScheduler).scheduleRun(RUN_ID);
}
@Test
@DisplayName("run-aware task creation requires a planning run in the same team")
void createRequiresPlanningRunInSameTeam() {
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planningRun(999L));
IllegalArgumentException wrongTeam = assertThrows(IllegalArgumentException.class,
() -> service.createTask(baseCreate().runId(RUN_ID).build()));
assertTrue(wrongTeam.getMessage().contains("same team"));
TeamRunEntity running = planningRun(TEAM_ID);
running.setStatus(TeamRunStatus.RUNNING);
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(running);
IllegalStateException wrongStatus = assertThrows(IllegalStateException.class,
() -> service.createTask(baseCreate().runId(RUN_ID).build()));
assertTrue(wrongStatus.getMessage().contains("planning"));
verify(taskMapper, never()).insert(any(TeamTaskEntity.class));
}
@Test
@DisplayName("blockedBy tasks must belong to the same run")
void createRequiresBlockersInSameRun() {
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planningRun(TEAM_ID));
when(taskMapper.selectById(99L)).thenReturn(task(99L, TeamTaskStatus.PENDING));
IllegalArgumentException runTaskWithLegacyBlocker = assertThrows(IllegalArgumentException.class,
() -> service.createTask(baseCreate().runId(RUN_ID).blockedBy(List.of(99L)).build()));
assertTrue(runTaskWithLegacyBlocker.getMessage().contains("same run"));
when(taskMapper.selectById(99L)).thenReturn(runTask(99L, TeamTaskStatus.PENDING));
IllegalArgumentException legacyTaskWithRunBlocker = assertThrows(IllegalArgumentException.class,
() -> service.createTask(baseCreate().blockedBy(List.of(99L)).build()));
assertTrue(legacyTaskWithRunBlocker.getMessage().contains("same run"));
}
@Test
@DisplayName("legacy task creation does not trigger run projection")
void createLegacyTaskDoesNotProject() {
service.createTask(baseCreate().build());
verify(projectionScheduler, never()).scheduleRun(any());
verify(projectionScheduler, never()).scheduleTask(any());
}
// ==================== completion ==================== // ==================== completion ====================
@Test @Test
@ -207,6 +289,107 @@ class TeamTaskServiceTest {
assertThrows(IllegalStateException.class, () -> service.completeTask(5L, MEMBER_ID, "late")); assertThrows(IllegalStateException.class, () -> service.completeTask(5L, MEMBER_ID, "late"));
} }
@Test
@DisplayName("successful completion triggers run projection")
void completeProjectsRun() {
TeamTaskEntity running = runTask(5L, TeamTaskStatus.IN_PROGRESS);
running.setOwnerAgentId(MEMBER_ID);
when(taskMapper.selectById(5L)).thenReturn(running);
when(taskMapper.update(isNull(), any())).thenReturn(1);
when(taskMapper.selectList(any())).thenReturn(List.of());
service.completeTask(5L, MEMBER_ID, "done");
verify(projectionScheduler).scheduleRun(RUN_ID);
}
@Test
@DisplayName("successful failure triggers run projection")
void failProjectsRun() {
when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.IN_PROGRESS));
when(taskMapper.update(isNull(), any())).thenReturn(1);
InOrder mutationOrder = inOrder(taskMapper);
assertTrue(service.failTask(5L, "error"));
mutationOrder.verify(taskMapper).selectById(5L);
mutationOrder.verify(taskMapper).update(isNull(), any());
mutationOrder.verifyNoMoreInteractions();
verify(projectionScheduler).scheduleTask(5L);
}
@Test
@DisplayName("successful cancellation triggers run projection")
void cancelProjectsRun() {
when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.IN_PROGRESS));
when(taskMapper.update(isNull(), any())).thenReturn(1);
when(taskMapper.selectList(any())).thenReturn(List.of());
service.cancelTask(5L, "stop");
verify(projectionScheduler).scheduleRun(RUN_ID);
}
@Test
@DisplayName("successful retry triggers run projection")
void retryProjectsRun() {
when(taskMapper.update(isNull(), any())).thenReturn(1);
when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.PENDING));
assertTrue(service.retryTask(5L));
verify(projectionScheduler).scheduleTask(5L);
}
@Test
@DisplayName("claim does not query the task after a successful mutation")
void claimDoesNotQueryTaskAfterMutation() {
when(taskMapper.update(isNull(), any())).thenReturn(1);
assertTrue(service.claimTask(5L, MEMBER_ID));
verify(taskMapper, never()).selectById(5L);
verify(projectionScheduler).scheduleTask(5L);
}
@Test
@DisplayName("assign does not query the task after a successful mutation")
void assignDoesNotQueryTaskAfterMutation() {
when(taskMapper.update(isNull(), any())).thenReturn(1);
assertTrue(service.assignTask(5L, MEMBER_ID));
verify(taskMapper, never()).selectById(5L);
verify(projectionScheduler).scheduleTask(5L);
}
@Test
@DisplayName("completion succeeds when projection scheduling fails")
void completeIgnoresProjectionSchedulingFailure() {
TeamTaskEntity running = runTask(5L, TeamTaskStatus.IN_PROGRESS);
running.setOwnerAgentId(MEMBER_ID);
when(taskMapper.selectById(5L)).thenReturn(running);
when(taskMapper.update(isNull(), any())).thenReturn(1);
when(taskMapper.selectList(any())).thenReturn(List.of());
doThrow(new IllegalStateException("scheduler unavailable"))
.when(projectionScheduler).scheduleRun(RUN_ID);
assertTrue(service.completeTask(5L, MEMBER_ID, "done").isEmpty());
}
@Test
@DisplayName("successful progress update triggers run projection")
void progressProjectsRun() {
when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.IN_PROGRESS));
when(taskMapper.update(isNull(), any())).thenReturn(1);
InOrder mutationOrder = inOrder(taskMapper);
assertTrue(service.updateProgress(5L, MEMBER_ID, 50, "halfway"));
mutationOrder.verify(taskMapper).selectById(5L);
mutationOrder.verify(taskMapper).update(isNull(), any());
mutationOrder.verifyNoMoreInteractions();
verify(projectionScheduler).scheduleTask(5L);
}
// ==================== blocker comment ==================== // ==================== blocker comment ====================
@Test @Test
@ -374,4 +557,33 @@ class TeamTaskServiceTest {
assertTrue(service.listDeliverables(junk).isEmpty()); assertTrue(service.listDeliverables(junk).isEmpty());
assertTrue(service.listDeliverables(null).isEmpty()); assertTrue(service.listDeliverables(null).isEmpty());
} }
@Test
@DisplayName("dispatch candidates exclude planning runs but keep running and legacy tasks")
void findDispatchableExcludesPlanningRuns() {
TeamTaskEntity planning = runTask(1L, TeamTaskStatus.PENDING);
TeamTaskEntity running = task(2L, TeamTaskStatus.PENDING);
running.setRunId(21L);
TeamTaskEntity legacy = task(3L, TeamTaskStatus.PENDING);
when(taskMapper.selectList(any())).thenReturn(List.of(planning, running, legacy));
when(runService.findPlanningRunIds(Set.of(RUN_ID, 21L))).thenReturn(Set.of(RUN_ID));
assertEquals(List.of(running, legacy), service.findDispatchable(TEAM_ID));
verify(runService).findPlanningRunIds(Set.of(RUN_ID, 21L));
}
@Test
@DisplayName("run task lookup is scoped by run id")
void listTasksByRunScopesQuery() {
TeamTaskEntity first = runTask(1L, TeamTaskStatus.PENDING);
when(taskMapper.selectList(any())).thenReturn(List.of(first));
assertEquals(List.of(first), service.listTasksByRun(RUN_ID));
ArgumentCaptor<LambdaQueryWrapper<TeamTaskEntity>> query =
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskMapper).selectList(query.capture());
query.getValue().getSqlSegment();
assertTrue(query.getValue().getParamNameValuePairs().containsValue(RUN_ID));
}
} }

View File

@ -5,15 +5,22 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.springframework.ai.chat.model.ToolContext;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamRunCreateCommand;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamTaskCreateCommand; import vip.mate.team.model.TeamTaskCreateCommand;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus; import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamDispatchService;
import vip.mate.team.service.TeamEventChannel; import vip.mate.team.service.TeamEventChannel;
import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamService;
import vip.mate.team.service.TeamRunService;
import vip.mate.team.service.TeamTaskService; import vip.mate.team.service.TeamTaskService;
import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
@ -39,9 +46,12 @@ class TeamTasksToolTest {
private static final Long TEAM_ID = 10L; private static final Long TEAM_ID = 10L;
private static final Long LEAD_ID = 1L; private static final Long LEAD_ID = 1L;
private static final Long MEMBER_ID = 2L; private static final Long MEMBER_ID = 2L;
private static final Long WORKSPACE_ID = 30L;
private static final Long RUN_ID = 20L;
private TeamService teamService; private TeamService teamService;
private TeamTaskService taskService; private TeamTaskService taskService;
private TeamRunService runService;
private TeamDispatchService dispatchService; private TeamDispatchService dispatchService;
private TeamEventChannel eventChannel; private TeamEventChannel eventChannel;
private ConversationService conversationService; private ConversationService conversationService;
@ -53,17 +63,19 @@ class TeamTasksToolTest {
void setUp() { void setUp() {
teamService = mock(TeamService.class); teamService = mock(TeamService.class);
taskService = mock(TeamTaskService.class); taskService = mock(TeamTaskService.class);
runService = mock(TeamRunService.class);
dispatchService = mock(TeamDispatchService.class); dispatchService = mock(TeamDispatchService.class);
eventChannel = mock(TeamEventChannel.class); eventChannel = mock(TeamEventChannel.class);
conversationService = mock(ConversationService.class); conversationService = mock(ConversationService.class);
agentMapper = mock(AgentMapper.class); agentMapper = mock(AgentMapper.class);
tool = new TeamTasksTool(teamService, taskService, dispatchService, tool = new TeamTasksTool(teamService, taskService, runService, dispatchService,
eventChannel, conversationService, agentMapper); eventChannel, conversationService, agentMapper);
team = new AgentTeamEntity(); team = new AgentTeamEntity();
team.setId(TEAM_ID); team.setId(TEAM_ID);
team.setName("研发组"); team.setName("研发组");
team.setLeadAgentId(LEAD_ID); team.setLeadAgentId(LEAD_ID);
team.setWorkspaceId(WORKSPACE_ID);
ToolExecutionContext.set(CONV, "admin"); ToolExecutionContext.set(CONV, "admin");
} }
@ -77,6 +89,7 @@ class TeamTasksToolTest {
ConversationEntity conv = new ConversationEntity(); ConversationEntity conv = new ConversationEntity();
conv.setConversationId(CONV); conv.setConversationId(CONV);
conv.setAgentId(agentId); conv.setAgentId(agentId);
conv.setWorkspaceId(WORKSPACE_ID);
when(conversationService.findByConversationId(CONV)).thenReturn(conv); when(conversationService.findByConversationId(CONV)).thenReturn(conv);
when(teamService.getTeamForAgent(agentId)).thenReturn(Optional.of(team)); when(teamService.getTeamForAgent(agentId)).thenReturn(Optional.of(team));
when(teamService.isLead(team, agentId)).thenReturn(agentId.equals(LEAD_ID)); when(teamService.isLead(team, agentId)).thenReturn(agentId.equals(LEAD_ID));
@ -94,8 +107,18 @@ class TeamTasksToolTest {
} }
private String invoke(String action, String taskId) { private String invoke(String action, String taskId) {
return tool.team_tasks(action, taskId, null, null, null, null, null, return tool.team_tasks(action, taskId, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null); null, null, null, null, null, null, null, null, null, null, null);
}
private TeamRunEntity run(Long teamId, String conversationId, String status) {
TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID);
run.setTeamId(teamId);
run.setWorkspaceId(WORKSPACE_ID);
run.setLeadConversationId(conversationId);
run.setStatus(status);
return run;
} }
// ==================== context & membership gating ==================== // ==================== context & membership gating ====================
@ -113,6 +136,7 @@ class TeamTasksToolTest {
ConversationEntity conv = new ConversationEntity(); ConversationEntity conv = new ConversationEntity();
conv.setConversationId(CONV); conv.setConversationId(CONV);
conv.setAgentId(99L); conv.setAgentId(99L);
conv.setWorkspaceId(WORKSPACE_ID);
when(conversationService.findByConversationId(CONV)).thenReturn(conv); when(conversationService.findByConversationId(CONV)).thenReturn(conv);
when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty()); when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty());
@ -123,7 +147,21 @@ class TeamTasksToolTest {
@DisplayName("unknown action lists the valid ones") @DisplayName("unknown action lists the valid ones")
void unknownAction() { void unknownAction() {
callerIs(LEAD_ID); callerIs(LEAD_ID);
assertTrue(invoke("destroy", null).contains("unknown action")); String output = invoke("destroy", null);
assertTrue(output.contains("unknown action"));
assertTrue(output.contains("start_run"));
assertTrue(output.contains("seal_run"));
}
@Test
@DisplayName("a conversation without workspace context yields a structured error")
void missingWorkspaceError() {
ConversationEntity conv = new ConversationEntity();
conv.setConversationId(CONV);
conv.setAgentId(LEAD_ID);
when(conversationService.findByConversationId(CONV)).thenReturn(conv);
assertTrue(invoke("list", null).contains("workspaceId"));
} }
// ==================== role gating ==================== // ==================== role gating ====================
@ -133,8 +171,8 @@ class TeamTasksToolTest {
void memberCannotCreate() { void memberCannotCreate() {
callerIs(MEMBER_ID); callerIs(MEMBER_ID);
String out = tool.team_tasks("create", null, "subj", "desc", String out = tool.team_tasks("create", null, "subj", "desc",
String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null, null, null, null, String.valueOf(MEMBER_ID), null, null, null, null, null, null,
null, null, null); null, null, null, null, null);
assertTrue(out.contains("only the team lead can create")); assertTrue(out.contains("only the team lead can create"));
verify(taskService, never()).createTask(any()); verify(taskService, never()).createTask(any());
} }
@ -150,21 +188,64 @@ class TeamTasksToolTest {
verify(taskService, never()).retryTask(any()); verify(taskService, never()).retryTask(any());
} }
@Test
@DisplayName("members cannot start or seal runs")
void memberCannotStartOrSealRuns() {
callerIs(MEMBER_ID);
String start = tool.team_tasks("start_run", null, null, "Run", "Objective",
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
String seal = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
assertTrue(start.contains("only the team lead"));
assertTrue(seal.contains("only the team lead"));
verifyNoInteractions(runService);
}
@Test
@DisplayName("start_run keeps the explicit origin id after later conversation activity")
void startRunUsesExplicitOriginMessage() {
callerIs(LEAD_ID);
when(runService.startRun(any())).thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
ToolContext originalTurn = ChatOrigin.web(CONV, "admin", WORKSPACE_ID, null)
.withOriginMessageId(99L)
.toToolContext();
String output = tool.team_tasks("start_run", null, null, "Research", "Find evidence",
null, null, null, null, null, null, null, null, null, null, null, null, null,
originalTurn);
assertEquals(String.valueOf(RUN_ID), output);
ArgumentCaptor<TeamRunCreateCommand> captor = ArgumentCaptor.forClass(TeamRunCreateCommand.class);
verify(runService).startRun(captor.capture());
TeamRunCreateCommand command = captor.getValue();
assertEquals(TEAM_ID, command.getTeamId());
assertEquals(WORKSPACE_ID, command.getWorkspaceId());
assertEquals(LEAD_ID, command.getLeadAgentId());
assertEquals(CONV, command.getLeadConversationId());
assertEquals(99L, command.getOriginMessageId());
assertEquals("Research", command.getTitle());
assertEquals("Find evidence", command.getObjective());
}
// ==================== create pass-through ==================== // ==================== create pass-through ====================
@Test @Test
@DisplayName("lead create parses ids, wires the lead conversation and reports the assignee") @DisplayName("lead create parses ids, wires the lead conversation and reports the assignee")
void leadCreatePassesThrough() { void leadCreatePassesThrough() {
callerIs(LEAD_ID); callerIs(LEAD_ID);
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
TeamTaskEntity created = task(50L, TeamTaskStatus.PENDING); TeamTaskEntity created = task(50L, TeamTaskStatus.PENDING);
when(taskService.createTask(any())).thenReturn(created); when(taskService.createTask(any())).thenReturn(created);
AgentEntity member = new AgentEntity(); AgentEntity member = new AgentEntity();
member.setName("写手"); member.setName("写手");
when(agentMapper.selectById(MEMBER_ID)).thenReturn(member); when(agentMapper.selectById(MEMBER_ID)).thenReturn(member);
String out = tool.team_tasks("create", null, "collect data", "step details", String out = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
String.valueOf(MEMBER_ID), "11,12", 5, null, null, null, null, null, null, "collect data", "step details", String.valueOf(MEMBER_ID), "11,12", 5,
null, null, null); null, null, null, null, null, null, null, null, null);
assertTrue(out.startsWith("✓ Created task #3")); assertTrue(out.startsWith("✓ Created task #3"));
assertTrue(out.contains("写手")); assertTrue(out.contains("写手"));
@ -176,17 +257,20 @@ class TeamTasksToolTest {
assertEquals(List.of(11L, 12L), cmd.getBlockedBy()); assertEquals(List.of(11L, 12L), cmd.getBlockedBy());
assertEquals(LEAD_ID, cmd.getCreatedByAgentId()); assertEquals(LEAD_ID, cmd.getCreatedByAgentId());
assertEquals(CONV, cmd.getLeadConversationId()); assertEquals(CONV, cmd.getLeadConversationId());
verify(dispatchService).requestDispatch(TEAM_ID); assertEquals(RUN_ID, cmd.getRunId());
verify(dispatchService, never()).requestDispatch(any());
} }
@Test @Test
@DisplayName("creating a blocked task does not trigger a dispatch sweep") @DisplayName("creating a blocked task does not trigger a dispatch sweep")
void blockedCreateDoesNotDispatch() { void blockedCreateDoesNotDispatch() {
callerIs(LEAD_ID); callerIs(LEAD_ID);
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
TeamTaskEntity blocked = task(51L, TeamTaskStatus.BLOCKED); TeamTaskEntity blocked = task(51L, TeamTaskStatus.BLOCKED);
when(taskService.createTask(any())).thenReturn(blocked); when(taskService.createTask(any())).thenReturn(blocked);
tool.team_tasks("create", null, "later step", null, tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, "later step", null,
String.valueOf(MEMBER_ID), "50", null, null, null, null, null, null, null, String.valueOf(MEMBER_ID), "50", null, null, null, null, null, null, null,
null, null, null); null, null, null);
@ -197,9 +281,11 @@ class TeamTasksToolTest {
@DisplayName("lead create passes requireApproval through to the command") @DisplayName("lead create passes requireApproval through to the command")
void createPassesRequireApproval() { void createPassesRequireApproval() {
callerIs(LEAD_ID); callerIs(LEAD_ID);
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
when(taskService.createTask(any())).thenReturn(task(52L, TeamTaskStatus.PENDING)); when(taskService.createTask(any())).thenReturn(task(52L, TeamTaskStatus.PENDING));
tool.team_tasks("create", null, "publish notes", null, tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, "publish notes", null,
String.valueOf(MEMBER_ID), null, null, true, null, null, null, null, null, String.valueOf(MEMBER_ID), null, null, true, null, null, null, null, null,
null, null, null); null, null, null);
@ -209,6 +295,101 @@ class TeamTasksToolTest {
assertTrue(captor.getValue().isRequireApproval()); assertTrue(captor.getValue().isRequireApproval());
} }
@Test
@DisplayName("create requires an explicit run id")
void createRequiresRunId() {
callerIs(LEAD_ID);
String output = tool.team_tasks("create", null, null, null, null, "subject", "details",
String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null,
null, null, null);
assertTrue(output.contains("runId is required"));
verify(taskService, never()).createTask(any());
}
@Test
@DisplayName("create rejects a run owned by another team")
void createRejectsForeignRun() {
callerIs(LEAD_ID);
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
.thenReturn(run(999L, CONV, TeamRunStatus.PLANNING));
String output = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
"subject", "details", String.valueOf(MEMBER_ID), null, null, null, null,
null, null, null, null, null, null, null);
assertTrue(output.contains("runId"));
verify(taskService, never()).createTask(any());
}
@Test
@DisplayName("create rejects a run owned by another lead conversation")
void createRejectsForeignConversationRun() {
callerIs(LEAD_ID);
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
.thenReturn(run(TEAM_ID, "other-conversation", TeamRunStatus.PLANNING));
String output = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
"subject", "details", String.valueOf(MEMBER_ID), null, null, null, null,
null, null, null, null, null, null, null);
assertTrue(output.contains("lead conversation"));
verify(taskService, never()).createTask(any());
}
@Test
@DisplayName("seal_run dispatches once only after the run is sealed")
void sealRunDispatchesAfterSeal() {
callerIs(LEAD_ID);
TeamRunEntity planning = run(TEAM_ID, CONV, TeamRunStatus.PLANNING);
TeamRunEntity running = run(TEAM_ID, CONV, TeamRunStatus.RUNNING);
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planning);
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
.thenReturn(new TeamRunService.SealResult(running, true));
String output = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
assertTrue(output.contains("sealed"));
InOrder order = inOrder(runService, dispatchService);
order.verify(runService).sealRunWithResult(RUN_ID, WORKSPACE_ID);
order.verify(dispatchService).requestDispatch(TEAM_ID);
verify(dispatchService, times(1)).requestDispatch(TEAM_ID);
}
@Test
@DisplayName("repeated seal_run does not dispatch again")
void repeatedSealRunDoesNotDispatch() {
callerIs(LEAD_ID);
TeamRunEntity running = run(TEAM_ID, CONV, TeamRunStatus.RUNNING);
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(running);
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
.thenReturn(new TeamRunService.SealResult(running, false));
String output = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
assertTrue(output.contains("already sealed"));
verify(dispatchService, never()).requestDispatch(any());
}
@Test
@DisplayName("seal_run does not dispatch when sealing fails")
void sealRunFailureDoesNotDispatch() {
callerIs(LEAD_ID);
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
when(runService.sealRunWithResult(RUN_ID, WORKSPACE_ID))
.thenThrow(new IllegalStateException("cannot seal a team run without tasks"));
String output = tool.team_tasks("seal_run", null, String.valueOf(RUN_ID), null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
assertTrue(output.startsWith("Error:"));
verify(dispatchService, never()).requestDispatch(any());
}
@Test @Test
@DisplayName("lead cancel interrupts the running member conversation") @DisplayName("lead cancel interrupts the running member conversation")
void cancelInterruptsRun() { void cancelInterruptsRun() {
@ -231,9 +412,11 @@ class TeamTasksToolTest {
callerIs(LEAD_ID); callerIs(LEAD_ID);
when(taskService.createTask(any())) when(taskService.createTask(any()))
.thenThrow(new IllegalArgumentException("assignee is required")); .thenThrow(new IllegalArgumentException("assignee is required"));
String out = tool.team_tasks("create", null, "s", null, when(runService.requireRun(RUN_ID, WORKSPACE_ID))
String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null, .thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
null, null, null); String out = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
"s", null, String.valueOf(MEMBER_ID), null, null, null, null, null, null,
null, null, null, null, null);
assertEquals("Error: assignee is required", out); assertEquals("Error: assignee is required", out);
} }
@ -249,8 +432,8 @@ class TeamTasksToolTest {
assertTrue(invoke("complete", "5").startsWith("Error: result is required")); assertTrue(invoke("complete", "5").startsWith("Error: result is required"));
String ok = tool.team_tasks("complete", "5", null, null, null, null, null, String ok = tool.team_tasks("complete", "5", null, null, null, null, null, null, null,
null, "done, see report", null, null, null, null, null, null, null); null, null, "done, see report", null, null, null, null, null, null, null);
assertTrue(ok.contains("Released 1 dependent task(s)")); assertTrue(ok.contains("Released 1 dependent task(s)"));
} }
@ -262,8 +445,8 @@ class TeamTasksToolTest {
when(taskService.addComment(eq(5L), eq(TeamTaskService.AUTHOR_AGENT), when(taskService.addComment(eq(5L), eq(TeamTaskService.AUTHOR_AGENT),
anyString(), eq("blocker"), anyString())).thenReturn(true); anyString(), eq("blocker"), anyString())).thenReturn(true);
String out = tool.team_tasks("comment", "5", null, null, null, null, null, String out = tool.team_tasks("comment", "5", null, null, null, null, null, null, null,
null, null, null, null, "missing credentials", "blocker", null, null, null); null, null, null, null, null, "missing credentials", "blocker", null, null, null);
assertTrue(out.contains("stop working")); assertTrue(out.contains("stop working"));
} }
@ -273,8 +456,8 @@ class TeamTasksToolTest {
callerIs(MEMBER_ID); callerIs(MEMBER_ID);
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS)); when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
String out = tool.team_tasks("attach", "5", null, null, null, null, null, String out = tool.team_tasks("attach", "5", null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null,
"report.docx", "/api/v1/files/generated/abc", null); "report.docx", "/api/v1/files/generated/abc", null);
assertTrue(out.startsWith("✓ Deliverable attached: report.docx")); assertTrue(out.startsWith("✓ Deliverable attached: report.docx"));

View File

@ -0,0 +1,92 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { http, teamApi, teamRunApi } from '@/api/index'
import type { TeamRun } from '@/api/index'
describe('teamRunApi', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('uses the run lifecycle endpoints without numeric id coercion', () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({} as never)
const post = vi.spyOn(http, 'post').mockResolvedValue({} as never)
const runId = '9007199254740993'
const teamId = '9007199254740995'
const conversationId = 'lead/conversation'
teamRunApi.get(runId)
teamRunApi.listByTeam(teamId)
teamRunApi.listByConversation(conversationId)
teamRunApi.cancel(runId, 'stop')
teamApi.createTask(teamId, {
runId,
subject: 'Task',
assigneeAgentId: '2',
})
expect(get).toHaveBeenNthCalledWith(1, `/team-runs/${runId}`)
expect(get).toHaveBeenNthCalledWith(2, `/teams/${teamId}/runs`)
expect(get).toHaveBeenNthCalledWith(
3,
`/conversations/${encodeURIComponent(conversationId)}/team-runs`,
)
expect(post).toHaveBeenCalledWith(`/team-runs/${runId}/cancel`, { reason: 'stop' })
expect(post).toHaveBeenCalledWith(`/teams/${teamId}/tasks`, {
runId,
subject: 'Task',
assigneeAgentId: '2',
})
})
it('models every run projection id as a string', () => {
const run = {
id: '9007199254740993',
teamId: '9007199254740995',
workspaceId: '30',
leadAgentId: '1',
leadConversationId: 'lead-conversation',
originMessageId: '9007199254740997',
title: 'Run',
objective: 'Objective',
status: 'running',
finalSummary: null,
stopReason: null,
metadata: null,
startedAt: null,
completedAt: null,
createTime: null,
updateTime: null,
progress: { total: 1, done: 0, failed: 0, inReview: 0, percent: 0 },
tasks: [{
id: '9007199254740999',
teamId: '9007199254740995',
runId: '9007199254740993',
taskNumber: 1,
subject: 'Task',
description: null,
status: 'pending',
priority: 0,
taskType: 'general',
assigneeAgentId: '2',
ownerAgentId: null,
blockedBy: '["9007199254740997"]',
requireApproval: false,
progressPercent: null,
progressStep: null,
result: null,
reason: null,
conversationId: null,
metadata: '{"planId":"9007199254740993"}',
createTime: null,
updateTime: null,
}],
} satisfies TeamRun
expect(typeof run.id).toBe('string')
expect(typeof run.teamId).toBe('string')
expect(typeof run.tasks[0].id).toBe('string')
expect(typeof run.tasks[0].runId).toBe('string')
expect(run.tasks[0].blockedBy).toBe('["9007199254740997"]')
expect(run.tasks[0].metadata).toBe('{"planId":"9007199254740993"}')
})
})

View File

@ -893,6 +893,7 @@ export interface TeamMemberVO {
export interface TeamTask { export interface TeamTask {
id: string id: string
teamId: string teamId: string
runId?: string | null
taskNumber: number taskNumber: number
subject: string subject: string
description: string | null description: string | null
@ -924,6 +925,7 @@ export interface TeamTaskVO {
task: TeamTask task: TeamTask
assigneeName: string | null assigneeName: string | null
ownerName: string | null ownerName: string | null
runId?: string | null
} }
export interface TeamTaskComment { export interface TeamTaskComment {
@ -977,6 +979,7 @@ export const teamApi = {
data: { data: {
subject: string subject: string
description?: string description?: string
runId?: string
assigneeAgentId: string assigneeAgentId: string
priority?: number priority?: number
blockedBy?: string[] blockedBy?: string[]
@ -994,6 +997,78 @@ export const teamApi = {
http.post(`/teams/${id}/tasks/${taskId}/comments`, { content }), http.post(`/teams/${id}/tasks/${taskId}/comments`, { content }),
} }
export type TeamRunStatus =
| 'planning'
| 'running'
| 'awaiting_review'
| 'finalizing'
| 'completed'
| 'partial'
| 'failed'
| 'cancelled'
export interface TeamRunProgress {
total: number
done: number
failed: number
inReview: number
percent: number
}
export interface TeamRunTask {
id: string
teamId: string
runId: string
taskNumber: number
subject: string
description: string | null
status: string
priority: number
taskType: string
assigneeAgentId: string
ownerAgentId: string | null
blockedBy: string | null
requireApproval: boolean | null
progressPercent: number | null
progressStep: string | null
result: string | null
reason: string | null
conversationId: string | null
metadata: string | null
createTime: string | null
updateTime: string | null
}
export interface TeamRun {
id: string
teamId: string
workspaceId: string
leadAgentId: string
leadConversationId: string
originMessageId: string | null
title: string
objective: string
status: TeamRunStatus
finalSummary: string | null
stopReason: string | null
metadata: string | null
startedAt: string | null
completedAt: string | null
createTime: string | null
updateTime: string | null
progress: TeamRunProgress
tasks: TeamRunTask[]
}
export const teamRunApi = {
get: (runId: string) => http.get(`/team-runs/${runId}`),
listByTeam: (teamId: string) => http.get(`/teams/${teamId}/runs`),
listByConversation: (conversationId: string) =>
http.get(`/conversations/${encId(conversationId)}/team-runs`),
cancel: (runId: string, reason?: string) =>
http.post(`/team-runs/${runId}/cancel`, { reason }),
}
// ==================== Wiki Knowledge Base ==================== // ==================== Wiki Knowledge Base ====================
// One row in the cross-KB failure center. ids are strings (global Long→String // One row in the cross-KB failure center. ids are strings (global Long→String
// Jackson config) to avoid Snowflake precision loss. // Jackson config) to avoid Snowflake precision loss.

View File

@ -49,35 +49,46 @@
</button> </button>
</div> </div>
<template v-for="(msg, index) in messages" :key="msg.id || index"> <template v-for="item in timelineItems" :key="item.key">
<div v-if="item.type === 'team-run'" class="team-run-timeline-item">
<TeamRunCard
:run="item.run"
:expanded="expandedTeamRunId === item.run.id"
:selected-task-id="selectedTeamTaskId"
@toggle="$emit('team-run-toggle', item.run.id, $event)"
@select-task="$emit('team-run-select-task', $event)"
@cancel="$emit('team-run-cancel', $event)"
@navigate="$emit('team-run-navigate', $event)"
/>
</div>
<!-- 压缩摘要消息特殊渲染 --> <!-- 压缩摘要消息特殊渲染 -->
<CompressionSummary <CompressionSummary
v-if="isCompressionSummary(msg)" v-else-if="isCompressionSummary(item.message)"
:message="msg" :message="item.message"
/> />
<!-- Cron-run 头部分隔卡system 消息且以 📋 开头 <!-- Cron-run 头部分隔卡system 消息且以 📋 开头
tasks_<wsId> / IM 镜像会话里把"这是哪个 cron 跑的"清晰标出来 tasks_<wsId> / IM 镜像会话里把"这是哪个 cron 跑的"清晰标出来
LLM 历史读取时会跳过 system 消息所以不污染下次提示词 --> LLM 历史读取时会跳过 system 消息所以不污染下次提示词 -->
<div v-else-if="isCronHeader(msg)" class="cron-divider"> <div v-else-if="isCronHeader(item.message)" class="cron-divider">
<div class="cron-divider__line"></div> <div class="cron-divider__line"></div>
<span class="cron-divider__label">{{ msg.content }}</span> <span class="cron-divider__label">{{ item.message.content }}</span>
<div class="cron-divider__line"></div> <div class="cron-divider__line"></div>
</div> </div>
<!-- Team task settlement note user-role for the context pipeline, <!-- Team task settlement note user-role for the context pipeline,
but rendered as a collapsed system strip instead of a user but rendered as a collapsed system strip instead of a user
bubble so orchestration bookkeeping doesn't flood the chat. --> bubble so orchestration bookkeeping doesn't flood the chat. -->
<TeamAnnouncePanel v-else-if="isTeamAnnounce(msg)" :message="msg" /> <TeamAnnouncePanel v-else-if="isTeamAnnounce(item.message)" :message="item.message" />
<!-- 普通消息气泡 --> <!-- 普通消息气泡 -->
<MessageBubble <MessageBubble
v-else v-else
:message="msg" :message="item.message"
:is-last="index === messages.length - 1" :is-last="item.messageIndex === messages.length - 1"
:assistant-icon="assistantIcon" :assistant-icon="assistantIcon"
:user-icon="userIcon" :user-icon="userIcon"
:show-cursor="showCursorForMessage(msg)" :show-cursor="showCursorForMessage(item.message)"
@regenerate="$emit('regenerate', msg)" @regenerate="$emit('regenerate', item.message)"
@rewind="$emit('rewind', msg)" @rewind="$emit('rewind', item.message)"
@toggle-thinking="(expanded) => $emit('toggle-thinking', msg, expanded)" @toggle-thinking="(expanded) => $emit('toggle-thinking', item.message, expanded)"
@approve="(pendingId) => $emit('approve', pendingId)" @approve="(pendingId) => $emit('approve', pendingId)"
@deny="(pendingId) => $emit('deny', pendingId)" @deny="(pendingId) => $emit('deny', pendingId)"
/> />
@ -125,7 +136,12 @@ const { t } = useI18n()
import MessageBubble from './MessageBubble.vue' import MessageBubble from './MessageBubble.vue'
import CompressionSummary from './CompressionSummary.vue' import CompressionSummary from './CompressionSummary.vue'
import TeamAnnouncePanel from './TeamAnnouncePanel.vue' import TeamAnnouncePanel from './TeamAnnouncePanel.vue'
import TeamRunCard from '@/components/team-run/TeamRunCard.vue'
import { useStickToBottom } from '@/composables/chat/useStickToBottom' import { useStickToBottom } from '@/composables/chat/useStickToBottom'
import { parseTeamMessageMetadata } from '@/composables/chat/messageMetadata'
import { assembleTeamRunTimeline, type TeamRunTimelineItem } from '@/composables/chat/teamRunTimeline'
import type { TeamRun, TeamRunTask } from '@/api'
import type { TeamRunRoute } from '@/components/team-run/teamRunPresentation'
import type { Message } from '@/types' import type { Message } from '@/types'
interface Props { interface Props {
@ -149,6 +165,10 @@ interface Props {
hasMore?: boolean hasMore?: boolean
/** 是否正在加载更早消息 */ /** 是否正在加载更早消息 */
loadingOlder?: boolean loadingOlder?: boolean
/** Canonical run projections. Omit to preserve the legacy message-only view. */
teamRuns?: TeamRun[]
expandedTeamRunId?: string | null
selectedTeamTaskId?: string | null
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -172,8 +192,22 @@ const emit = defineEmits<{
approve: [pendingId: string] approve: [pendingId: string]
deny: [pendingId: string] deny: [pendingId: string]
'load-more': [] 'load-more': []
'team-run-toggle': [runId: string, expanded: boolean]
'team-run-select-task': [task: TeamRunTask]
'team-run-cancel': [runId: string]
'team-run-navigate': [route: TeamRunRoute]
}>() }>()
const timelineItems = computed<TeamRunTimelineItem[]>(() => {
if (props.teamRuns !== undefined) return assembleTeamRunTimeline(props.messages, props.teamRuns)
return props.messages.map((message, messageIndex) => ({
type: 'message' as const,
key: `message:${String(message.id ?? messageIndex)}`,
message,
messageIndex,
}))
})
// //
const isCompressionSummary = (msg: Message) => { const isCompressionSummary = (msg: Message) => {
if (msg.role !== 'system') return false if (msg.role !== 'system') return false
@ -196,11 +230,7 @@ const isCronHeader = (msg: Message) => {
// the content-prefix fallback catches rows persisted before that marker existed. // the content-prefix fallback catches rows persisted before that marker existed.
const isTeamAnnounce = (msg: Message) => { const isTeamAnnounce = (msg: Message) => {
if (msg.role !== 'user') return false if (msg.role !== 'user') return false
try { return parseTeamMessageMetadata(msg).isTeamAnnounce
const metadata = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata
if (metadata?.type === 'team_announce') return true
} catch { /* fall through to prefix check */ }
return typeof msg.content === 'string' && msg.content.startsWith('[System Message] ')
} }
// //
@ -340,6 +370,11 @@ onUnmounted(() => {
max-width: 100%; max-width: 100%;
} }
.team-run-timeline-item {
width: min(760px, calc(100% - 32px));
margin: 8px auto;
}
/* ==================== 空状态 / 欢迎屏 ==================== */ /* ==================== 空状态 / 欢迎屏 ==================== */
.empty-state { .empty-state {
flex: 1; flex: 1;

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { ChatDotRound, Grid, Lock, User } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import {
buildAgentRunRoute,
buildChatRunRoute,
buildTeamRunRoute,
type TeamRunRoute,
} from '@/components/team-run/teamRunPresentation'
const props = defineProps<{
runId: string
taskId: string
teamId?: string
leadConversationId?: string
}>()
const emit = defineEmits<{ navigate: [route: TeamRunRoute] }>()
const { t } = useI18n()
</script>
<template>
<aside class="worker-banner" role="status">
<el-icon class="worker-banner__lock"><Lock /></el-icon>
<span class="worker-banner__copy">
<strong>{{ t('teamRuns.workerReadOnly') }}</strong>
<span>{{ t('teamRuns.workerReadOnlyDescription') }}</span>
</span>
<span class="worker-banner__actions">
<button
v-if="leadConversationId"
type="button"
@click="emit('navigate', buildChatRunRoute(runId, leadConversationId))"
>
<el-icon><ChatDotRound /></el-icon><span>{{ t('teamRuns.backToLead') }}</span>
</button>
<button
v-if="teamId"
type="button"
@click="emit('navigate', buildTeamRunRoute(teamId, runId, taskId))"
>
<el-icon><Grid /></el-icon><span>{{ t('teamRuns.openInTeams') }}</span>
</button>
<button type="button" @click="emit('navigate', buildAgentRunRoute(runId, taskId))">
<el-icon><User /></el-icon><span>{{ t('teamRuns.openInAgents') }}</span>
</button>
</span>
</aside>
</template>
<style scoped>
.worker-banner {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 12px 0;
padding: 9px 12px;
border: 1px solid var(--mc-border, #d9e1e7);
border-left: 3px solid #b96c08;
border-radius: 6px;
background: var(--mc-panel, #fff);
color: var(--mc-text-primary, #1f2937);
letter-spacing: 0;
}
.worker-banner__lock { flex: none; color: #b96c08; }
.worker-banner__copy { display: grid; min-width: 0; gap: 2px; font-size: 12px; }
.worker-banner__copy strong { font-size: 13px; }
.worker-banner__copy span { color: var(--mc-text-secondary, #64748b); }
.worker-banner__actions { display: flex; flex: none; gap: 6px; margin-left: auto; }
.worker-banner__actions button { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 4px 8px; border: 1px solid var(--mc-border, #d9e1e7); border-radius: 5px; background: transparent; color: inherit; cursor: pointer; letter-spacing: 0; }
.worker-banner__actions button:hover { border-color: #1b8f68; color: #167454; }
.worker-banner__actions button:focus-visible { outline: 2px solid #1b8f68; outline-offset: 1px; }
@media (max-width: 720px) {
.worker-banner { align-items: flex-start; flex-wrap: wrap; }
.worker-banner__actions { width: 100%; margin-left: 24px; overflow-x: auto; }
}
</style>

View File

@ -0,0 +1,36 @@
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it } from 'vitest'
import TeamWorkerBanner from '../TeamWorkerBanner.vue'
const apps: Array<ReturnType<typeof createApp>> = []
afterEach(() => {
apps.splice(0).forEach(app => app.unmount())
document.body.innerHTML = ''
})
describe('TeamWorkerBanner', () => {
it('emits string-safe lead, team, and agent routes', () => {
const routes: unknown[] = []
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp(TeamWorkerBanner, {
runId: '9007199254740991', taskId: '501', teamId: '20', leadConversationId: 'lead',
onNavigate: (route: unknown) => routes.push(route),
})
app.use(createI18n({ legacy: false, locale: 'en', messages: { en: { teamRuns: {
workerReadOnly: 'Worker conversation', workerReadOnlyDescription: 'Read only',
backToLead: 'Lead chat', openInTeams: 'Teams', openInAgents: 'Agents',
} } } }))
app.mount(host)
apps.push(app)
host.querySelectorAll<HTMLButtonElement>('button').forEach(button => button.click())
expect(routes).toEqual([
{ path: '/chat', query: { conversationId: 'lead', teamRunId: '9007199254740991' } },
{ path: '/teams', query: { teamId: '20', view: 'runs', runId: '9007199254740991', taskId: '501' } },
{ path: '/agents', query: { view: 'live', teamRunId: '9007199254740991', taskId: '501' } },
])
})
})

View File

@ -0,0 +1,92 @@
import { createApp, defineComponent, h, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TeamRun } from '@/api'
import type { Message } from '@/types'
vi.mock('../MessageBubble.vue', () => ({
default: defineComponent({
props: ['message'],
setup: props => () => h('div', { 'data-message-id': String(props.message.id) }, props.message.content),
}),
}))
vi.mock('../CompressionSummary.vue', () => ({ default: defineComponent({ setup: () => () => h('div') }) }))
vi.mock('../TeamAnnouncePanel.vue', () => ({
default: defineComponent({ props: ['message'], setup: props => () => h('div', { 'data-announce-id': props.message.id }) }),
}))
vi.mock('@/composables/chat/useStickToBottom', () => ({
useStickToBottom: () => ({
scrollRef: { value: null }, contentRef: { value: null }, isAtBottom: { value: true },
escapedFromLock: { value: false }, scrollToBottom: vi.fn(), resetLock: vi.fn(),
}),
}))
import MessageList from '../MessageList.vue'
const messages = {
chat: { loadingOlder: 'Loading', loadOlderMessages: 'Load older', scrollToBottom: 'Bottom' },
teamRuns: {
status: { planning: 'Planning', running: 'Running', awaiting_review: 'Review', finalizing: 'Finalizing', completed: 'Completed', partial: 'Partial', failed: 'Failed', cancelled: 'Cancelled' },
duration: { day: 'd', hour: 'h', minute: 'm', second: 's' }, progress: '{done}/{total}', tasks: 'Tasks',
emptyTasks: 'No tasks', assignee: 'Assignee', dependencies: 'Dependencies', noDependencies: 'None', result: 'Result',
noResult: 'No result', summary: 'Summary', noSummary: 'No summary', deliverables: 'Deliverables', noDeliverables: 'None',
cancel: 'Cancel', expand: 'Expand', collapse: 'Collapse', openTask: 'Open task', objective: 'Objective',
taskProgress: 'Progress', stopReason: 'Stop reason',
},
}
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 => ({
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: [],
})
const apps: Array<ReturnType<typeof createApp>> = []
function mount(props: Record<string, unknown>) {
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp(MessageList, 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('MessageList team run timeline', () => {
it('preserves legacy rendering when teamRuns is not provided', () => {
const host = mount({ messages: [
message('1', 'user', 'hello'),
message('2', 'user', '[System Message] settled'),
] })
expect(host.querySelectorAll('[data-message-id]')).toHaveLength(1)
expect(host.querySelector('[data-announce-id="2"]')).not.toBeNull()
expect(host.querySelector('[data-team-run-toggle]')).toBeNull()
})
it('renders an anchored run, hides linked bookkeeping, and expands a deep link', async () => {
const host = mount({
messages: [
message('1', 'user', 'delegate'),
message('2', 'user', 'protocol', { type: 'team_announce', runId: '10', taskId: '501' }),
message('3', 'assistant', 'unrelated'),
],
teamRuns: [run()],
expandedTeamRunId: '10',
})
await nextTick()
expect(Array.from(host.querySelectorAll('[data-message-id]')).map(node => node.getAttribute('data-message-id'))).toEqual(['1', '3'])
expect(host.querySelector('[data-team-run-toggle]')?.getAttribute('aria-expanded')).toBe('true')
expect(host.textContent).toContain('Launch research')
})
})

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { ArrowRight } from '@element-plus/icons-vue'
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 AgentRunWorkerRow from './AgentRunWorkerRow.vue'
withDefaults(defineProps<{
groups: AgentRunGroup[]
selectedRunId?: string | null
selectedTaskId?: string | null
}>(), { selectedRunId: null, selectedTaskId: null })
const emit = defineEmits<{ 'open-run': [runId: string]; 'open-worker': [worker: AgentRunWorker] }>()
const { t } = useI18n()
</script>
<template>
<section v-if="groups.length" class="agent-run-groups">
<h2>{{ t('live.teamRuns.title') }}</h2>
<article
v-for="group in groups"
:key="group.run.id"
data-agent-run-group
class="agent-run-group"
:class="[`is-${group.state}`, { 'is-selected': selectedRunId === group.run.id }]"
>
<header class="agent-run-group__header">
<button data-open-agent-run type="button" class="agent-run-group__open" @click="emit('open-run', group.run.id)">
<span class="agent-run-group__copy">
<strong>{{ group.run.title }}</strong>
<span>{{ group.run.objective }}</span>
<TeamRunStatus :status="group.run.status" :started-at="group.run.startedAt" :completed-at="group.run.completedAt" show-duration />
</span>
<TeamRunProgress :progress="group.run.progress" compact />
<el-icon :size="15"><ArrowRight /></el-icon>
</button>
</header>
<div class="agent-run-group__lead">
<span>{{ t('live.teamRuns.lead') }}</span>
<strong>{{ group.leadRuntime?.agentName || group.run.leadAgentId }}</strong>
<span>{{ group.leadRuntime?.currentPhase || group.state }}</span>
</div>
<div v-if="group.workers.length" data-agent-run-workers>
<AgentRunWorkerRow
v-for="worker in group.workers"
:key="worker.task.id"
:worker="worker"
:selected="selectedTaskId === worker.task.id"
@open="emit('open-worker', $event)"
/>
</div>
<p v-else class="agent-run-group__empty">{{ t('live.teamRuns.noWorkers') }}</p>
</article>
</section>
</template>
<style scoped>
.agent-run-groups { display: grid; gap: 10px; margin-bottom: 18px; letter-spacing: 0; }
.agent-run-groups > h2 { margin: 0; color: var(--mc-text-primary); font-size: 14px; }
.agent-run-group { overflow: hidden; border: 1px solid var(--mc-border); border-left: 3px solid #718096; border-radius: 8px; background: var(--mc-bg-elevated); }
.agent-run-group.is-active, .agent-run-group.is-finalizing, .agent-run-group.is-selected { border-left-color: #16835b; }.agent-run-group.is-waiting, .agent-run-group.is-review { border-left-color: #b96c08; }.agent-run-group.is-stuck, .agent-run-group.is-failed { border-left-color: #c13d3d; }
.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__empty { margin: 0; padding: 12px; border-top: 1px solid var(--mc-border-light); color: var(--mc-text-tertiary); font-size: 11px; }
</style>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import { CircleCheck, Clock, DocumentChecked, Loading, VideoPause, Warning } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import type { AgentRunWorker, AgentWorkerState } from '@/composables/useAgentRunGroups'
import { taskDependencyIds } from '@/components/team-run/teamRunPresentation'
defineProps<{ worker: AgentRunWorker; selected?: boolean }>()
const emit = defineEmits<{ open: [worker: AgentRunWorker] }>()
const { t } = useI18n()
const icons: Record<AgentWorkerState, unknown> = {
active: Loading, waiting: Clock, review: DocumentChecked, stuck: Warning,
cancelled: VideoPause, completed: CircleCheck, failed: Warning,
}
</script>
<template>
<button
type="button"
class="agent-run-worker"
:class="[`is-${worker.state}`, { 'is-selected': selected }]"
:disabled="!worker.task.conversationId"
@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">
<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>
</button>
</template>
<style scoped>
.agent-run-worker { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 48px; padding: 8px 10px; border: 0; border-top: 1px solid var(--mc-border-light); background: transparent; color: var(--mc-text-secondary); cursor: pointer; text-align: left; letter-spacing: 0; }
.agent-run-worker:hover:not(:disabled), .agent-run-worker.is-selected { background: rgba(27, 143, 104, 0.07); }
.agent-run-worker:focus-visible { outline: 2px solid #16835b; outline-offset: -2px; }
.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.is-stuck { color: #c13d3d; }.agent-run-worker.is-review, .agent-run-worker.is-waiting { color: #a15c05; }.agent-run-worker.is-completed { color: #16835b; }
</style>

View File

@ -72,28 +72,38 @@
</div> </div>
</div> </div>
<!-- Lifecycle board: runs + goals across status columns --> <template v-else>
<LiveBoard <AgentRunGroups
v-else-if="layout === 'board'" :groups="teamProjection.groups"
:runs="snapshot?.runs ?? []" :selected-run-id="selectedTeamRunId"
:summary="snapshot?.summary ?? null" :selected-task-id="selectedTeamTaskId"
:goal-by-conv="goalByConv" @open-run="openTeamRun"
:done-goals="doneGoals" @open-worker="openTeamWorker"
:failed-goals="failedGoals" />
@open="openDetail" <p v-if="teamGroups.error.value" class="team-runs-error">{{ t('live.teamRuns.loadError') }}</p>
@stop="confirmStop" <h2 v-if="teamProjection.groups.length && visibleRuns.length" class="other-live-title">{{ t('live.teamRuns.otherSessions') }}</h2>
@recycle="confirmRecycle"
/>
<!-- Empty: nothing to see --> <!-- Lifecycle board: non-team runs + goals across status columns -->
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still"> <LiveBoard
<div class="empty-orb"></div> v-if="layout === 'board' && visibleRuns.length"
<div class="empty-line">{{ t('live.empty.allQuiet') }}</div> :runs="visibleRuns"
<div class="empty-hint">{{ t('live.empty.hint') }}</div> :summary="snapshot?.summary ?? null"
</div> :goal-by-conv="goalByConv"
:done-goals="doneGoals"
:failed-goals="failedGoals"
@open="openDetail"
@stop="confirmStop"
@recycle="confirmRecycle"
/>
<!-- Active runs --> <div v-else-if="teamProjection.groups.length === 0 && visibleRuns.length === 0" class="empty-still">
<div v-else class="cards-grid"> <div class="empty-orb"></div>
<div class="empty-line">{{ t('live.empty.allQuiet') }}</div>
<div class="empty-hint">{{ t('live.empty.hint') }}</div>
</div>
<!-- Non-team active runs -->
<div v-else-if="layout === 'grid' && visibleRuns.length" class="cards-grid">
<article <article
v-for="run in visibleRuns" v-for="run in visibleRuns"
:key="run.conversationId" :key="run.conversationId"
@ -172,7 +182,8 @@
</div> </div>
</div> </div>
</article> </article>
</div> </div>
</template>
</div> </div>
<LiveFocusPanel <LiveFocusPanel
@ -188,16 +199,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast' import { mcToast } from '@/composables/useMcToast'
import SkillIcon from '@/components/common/SkillIcon.vue' import SkillIcon from '@/components/common/SkillIcon.vue'
import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue' import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue'
import LiveBoard from '@/components/live/LiveBoard.vue' import LiveBoard from '@/components/live/LiveBoard.vue'
import AgentRunGroups from '@/components/live/AgentRunGroups.vue'
import { useLiveAgent } from '@/composables/useLiveAgent' import { useLiveAgent } from '@/composables/useLiveAgent'
import { buildAgentWorkerChatRoute, useAgentRunGroups, type AgentRunWorker } from '@/composables/useAgentRunGroups'
import { createAgentsLiveRouteHydrator, parseAgentsLiveRoute, reconcileAgentsLiveRoute } from '@/composables/agentsLiveRouteState'
import { useLiveSnapshot } from '@/composables/useLiveSnapshot'
import { mcConfirm } from '@/components/common/useConfirm' import { mcConfirm } from '@/components/common/useConfirm'
import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api' import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api'
import { buildTeamRunRoute } from '@/components/team-run/teamRunPresentation'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { const {
avatarLetter, avatarLetter,
avatarBgStyle, avatarBgStyle,
@ -209,8 +228,22 @@ const {
type FilterKey = 'all' | 'working' | 'attention' | 'quiet' type FilterKey = 'all' | 'working' | 'attention' | 'quiet'
const snapshot = ref<LiveSnapshot | null>(null) let teamGroups!: ReturnType<typeof useAgentRunGroups>
const isInitialLoading = ref(true) const liveSnapshot = useLiveSnapshot({ load: liveApi.snapshot, refreshRuns: () => teamGroups.refreshForSnapshot() })
const snapshot = liveSnapshot.snapshot
teamGroups = useAgentRunGroups(snapshot)
const teamProjection = computed(() => teamGroups.projection.value)
const liveRoute = computed(() => parseAgentsLiveRoute(route.query))
const selection = computed(() => reconcileAgentsLiveRoute(liveRoute.value, teamGroups.runs.value, snapshot.value))
const routeHydrator = createAgentsLiveRouteHydrator({
invalidatePoll: liveSnapshot.invalidate,
ensureRun: (runId, revision) => teamGroups.ensureRun(runId, revision),
reconcile: routeState => reconcileAgentsLiveRoute(routeState, teamGroups.runs.value, snapshot.value),
replace: query => router.replace({ query }),
})
const selectedTeamRunId = computed(() => selection.value.selectedRunId)
const selectedTeamTaskId = computed(() => selection.value.selectedTaskId)
const isInitialLoading = liveSnapshot.loading
const autoRefresh = ref(true) const autoRefresh = ref(true)
const drawerOpen = ref(false) const drawerOpen = ref(false)
const detail = ref<LiveRunCard | null>(null) const detail = ref<LiveRunCard | null>(null)
@ -308,7 +341,7 @@ function tierOf(r: LiveRunCard): number {
} }
const visibleRuns = computed<LiveRunCard[]>(() => { const visibleRuns = computed<LiveRunCard[]>(() => {
const runs = snapshot.value?.runs ?? [] const runs = teamProjection.value.ungrouped
const filtered = (() => { const filtered = (() => {
switch (activeFilter.value) { switch (activeFilter.value) {
case 'working': return runs.filter(isWorking) case 'working': return runs.filter(isWorking)
@ -411,22 +444,40 @@ function closeDetail() {
} }
async function refresh() { async function refresh() {
try { const accepted = await liveSnapshot.refresh()
const res: any = await liveApi.snapshot() if (accepted) {
snapshot.value = (res?.data ?? res) as LiveSnapshot
if (detail.value && snapshot.value) { if (detail.value && snapshot.value) {
const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId) const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId)
if (fresh) detail.value = fresh if (fresh) detail.value = fresh
} }
// Keep the board's goal columns fresh on the same cadence as the snapshot. // Keep the board's goal columns fresh on the same cadence as the snapshot.
if (layout.value === 'board') loadGoals() if (layout.value === 'board') loadGoals()
} catch (e: any) { } else if (liveSnapshot.error.value) {
if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed')) const cause = liveSnapshot.error.value as { message?: string }
} finally { mcToast.error(cause.message || t('live.errors.loadFailed'))
isInitialLoading.value = false
} }
} }
watch(
() => [liveRoute.value.view, liveRoute.value.runId, liveRoute.value.taskId] as const,
() => routeHydrator.hydrate(liveRoute.value),
{ flush: 'sync' },
)
function openTeamRun(runId: string) {
const group = teamProjection.value.groups.find(item => item.run.id === runId)
if (group) router.push(buildTeamRunRoute(group.run.teamId, group.run.id))
}
function openTeamWorker(worker: AgentRunWorker) {
const conversationId = worker.task.conversationId
if (!conversationId) return
const group = teamProjection.value.groups.find(item => item.run.id === worker.task.runId)
if (!group) return
const target = buildAgentWorkerChatRoute(group, worker)
if (target) router.push(target)
}
function toggleAutoRefresh() { function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value autoRefresh.value = !autoRefresh.value
if (autoRefresh.value) { if (autoRefresh.value) {
@ -520,6 +571,8 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (timer) clearInterval(timer) if (timer) clearInterval(timer)
liveSnapshot.close()
teamGroups.close()
}) })
</script> </script>
@ -527,6 +580,8 @@ onBeforeUnmount(() => {
.live-panel { .live-panel {
--card-radius: 24px; --card-radius: 24px;
} }
.other-live-title { margin: 0 0 10px; color: var(--mc-text-primary); font-size: 14px; letter-spacing: 0; }
.team-runs-error { margin: -8px 0 12px; color: var(--mc-danger, #c13d3d); font-size: 12px; }
/* ===== Toolbar: live toggle + status filters + sweep ===== */ /* ===== Toolbar: live toggle + status filters + sweep ===== */
.live-toolbar { .live-toolbar {

View File

@ -0,0 +1,49 @@
import { createApp, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentRunGroup } from '@/composables/useAgentRunGroups'
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' } } }
const group: AgentRunGroup = {
run: {
id: '20', teamId: '10', workspaceId: '1', leadAgentId: '2', leadConversationId: 'lead', originMessageId: null,
title: 'Research', objective: 'Collect evidence', status: 'running', finalSummary: null, stopReason: null,
metadata: null, startedAt: '2026-08-13T10:00:00Z', completedAt: null, createTime: null, updateTime: null,
progress: { total: 1, done: 0, failed: 0, inReview: 0, percent: 10 }, tasks: [],
},
state: 'active', leadRuntime: null, workers: [{
state: 'waiting', runtime: null,
task: {
id: '101', teamId: '10', runId: '20', taskNumber: 1, subject: 'Collect evidence', description: null,
status: 'blocked', priority: 0, taskType: 'execution', assigneeAgentId: '3', ownerAgentId: null,
blockedBy: '["100"]', requireApproval: false, progressPercent: null, progressStep: null, result: null,
reason: null, conversationId: null, metadata: null, createTime: null, updateTime: null,
},
}],
}
const apps: Array<ReturnType<typeof createApp>> = []
afterEach(() => { apps.splice(0).forEach(app => app.unmount()); document.body.innerHTML = '' })
describe('AgentRunGroups', () => {
it('hydrates selected run and emits route actions', async () => {
const opened: string[] = []
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp(AgentRunGroups, {
groups: [group], selectedRunId: '20', selectedTaskId: '101', onOpenRun: (id: string) => opened.push(id),
})
app.use(createI18n({ legacy: false, locale: 'en', messages: { en: messages } }))
app.mount(host)
apps.push(app)
expect(host.querySelector('[data-agent-run-group]')?.classList.contains('is-selected')).toBe(true)
expect(host.querySelector('.agent-run-worker')?.classList.contains('is-selected')).toBe(true)
host.querySelector<HTMLButtonElement>('[data-open-agent-run]')!.click()
await nextTick()
expect(opened).toEqual(['20'])
})
})

View File

@ -0,0 +1,105 @@
<script setup lang="ts">
import { 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 TeamRunProgress from './TeamRunProgress.vue'
import TeamRunStatus from './TeamRunStatus.vue'
import type { TeamRunRoute } from './teamRunPresentation'
const props = withDefaults(defineProps<{
run: TeamRun
expanded?: boolean
canCancel?: boolean
selectedTaskId?: string | null
}>(), {
expanded: false,
canCancel: false,
selectedTaskId: null,
})
const emit = defineEmits<{
toggle: [expanded: boolean]
'select-task': [task: TeamRunTask]
cancel: [runId: string]
navigate: [route: TeamRunRoute]
}>()
const { t } = useI18n()
const isExpanded = ref(props.expanded)
watch(() => props.expanded, value => { isExpanded.value = value })
function toggle() {
isExpanded.value = !isExpanded.value
emit('toggle', isExpanded.value)
}
</script>
<template>
<article class="run-card" :class="`is-${run.status}`">
<button
data-team-run-toggle
type="button"
class="run-card__toggle"
:aria-expanded="isExpanded"
:aria-controls="`team-run-detail-${run.id}`"
:aria-label="t(isExpanded ? 'teamRuns.collapse' : 'teamRuns.expand')"
@click="toggle"
@keydown.enter.space.prevent="toggle"
>
<span class="run-card__copy">
<span class="run-card__title">{{ run.title }}</span>
<span class="run-card__objective">{{ run.objective }}</span>
<TeamRunStatus
:status="run.status"
:started-at="run.startedAt"
:completed-at="run.completedAt"
show-duration
/>
</span>
<span class="run-card__controls">
<TeamRunProgress :progress="run.progress" compact />
<el-icon class="run-card__arrow" :class="{ 'is-expanded': isExpanded }" :size="15"><ArrowDown /></el-icon>
</span>
</button>
<TeamRunDetail
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>
<style scoped>
.run-card {
width: 100%;
overflow: hidden;
border: 1px solid var(--mc-border, #d9e1e7);
border-left: 3px solid #7b8794;
border-radius: 8px;
background: var(--mc-bg, #fff);
letter-spacing: 0;
}
.run-card.is-running, .run-card.is-finalizing, .run-card.is-completed { border-left-color: #1b8f68; }
.run-card.is-awaiting_review, .run-card.is-partial { border-left-color: #b96c08; }
.run-card.is-failed { border-left-color: #c13d3d; }
.run-card__toggle { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; min-height: 76px; padding: 12px 14px; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; letter-spacing: 0; }
.run-card__toggle:hover { background: rgba(71, 85, 105, 0.035); }
.run-card__toggle:focus-visible { outline: 2px solid #1b8f68; outline-offset: -2px; }
.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__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 (max-width: 520px) {
.run-card__toggle { align-items: flex-start; gap: 8px; padding: 11px 10px; }
.run-card__controls { gap: 5px; }
}
</style>

View File

@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Document, Link, 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'
const props = withDefaults(defineProps<{
run: TeamRun
selectedTaskId?: string | null
canCancel?: boolean
}>(), {
selectedTaskId: null,
canCancel: false,
})
const emit = defineEmits<{
'select-task': [task: TeamRunTask]
cancel: [runId: string]
navigate: [route: ReturnType<typeof buildTeamRunRoute>]
}>()
const { t } = useI18n()
const localTaskId = ref<string | null>(props.selectedTaskId)
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))
function selectTask(task: TeamRunTask) {
localTaskId.value = task.id
emit('select-task', task)
}
</script>
<template>
<div class="run-detail">
<section class="run-detail__summary">
<div class="run-detail__summary-copy">
<h4>{{ t('teamRuns.summary') }}</h4>
<p>{{ run.finalSummary || 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>
<section class="run-detail__section">
<h4>{{ t('teamRuns.tasks') }}</h4>
<TeamRunTaskList
:tasks="run.tasks"
:selected-task-id="localTaskId"
@select-task="selectTask"
/>
</section>
<section v-if="selectedTask" class="run-detail__task-detail">
<div class="run-detail__task-heading">
<h4>{{ selectedTask.taskNumber }}. {{ selectedTask.subject }}</h4>
<button
type="button"
class="run-detail__link-button"
@click="emit('navigate', buildTeamRunRoute(run.teamId, run.id, selectedTask.id))"
>{{ t('teamRuns.openTask') }}</button>
</div>
<p v-if="selectedTask.description">{{ selectedTask.description }}</p>
<dl>
<div>
<dt>{{ t('teamRuns.assignee') }}</dt>
<dd>{{ selectedTask.assigneeAgentId }}</dd>
</div>
<div>
<dt>{{ t('teamRuns.result') }}</dt>
<dd class="run-detail__result">{{ selectedTask.result || t('teamRuns.noResult') }}</dd>
</div>
</dl>
</section>
<footer v-if="canCancel && !terminal" class="run-detail__actions">
<button data-team-run-cancel type="button" class="run-detail__cancel" @click="emit('cancel', run.id)">
<el-icon :size="14"><VideoPause /></el-icon>
<span>{{ t('teamRuns.cancel') }}</span>
</button>
</footer>
</div>
</template>
<style scoped>
.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__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); }
.run-detail dl { display: grid; gap: 8px; margin: 12px 0 0; }
.run-detail dl > div { display: grid; grid-template-columns: minmax(80px, 120px) minmax(0, 1fr); gap: 10px; }
.run-detail dt { color: var(--mc-text-tertiary, #64748b); font-size: 11px; }
.run-detail dd { min-width: 0; margin: 0; color: var(--mc-text-secondary, #475569); font-size: 12px; overflow-wrap: anywhere; }
.run-detail__deliverables { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
.run-detail__deliverable { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; padding: 5px 8px; border: 1px solid #bddbd0; border-radius: 6px; color: #126c4d; font-size: 12px; text-decoration: none; }
.run-detail__deliverable span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.run-detail__deliverable:hover { background: rgba(27, 143, 104, 0.07); }
.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__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; }
.run-detail__cancel:hover { background: rgba(193, 61, 61, 0.06); }
@media (max-width: 640px) {
.run-detail__summary { align-items: center; }
.run-detail dl > div { grid-template-columns: 1fr; gap: 2px; }
}
</style>

View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import type { TeamRun, TeamRunTask } from '@/api'
import TeamRunDetail from './TeamRunDetail.vue'
import TeamRunStatus from './TeamRunStatus.vue'
import type { TeamRunRoute } from './teamRunPresentation'
withDefaults(defineProps<{
run: TeamRun | null
open?: boolean
selectedTaskId?: string | null
canCancel?: boolean
}>(), { open: false, selectedTaskId: null, canCancel: false })
const emit = defineEmits<{
close: []
cancel: [runId: string]
'select-task': [task: TeamRunTask]
navigate: [route: TeamRunRoute]
}>()
const { t } = useI18n()
</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">
<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')">
<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>
<TeamRunDetail
:run="run"
:selected-task-id="selectedTaskId"
:can-cancel="canCancel"
@select-task="emit('select-task', $event)"
@cancel="emit('cancel', $event)"
@navigate="emit('navigate', $event)"
/>
</aside>
</div>
</template>
<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 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; }
</style>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { TeamRunProgress } from '@/api'
const props = defineProps<{
progress: TeamRunProgress
compact?: boolean
}>()
const { t } = useI18n()
const percent = computed(() => Math.max(0, Math.min(100, Math.round(props.progress.percent || 0))))
const style = computed(() => ({ '--run-progress': `${percent.value * 3.6}deg` }))
</script>
<template>
<div
class="run-progress"
:class="{ 'is-compact': compact }"
:aria-label="t('teamRuns.progress', { done: progress.done, total: progress.total })"
:aria-valuenow="percent"
aria-valuemin="0"
aria-valuemax="100"
role="progressbar"
>
<span class="run-progress__ring" :style="style"><span>{{ percent }}</span></span>
<span v-if="!compact" class="run-progress__counts">
{{ t('teamRuns.progress', { done: progress.done, total: progress.total }) }}
</span>
</div>
</template>
<style scoped>
.run-progress {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 44px;
min-height: 44px;
letter-spacing: 0;
}
.run-progress__ring {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex: 0 0 44px;
border-radius: 50%;
background: conic-gradient(#1b8f68 var(--run-progress), #d9e1e7 0);
position: relative;
}
.run-progress__ring::before {
content: '';
position: absolute;
inset: 4px;
border-radius: 50%;
background: var(--mc-bg, #fff);
}
.run-progress__ring > span {
position: relative;
font-size: 10px;
font-weight: 700;
color: var(--mc-text-primary, #1f2937);
}
.run-progress__ring > span::after { content: '%'; }
.run-progress__counts {
color: var(--mc-text-tertiary, #64748b);
font-size: 12px;
white-space: nowrap;
}
.run-progress.is-compact { width: 44px; }
</style>

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
CircleCheckFilled,
CircleCloseFilled,
Clock,
Loading,
RemoveFilled,
View,
WarningFilled,
} from '@element-plus/icons-vue'
import type { TeamRunStatus } from '@/api'
import { formatRunDuration, getRunStatusPresentation } from './teamRunPresentation'
const props = defineProps<{
status: TeamRunStatus
startedAt?: string | null
completedAt?: string | null
showDuration?: boolean
}>()
const { t } = useI18n()
const presentation = computed(() => getRunStatusPresentation(props.status))
const statusIcon = computed(() => ({
planning: Clock,
running: Loading,
awaiting_review: View,
finalizing: Loading,
completed: CircleCheckFilled,
partial: WarningFilled,
failed: CircleCloseFilled,
cancelled: RemoveFilled,
})[props.status])
const duration = computed(() => formatRunDuration(
props.startedAt ?? null,
props.completedAt ?? null,
new Date(),
{
day: t('teamRuns.duration.day'),
hour: t('teamRuns.duration.hour'),
minute: t('teamRuns.duration.minute'),
second: t('teamRuns.duration.second'),
},
))
</script>
<template>
<span class="run-status" :class="`is-${presentation.tone}`">
<el-icon :class="{ 'is-loading': status === 'running' || status === 'finalizing' }" :size="14">
<component :is="statusIcon" />
</el-icon>
<span>{{ t(presentation.labelKey) }}</span>
<span v-if="showDuration && duration" class="run-status__duration">{{ duration }}</span>
</span>
</template>
<style scoped>
.run-status {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 24px;
color: var(--mc-text-secondary, #475569);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
white-space: nowrap;
}
.run-status.is-green { color: #16835b; }
.run-status.is-amber { color: #a15c05; }
.run-status.is-red { color: #c13d3d; }
.run-status__duration {
color: var(--mc-text-tertiary, #64748b);
font-weight: 500;
}
</style>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { CircleCheckFilled, CircleCloseFilled, Clock, Loading, Lock, RemoveFilled, View, WarningFilled } from '@element-plus/icons-vue'
import type { TeamRunTask } from '@/api'
import { orderTasksByDependencies, taskDependencyIds } from './teamRunPresentation'
const props = defineProps<{
tasks: TeamRunTask[]
selectedTaskId?: string | null
}>()
const emit = defineEmits<{
'select-task': [task: TeamRunTask]
}>()
const { t, te } = useI18n()
const orderedTasks = computed(() => orderTasksByDependencies(props.tasks))
const byId = computed(() => new Map(props.tasks.map(task => [task.id, task])))
function statusLabel(status: string) {
const key = `teams.status.${status}`
return te(key) ? t(key) : status
}
function iconFor(status: string) {
if (status === 'completed') return CircleCheckFilled
if (status === 'failed') return CircleCloseFilled
if (status === 'cancelled' || status === 'stale') return RemoveFilled
if (status === 'in_review') return View
if (status === 'in_progress') return Loading
if (status === 'blocked') return Lock
if (status === 'pending') return Clock
return WarningFilled
}
function dependencyLabel(id: string) {
const dependency = byId.value.get(id)
return dependency ? `${dependency.taskNumber}. ${dependency.subject}` : id
}
function preview(value: string | null) {
if (!value) return ''
const normalized = value.replace(/\s+/g, ' ').trim()
return normalized.length > 140 ? `${normalized.slice(0, 137)}...` : normalized
}
</script>
<template>
<div v-if="orderedTasks.length" class="run-task-list">
<button
v-for="task in orderedTasks"
:key="task.id"
type="button"
class="run-task-row"
:class="{ 'is-selected': selectedTaskId === task.id }"
:aria-pressed="selectedTaskId === task.id"
@click="emit('select-task', task)"
>
<span class="run-task-row__state" :class="`is-${task.status}`">
<el-icon :class="{ 'is-loading': task.status === 'in_progress' }" :size="15">
<component :is="iconFor(task.status)" />
</el-icon>
</span>
<span class="run-task-row__body">
<span class="run-task-row__title">
<span>{{ task.taskNumber }}. {{ task.subject }}</span>
<span class="run-task-row__status">{{ statusLabel(task.status) }}</span>
</span>
<span class="run-task-row__meta">
<span>{{ t('teamRuns.assignee') }}: {{ task.assigneeAgentId }}</span>
<span v-if="taskDependencyIds(task).length">
{{ t('teamRuns.dependencies') }}:
{{ taskDependencyIds(task).map(dependencyLabel).join(', ') }}
</span>
<span v-else>{{ t('teamRuns.dependencies') }}: {{ t('teamRuns.noDependencies') }}</span>
</span>
<span v-if="preview(task.result)" class="run-task-row__result">{{ preview(task.result) }}</span>
</span>
</button>
</div>
<div v-else class="run-task-list__empty">{{ t('teamRuns.emptyTasks') }}</div>
</template>
<style scoped>
.run-task-list { display: grid; gap: 1px; }
.run-task-row {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 8px;
width: 100%;
padding: 10px 8px;
border: 0;
border-bottom: 1px solid var(--mc-border-light, #e7ebef);
background: transparent;
color: var(--mc-text-primary, #1f2937);
cursor: pointer;
text-align: left;
letter-spacing: 0;
}
.run-task-row:hover, .run-task-row.is-selected { background: rgba(27, 143, 104, 0.07); }
.run-task-row:focus-visible { outline: 2px solid #1b8f68; outline-offset: -2px; }
.run-task-row__state { padding-top: 2px; color: #64748b; }
.run-task-row__state.is-completed { color: #16835b; }
.run-task-row__state.is-in_review, .run-task-row__state.is-blocked { color: #a15c05; }
.run-task-row__state.is-failed { color: #c13d3d; }
.run-task-row__body { min-width: 0; display: grid; gap: 5px; }
.run-task-row__title { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; font-weight: 600; }
.run-task-row__title > span:first-child { min-width: 0; overflow-wrap: anywhere; }
.run-task-row__status { flex: none; color: var(--mc-text-tertiary, #64748b); font-size: 11px; font-weight: 500; }
.run-task-row__meta { display: flex; flex-wrap: wrap; gap: 5px 14px; color: var(--mc-text-tertiary, #64748b); font-size: 11px; }
.run-task-row__result { color: var(--mc-text-secondary, #475569); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }
.run-task-list__empty { padding: 22px 8px; color: var(--mc-text-tertiary, #64748b); font-size: 12px; text-align: center; }
</style>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { RefreshRight } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import type { TeamRun } from '@/api'
import TeamRunProgress from './TeamRunProgress.vue'
import TeamRunStatus from './TeamRunStatus.vue'
withDefaults(defineProps<{
runs: TeamRun[]
loading?: boolean
error?: string | null
selectedRunId?: string | null
}>(), { loading: false, error: null, selectedRunId: null })
const emit = defineEmits<{
refresh: []
'select-run': [run: TeamRun]
}>()
const { t } = useI18n()
</script>
<template>
<section class="runs-panel" aria-live="polite">
<header class="runs-panel__header">
<h2>{{ t('teamRuns.history') }}</h2>
<button type="button" class="runs-panel__refresh" :aria-label="t('teamRuns.refresh')" @click="emit('refresh')">
<el-icon :size="15"><RefreshRight /></el-icon>
</button>
</header>
<div v-if="loading && runs.length === 0" class="runs-panel__state">{{ t('teamRuns.loading') }}</div>
<div v-else-if="error && runs.length === 0" class="runs-panel__state is-error">
<span>{{ t('teamRuns.loadError') }}</span>
<button type="button" @click="emit('refresh')">{{ t('teamRuns.retryLoad') }}</button>
</div>
<div v-else-if="runs.length === 0" class="runs-panel__state">{{ t('teamRuns.empty') }}</div>
<div v-else class="runs-panel__list">
<button
v-for="run in runs"
:key="run.id"
data-team-run-row
type="button"
class="run-row"
:class="{ 'is-selected': selectedRunId === run.id }"
@click="emit('select-run', run)"
>
<span class="run-row__copy">
<strong>{{ run.title }}</strong>
<span>{{ run.objective }}</span>
<TeamRunStatus :status="run.status" :started-at="run.startedAt" :completed-at="run.completedAt" show-duration />
</span>
<TeamRunProgress :progress="run.progress" compact />
</button>
</div>
<p v-if="error && runs.length > 0" class="runs-panel__stale">{{ t('teamRuns.loadError') }}</p>
</section>
</template>
<style scoped>
.runs-panel { width: 100%; letter-spacing: 0; }
.runs-panel__header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 10px; border-bottom: 1px solid var(--mc-border-light, #e7ebef); }
.runs-panel__header h2 { margin: 0; color: var(--mc-text-primary); font-size: 14px; }
.runs-panel__refresh { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--mc-border); border-radius: 6px; background: var(--mc-bg-elevated); color: var(--mc-text-secondary); cursor: pointer; }
.runs-panel__list { display: grid; gap: 6px; padding-top: 10px; }
.run-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; min-height: 72px; padding: 11px 12px; border: 1px solid var(--mc-border); border-left: 3px solid #718096; border-radius: 8px; background: var(--mc-bg-elevated); color: inherit; cursor: pointer; text-align: left; letter-spacing: 0; }
.run-row:hover, .run-row.is-selected { border-left-color: #16835b; background: rgba(27, 143, 104, 0.055); }
.run-row:focus-visible, .runs-panel__refresh:focus-visible { outline: 2px solid #16835b; outline-offset: 2px; }
.run-row__copy { display: grid; min-width: 0; gap: 4px; }
.run-row__copy strong { color: var(--mc-text-primary); font-size: 13px; overflow-wrap: anywhere; }
.run-row__copy > span { display: -webkit-box; overflow: hidden; color: var(--mc-text-secondary); font-size: 12px; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 1; }
.runs-panel__state { display: grid; min-height: 180px; place-content: center; gap: 8px; color: var(--mc-text-tertiary); font-size: 13px; text-align: center; }
.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; }
</style>

View File

@ -0,0 +1,115 @@
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 TeamRunCard from '../TeamRunCard.vue'
import TeamRunDetail from '../TeamRunDetail.vue'
const messages = {
teamRuns: {
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 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',
cancel: 'Cancel run', expand: 'Expand run', collapse: 'Collapse run', openTask: 'Open task',
objective: 'Objective', taskProgress: 'Task progress', stopReason: 'Stop reason',
},
}
function sampleRun(extra: Partial<TeamRun> = {}): TeamRun {
return {
id: '20', teamId: '10', workspaceId: '1', leadAgentId: '30', leadConversationId: 'lead-1',
originMessageId: null, title: 'Research launch', objective: 'Prepare launch research', status: 'running',
finalSummary: null, stopReason: null, metadata: null, startedAt: '2026-08-13T10:00:00Z', completedAt: null,
createTime: null, updateTime: null, progress: { total: 0, done: 0, failed: 0, inReview: 0, percent: 0 },
tasks: [], ...extra,
}
}
const mounted: 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)
mounted.push(app)
return host
}
afterEach(() => {
mounted.splice(0).forEach(app => app.unmount())
document.body.innerHTML = ''
})
describe('TeamRunCard', () => {
it.each([
['planning', 'Planning'],
['running', 'Running'],
['awaiting_review', 'Awaiting review'],
['partial', 'Partial'],
['failed', 'Failed'],
['cancelled', 'Cancelled'],
] as const)('renders the %s run state', (status, label) => {
const host = mount(TeamRunCard, { run: sampleRun({ status }) })
expect(host.textContent).toContain(label)
})
it('uses focused Enter and Space activation without click fallback', async () => {
const toggles: boolean[] = []
const host = mount(TeamRunCard, { run: sampleRun(), onToggle: (value: boolean) => toggles.push(value) })
const toggle = host.querySelector<HTMLButtonElement>('[data-team-run-toggle]')!
expect(toggle.tagName).toBe('BUTTON')
expect(toggle.getAttribute('aria-expanded')).toBe('false')
toggle.focus()
expect(document.activeElement).toBe(toggle)
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
toggle.dispatchEvent(enter)
await nextTick()
expect(enter.defaultPrevented).toBe(true)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(host.textContent).toContain('No tasks in this run')
expect(toggles).toEqual([true])
const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
toggle.dispatchEvent(space)
await nextTick()
expect(space.defaultPrevented).toBe(true)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggles).toEqual([true, false])
})
})
describe('TeamRunDetail', () => {
it('renders summary, task drilldown and emits cancel', async () => {
let cancelled = 0
const task = {
id: '101', teamId: '10', runId: '20', taskNumber: 1, subject: 'Collect evidence', description: 'Read sources',
status: 'completed', priority: 0, taskType: 'execution', assigneeAgentId: '31', ownerAgentId: null,
blockedBy: null, requireApproval: false, progressPercent: 100, progressStep: null,
result: 'Evidence collected', reason: null, conversationId: 'worker-1', metadata: null,
createTime: null, updateTime: null,
}
const host = mount(TeamRunDetail, {
run: sampleRun({ finalSummary: 'Launch is viable', progress: { total: 1, done: 1, failed: 0, inReview: 0, percent: 100 }, tasks: [task] }),
canCancel: true,
selectedTaskId: '101',
onCancel: () => { cancelled += 1 },
})
expect(host.textContent).toContain('Launch is viable')
expect(host.textContent).toContain('Evidence collected')
host.querySelector<HTMLButtonElement>('[data-team-run-cancel]')!.click()
await nextTick()
expect(cancelled).toBe(1)
})
})

View File

@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest'
import type { TeamRun, TeamRunTask } from '@/api'
import {
buildAgentRunRoute,
buildChatRunRoute,
buildTeamRunRoute,
buildWorkerChatRoute,
extractRunDeliverables,
formatRunDuration,
getRunStatusPresentation,
orderTasksByDependencies,
} from '../teamRunPresentation'
const task = (id: string, taskNumber: number, extra: Record<string, unknown> = {}): TeamRunTask => ({
id,
teamId: '10',
runId: '20',
taskNumber,
subject: `Task ${id}`,
description: null,
status: 'pending',
priority: 0,
taskType: 'execution',
assigneeAgentId: '30',
ownerAgentId: null,
blockedBy: null,
requireApproval: false,
progressPercent: null,
progressStep: null,
result: null,
reason: null,
conversationId: null,
metadata: null,
createTime: null,
updateTime: null,
...extra,
})
const run = (extra: Partial<TeamRun> = {}): TeamRun => ({
id: '20',
teamId: '10',
workspaceId: '1',
leadAgentId: '30',
leadConversationId: 'lead-1',
originMessageId: null,
title: 'Quarterly analysis',
objective: 'Compare the quarter',
status: 'running',
finalSummary: null,
stopReason: null,
metadata: null,
startedAt: '2026-08-13T10:00:00Z',
completedAt: null,
createTime: null,
updateTime: null,
progress: { total: 2, done: 0, failed: 0, inReview: 0, percent: 10 },
tasks: [],
...extra,
})
describe('team run status presentation', () => {
it.each([
['planning', 'neutral'],
['running', 'green'],
['awaiting_review', 'amber'],
['finalizing', 'green'],
['completed', 'green'],
['partial', 'amber'],
['failed', 'red'],
['cancelled', 'neutral'],
] as const)('maps %s to a label key and %s tone', (status, tone) => {
expect(getRunStatusPresentation(status)).toEqual({
labelKey: `teamRuns.status.${status}`,
tone,
})
})
})
describe('formatRunDuration', () => {
it('formats elapsed and completed runs with injected translated units', () => {
const units = { day: 'D', hour: 'H', minute: 'M', second: 'S' }
expect(formatRunDuration('2026-08-13T10:00:00Z', null, new Date('2026-08-13T10:02:05Z'), units))
.toBe('2M 5S')
expect(formatRunDuration('2026-08-12T08:00:00Z', '2026-08-13T10:03:00Z', undefined, units))
.toBe('1D 2H')
})
it('returns an empty string for missing or invalid timestamps', () => {
expect(formatRunDuration(null, null)).toBe('')
expect(formatRunDuration('invalid', null)).toBe('')
})
})
describe('extractRunDeliverables', () => {
it('extracts valid run and task metadata entries and de-duplicates them', () => {
const shared = { name: 'report.pdf', url: '/api/v1/files/generated/report.pdf', time: 'now' }
const value = run({
metadata: JSON.stringify({ deliverables: [shared, { name: '', url: '/invalid' }] }),
tasks: [task('101', 1, {
metadata: JSON.stringify({ deliverables: [shared, { name: 'data.csv', url: '/api/v1/files/generated/data.csv' }] }),
})],
})
expect(extractRunDeliverables(value)).toEqual([
{ ...shared, taskId: undefined },
{ name: 'data.csv', url: '/api/v1/files/generated/data.csv', time: undefined, taskId: '101' },
])
})
it('tolerates malformed metadata', () => {
expect(extractRunDeliverables(run({ metadata: '{', tasks: [task('1', 1, { metadata: 'bad' })] }))).toEqual([])
})
it('rejects executable, local-file and protocol-relative URLs', () => {
const deliverables = [
{ name: 'web', url: 'https://example.com/report.pdf' },
{ name: 'local', url: '/api/v1/files/generated/report' },
{ name: 'script', url: 'javascript:alert(1)' },
{ name: 'data', url: 'data:text/html,unsafe' },
{ name: 'file', url: 'file:///tmp/private' },
{ name: 'host-relative', url: '//evil.example/payload' },
{ name: 'relative', url: 'downloads/report.pdf' },
]
expect(extractRunDeliverables(run({ metadata: JSON.stringify({ deliverables }) })))
.toEqual([
{ name: 'web', url: 'https://example.com/report.pdf', time: undefined, taskId: undefined },
{ name: 'local', url: '/api/v1/files/generated/report', time: undefined, taskId: undefined },
])
})
})
describe('orderTasksByDependencies', () => {
it('uses a stable topological order and accepts JSON dependency metadata', () => {
const tasks = [
task('3', 3, { blockedBy: '["1","2"]' }),
task('2', 2, { blockedBy: '["1"]' }),
task('4', 4),
task('1', 1),
]
expect(orderTasksByDependencies(tasks).map(item => item.id)).toEqual(['4', '1', '2', '3'])
})
it('keeps original relative order for cycles and does not mutate input', () => {
const tasks = [task('2', 2, { blockedBy: '["1"]' }), task('1', 1, { blockedBy: '["2"]' })]
expect(orderTasksByDependencies(tasks).map(item => item.id)).toEqual(['2', '1'])
expect(tasks.map(item => item.id)).toEqual(['2', '1'])
})
})
describe('team run route builders', () => {
it('preserves all ids as strings', () => {
expect(buildChatRunRoute('20', 'lead-1')).toEqual({
path: '/chat',
query: { conversationId: 'lead-1', teamRunId: '20' },
})
expect(buildAgentRunRoute('20', '101')).toEqual({
path: '/agents',
query: { view: 'live', teamRunId: '20', taskId: '101' },
})
expect(buildTeamRunRoute('10', '20', '101')).toEqual({
path: '/teams',
query: { teamId: '10', view: 'runs', runId: '20', taskId: '101' },
})
expect(buildWorkerChatRoute({
conversationId: 'worker-1', agentId: '30', runId: '20', taskId: '101',
teamId: '10', leadConversationId: 'lead-1',
})).toEqual({
path: '/chat',
query: {
agentId: '30', conversationId: 'worker-1', teamRunId: '20', taskId: '101',
teamId: '10', leadConversationId: 'lead-1',
},
})
})
it('keeps legacy null-run transcripts editable by omitting teamRunId', () => {
expect(buildWorkerChatRoute({
conversationId: 'legacy-worker', agentId: '30', runId: null, taskId: '101',
teamId: '10', leadConversationId: 'lead-1',
})).toEqual({
path: '/chat',
query: {
agentId: '30', conversationId: 'legacy-worker', taskId: '101',
teamId: '10', leadConversationId: 'lead-1',
},
})
})
it('rejects numeric and blank ids at runtime', () => {
expect(() => buildTeamRunRoute(10 as unknown as string, '20')).toThrow(TypeError)
expect(() => buildAgentRunRoute(' ', '101')).toThrow(TypeError)
})
})

View File

@ -0,0 +1,73 @@
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 TeamRunDrawer from '../TeamRunDrawer.vue'
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.',
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',
result: 'Result', noResult: 'No result', summary: 'Summary', noSummary: 'No summary', deliverables: 'Deliverables',
noDeliverables: 'No deliverables', cancel: 'Cancel run', objective: 'Objective', taskProgress: 'Task progress',
stopReason: 'Stop reason', openTask: 'Open task',
} }
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: 'running', finalSummary: null, stopReason: null,
metadata: null, startedAt: '2026-08-13T10:00:00Z', completedAt: null, createTime: '2026-08-13T10:00:00Z',
updateTime: null, progress: { total: 1, done: 0, failed: 0, inReview: 0, percent: 30 }, 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('TeamRunsPanel', () => {
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 } })
expect(host.textContent).toContain('Research')
expect(host.querySelectorAll('[data-team-run-row]')).toHaveLength(1)
expect(host.querySelector('[data-team-run-task-list]')).toBeNull()
host.querySelector<HTMLButtonElement>('[data-team-run-row]')!.click()
await nextTick()
expect(selected).toBe('20')
})
it('renders loading, empty, and error states', () => {
expect(mount(TeamRunsPanel, { runs: [], loading: true }).textContent).toContain('Loading runs')
expect(mount(TeamRunsPanel, { runs: [] }).textContent).toContain('No runs yet')
expect(mount(TeamRunsPanel, { runs: [], error: 'offline' }).textContent).toContain('Could not load runs')
})
})
describe('TeamRunDrawer', () => {
it('shows partial state and forwards close and cancel actions', async () => {
let closed = 0
let cancelled = ''
const partialHost = mount(TeamRunDrawer, { run: run({ status: 'partial' }), open: true })
expect(partialHost.textContent).toContain('Some tasks did not complete.')
const host = mount(TeamRunDrawer, {
run: run(), open: true, canCancel: true,
onClose: () => { closed += 1 }, onCancel: (id: string) => { cancelled = id },
})
host.querySelector<HTMLButtonElement>('[data-team-run-cancel]')!.click()
host.querySelector<HTMLButtonElement>('[data-team-run-drawer-close]')!.click()
await nextTick()
expect(cancelled).toBe('20')
expect(closed).toBe(1)
})
})

View File

@ -0,0 +1,8 @@
export { default as TeamRunCard } from './TeamRunCard.vue'
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 TeamRunsPanel } from './TeamRunsPanel.vue'
export { default as TeamRunDrawer } from './TeamRunDrawer.vue'
export * from './teamRunPresentation'

View File

@ -0,0 +1,220 @@
import type { TeamRun, TeamRunStatus, TeamRunTask } from '@/api'
import { isSafeFileUrl } from '@/utils/generatedFileLinks'
export type TeamRunTone = 'neutral' | 'green' | 'amber' | 'red'
export interface TeamRunStatusPresentation {
labelKey: `teamRuns.status.${TeamRunStatus}`
tone: TeamRunTone
}
export interface DurationUnits {
day: string
hour: string
minute: string
second: string
}
export interface TeamRunDeliverable {
name: string
url: string
time?: string
taskId?: string
}
export interface TeamRunRoute {
path: '/chat' | '/agents' | '/teams'
query: Record<string, string>
}
const statusTones: Record<TeamRunStatus, TeamRunTone> = {
planning: 'neutral',
running: 'green',
awaiting_review: 'amber',
finalizing: 'green',
completed: 'green',
partial: 'amber',
failed: 'red',
cancelled: 'neutral',
}
const defaultDurationUnits: DurationUnits = {
day: 'd',
hour: 'h',
minute: 'm',
second: 's',
}
export function getRunStatusPresentation(status: TeamRunStatus): TeamRunStatusPresentation {
return { labelKey: `teamRuns.status.${status}`, tone: statusTones[status] }
}
export function formatRunDuration(
startedAt: string | null,
completedAt: string | null,
now = new Date(),
units: DurationUnits = defaultDurationUnits,
): string {
if (!startedAt) return ''
const start = Date.parse(startedAt)
const end = completedAt ? Date.parse(completedAt) : now.getTime()
if (!Number.isFinite(start) || !Number.isFinite(end)) return ''
let remaining = Math.max(0, Math.floor((end - start) / 1_000))
const values = [
[Math.floor(remaining / 86_400), units.day],
[Math.floor((remaining %= 86_400) / 3_600), units.hour],
[Math.floor((remaining %= 3_600) / 60), units.minute],
[remaining % 60, units.second],
] as const
const visible = values.filter(([value]) => value > 0).slice(0, 2)
if (visible.length === 0) return `0${units.second}`
return visible.map(([value, unit]) => `${value}${unit}`).join(' ')
}
function parseMetadata(raw: unknown): Record<string, unknown> {
let value = raw
for (let depth = 0; depth < 2 && typeof value === 'string'; depth += 1) {
if (!value.trim()) return {}
try {
value = JSON.parse(value)
} catch {
return {}
}
}
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}
function deliverablesFrom(raw: unknown, taskId?: string): TeamRunDeliverable[] {
const entries = parseMetadata(raw).deliverables
if (!Array.isArray(entries)) return []
return entries.flatMap((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
const item = entry as Record<string, unknown>
const name = typeof item.name === 'string' ? item.name.trim() : ''
const url = typeof item.url === 'string' ? item.url.trim() : ''
if (!name || !isSafeFileUrl(url)) return []
return [{
name,
url,
time: typeof item.time === 'string' && item.time.trim() ? item.time : undefined,
taskId,
}]
})
}
export function extractRunDeliverables(run: TeamRun): TeamRunDeliverable[] {
const all = [
...deliverablesFrom(run.metadata),
...run.tasks.flatMap(task => deliverablesFrom(task.metadata, task.id)),
]
const seen = new Set<string>()
return all.filter((item) => {
const key = `${item.url}\u0000${item.name}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
export function taskDependencyIds(task: TeamRunTask): string[] {
const metadata = parseMetadata(task.metadata)
const raw = task.blockedBy ?? metadata.blockedBy
let value: unknown = raw
if (typeof value === 'string') {
try {
value = JSON.parse(value)
} catch {
return []
}
}
if (!Array.isArray(value)) return []
return value.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
}
export function orderTasksByDependencies<T extends TeamRunTask>(tasks: readonly T[]): T[] {
const byId = new Map(tasks.map(task => [task.id, task]))
const index = new Map(tasks.map((task, position) => [task.id, position]))
const indegree = new Map(tasks.map(task => [task.id, 0]))
const dependents = new Map(tasks.map(task => [task.id, [] as string[]]))
for (const task of tasks) {
const dependencies = [...new Set(taskDependencyIds(task))].filter(id => id !== task.id && byId.has(id))
indegree.set(task.id, dependencies.length)
dependencies.forEach(id => dependents.get(id)!.push(task.id))
}
const ready = tasks.filter(task => indegree.get(task.id) === 0)
const ordered: T[] = []
while (ready.length > 0) {
ready.sort((a, b) => index.get(a.id)! - index.get(b.id)!)
const task = ready.shift()!
ordered.push(task)
for (const dependentId of dependents.get(task.id)!) {
const next = indegree.get(dependentId)! - 1
indegree.set(dependentId, next)
if (next === 0) ready.push(byId.get(dependentId)!)
}
}
if (ordered.length < tasks.length) {
const emitted = new Set(ordered.map(task => task.id))
ordered.push(...tasks.filter(task => !emitted.has(task.id)))
}
return ordered
}
function stringId(value: string, name: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new TypeError(`${name} must be a non-empty string`)
}
return value
}
export function buildChatRunRoute(runId: string, conversationId: string): TeamRunRoute {
return {
path: '/chat',
query: {
conversationId: stringId(conversationId, 'conversationId'),
teamRunId: stringId(runId, 'runId'),
},
}
}
export function buildWorkerChatRoute(context: {
conversationId: string
agentId?: string | null
runId?: string | null
taskId: string
teamId: string
leadConversationId?: string | null
}): TeamRunRoute {
const query: Record<string, string> = {
conversationId: stringId(context.conversationId, 'conversationId'),
taskId: stringId(context.taskId, 'taskId'),
teamId: stringId(context.teamId, 'teamId'),
}
if (context.agentId) query.agentId = stringId(context.agentId, 'agentId')
if (context.runId) query.teamRunId = stringId(context.runId, 'runId')
if (context.leadConversationId) {
query.leadConversationId = stringId(context.leadConversationId, 'leadConversationId')
}
return { path: '/chat', query }
}
export function buildAgentRunRoute(runId: string, taskId?: string): TeamRunRoute {
const query: Record<string, string> = { view: 'live', teamRunId: stringId(runId, 'runId') }
if (taskId !== undefined) query.taskId = stringId(taskId, 'taskId')
return { path: '/agents', query }
}
export function buildTeamRunRoute(teamId: string, runId: string, taskId?: string): TeamRunRoute {
const query: Record<string, string> = {
teamId: stringId(teamId, 'teamId'),
view: 'runs',
runId: stringId(runId, 'runId'),
}
if (taskId !== undefined) query.taskId = stringId(taskId, 'taskId')
return { path: '/teams', query }
}

View File

@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import type { LiveSnapshot, TeamRun, TeamRunTask } from '@/api'
import { createAgentsLiveRouteHydrator, parseAgentsLiveRoute, reconcileAgentsLiveRoute } from '../agentsLiveRouteState'
const task = (id: string, conversationId: string | null): TeamRunTask => ({
id, teamId: '10', runId: '20', taskNumber: 1, subject: id, description: null, status: 'in_progress',
priority: 0, taskType: 'execution', assigneeAgentId: 'agent', ownerAgentId: null, blockedBy: null,
requireApproval: false, progressPercent: null, progressStep: null, result: null, reason: null,
conversationId, metadata: null, createTime: null, updateTime: null,
})
const run = (id: string, tasks: TeamRunTask[]): TeamRun => ({
id, teamId: '10', workspaceId: '1', leadAgentId: 'lead', leadConversationId: 'lead-conv',
originMessageId: null, title: id, objective: id, status: 'running', finalSummary: null, stopReason: null,
metadata: null, startedAt: null, completedAt: null, createTime: null, updateTime: null,
progress: { total: tasks.length, done: 0, failed: 0, inReview: 0, percent: 0 }, tasks,
})
const live = (...conversationIds: string[]): LiveSnapshot => ({
runs: conversationIds.map(conversationId => ({
conversationId, agentId: 1, agentName: conversationId, agentIcon: null, username: null,
currentPhase: 'tools', runningToolName: null, waitingReason: null, done: false, stopRequested: false,
firstTokenReceived: true, subscriberCount: 1, queueLen: 0, ageMs: 1, msSinceLastEvent: 1,
stuckReason: null, orphan: false, subagentCount: 0,
})),
subagents: [], timestamp: 1,
summary: { running: conversationIds.length, stuck: 0, orphan: 0, queued: 0, subagentsActive: 0 },
})
describe('agents live route state', () => {
it('parses the whole route and reconciles browser navigation A to B to none', () => {
const selectedRun = run('20', [task('A', 'worker-a'), task('B', 'worker-b')])
expect(parseAgentsLiveRoute({ view: 'live', teamRunId: '20', taskId: 'A' })).toEqual({
view: 'live', runId: '20', taskId: 'A', requiredRunId: '20',
})
expect(reconcileAgentsLiveRoute(parseAgentsLiveRoute({ view: 'live', teamRunId: '20', taskId: 'A' }), [selectedRun], live('worker-a', 'worker-b')).selectedTaskId).toBe('A')
expect(reconcileAgentsLiveRoute(parseAgentsLiveRoute({ view: 'live', teamRunId: '20', taskId: 'B' }), [selectedRun], live('worker-a', 'worker-b')).selectedTaskId).toBe('B')
expect(reconcileAgentsLiveRoute(parseAgentsLiveRoute({ view: 'live', teamRunId: '20' }), [selectedRun], live('worker-a', 'worker-b')).selectedTaskId).toBeNull()
})
it('preserves an ended task deep link and reports its worker as offline', () => {
const selectedRun = run('20', [task('A', 'worker-a'), task('offline', 'worker-offline')])
expect(reconcileAgentsLiveRoute(
parseAgentsLiveRoute({ view: 'live', teamRunId: '20', taskId: 'offline' }), [selectedRun], live('worker-a'),
)).toMatchObject({
selectedRunId: '20', selectedTaskId: 'offline',
selectedWorker: { taskId: 'offline', conversationId: 'worker-offline', online: false },
replaceQuery: null,
})
})
it('clears a task outside the selected run and clears both ids for an invalid run', () => {
const selectedRun = run('20', [task('A', 'worker-a')])
expect(reconcileAgentsLiveRoute(
parseAgentsLiveRoute({ view: 'live', teamRunId: '20', taskId: 'other' }), [selectedRun], live('worker-a'),
)).toMatchObject({ selectedRunId: '20', selectedTaskId: null, replaceQuery: { view: 'live', teamRunId: '20' } })
expect(reconcileAgentsLiveRoute(
parseAgentsLiveRoute({ view: 'live', teamRunId: 'missing', taskId: 'A' }), [selectedRun], live('worker-a'),
)).toMatchObject({ selectedRunId: null, selectedTaskId: null, replaceQuery: { view: 'live' } })
})
it('ignores an older route hydration after a newer route finishes', async () => {
const oldLoad = deferred<void>()
const newLoad = deferred<void>()
const refreshed: Array<string | null> = []
const replaced: unknown[] = []
const hydrator = createAgentsLiveRouteHydrator({
invalidatePoll: () => {},
ensureRun: (runId) => {
refreshed.push(runId)
return runId === 'old' ? oldLoad.promise : newLoad.promise
},
reconcile: route => ({
selectedRunId: route.runId, selectedTaskId: route.taskId, selectedWorker: null,
replaceQuery: route.runId === 'old' ? { view: 'live' } : null,
}),
replace: query => { replaced.push(query); return Promise.resolve() },
})
const oldHydration = hydrator.hydrate(parseAgentsLiveRoute({ view: 'live', teamRunId: 'old' }))
const newHydration = hydrator.hydrate(parseAgentsLiveRoute({ view: 'live', teamRunId: 'new' }))
newLoad.resolve()
await newHydration
oldLoad.resolve()
await oldHydration
expect(refreshed).toEqual(['old', 'new'])
expect(replaced).toEqual([])
})
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => { resolve = done })
return { promise, resolve }
}

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { isHigherSseEventId } from '@/composables/sseEventIds'
describe('isHigherSseEventId', () => {
it('orders adjacent ids above the JavaScript safe integer range', () => {
expect(isHigherSseEventId('1850000000000000001', '1850000000000000000')).toBe(true)
expect(isHigherSseEventId('1850000000000000000', '1850000000000000001')).toBe(false)
})
it('compares decimal ids by magnitude without numeric coercion', () => {
expect(isHigherSseEventId('1000', '999')).toBe(true)
expect(isHigherSseEventId('0001000', '999')).toBe(true)
expect(isHigherSseEventId('not-numeric', '999')).toBe(false)
})
})

View File

@ -0,0 +1,102 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LiveRunCard, LiveSnapshot, TeamRun, TeamRunTask } from '@/api'
import { teamApi, teamRunApi } from '@/api'
import { buildAgentWorkerChatRoute, projectAgentRunGroups, useAgentRunGroups } from '../useAgentRunGroups'
vi.mock('@/api', async (importOriginal) => {
const original = await importOriginal<typeof import('@/api')>()
return {
...original,
teamApi: { ...original.teamApi, list: vi.fn() },
teamRunApi: { ...original.teamRunApi, listByTeam: vi.fn(), get: vi.fn() },
}
})
const live = (conversationId: string, stuckReason: string | null = null): LiveRunCard => ({
conversationId, agentId: 2, agentName: conversationId, agentIcon: null, username: null,
currentPhase: 'tools', runningToolName: null, waitingReason: null, done: false, stopRequested: false,
firstTokenReceived: true, subscriberCount: 1, queueLen: 0, ageMs: 60_000, msSinceLastEvent: 1_000,
stuckReason, orphan: false, subagentCount: 0,
})
const task = (id: string, taskNumber: number, status: string, conversationId: string | null, blockedBy: string | null = null): TeamRunTask => ({
id, teamId: '10', runId: '20', taskNumber, subject: `Task ${id}`, description: null,
status, priority: 0, taskType: 'execution', assigneeAgentId: `agent-${id}`, ownerAgentId: null,
blockedBy, requireApproval: false, progressPercent: null, progressStep: null, result: null, reason: null,
conversationId, metadata: null, createTime: null, updateTime: null,
})
const run = (status: TeamRun['status'], tasks: TeamRunTask[]): TeamRun => ({
id: '20', teamId: '10', workspaceId: '1', leadAgentId: 'lead-agent', leadConversationId: 'lead-conv',
originMessageId: null, title: 'Launch research', objective: 'Prepare launch', status, finalSummary: null,
stopReason: null, metadata: null, startedAt: '2026-08-13T10:00:00Z', completedAt: null,
createTime: '2026-08-13T10:00:00Z', updateTime: '2026-08-13T10:01:00Z',
progress: { total: tasks.length, done: 0, failed: 0, inReview: 0, percent: 20 }, tasks,
})
const snapshot = (runs: LiveRunCard[]): LiveSnapshot => ({
runs, subagents: [], timestamp: 1, summary: { running: runs.length, stuck: 0, orphan: 0, queued: 0, subagentsActive: 0 },
})
describe('projectAgentRunGroups', () => {
it('joins only explicit task and lead conversation ids and keeps non-team sessions separate', () => {
const tasks = [
task('1', 1, 'in_progress', 'worker-active'),
task('2', 2, 'blocked', null, '["1"]'),
task('3', 3, 'in_review', 'worker-review'),
task('4', 4, 'in_progress', 'worker-stuck'),
task('5', 5, 'cancelled', null),
]
const result = projectAgentRunGroups(snapshot([
live('lead-conv'), live('worker-active'), live('worker-review'), live('worker-stuck', 'idle_silent'),
live('Task 1 child guessed name'), live('unrelated'),
]), [run('running', tasks)])
expect(result.groups).toHaveLength(1)
expect(result.groups[0].leadRuntime?.conversationId).toBe('lead-conv')
expect(result.groups[0].workers.map(worker => `${worker.task.id}:${worker.state}`)).toEqual([
'1:active', '2:waiting', '3:review', '4:stuck', '5:cancelled',
])
expect(result.ungrouped.map(item => item.conversationId)).toEqual(['Task 1 child guessed name', 'unrelated'])
expect(buildAgentWorkerChatRoute(result.groups[0], result.groups[0].workers[0])).toEqual({
path: '/chat',
query: {
conversationId: 'worker-active', agentId: 'agent-1', teamRunId: '20', taskId: '1',
teamId: '10', leadConversationId: 'lead-conv',
},
})
})
it.each([
['finalizing', 'finalizing'],
['cancelled', 'cancelled'],
] as const)('projects %s run state as %s', (status, expected) => {
expect(projectAgentRunGroups(snapshot([]), [run(status, [])]).groups[0].state).toBe(expected)
})
})
describe('useAgentRunGroups hydration priority', () => {
beforeEach(() => vi.clearAllMocks())
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.get).mockReturnValue(routeDetail.promise as never)
const groups = useAgentRunGroups(ref(snapshot([])))
const routeLoad = groups.ensureRun('historical', 1)
const pollLoad = groups.refreshForSnapshot()
await pollLoad
routeDetail.resolve({ data: { ...run('cancelled', [task('ended', 1, 'cancelled', 'worker-ended')]), id: 'historical' } })
await routeLoad
expect(groups.runs.value.map(item => item.id)).toContain('historical')
})
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => { resolve = done })
return { promise, resolve }
}

View File

@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest'
import type { LiveSnapshot } from '@/api'
import { createAgentsLiveRouteHydrator, parseAgentsLiveRoute } from '../agentsLiveRouteState'
import { useLiveSnapshot } from '../useLiveSnapshot'
const snapshot = (conversationId: string): LiveSnapshot => ({
runs: [{
conversationId, agentId: 1, agentName: conversationId, agentIcon: null, username: null,
currentPhase: 'tools', runningToolName: null, waitingReason: null, done: false, stopRequested: false,
firstTokenReceived: true, subscriberCount: 1, queueLen: 0, ageMs: 1, msSinceLastEvent: 1,
stuckReason: null, orphan: false, subagentCount: 0,
}],
subagents: [], timestamp: 1,
summary: { running: 1, stuck: 0, orphan: 0, queued: 0, subagentsActive: 0 },
})
describe('useLiveSnapshot', () => {
it('ignores an older overlapping response and refreshes runs only for the latest snapshot', async () => {
const older = deferred<unknown>()
const newer = deferred<unknown>()
const load = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
const refreshRuns = vi.fn().mockResolvedValue(undefined)
const live = useLiveSnapshot({ load, refreshRuns })
const olderRequest = live.refresh()
const newerRequest = live.refresh()
newer.resolve({ data: snapshot('new') })
await newerRequest
older.resolve({ data: snapshot('old') })
await olderRequest
expect(live.snapshot.value?.runs[0].conversationId).toBe('new')
expect(refreshRuns).toHaveBeenCalledTimes(1)
expect(refreshRuns).toHaveBeenCalledWith()
})
it('does not let a poll started before route hydration load or reconcile the old run', async () => {
const poll = deferred<unknown>()
const routeLoad = deferred<void>()
const refreshRuns = vi.fn().mockImplementation(runId => runId === 'run-new' ? routeLoad.promise : Promise.resolve())
const live = useLiveSnapshot({ load: vi.fn().mockReturnValue(poll.promise), refreshRuns })
const reconcile = vi.fn().mockReturnValue({
selectedRunId: 'run-new', selectedTaskId: null, selectedWorker: null, replaceQuery: null,
})
const hydrator = createAgentsLiveRouteHydrator({
invalidatePoll: live.invalidate,
ensureRun: (runId) => refreshRuns(runId),
reconcile,
replace: vi.fn(),
})
const oldPoll = live.refresh()
const routeHydration = hydrator.hydrate(parseAgentsLiveRoute({ view: 'live', teamRunId: 'run-new' }))
routeLoad.resolve()
await routeHydration
poll.resolve({ data: snapshot('old') })
expect(await oldPoll).toBe(false)
expect(refreshRuns.mock.calls).toEqual([['run-new']])
expect(reconcile).toHaveBeenCalledOnce()
expect(live.snapshot.value).toBeNull()
})
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => { resolve = done })
return { promise, resolve }
}

View File

@ -0,0 +1,153 @@
import { describe, expect, it, vi } from 'vitest'
import {
parseTeamSseFrames,
subscribeTeamEvents,
} from '@/composables/useTeamEvents'
function response(body: string): Response {
const bytes = new TextEncoder().encode(body)
let delivered = false
return {
ok: true,
body: {
getReader: () => ({
read: async () => {
if (delivered) return { done: true, value: undefined }
delivered = true
return { done: false, value: bytes }
},
}),
},
} as unknown as Response
}
function dependencies(fetchImpl: typeof fetch) {
const timers: Array<{ callback: () => void; delay: number }> = []
return {
timers,
options: {
fetchImpl,
storage: { getItem: () => null },
retryBaseMs: 100,
retryMaxMs: 1_000,
setTimeoutImpl: (callback: () => void, delay: number) => {
const timer = { callback, delay }
timers.push(timer)
return timer
},
clearTimeoutImpl: vi.fn(),
},
}
}
describe('parseTeamSseFrames', () => {
it('parses CRLF frames with ids and multiline data while retaining partial input', () => {
const parsed = parseTeamSseFrames(
'id: 9007199254740993\r\nevent: team_run_progress\r\n'
+ 'data: first line\r\ndata: second line\r\n\r\nid: 2\r\ndata: partial',
)
expect(parsed.frames).toEqual([{
id: '9007199254740993',
event: 'team_run_progress',
data: 'first line\nsecond line',
}])
expect(parsed.remainder).toBe('id: 2\r\ndata: partial')
})
})
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: {"step":1}\n\n'
+ 'id: 8\r\nevent: team_run_progress\r\ndata: {"step":2}\r\n\r\n',
)) as unknown as typeof fetch
const { timers, options } = dependencies(fetchImpl)
const events: Array<{ id?: string; event: string }> = []
const stop = subscribeTeamEvents('9007199254740995', event => events.push(event), options)
await vi.waitFor(() => expect(timers).toHaveLength(1))
timers.shift()!.callback()
await vi.waitFor(() => expect(events).toHaveLength(2))
expect(events.map(event => event.id)).toEqual(['7', '8'])
const secondRequest = vi.mocked(fetchImpl).mock.calls[1][1] as RequestInit
expect(secondRequest.headers).toMatchObject({ 'Last-Event-ID': '7' })
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)
const stop = subscribeTeamEvents('10', vi.fn(), options)
await vi.waitFor(() => expect(timers.map(timer => timer.delay)).toEqual([100]))
timers[0].callback()
await vi.waitFor(() => expect(timers.map(timer => timer.delay)).toEqual([100, 200]))
timers[1].callback()
await vi.waitFor(() => expect(timers.map(timer => timer.delay)).toEqual([100, 200, 400]))
stop()
})
it('delivers a higher id from a recreated server stream', async () => {
const highId = '1850000000000000000'
const fetchImpl = vi.fn()
.mockResolvedValueOnce(response('id: 7\nevent: team_task_progress\ndata: {"step":1}\n\n'))
.mockResolvedValueOnce(response(
`id: ${highId}\nevent: team_run_progress\ndata: {"step":2}\n\n`,
)) as unknown as typeof fetch
const { timers, options } = dependencies(fetchImpl)
const ids: string[] = []
const stop = subscribeTeamEvents('10', event => ids.push(event.id!), options)
await vi.waitFor(() => expect(timers).toHaveLength(1))
timers.shift()!.callback()
await vi.waitFor(() => expect(ids).toEqual(['7', highId]))
stop()
})
it('bounds the seen id cache without moving Last-Event-ID backwards', async () => {
const fetchImpl = vi.fn()
.mockResolvedValueOnce(response(
'id: 100\nevent: update\ndata: {"step":1}\n\n'
+ 'id: 101\nevent: update\ndata: {"step":2}\n\n'
+ 'id: 102\nevent: update\ndata: {"step":3}\n\n'
+ 'id: 100\nevent: update\ndata: {"step":4}\n\n',
))
.mockResolvedValueOnce(response('')) as unknown as typeof fetch
const { timers, options } = dependencies(fetchImpl)
const ids: string[] = []
const stop = subscribeTeamEvents('10', event => ids.push(event.id!), {
...options,
seenEventLimit: 2,
})
await vi.waitFor(() => expect(timers).toHaveLength(1))
expect(ids).toEqual(['100', '101', '102', '100'])
timers.shift()!.callback()
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2))
const secondRequest = vi.mocked(fetchImpl).mock.calls[1][1] as RequestInit
expect(secondRequest.headers).toMatchObject({ 'Last-Event-ID': '102' })
stop()
})
it('does not reconnect after aborting an active request', async () => {
const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
})) as unknown as typeof fetch
const { timers, options } = dependencies(fetchImpl)
const stop = subscribeTeamEvents('10', vi.fn(), options)
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledOnce())
stop()
await Promise.resolve()
expect(timers).toHaveLength(0)
expect(fetchImpl).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,218 @@
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { TeamRun } from '@/api'
import {
buildTeamsRouteQuery,
clearTeamsRunSelection,
parseTeamsRouteQuery,
reconcileTeamsRoute,
} from '../teamsRouteState'
import { sortTeamRuns, useTeamRunHistory } from '../useTeamRunHistory'
function run(id: string, createTime: string | null, status: TeamRun['status'] = 'running'): TeamRun {
return {
id, teamId: '10', workspaceId: '1', leadAgentId: '2', leadConversationId: 'lead', originMessageId: null,
title: `Run ${id}`, objective: 'Objective', status, finalSummary: null, stopReason: null, metadata: null,
startedAt: createTime, completedAt: null, createTime, updateTime: createTime,
progress: { total: 0, done: 0, failed: 0, inReview: 0, percent: 0 }, tasks: [],
}
}
describe('teams run routes', () => {
it('hydrates string ids and defaults an opened team to runs', () => {
expect(parseTeamsRouteQuery({ teamId: '10', runId: '20', taskId: '30' })).toEqual({
teamId: '10', view: 'runs', runId: '20', taskId: '30',
})
expect(parseTeamsRouteQuery({ teamId: ['10'], view: 'unknown', runId: 20 })).toEqual({
teamId: '10', view: 'runs', runId: null, taskId: null,
})
expect(parseTeamsRouteQuery({})).toEqual({ teamId: null, view: null, runId: null, taskId: null })
})
it('reconciles browser navigation from task A to B to no task without navigation writes', () => {
const base = parseTeamsRouteQuery({ teamId: '10', view: 'runs', runId: '20', taskId: 'A' })
const taskB = parseTeamsRouteQuery({ teamId: '10', view: 'runs', runId: '20', taskId: 'B' })
const noTask = parseTeamsRouteQuery({ teamId: '10', view: 'runs', runId: '20' })
const board = parseTeamsRouteQuery({ teamId: '10', view: 'board', runId: '20', taskId: 'B' })
expect(reconcileTeamsRoute(base, taskB)).toMatchObject({
selectedRunId: '20', selectedTaskId: 'B', taskAction: 'load',
})
expect(reconcileTeamsRoute(taskB, noTask)).toMatchObject({
selectedRunId: '20', selectedTaskId: null, taskAction: 'close',
})
expect(reconcileTeamsRoute(taskB, board)).toMatchObject({
selectedRunId: null, selectedTaskId: null, taskAction: 'close',
})
})
it('builds stable route queries without coercing snowflake ids', () => {
expect(buildTeamsRouteQuery('9007199254740993', 'runs', '9007199254740995', '9007199254740997'))
.toEqual({ teamId: '9007199254740993', view: 'runs', runId: '9007199254740995', taskId: '9007199254740997' })
expect(buildTeamsRouteQuery('10', 'members')).toEqual({ teamId: '10', view: 'members' })
expect(clearTeamsRunSelection(parseTeamsRouteQuery({
teamId: '10', view: 'runs', runId: '9007199254740995', taskId: '9007199254740997',
}))).toEqual({ teamId: '10', view: 'runs' })
})
})
describe('useTeamRunHistory', () => {
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
const listByTeam = vi.fn().mockReturnValue(new Promise(resolve => { resolveList = resolve }))
const get = vi.fn().mockResolvedValue({ data: run('1', '2026-03-01', 'completed') })
const timers: Array<() => void> = []
const history = useTeamRunHistory({
api: { listByTeam, get },
subscribe: (_teamId, handler) => { callback = handler; return vi.fn() },
setTimeoutImpl: handler => { timers.push(handler); return handler },
})
const loading = history.open('10')
callback?.({ event: 'team_run_completed', data: { runId: '1' } })
timers.at(-1)?.()
await vi.waitFor(() => expect(get).toHaveBeenCalledOnce())
resolveList({ data: [run('1', '2026-01-01', 'running'), run('2', '2026-02-01')] })
await loading
expect(history.runs.value.map(item => `${item.id}:${item.status}`)).toEqual(['1:completed', '2:running'])
})
it('does not merge a detail projection from another team', async () => {
const history = useTeamRunHistory({
api: {
listByTeam: vi.fn().mockResolvedValue({ data: [] }),
get: vi.fn().mockResolvedValue({ data: { ...run('9', '2026-03-01'), teamId: '20' } }),
},
subscribe: () => vi.fn(),
})
await history.open('10')
expect(await history.refreshRun('9', '10')).toBeNull()
expect(history.runs.value).toEqual([])
})
it('keeps the latest same-run detail when responses resolve in reverse order', async () => {
const first = deferred<unknown>()
const second = deferred<unknown>()
const history = useTeamRunHistory({
api: {
listByTeam: vi.fn().mockResolvedValue({ data: [] }),
get: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
},
subscribe: () => vi.fn(),
})
await history.open('10')
const olderRequest = history.refreshRun('1', '10')
const newerRequest = history.refreshRun('1', '10')
second.resolve({ data: run('1', '2026-04-01', 'completed') })
await newerRequest
first.resolve({ data: run('1', '2026-03-01', 'running') })
await olderRequest
expect(history.runs.value.map(item => item.status)).toEqual(['completed'])
})
it('ignores an older same-run refresh error after a newer request succeeds', async () => {
const older = deferred<unknown>()
const newer = deferred<unknown>()
const history = useTeamRunHistory({
api: {
listByTeam: vi.fn().mockResolvedValue({ data: [] }),
get: vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise),
},
subscribe: () => vi.fn(),
})
await history.open('10')
const olderRequest = history.refreshRun('1', '10')
const newerRequest = history.refreshRun('1', '10')
newer.resolve({ data: run('1', '2026-04-01', 'completed') })
await newerRequest
older.reject(new Error('stale failure'))
await olderRequest
expect(history.runs.value.map(item => item.status)).toEqual(['completed'])
expect(history.error.value).toBeNull()
})
it('invalidates same-run detail when closed and reopened', async () => {
const stale = deferred<unknown>()
const get = vi.fn().mockReturnValueOnce(stale.promise)
const history = useTeamRunHistory({
api: { listByTeam: vi.fn().mockResolvedValue({ data: [] }), get },
subscribe: () => vi.fn(),
})
await history.open('10')
const request = history.refreshRun('1', '10')
history.close()
await history.open('10')
stale.resolve({ data: run('1', '2026-03-01', 'completed') })
await request
expect(history.runs.value).toEqual([])
})
it('loads independently, sorts newest first, and refreshes only the event run', async () => {
let callback: ((event: { event: string; data: Record<string, unknown> }) => void) | undefined
const listByTeam = vi.fn().mockResolvedValue({ data: [run('1', '2026-01-01'), run('2', '2026-02-01')] })
const get = vi.fn().mockResolvedValue({ data: run('1', '2026-03-01', 'completed') })
const timers: Array<() => void> = []
const history = useTeamRunHistory({
api: { listByTeam, get },
subscribe: (_teamId, handler) => { callback = handler; return vi.fn() },
setTimeoutImpl: handler => { timers.push(handler); return handler },
clearTimeoutImpl: vi.fn(),
})
await history.open('10')
expect(history.runs.value.map(item => item.id)).toEqual(['2', '1'])
callback?.({ event: 'team_task_completed', data: { runId: '1', taskId: '50' } })
callback?.({ event: 'team_run_completed', data: { runId: '1' } })
expect(get).not.toHaveBeenCalled()
timers.at(-1)?.()
await nextTick()
await Promise.resolve()
expect(get).toHaveBeenCalledTimes(1)
expect(history.runs.value.map(item => `${item.id}:${item.status}`)).toEqual(['1:completed', '2:running'])
})
it('ignores stale loads and cleans up subscriptions', async () => {
let resolveA!: (value: unknown) => void
const stop = vi.fn()
const listByTeam = vi.fn()
.mockReturnValueOnce(new Promise(resolve => { resolveA = resolve }))
.mockResolvedValueOnce({ data: [{ ...run('2', '2026-02-01'), teamId: '20' }] })
const history = useTeamRunHistory({
api: { listByTeam, get: vi.fn() },
subscribe: () => stop,
})
const loadingA = history.open('10')
await history.open('20')
resolveA({ data: [run('1', '2026-01-01')] })
await loadingA
expect(history.runs.value.map(item => item.id)).toEqual(['2'])
history.close()
expect(stop).toHaveBeenCalled()
expect(history.runs.value).toEqual([])
})
})
describe('sortTeamRuns', () => {
it('keeps a stable order when timestamps match or are absent', () => {
expect(sortTeamRuns([run('1', null), run('2', null), run('3', '2026-03-01')]).map(item => item.id))
.toEqual(['3', '1', '2'])
})
})
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail })
return { promise, resolve, reject }
}

View File

@ -0,0 +1,72 @@
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
import type { LiveSnapshot, TeamRun } from '@/api'
export type AgentsView = 'roster' | 'live' | 'plans'
export interface AgentsLiveRouteState {
view: AgentsView
runId: string | null
taskId: string | null
requiredRunId: string | null
}
export interface AgentsLiveSelection {
selectedRunId: string | null
selectedTaskId: string | null
selectedWorker: { taskId: string; conversationId: string | null; online: boolean } | null
replaceQuery: LocationQueryRaw | null
}
function id(value: unknown): string | null {
return typeof value === 'string' && value ? value : null
}
export function parseAgentsLiveRoute(query: LocationQuery | Record<string, unknown>): AgentsLiveRouteState {
const view = query.view === 'live' || query.view === 'plans' ? query.view : 'roster'
const runId = view === 'live' ? id(query.teamRunId) : null
const taskId = runId ? id(query.taskId) : null
return { view, runId, taskId, requiredRunId: runId }
}
export function reconcileAgentsLiveRoute(route: AgentsLiveRouteState, runs: readonly TeamRun[], snapshot: LiveSnapshot | null): AgentsLiveSelection {
const empty = { selectedRunId: null, selectedTaskId: null, selectedWorker: null }
if (route.view !== 'live' || !route.runId) return { ...empty, replaceQuery: null }
const run = runs.find(item => item.id === route.runId)
if (!run) return { ...empty, replaceQuery: { view: 'live' } }
if (!route.taskId) return { selectedRunId: run.id, selectedTaskId: null, selectedWorker: null, replaceQuery: null }
const task = run.tasks.find(item => item.id === route.taskId)
if (!task) {
return { selectedRunId: run.id, selectedTaskId: null, selectedWorker: null, replaceQuery: { view: 'live', teamRunId: run.id } }
}
const online = task.conversationId != null && (snapshot?.runs ?? []).some(item => item.conversationId === task.conversationId)
return {
selectedRunId: run.id,
selectedTaskId: task.id,
selectedWorker: { taskId: task.id, conversationId: task.conversationId, online },
replaceQuery: null,
}
}
interface HydratorDependencies {
invalidatePoll: () => void
ensureRun: (runId: string | null, routeRevision: number) => Promise<unknown>
reconcile: (route: AgentsLiveRouteState) => AgentsLiveSelection
replace: (query: LocationQueryRaw) => Promise<unknown>
}
export function createAgentsLiveRouteHydrator(dependencies: HydratorDependencies) {
let revision = 0
async function hydrate(route: AgentsLiveRouteState) {
const expectedRevision = ++revision
dependencies.invalidatePoll()
await dependencies.ensureRun(route.view === 'live' ? route.requiredRunId : null, expectedRevision)
if (expectedRevision !== revision) return false
if (route.view !== 'live') return true
const correction = dependencies.reconcile(route).replaceQuery
if (correction) await dependencies.replace(correction)
return expectedRevision === revision
}
return { hydrate }
}

View File

@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import { isConversationReadOnly, parseTeamMessageMetadata, resolveWorkerRunContext } from '../messageMetadata'
import type { TeamRun } from '@/api'
import type { Message } from '@/types'
const message = (overrides: Partial<Message> = {}): Message => ({
id: '100',
conversationId: 'lead-conversation',
role: 'user',
content: 'hello',
contentParts: [],
...overrides,
})
const run = (overrides: Partial<TeamRun> = {}): TeamRun => ({
id: '9007199254740991',
teamId: '20',
workspaceId: '30',
leadAgentId: '40',
leadConversationId: 'lead-conversation',
originMessageId: '100',
title: 'Research',
objective: 'Research the launch',
status: 'running',
finalSummary: null,
stopReason: null,
metadata: null,
startedAt: null,
completedAt: null,
createTime: null,
updateTime: null,
progress: { total: 1, done: 0, failed: 0, inReview: 0, percent: 0 },
tasks: [{
id: '501', teamId: '20', runId: '9007199254740991', taskNumber: 1,
subject: 'Collect facts', description: null, status: 'in_progress', priority: 0,
taskType: 'general', assigneeAgentId: '41', ownerAgentId: null, blockedBy: null,
requireApproval: false, progressPercent: 10, progressStep: null, result: null,
reason: null, conversationId: 'worker-conversation', metadata: null,
createTime: null, updateTime: null,
}],
...overrides,
})
describe('parseTeamMessageMetadata', () => {
it('defensively parses double-encoded metadata and keeps ids as strings', () => {
const metadata = JSON.stringify(JSON.stringify({
type: 'team_run',
runId: '9007199254740991',
taskId: '501',
originMessageId: '100',
}))
expect(parseTeamMessageMetadata(message({ metadata: metadata as never }))).toMatchObject({
type: 'team_run',
runId: '9007199254740991',
taskId: '501',
originMessageId: '100',
isTeamRunProtocol: true,
isTeamAnnounce: false,
})
})
it('rejects unsafe numeric ids instead of rounding Snowflake values', () => {
const parsed = parseTeamMessageMetadata(message({
metadata: { type: 'team_run', runId: 9007199254740992 } as never,
}))
expect(parsed.runId).toBeUndefined()
})
it('uses the content prefix only for legacy null-run announcements', () => {
const legacy = parseTeamMessageMetadata(message({ content: '[System Message] settled' }))
const linked = parseTeamMessageMetadata(message({
content: '[System Message] settled',
metadata: { runId: '77' } as never,
}))
expect(legacy).toMatchObject({ isTeamAnnounce: true, isLegacyTeamAnnounce: true })
expect(linked).toMatchObject({ isTeamAnnounce: false, isLegacyTeamAnnounce: false })
})
})
describe('resolveWorkerRunContext', () => {
it('uses explicit route ids and backend task mappings without conversation-name inference', () => {
expect(resolveWorkerRunContext({
messages: [], runs: [run()], conversationId: 'worker-conversation',
routeRunId: '9007199254740991', routeTaskId: '501',
})).toMatchObject({ runId: '9007199254740991', taskId: '501', source: 'route' })
expect(resolveWorkerRunContext({
messages: [], runs: [run()], conversationId: 'worker-conversation',
})).toMatchObject({ runId: '9007199254740991', taskId: '501', source: 'projection' })
expect(resolveWorkerRunContext({
messages: [], runs: [], conversationId: 'team-task-501',
})).toBeNull()
expect(resolveWorkerRunContext({
messages: [], runs: [run()], conversationId: 'different-conversation',
routeRunId: '9007199254740991', routeTaskId: '501',
})).toBeNull()
})
it('requires persisted metadata to match a projected worker task', () => {
const metadataRun = run({
id: '77',
tasks: [{ ...run().tasks[0], id: '88', runId: '77' }],
})
expect(resolveWorkerRunContext({
messages: [message({
conversationId: 'worker-conversation',
metadata: { runId: '77', taskId: '88', leadConversationId: 'lead' } as never,
})],
runs: [metadataRun],
conversationId: 'worker-conversation',
})).toEqual({
runId: '77', taskId: '88', leadConversationId: 'lead-conversation', teamId: '20',
source: 'metadata',
})
expect(resolveWorkerRunContext({
messages: [message({ metadata: { runId: '77', taskId: '88' } as never })],
runs: [], conversationId: 'worker-conversation',
})).toBeNull()
})
it('does not treat run-aware announce bookkeeping as a worker conversation', () => {
expect(resolveWorkerRunContext({
messages: [message({
conversationId: 'lead-conversation',
metadata: { type: 'team_announce', runId: '9007199254740991', taskId: '501' } as never,
})],
runs: [run()], conversationId: 'lead-conversation',
})).toBeNull()
})
})
describe('isConversationReadOnly', () => {
it('blocks sends only after worker context has been verified', () => {
const verified = resolveWorkerRunContext({
messages: [], runs: [run()], conversationId: 'worker-conversation',
routeRunId: '9007199254740991', routeTaskId: '501',
})
expect(isConversationReadOnly(verified)).toBe(true)
expect(isConversationReadOnly(null)).toBe(false)
})
})

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { assembleTeamRunTimeline } from '../teamRunTimeline'
import type { TeamRun } from '@/api'
import type { Message } from '@/types'
const message = (id: string, role: Message['role'], content: string, metadata?: unknown): Message => ({
id, conversationId: 'lead', role, content, contentParts: [], metadata: metadata as never,
})
const run = (id: string, originMessageId: string | null): TeamRun => ({
id, teamId: `team-${id}`, workspaceId: 'workspace', leadAgentId: 'lead-agent',
leadConversationId: 'lead', originMessageId, title: `Run ${id}`, objective: 'Work',
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: [],
})
const keys = (items: ReturnType<typeof assembleTeamRunTimeline>) => items.map(item =>
item.type === 'message' ? `m:${item.message.id}` : `r:${item.run.id}`)
describe('assembleTeamRunTimeline', () => {
it('keeps the origin user message and anchors its run immediately after it', () => {
const messages = [
message('1', 'assistant', 'before'),
message('2', 'user', 'delegate'),
message('3', 'assistant', 'after'),
]
expect(keys(assembleTeamRunTimeline(messages, [run('10', '2')]))).toEqual([
'm:1', 'm:2', 'r:10', 'm:3',
])
})
it('absorbs only same-run bookkeeping while preserving unrelated order', () => {
const messages = [
message('1', 'user', 'delegate'),
message('2', 'user', 'protocol', { type: 'team_announce', runId: '10', taskId: '101' }),
message('3', 'assistant', 'reply', { type: 'team_announce_reply', runId: '10', taskId: '101' }),
message('4', 'assistant', 'unrelated'),
message('5', 'user', '[System Message] legacy settlement'),
message('6', 'user', 'unknown run', { type: 'team_announce', runId: '99' }),
]
expect(keys(assembleTeamRunTimeline(messages, [run('10', '1')]))).toEqual([
'm:1', 'r:10', 'm:4', 'm:5', 'm:6',
])
})
it('supports multiple runs sharing an origin and de-duplicates projections by string id', () => {
const messages = [message('1', 'user', 'delegate'), message('2', 'assistant', 'done')]
expect(keys(assembleTeamRunTimeline(messages, [
run('10', '1'), run('11', '1'), run('10', '1'),
]))).toEqual(['m:1', 'r:10', 'r:11', 'm:2'])
})
it('uses the first bookkeeping position when paginated history omits the origin', () => {
const messages = [
message('20', 'assistant', 'older visible'),
message('21', 'user', 'run update', { type: 'team_announce', runId: '10' }),
message('22', 'assistant', 'later'),
]
expect(keys(assembleTeamRunTimeline(messages, [run('10', '2')]))).toEqual([
'm:20', 'r:10', 'm:22',
])
})
it('appends runs with neither a visible origin nor bookkeeping without changing messages', () => {
const messages = [message('20', 'assistant', 'history'), message('21', 'user', 'question')]
expect(keys(assembleTeamRunTimeline(messages, [run('10', null)]))).toEqual([
'm:20', 'm:21', 'r:10',
])
expect(messages).toHaveLength(2)
})
})

View File

@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useStream } from '@/composables/chat/useStream'
describe('useStream SSE event ids', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('keeps adjacent JavaScript-safe ids distinct in Number comparisons', async () => {
const firstId = '9007199254740990'
const secondId = '9007199254740991'
const payload = [
`id: ${firstId}\nevent: content_delta\ndata: {"value":1}\n\n`,
`id: ${secondId}\nevent: content_delta\ndata: {"value":2}\n\n`,
].join('')
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(payload, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})))
vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) })
const stream = useStream({ url: '/api/test-stream' })
const receivedIds: string[] = []
stream.onEvent(event => receivedIds.push(event.id!))
await stream.connect({ conversationId: '1' })
expect(receivedIds).toEqual([firstId, secondId])
expect(stream.lastEventId.value).toBe(secondId)
})
})

View File

@ -0,0 +1,192 @@
import { effectScope, nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { useTeamRuns, type TeamRunsDependencies } from '../useTeamRuns'
import type { TeamRun } from '@/api'
import type { TeamBoardEvent } from '@/composables/useTeamEvents'
const run = (id: string, teamId = 'team-1', status: TeamRun['status'] = 'running'): TeamRun => ({
id, teamId, workspaceId: 'workspace', leadAgentId: 'lead-agent', leadConversationId: 'lead',
originMessageId: '100', title: `Run ${id}`, objective: 'Work', status,
finalSummary: null, stopReason: null, metadata: null, startedAt: null, completedAt: null,
createTime: null, updateTime: null,
progress: { total: 1, done: status === 'completed' ? 1 : 0, failed: 0, inReview: 0, percent: status === 'completed' ? 100 : 0 },
tasks: [],
})
const flush = async () => {
await Promise.resolve()
await Promise.resolve()
await nextTick()
}
describe('useTeamRuns', () => {
it('hydrates by conversation, de-duplicates runs, and subscribes once per team', async () => {
let onEvent: ((event: TeamBoardEvent) => void) | undefined
const cleanup = vi.fn()
const dependencies: TeamRunsDependencies = {
listByConversation: vi.fn().mockResolvedValue({ data: [run('10'), run('10'), run('11')] }),
getRun: vi.fn(),
subscribe: vi.fn((_teamId, callback) => { onEvent = callback; return cleanup }),
}
const conversationId = ref('lead')
const scope = effectScope()
const state = scope.run(() => useTeamRuns(conversationId, { dependencies }))!
await flush()
expect(state.runs.value.map(item => item.id)).toEqual(['10', '11'])
expect(dependencies.subscribe).toHaveBeenCalledTimes(1)
expect(onEvent).toBeTypeOf('function')
scope.stop()
expect(cleanup).toHaveBeenCalledOnce()
})
it('merges a stream projection immediately and replaces it with refreshed detail', async () => {
let onEvent: ((event: TeamBoardEvent) => void) | undefined
const completed = run('10', 'team-1', 'completed')
const dependencies: TeamRunsDependencies = {
listByConversation: vi.fn().mockResolvedValue({ data: [run('10')] }),
getRun: vi.fn().mockResolvedValue({ data: completed }),
subscribe: vi.fn((_teamId, callback) => { onEvent = callback; return vi.fn() }),
}
const scope = effectScope()
const state = scope.run(() => useTeamRuns(ref('lead'), { dependencies }))!
await flush()
onEvent!({ event: 'team_run_completed', data: {
runId: '10', status: 'completed', progress: completed.progress,
} })
expect(state.runs.value[0].status).toBe('completed')
await flush()
expect(dependencies.getRun).toHaveBeenCalledWith('10')
expect(state.runs.value[0]).toEqual(completed)
scope.stop()
})
it('coalesces duplicate run events while a detail refresh is in flight', async () => {
let onEvent: ((event: TeamBoardEvent) => void) | undefined
let resolveDetail!: (value: { data: TeamRun }) => void
const detail = new Promise<{ data: TeamRun }>(resolve => { resolveDetail = resolve })
const dependencies: TeamRunsDependencies = {
listByConversation: vi.fn().mockResolvedValue({ data: [run('10')] }),
getRun: vi.fn().mockReturnValue(detail),
subscribe: vi.fn((_teamId, callback) => { onEvent = callback; return vi.fn() }),
}
const scope = effectScope()
scope.run(() => useTeamRuns(ref('lead'), { dependencies }))
await flush()
onEvent!({ id: '1', event: 'team_run_progress', data: { runId: '10' } })
onEvent!({ id: '1', event: 'team_run_progress', data: { runId: '10' } })
expect(dependencies.getRun).toHaveBeenCalledTimes(1)
resolveDetail({ data: run('10') })
await flush()
scope.stop()
})
it('ignores team events belonging to another lead conversation', async () => {
let onEvent: ((event: TeamBoardEvent) => void) | undefined
const dependencies: TeamRunsDependencies = {
listByConversation: vi.fn().mockResolvedValue({ data: [run('10')] }),
getRun: vi.fn(),
subscribe: vi.fn((_teamId, callback) => { onEvent = callback; return vi.fn() }),
}
const scope = effectScope()
scope.run(() => useTeamRuns(ref('lead'), { dependencies }))
await flush()
onEvent!({ event: 'team_run_started', data: {
runId: '99', leadConversationId: 'another-lead',
} })
await flush()
expect(dependencies.getRun).not.toHaveBeenCalled()
scope.stop()
})
it('loads a deep-linked run and cleans up old subscriptions on conversation changes', async () => {
const cleanups = [vi.fn(), vi.fn()]
const dependencies: TeamRunsDependencies = {
listByConversation: vi.fn()
.mockResolvedValueOnce({ data: [] })
.mockResolvedValueOnce({ data: [run('20', 'team-2')] }),
getRun: vi.fn().mockResolvedValue({ data: run('10') }),
subscribe: vi.fn()
.mockImplementationOnce(() => cleanups[0])
.mockImplementationOnce(() => cleanups[1]),
}
const conversationId = ref('worker')
const linkedRunId = ref<string | undefined>('10')
const scope = effectScope()
const state = scope.run(() => useTeamRuns(conversationId, { linkedRunId, dependencies }))!
await flush()
expect(state.runs.value.map(item => item.id)).toEqual(['10'])
conversationId.value = 'lead-2'
linkedRunId.value = undefined
await flush()
expect(cleanups[0]).toHaveBeenCalledOnce()
expect(state.runs.value.map(item => item.id)).toEqual(['20'])
scope.stop()
expect(cleanups[1]).toHaveBeenCalledOnce()
})
it('validates a linked run with getRun even when conversation listing fails', async () => {
const dependencies: TeamRunsDependencies = {
listByConversation: vi.fn().mockRejectedValue(new Error('conversation runs unavailable')),
getRun: vi.fn().mockResolvedValue({ data: run('10') }),
subscribe: vi.fn(() => vi.fn()),
}
const scope = effectScope()
const state = scope.run(() => useTeamRuns(ref('worker'), {
linkedRunId: ref('10'), dependencies,
}))!
await flush()
expect(dependencies.getRun).toHaveBeenCalledWith('10')
expect(state.runs.value.map(item => item.id)).toEqual(['10'])
scope.stop()
})
it('isolates deferred detail requests and subscription callbacks by conversation generation', async () => {
const callbacks: Array<(event: TeamBoardEvent) => void> = []
let resolveOld!: (value: { data: TeamRun }) => void
let resolveFresh!: (value: { data: TeamRun }) => void
const oldDetail = new Promise<{ data: TeamRun }>(resolve => { resolveOld = resolve })
const freshDetail = new Promise<{ data: TeamRun }>(resolve => { resolveFresh = resolve })
const dependencies: TeamRunsDependencies = {
listByConversation: vi.fn().mockImplementation((conversationId: string) =>
Promise.resolve({ data: [run('10', conversationId === 'A' ? 'team-a' : 'team-b')] })),
getRun: vi.fn()
.mockReturnValueOnce(oldDetail)
.mockReturnValueOnce(freshDetail),
subscribe: vi.fn((_teamId, callback) => {
callbacks.push(callback)
return vi.fn()
}),
}
const conversationId = ref('A')
const scope = effectScope()
const state = scope.run(() => useTeamRuns(conversationId, { dependencies }))!
await flush()
callbacks[0]({ event: 'team_run_progress', data: { runId: '10' } })
conversationId.value = 'B'
await flush()
callbacks[0]({ event: 'team_run_completed', data: { runId: '10', status: 'completed' } })
expect(state.runs.value[0].status).toBe('running')
conversationId.value = 'A'
await flush()
callbacks[2]({ event: 'team_run_progress', data: { runId: '10' } })
expect(dependencies.getRun).toHaveBeenCalledTimes(2)
resolveOld({ data: run('10', 'team-a', 'failed') })
await flush()
expect(state.runs.value[0].status).toBe('running')
resolveFresh({ data: run('10', 'team-a', 'completed') })
await flush()
expect(state.runs.value[0].status).toBe('completed')
scope.stop()
})
})

View File

@ -0,0 +1,129 @@
import type { TeamRun } from '@/api'
import type { Message } from '@/types'
const TEAM_RUN_TYPES = new Set([
'team_run',
'team_run_start',
'team_run_started',
'team_run_sealed',
'team_run_protocol',
])
export interface ParsedTeamMessageMetadata {
type?: string
runId?: string
taskId?: string
originMessageId?: string
teamId?: string
leadConversationId?: string
isTeamRunProtocol: boolean
isTeamAnnounce: boolean
isLegacyTeamAnnounce: boolean
}
export interface WorkerRunContext {
runId: string
taskId: string
teamId?: string
leadConversationId?: string
source: 'route' | 'metadata' | 'projection'
}
export function isConversationReadOnly(context: WorkerRunContext | null): boolean {
return context !== null
}
function parseObject(value: unknown): Record<string, unknown> {
let current = value
for (let depth = 0; depth < 2 && typeof current === 'string'; depth += 1) {
try {
current = JSON.parse(current)
} catch {
return {}
}
}
return current !== null && typeof current === 'object' && !Array.isArray(current)
? current as Record<string, unknown>
: {}
}
function stringId(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function parseTeamMessageMetadata(message: Message): ParsedTeamMessageMetadata {
const metadata = parseObject(message.metadata)
const type = typeof metadata.type === 'string' ? metadata.type : undefined
const runId = stringId(metadata.runId)
const explicitAnnounce = type === 'team_announce' || type === 'team_announce_reply'
const isLegacyTeamAnnounce = !runId
&& message.role === 'user'
&& typeof message.content === 'string'
&& message.content.startsWith('[System Message] ')
return {
type,
runId,
taskId: stringId(metadata.taskId),
originMessageId: stringId(metadata.originMessageId),
teamId: stringId(metadata.teamId),
leadConversationId: stringId(metadata.leadConversationId),
isTeamRunProtocol: Boolean(type && (TEAM_RUN_TYPES.has(type) || type.startsWith('team_run_'))),
isTeamAnnounce: explicitAnnounce || isLegacyTeamAnnounce,
isLegacyTeamAnnounce,
}
}
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'
}
export function resolveWorkerRunContext(input: {
messages: Message[]
runs: TeamRun[]
conversationId: string
routeRunId?: string
routeTaskId?: string
}): WorkerRunContext | null {
const { messages, runs, conversationId, routeRunId, routeTaskId } = input
const projectedContext = (
runId: string,
taskId: string,
source: WorkerRunContext['source'],
): WorkerRunContext | null => {
const run = runs.find(candidate => candidate.id === runId)
const task = run?.tasks.find(candidate =>
candidate.id === taskId && candidate.conversationId === conversationId)
if (!run || !task) return null
return {
runId: run.id,
taskId: task.id,
teamId: run.teamId,
leadConversationId: run.leadConversationId,
source,
}
}
if (routeRunId && routeTaskId) {
return projectedContext(routeRunId, routeTaskId, 'route')
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const metadata = parseTeamMessageMetadata(messages[index])
if (metadata.runId && metadata.taskId
&& !metadata.isTeamAnnounce && !metadata.isTeamRunProtocol) {
const context = projectedContext(metadata.runId, metadata.taskId, 'metadata')
if (context) return context
}
}
for (const run of runs) {
const task = run.tasks.find(task => task.conversationId === conversationId)
if (task) {
return projectedContext(run.id, task.id, 'projection')
}
}
return null
}

Some files were not shown because too many files have changed in this diff Show More