mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(team): unify team run experience (#596)
This commit is contained in:
parent
bd35f28dad
commit
dc3506f748
@ -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 ----------------
|
||||||
|
|||||||
@ -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运行状态")
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 ≤ 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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package vip.mate.team.event;
|
||||||
|
|
||||||
|
/** Requests a team dispatch after the run transaction commits. */
|
||||||
|
public record TeamRunDispatchCommittedIntent(Long teamId) {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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. */
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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. */
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 "-";
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"));
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"));
|
||||||
|
|||||||
92
mateclaw-ui/src/api/__tests__/teamRuns.test.ts
Normal file
92
mateclaw-ui/src/api/__tests__/teamRuns.test.ts
Normal 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"}')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
77
mateclaw-ui/src/components/chat/TeamWorkerBanner.vue
Normal file
77
mateclaw-ui/src/components/chat/TeamWorkerBanner.vue
Normal 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>
|
||||||
@ -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' } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
68
mateclaw-ui/src/components/live/AgentRunGroups.vue
Normal file
68
mateclaw-ui/src/components/live/AgentRunGroups.vue
Normal 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>
|
||||||
45
mateclaw-ui/src/components/live/AgentRunWorkerRow.vue
Normal file
45
mateclaw-ui/src/components/live/AgentRunWorkerRow.vue
Normal 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>
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
105
mateclaw-ui/src/components/team-run/TeamRunCard.vue
Normal file
105
mateclaw-ui/src/components/team-run/TeamRunCard.vue
Normal 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>
|
||||||
144
mateclaw-ui/src/components/team-run/TeamRunDetail.vue
Normal file
144
mateclaw-ui/src/components/team-run/TeamRunDetail.vue
Normal 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>
|
||||||
61
mateclaw-ui/src/components/team-run/TeamRunDrawer.vue
Normal file
61
mateclaw-ui/src/components/team-run/TeamRunDrawer.vue
Normal 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>
|
||||||
72
mateclaw-ui/src/components/team-run/TeamRunProgress.vue
Normal file
72
mateclaw-ui/src/components/team-run/TeamRunProgress.vue
Normal 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>
|
||||||
77
mateclaw-ui/src/components/team-run/TeamRunStatus.vue
Normal file
77
mateclaw-ui/src/components/team-run/TeamRunStatus.vue
Normal 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>
|
||||||
114
mateclaw-ui/src/components/team-run/TeamRunTaskList.vue
Normal file
114
mateclaw-ui/src/components/team-run/TeamRunTaskList.vue
Normal 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>
|
||||||
74
mateclaw-ui/src/components/team-run/TeamRunsPanel.vue
Normal file
74
mateclaw-ui/src/components/team-run/TeamRunsPanel.vue
Normal 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>
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
8
mateclaw-ui/src/components/team-run/index.ts
Normal file
8
mateclaw-ui/src/components/team-run/index.ts
Normal 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'
|
||||||
220
mateclaw-ui/src/components/team-run/teamRunPresentation.ts
Normal file
220
mateclaw-ui/src/components/team-run/teamRunPresentation.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
15
mateclaw-ui/src/composables/__tests__/sseEventIds.test.ts
Normal file
15
mateclaw-ui/src/composables/__tests__/sseEventIds.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
102
mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts
Normal file
102
mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
153
mateclaw-ui/src/composables/__tests__/useTeamEvents.test.ts
Normal file
153
mateclaw-ui/src/composables/__tests__/useTeamEvents.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
218
mateclaw-ui/src/composables/__tests__/useTeamRunHistory.test.ts
Normal file
218
mateclaw-ui/src/composables/__tests__/useTeamRunHistory.test.ts
Normal 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 }
|
||||||
|
}
|
||||||
72
mateclaw-ui/src/composables/agentsLiveRouteState.ts
Normal file
72
mateclaw-ui/src/composables/agentsLiveRouteState.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
192
mateclaw-ui/src/composables/chat/__tests__/useTeamRuns.test.ts
Normal file
192
mateclaw-ui/src/composables/chat/__tests__/useTeamRuns.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
129
mateclaw-ui/src/composables/chat/messageMetadata.ts
Normal file
129
mateclaw-ui/src/composables/chat/messageMetadata.ts
Normal 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
Loading…
Reference in New Issue
Block a user