mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +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
|
||||
* 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}. */
|
||||
public static final String CTX_KEY = "mateclaw.chatOrigin";
|
||||
|
||||
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||
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 ----------------
|
||||
|
||||
@ -117,7 +131,7 @@ public record ChatOrigin(
|
||||
return new ChatOrigin(null, conversationId,
|
||||
requesterId != null ? requesterId : "",
|
||||
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
|
||||
requesterUserId);
|
||||
requesterUserId, null);
|
||||
}
|
||||
|
||||
public static ChatOrigin cron(@Nullable String conversationId,
|
||||
@ -126,7 +140,8 @@ public record ChatOrigin(
|
||||
@Nullable Long channelId,
|
||||
@Nullable ChannelTarget target) {
|
||||
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 ----------------
|
||||
@ -134,27 +149,27 @@ public record ChatOrigin(
|
||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
|
||||
}
|
||||
|
||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||
@Nullable String newWorkspaceBasePath) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId, originMessageId);
|
||||
}
|
||||
|
||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||
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()}). */
|
||||
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
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) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
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 ----------------
|
||||
|
||||
@ -27,6 +27,9 @@ import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
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.util.List;
|
||||
@ -47,6 +50,7 @@ import java.util.concurrent.Executors;
|
||||
public class AgentController {
|
||||
|
||||
private final AgentService agentService;
|
||||
private final ConversationService conversationService;
|
||||
private final AuditEventService auditEventService;
|
||||
private final AuthService authService;
|
||||
private final WorkspaceService workspaceService;
|
||||
@ -211,12 +215,13 @@ public class AgentController {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
verifyAgentEnabled(agent);
|
||||
ChatOrigin origin = persistOrigin(agent, id, message, conversationId, workspaceId);
|
||||
|
||||
// RFC-058 PR-1: Utf8SseEmitter 显式 charset=UTF-8,防止中文 SSE 乱码
|
||||
SseEmitter emitter = new Utf8SseEmitter(5 * 60 * 1000L);
|
||||
sseExecutor.execute(() -> {
|
||||
try {
|
||||
agentService.chatStream(id, message, conversationId)
|
||||
agentService.chatStream(id, message, conversationId, origin)
|
||||
.doOnNext(chunk -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("message").data(chunk));
|
||||
@ -251,7 +256,9 @@ public class AgentController {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
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)")
|
||||
@ -264,7 +271,21 @@ public class AgentController {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
|
||||
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运行状态")
|
||||
|
||||
@ -827,7 +827,8 @@ public class ChannelMessageRouter {
|
||||
// through unchanged (chatId is null).
|
||||
List<MessageContentPart> parts = message.getContentParts();
|
||||
String attributedContent = applyGroupTag(message, message.getContent());
|
||||
conversationService.saveMessage(conversationId, "user", attributedContent, parts);
|
||||
MessageEntity savedUser = conversationService.saveMessage(
|
||||
conversationId, "user", attributedContent, parts);
|
||||
|
||||
// 构建 prompt(语音输入时注入场景提示词)
|
||||
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
|
||||
@ -853,7 +854,8 @@ public class ChannelMessageRouter {
|
||||
// so cron jobs created during this conversation inherit the
|
||||
// channel binding (Issue #25 root path).
|
||||
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) {
|
||||
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
|
||||
// (Web channel today; future streaming IM channels inherit it).
|
||||
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());
|
||||
promptText = applyGroupTag(message, promptText);
|
||||
// RFC-063r §2.5: forward ChatOrigin so tools created during this
|
||||
// streaming conversation inherit channel binding.
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -581,10 +581,15 @@ public class ChatController {
|
||||
? regenerateSeed.parts()
|
||||
: normalizeRequestParts(request);
|
||||
String promptText = buildPromptText(message, requestParts);
|
||||
Long originMessageId;
|
||||
if (regenerateSeed == null) {
|
||||
// Regenerate reuses the already-persisted seed user row —
|
||||
// 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");
|
||||
|
||||
@ -602,7 +607,8 @@ public class ChatController {
|
||||
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
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)
|
||||
.doOnNext(delta -> {
|
||||
if (emitterDone.get()) return;
|
||||
@ -1105,13 +1111,16 @@ public class ChatController {
|
||||
return R.fail(401, "未登录,请先登录");
|
||||
}
|
||||
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());
|
||||
// Carry the web origin so per-owner memory recall (read) and the
|
||||
// post-conversation memory write below agree on the same owner key.
|
||||
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);
|
||||
String response = result.content();
|
||||
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
|
||||
@ -1417,9 +1426,11 @@ public class ChatController {
|
||||
// 持久化排队的用户消息(含 contentParts;幂等:如果 /interrupt 已提前持久化则跳过)。
|
||||
// 这里持久化是为了确保 user 消息在 assistant 消息(doOnError/doOnCancel 已写入)之后落库,
|
||||
// 让 listMessages ORDER BY create_time ASC 后顺序正确:Q1 → Asst1 → Q2 → Asst2。
|
||||
Long queuedOriginMessageId = null;
|
||||
if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) {
|
||||
conversationService.saveMessage(conversationId, "user", queuedMessage,
|
||||
MessageEntity savedUser = conversationService.saveMessage(conversationId, "user", queuedMessage,
|
||||
preConsumedInput.contentParts(), "queued");
|
||||
queuedOriginMessageId = savedUser == null ? null : savedUser.getId();
|
||||
}
|
||||
|
||||
// 广播 queued_input_started 事件
|
||||
@ -1444,7 +1455,8 @@ public class ChatController {
|
||||
// turn keeps a consistent (null-channel) binding.
|
||||
vip.mate.agent.context.ChatOrigin queuedOrigin =
|
||||
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)
|
||||
.doOnNext(delta -> {
|
||||
if (emitterDone.get()) return;
|
||||
|
||||
@ -58,6 +58,8 @@ public class ChatStreamTracker {
|
||||
|
||||
/** buffer 最大事件数,超出后丢弃最早的 thinking_delta 事件以释放空间 */
|
||||
private static final int MAX_BUFFER_SIZE = 16000;
|
||||
private static final SseEventIdGenerator EVENT_IDS =
|
||||
new SseEventIdGenerator(System::currentTimeMillis);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@ -130,10 +132,9 @@ public class ChatStreamTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* One buffered SSE event. The {@code id} is a per-conversation monotonic
|
||||
* sequence — the SSE protocol's standard {@code id:} line carries this
|
||||
* value so the client can echo it back via {@code lastEventId} when
|
||||
* reconnecting, allowing us to skip already-delivered events on replay.
|
||||
* One buffered SSE event. The {@code id} is process-global and monotonic,
|
||||
* with a wall-clock floor so a normally restarted process starts above
|
||||
* ids emitted by its predecessor.
|
||||
*/
|
||||
record SseEvent(long id, String name, String json) {}
|
||||
|
||||
@ -155,15 +156,6 @@ public class ChatStreamTracker {
|
||||
volatile boolean done;
|
||||
/** Guarded by lock; once true, cleanup owns this state. */
|
||||
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 流 */
|
||||
volatile Disposable disposable;
|
||||
/** 停止标志:requestStop() 设为 true,各图节点和 LLM 调用检查此标志以提前退出 */
|
||||
@ -673,7 +665,7 @@ public class ChatStreamTracker {
|
||||
return;
|
||||
}
|
||||
if ((isDone || isAsyncTask) || (!isHeartbeat && !skipBuffer)) {
|
||||
eventId = ++state.nextEventId;
|
||||
eventId = EVENT_IDS.nextId();
|
||||
state.buffer.add(new SseEvent(eventId, eventName, jsonData));
|
||||
if (state.buffer.size() > MAX_BUFFER_SIZE) {
|
||||
trimBuffer(state.buffer);
|
||||
@ -747,7 +739,7 @@ public class ChatStreamTracker {
|
||||
if (isDone || isAsyncTask) {
|
||||
if (state == null) return;
|
||||
synchronized (state.lock) {
|
||||
long id = ++state.nextEventId;
|
||||
long id = EVENT_IDS.nextId();
|
||||
SseEvent ev = new SseEvent(id, eventName, jsonData);
|
||||
state.buffer.add(ev);
|
||||
if (state.buffer.size() > MAX_BUFFER_SIZE) {
|
||||
@ -801,9 +793,10 @@ public class ChatStreamTracker {
|
||||
}
|
||||
|
||||
synchronized (state.lock) {
|
||||
long eventId = 0L;
|
||||
if (!skipBuffer) {
|
||||
long id = ++state.nextEventId;
|
||||
SseEvent event = new SseEvent(id, eventName, jsonData);
|
||||
eventId = EVENT_IDS.nextId();
|
||||
SseEvent event = new SseEvent(eventId, eventName, jsonData);
|
||||
state.buffer.add(event);
|
||||
if (state.buffer.size() > MAX_BUFFER_SIZE) {
|
||||
trimBuffer(state.buffer);
|
||||
@ -816,7 +809,7 @@ public class ChatStreamTracker {
|
||||
if (skipBuffer) {
|
||||
emitter.send(SseEmitter.event().name(eventName).data(jsonData));
|
||||
} 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) {
|
||||
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
|
||||
* behavior — same as the no-arg overload).
|
||||
*
|
||||
* <p>The id is the per-conversation monotonic sequence stamped on
|
||||
* each {@link SseEvent} when it was first emitted. Frontend tracks
|
||||
* <p>The id is the process-global monotonic value stamped on each
|
||||
* {@link SseEvent} when it was first emitted. Frontend tracks
|
||||
* the last id it processed and echoes it back via the request
|
||||
* body's {@code lastEventId} field, eliminating the duplicate-
|
||||
* 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;
|
||||
conversationService.getOrCreateConversation(
|
||||
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
|
||||
// memory recall (read) and the post-turn memory write (below) agree
|
||||
// on the same owner key.
|
||||
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(
|
||||
talkSession.agentId, transcript, talkSession.conversationId, talkOrigin);
|
||||
String reply = chatResult.content();
|
||||
|
||||
@ -235,10 +235,13 @@ public class WebChatController {
|
||||
// 保存用户消息(含访客本轮引用的附件)。附件元数据一律服务端按 fileId 回查,
|
||||
// 不信客户端传入;path 用于 Agent 侧工具读取,对外消息视图会被剥离。
|
||||
List<MessageContentPart> userParts = buildUserParts(conversationId, message, request.getAttachmentIds());
|
||||
Long originMessageId = request.getInternalOriginMessageId();
|
||||
if (!request.isInternalSkipUserPersist()) {
|
||||
// Regenerate reuses the already-persisted seed user row —
|
||||
// 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 流跟踪
|
||||
@ -271,7 +274,8 @@ public class WebChatController {
|
||||
// (publish) paths below.
|
||||
vip.mate.agent.context.ChatOrigin webchatOrigin =
|
||||
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);
|
||||
|
||||
reactor.core.Disposable disposable = agentService.chatStructuredStream(resolvedAgentId, message, conversationId, visitorId, null, webchatOrigin)
|
||||
@ -1572,6 +1576,7 @@ public class WebChatController {
|
||||
req.setVisitorId(visitorId);
|
||||
req.setSessionId(sid);
|
||||
req.setInternalSkipUserPersist(true);
|
||||
req.setInternalOriginMessageId(seed.seedMessageId());
|
||||
return chatStream(apiKey, req);
|
||||
}
|
||||
|
||||
@ -2099,6 +2104,7 @@ public class WebChatController {
|
||||
*/
|
||||
@JsonIgnore
|
||||
private boolean internalSkipUserPersist;
|
||||
private Long internalOriginMessageId;
|
||||
}
|
||||
|
||||
/** Compact view of one of a visitor's conversation threads. */
|
||||
|
||||
@ -29,6 +29,10 @@ public class CronChatOriginFactory {
|
||||
private final AgentMapper agentMapper;
|
||||
|
||||
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;
|
||||
Long workspaceId = agent != null && agent.getWorkspaceId() != null ? agent.getWorkspaceId() : 1L;
|
||||
|
||||
@ -36,6 +40,6 @@ public class CronChatOriginFactory {
|
||||
ChannelTarget target = dc != null ? dc.toChannelTarget() : 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.memory.event.ConversationCompletionPublisher;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@ -45,6 +46,9 @@ import java.time.LocalDateTime;
|
||||
@RequiredArgsConstructor
|
||||
public class CronJobLifecycleService {
|
||||
|
||||
public record StartResult(CronJobRunEntity run, Long originMessageId) {
|
||||
}
|
||||
|
||||
private final CronJobRunMapper runMapper;
|
||||
private final ConversationService conversationService;
|
||||
private final ConversationCompletionPublisher completionPublisher;
|
||||
@ -60,8 +64,8 @@ public class CronJobLifecycleService {
|
||||
* @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow)
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType,
|
||||
String conversationId) {
|
||||
public StartResult startRun(CronJobEntity job, String userMessage, String triggerType,
|
||||
String conversationId) {
|
||||
CronJobRunEntity run = new CronJobRunEntity();
|
||||
run.setCronJobId(job.getId());
|
||||
run.setConversationId(conversationId);
|
||||
@ -90,10 +94,13 @@ public class CronJobLifecycleService {
|
||||
// Persist the user message before the LLM call so history reads
|
||||
// see a coherent (user → assistant) ordering even if the agent
|
||||
// throws mid-run.
|
||||
Long originMessageId = null;
|
||||
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);
|
||||
|
||||
// T1 — short tx
|
||||
CronJobRunEntity run;
|
||||
CronJobLifecycleService.StartResult started;
|
||||
try {
|
||||
run = lifecycle.startRun(job, userMessage, triggerType, conversationId);
|
||||
started = lifecycle.startRun(job, userMessage, triggerType, conversationId);
|
||||
} catch (Exception e) {
|
||||
log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||
return;
|
||||
}
|
||||
CronJobRunEntity run = started.run();
|
||||
|
||||
// task_type='reminder' — pure notification, no LLM call. The user
|
||||
// (or the create_reminder tool on their behalf) supplied the exact
|
||||
@ -141,7 +142,8 @@ public class CronJobRunner {
|
||||
AgentService.ChatResult chatResult;
|
||||
AssistantMessage result;
|
||||
try {
|
||||
ChatOrigin origin = originFactory.from(job, conversationId);
|
||||
ChatOrigin origin = originFactory.from(
|
||||
job, conversationId, started.originMessageId());
|
||||
chatResult = runAgent(job, userMessage, origin, conversationId);
|
||||
result = new AssistantMessage(chatResult.content());
|
||||
} catch (Exception e) {
|
||||
|
||||
@ -21,6 +21,7 @@ import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.team.service.TeamAnnounceService;
|
||||
import vip.mate.team.service.TeamDispatchService;
|
||||
import vip.mate.team.service.TeamEventChannel;
|
||||
import vip.mate.team.service.TeamManualTaskService;
|
||||
import vip.mate.team.service.TeamService;
|
||||
import vip.mate.team.service.TeamTaskService;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
@ -48,6 +49,7 @@ public class TeamController {
|
||||
|
||||
private final TeamService teamService;
|
||||
private final TeamTaskService taskService;
|
||||
private final TeamManualTaskService manualTaskService;
|
||||
private final TeamDispatchService dispatchService;
|
||||
private final TeamAnnounceService announceService;
|
||||
private final TeamEventChannel eventChannel;
|
||||
@ -162,9 +164,10 @@ public class TeamController {
|
||||
public R<TaskVO> createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTeam(id);
|
||||
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
|
||||
AgentTeamEntity team = requireTeam(id);
|
||||
TeamTaskEntity task = manualTaskService.createTask(team, TeamTaskCreateCommand.builder()
|
||||
.teamId(id)
|
||||
.runId(req.getRunId())
|
||||
.subject(req.getSubject())
|
||||
.description(req.getDescription())
|
||||
.assigneeAgentId(req.getAssigneeAgentId())
|
||||
@ -175,9 +178,6 @@ public class TeamController {
|
||||
.channel("dashboard")
|
||||
.build());
|
||||
eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
|
||||
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
|
||||
dispatchService.requestDispatch(id);
|
||||
}
|
||||
return R.ok(toTaskVO(task));
|
||||
});
|
||||
}
|
||||
@ -398,7 +398,8 @@ public class TeamController {
|
||||
private TaskVO toTaskVO(TeamTaskEntity task) {
|
||||
return new TaskVO(task,
|
||||
agentName(task.getAssigneeAgentId()),
|
||||
task.getOwnerAgentId() == null ? null : agentName(task.getOwnerAgentId()));
|
||||
task.getOwnerAgentId() == null ? null : agentName(task.getOwnerAgentId()),
|
||||
task.getRunId());
|
||||
}
|
||||
|
||||
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 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) {
|
||||
@ -447,6 +448,7 @@ public class TeamController {
|
||||
|
||||
@Data
|
||||
public static class CreateTaskRequest {
|
||||
private Long runId;
|
||||
private String subject;
|
||||
private String description;
|
||||
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 runId;
|
||||
|
||||
private String subject;
|
||||
|
||||
private String description;
|
||||
|
||||
@ -22,6 +22,8 @@ public class TeamTaskEntity {
|
||||
|
||||
private Long teamId;
|
||||
|
||||
private Long runId;
|
||||
|
||||
/** Human-readable sequential number, unique within the team. */
|
||||
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;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -14,19 +15,23 @@ import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Delivers settled task results back to the team lead. Results arriving close
|
||||
* together are debounced per lead conversation and merged into ONE combined
|
||||
* announcement, so parallel members finishing near-simultaneously wake the
|
||||
* lead once instead of once per task.
|
||||
* together are debounced per lead conversation and run and merged into ONE
|
||||
* combined announcement, so parallel members in the same run wake the lead
|
||||
* once instead of once per task. Different runs never share a batch.
|
||||
*
|
||||
* Delivery is guaranteed, not opportunistic: when the lead is mid-turn the
|
||||
* 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 ConversationService conversationService;
|
||||
|
||||
/** Pending items per lead conversation; the first item arms the drain timer. */
|
||||
private final Map<String, List<AnnounceItem>> pending = new ConcurrentHashMap<>();
|
||||
/** Pending items stay isolated by run while lead wake turns serialize by conversation. */
|
||||
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) {
|
||||
}
|
||||
|
||||
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
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
AnnounceItem item = new AnnounceItem(task.getTeamId(), task.getTaskNumber(),
|
||||
AnnounceItem item = new AnnounceItem(task.getId(), task.getTeamId(), task.getTaskNumber(),
|
||||
task.getSubject(), task.getStatus(),
|
||||
agentName(task.getAssigneeAgentId()),
|
||||
detailWithFiles.toString());
|
||||
|
||||
String key = task.getLeadConversationId();
|
||||
List<AnnounceItem> drainNow = null;
|
||||
BatchKey key = new BatchKey(task.getLeadConversationId(), task.getRunId());
|
||||
boolean drainNow = false;
|
||||
synchronized (pending) {
|
||||
List<AnnounceItem> queue = pending.computeIfAbsent(key, k -> new ArrayList<>());
|
||||
queue.add(item);
|
||||
if (queue.size() >= MAX_BATCH) {
|
||||
drainNow = pending.remove(key);
|
||||
} else if (queue.size() == 1) {
|
||||
PendingBatch batch = pending.computeIfAbsent(key,
|
||||
ignored -> new PendingBatch(batchSequence.incrementAndGet()));
|
||||
batch.items.add(item);
|
||||
if (batch.items.size() >= MAX_BATCH) {
|
||||
drainNow = true;
|
||||
} else if (batch.items.size() == 1) {
|
||||
DEBOUNCE_SCHEDULER.schedule(() -> drain(key), DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
if (drainNow != null) {
|
||||
deliver(key, drainNow);
|
||||
if (drainNow) {
|
||||
drain(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Timer callback: take whatever accumulated and deliver it. */
|
||||
void drain(String leadConversationId) {
|
||||
List<AnnounceItem> items;
|
||||
/** Acquire the conversation turn, then take and deliver one run-isolated batch. */
|
||||
void drain(BatchKey key) {
|
||||
String conversationId = key.conversationId();
|
||||
if (!drainOwners.add(conversationId)) {
|
||||
return;
|
||||
}
|
||||
PendingBatch batch;
|
||||
synchronized (pending) {
|
||||
items = pending.remove(leadConversationId);
|
||||
}
|
||||
if (items != null && !items.isEmpty()) {
|
||||
deliver(leadConversationId, items);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
batch = pending.get(key);
|
||||
if (batch != null && batch.readyAtMillis <= System.currentTimeMillis()) {
|
||||
pending.remove(key);
|
||||
} else {
|
||||
batch = null;
|
||||
}
|
||||
List<AnnounceItem> retryItems = merged;
|
||||
DEBOUNCE_SCHEDULER.schedule(() -> deliver(leadConversationId, retryItems, busyRetries + 1),
|
||||
BUSY_RETRY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
if (batch == null) {
|
||||
releaseAndScheduleNext(conversationId);
|
||||
return;
|
||||
}
|
||||
String message = buildAnnouncement(items);
|
||||
ANNOUNCE_EXECUTOR.submit(() -> wakeLead(team, leadConversationId, message, items.size()));
|
||||
PendingBatch ownedBatch = batch;
|
||||
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. */
|
||||
private void wakeLead(AgentTeamEntity team, String leadConversationId,
|
||||
String message, int taskCount) {
|
||||
try {
|
||||
private void wakeLead(AgentTeamEntity team, BatchKey key,
|
||||
String message, List<AnnounceItem> items) {
|
||||
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",
|
||||
Map.of("teamId", String.valueOf(team.getId()), "tasks", taskCount));
|
||||
startPayload);
|
||||
// Persist the announce turn: message persistence is the caller's
|
||||
// contract, and without it the lead's synthesized reply would
|
||||
// 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.
|
||||
conversationService.saveMessage(leadConversationId, "user", message, null, "completed",
|
||||
0, 0, null, null,
|
||||
"{\"type\":\"team_announce\",\"taskCount\":" + taskCount + "}");
|
||||
announceMetadata("team_announce", key, taskIds));
|
||||
AgentService.ChatResult result = agentService.chatWithUsage(
|
||||
team.getLeadAgentId(), message, leadConversationId);
|
||||
String reply = result == null ? null : result.content();
|
||||
if (reply != null && !reply.isBlank()) {
|
||||
conversationService.saveMessage(leadConversationId, "assistant", reply, null, "completed",
|
||||
0, 0, null, null,
|
||||
"{\"type\":\"team_announce_reply\"}");
|
||||
announceMetadata("team_announce_reply", key, taskIds));
|
||||
}
|
||||
streamTracker.broadcastObject(leadConversationId, "team_announce_reply",
|
||||
Map.of("teamId", String.valueOf(team.getId()),
|
||||
"content", reply == null ? "" : reply));
|
||||
log.info("Team {} lead woken with {} task result(s)", team.getId(), taskCount);
|
||||
} catch (Exception e) {
|
||||
log.warn("Team {} lead wake-up failed: {}", team.getId(), e.getMessage());
|
||||
Map<String, Object> replyPayload = new HashMap<>(startPayload);
|
||||
replyPayload.put("content", reply == null ? "" : reply);
|
||||
streamTracker.broadcastObject(leadConversationId, "team_announce_reply", replyPayload);
|
||||
log.info("Team {} lead woken with {} task result(s)", team.getId(), items.size());
|
||||
}
|
||||
|
||||
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. */
|
||||
|
||||
@ -156,10 +156,12 @@ public class TeamContextBuilder {
|
||||
return """
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@ -3,9 +3,10 @@ package vip.mate.team.service;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
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.agent.AgentService;
|
||||
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
|
||||
* depend on this service directly (bean cycle through the graph builder).
|
||||
*/
|
||||
@EventListener
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onTeamTasksDelegated(TeamTasksDelegatedEvent event) {
|
||||
requestDispatch(event.teamId());
|
||||
}
|
||||
|
||||
@ -5,9 +5,12 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.team.model.TeamRunView;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@ -38,12 +41,17 @@ public class TeamEventChannel {
|
||||
return;
|
||||
}
|
||||
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("taskNumber", task.getTaskNumber());
|
||||
payload.put("subject", task.getSubject());
|
||||
payload.put("teamId", String.valueOf(task.getTeamId()));
|
||||
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());
|
||||
streamTracker.register(channelId);
|
||||
@ -52,9 +60,41 @@ public class TeamEventChannel {
|
||||
if (task.getLeadConversationId() != null) {
|
||||
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) {
|
||||
// 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) {
|
||||
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 org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
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.TeamRole;
|
||||
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.TeamTaskStatus;
|
||||
|
||||
@ -50,6 +54,7 @@ public class TeamPlanBridge {
|
||||
|
||||
private final TeamService teamService;
|
||||
private final TeamTaskService taskService;
|
||||
private final TeamRunService runService;
|
||||
private final PlanningService planningService;
|
||||
private final AgentMapper agentMapper;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
@ -121,9 +126,30 @@ public class TeamPlanBridge {
|
||||
* referencing an earlier step); the caller guarantees
|
||||
* validity via its sequential-chain fallback
|
||||
*/
|
||||
@Transactional
|
||||
public String delegatePlan(AgentTeamEntity team, Long planId, String goal,
|
||||
List<String> steps, List<List<Integer>> stepDeps,
|
||||
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<>();
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
String step = steps.get(i);
|
||||
@ -133,6 +159,7 @@ public class TeamPlanBridge {
|
||||
}
|
||||
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
|
||||
.teamId(team.getId())
|
||||
.runId(run.getId())
|
||||
.subject(subjectOf(step))
|
||||
.description(step + "\n\n[Plan context]\nOverall request: " + goal)
|
||||
.assigneeAgentId(memberIds.get(i))
|
||||
@ -147,13 +174,21 @@ public class TeamPlanBridge {
|
||||
.build());
|
||||
created.add(task);
|
||||
}
|
||||
planningService.markPlanDelegated(planId);
|
||||
eventPublisher.publishEvent(new TeamTasksDelegatedEvent(team.getId()));
|
||||
sealAndPublish(team, planId, run);
|
||||
log.info("Plan {} delegated to team {} board as {} task(s)", planId, team.getId(),
|
||||
created.size());
|
||||
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 ====================
|
||||
|
||||
/** 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.TeamTaskEntity;
|
||||
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.repository.TeamTaskCommentMapper;
|
||||
import vip.mate.team.repository.TeamTaskEventMapper;
|
||||
@ -26,6 +28,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Shared task board service. All status transitions are guarded conditional
|
||||
@ -57,6 +60,8 @@ public class TeamTaskService {
|
||||
private final TeamTaskCommentMapper commentMapper;
|
||||
private final TeamTaskEventMapper eventMapper;
|
||||
private final TeamService teamService;
|
||||
private final TeamRunProjectionScheduler projectionScheduler;
|
||||
private final TeamRunService runService;
|
||||
|
||||
// ==================== creation ====================
|
||||
|
||||
@ -66,6 +71,15 @@ public class TeamTaskService {
|
||||
if (team == null || !TeamService.STATUS_ACTIVE.equals(team.getStatus())) {
|
||||
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()) {
|
||||
throw new IllegalArgumentException("subject is required");
|
||||
}
|
||||
@ -92,6 +106,9 @@ public class TeamTaskService {
|
||||
if (blocker == null || !blocker.getTeamId().equals(cmd.getTeamId())) {
|
||||
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())) {
|
||||
throw new IllegalArgumentException("blocking task " + blockerId
|
||||
+ " is already " + blocker.getStatus()
|
||||
@ -101,6 +118,7 @@ public class TeamTaskService {
|
||||
|
||||
TeamTaskEntity task = new TeamTaskEntity();
|
||||
task.setTeamId(cmd.getTeamId());
|
||||
task.setRunId(cmd.getRunId());
|
||||
task.setTaskNumber(teamService.nextTaskNumber(cmd.getTeamId()));
|
||||
task.setSubject(cmd.getSubject());
|
||||
task.setDescription(cmd.getDescription());
|
||||
@ -125,6 +143,7 @@ public class TeamTaskService {
|
||||
"assignee: agent " + assignee);
|
||||
log.info("Team {} task #{} created ({}), assignee={} status={}",
|
||||
cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus());
|
||||
projectTask(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@ -135,13 +154,17 @@ public class TeamTaskService {
|
||||
* get false. The WHERE clause is the mutex.
|
||||
*/
|
||||
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::getStatus, TeamTaskStatus.PENDING)
|
||||
.isNull(TeamTaskEntity::getOwnerAgentId)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.set(TeamTaskEntity::getOwnerAgentId, agentId)
|
||||
.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.
|
||||
*/
|
||||
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::getStatus, TeamTaskStatus.PENDING)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.set(TeamTaskEntity::getOwnerAgentId, agentId)
|
||||
.set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1;
|
||||
if (assigned) {
|
||||
projectTask(taskId);
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
/** Record the member conversation executing the task. */
|
||||
@ -207,7 +234,9 @@ public class TeamTaskService {
|
||||
toReview ? TeamTaskEventEntity.IN_REVIEW : TeamTaskEventEntity.COMPLETED,
|
||||
agentId != null ? AUTHOR_AGENT : AUTHOR_SYSTEM,
|
||||
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. */
|
||||
@ -221,7 +250,9 @@ public class TeamTaskService {
|
||||
if (rows != 1) {
|
||||
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. */
|
||||
@ -236,11 +267,14 @@ public class TeamTaskService {
|
||||
if (rows != 1) {
|
||||
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. */
|
||||
public boolean failTask(Long taskId, String reason) {
|
||||
TeamTaskEntity task = taskMapper.selectById(taskId);
|
||||
boolean failed = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.in(TeamTaskEntity::getStatus,
|
||||
@ -249,9 +283,9 @@ public class TeamTaskService {
|
||||
.set(TeamTaskEntity::getReason, reason)
|
||||
.set(TeamTaskEntity::getLockExpiresAt, null)) == 1;
|
||||
if (failed) {
|
||||
TeamTaskEntity task = taskMapper.selectById(taskId);
|
||||
recordEvent(task == null ? null : task.getTeamId(), taskId,
|
||||
TeamTaskEventEntity.FAILED, AUTHOR_SYSTEM, null, reason);
|
||||
projectTask(taskId);
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
@ -270,12 +304,14 @@ public class TeamTaskService {
|
||||
if (rows != 1) {
|
||||
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. */
|
||||
public boolean retryTask(Long taskId) {
|
||||
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
boolean retried = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.in(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED, TeamTaskStatus.STALE)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING)
|
||||
@ -283,12 +319,17 @@ public class TeamTaskService {
|
||||
.set(TeamTaskEntity::getLockExpiresAt, null)
|
||||
.set(TeamTaskEntity::getReason, null)
|
||||
.set(TeamTaskEntity::getDispatchCount, 0)) == 1;
|
||||
if (retried) {
|
||||
projectTask(taskId);
|
||||
}
|
||||
return retried;
|
||||
}
|
||||
|
||||
// ==================== progress / comments ====================
|
||||
|
||||
/** Update progress and renew the execution lease in one shot. */
|
||||
public boolean updateProgress(Long taskId, Long agentId, Integer percent, String step) {
|
||||
TeamTaskEntity task = taskMapper.selectById(taskId);
|
||||
boolean updated = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
@ -297,11 +338,11 @@ public class TeamTaskService {
|
||||
.set(step != null, TeamTaskEntity::getProgressStep, step)
|
||||
.set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1;
|
||||
if (updated) {
|
||||
TeamTaskEntity task = taskMapper.selectById(taskId);
|
||||
recordEvent(task == null ? null : task.getTeamId(), taskId,
|
||||
TeamTaskEventEntity.PROGRESS, AUTHOR_AGENT,
|
||||
agentId != null ? String.valueOf(agentId) : null,
|
||||
(percent != null ? percent + "%" : "") + (step != null ? " — " + step : ""));
|
||||
projectTask(taskId);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@ -524,12 +565,20 @@ public class TeamTaskService {
|
||||
* picks at most one per assignee so a member never runs two tasks at once.
|
||||
*/
|
||||
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::getStatus, TeamTaskStatus.PENDING)
|
||||
.isNotNull(TeamTaskEntity::getAssigneeAgentId)
|
||||
.orderByDesc(TeamTaskEntity::getPriority)
|
||||
.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. */
|
||||
@ -551,13 +600,16 @@ public class TeamTaskService {
|
||||
.isNotNull(TeamTaskEntity::getLockExpiresAt)
|
||||
.lt(TeamTaskEntity::getLockExpiresAt, LocalDateTime.now()));
|
||||
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::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE)
|
||||
.set(TeamTaskEntity::getReason, "execution lease expired"));
|
||||
recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE,
|
||||
AUTHOR_SYSTEM, null, "execution lease expired");
|
||||
if (rows == 1) {
|
||||
recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE,
|
||||
AUTHOR_SYSTEM, null, "execution lease expired");
|
||||
projectTask(task);
|
||||
}
|
||||
}
|
||||
if (!expired.isEmpty()) {
|
||||
log.warn("Marked {} team task(s) stale after lease expiry", expired.size());
|
||||
@ -571,6 +623,12 @@ public class TeamTaskService {
|
||||
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
|
||||
* plan linkage lives in the task metadata JSON ({@code "planId"} written
|
||||
@ -659,6 +717,7 @@ public class TeamTaskService {
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.PENDING));
|
||||
if (rows == 1) {
|
||||
released.add(candidate.getId());
|
||||
projectTask(candidate);
|
||||
}
|
||||
}
|
||||
if (!released.isEmpty()) {
|
||||
@ -678,6 +737,26 @@ public class TeamTaskService {
|
||||
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() {
|
||||
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.team.model.AgentTeamEntity;
|
||||
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.TeamTaskEntity;
|
||||
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.TeamEventChannel;
|
||||
import vip.mate.team.service.TeamService;
|
||||
import vip.mate.team.service.TeamRunService;
|
||||
import vip.mate.team.service.TeamTaskService;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
@ -47,6 +50,7 @@ public class TeamTasksTool {
|
||||
|
||||
private final TeamService teamService;
|
||||
private final TeamTaskService taskService;
|
||||
private final TeamRunService runService;
|
||||
private final TeamDispatchService dispatchService;
|
||||
private final TeamEventChannel eventChannel;
|
||||
private final ConversationService conversationService;
|
||||
@ -54,9 +58,11 @@ public class TeamTasksTool {
|
||||
|
||||
@Tool(description = "Operate your team's shared task board. Actions: "
|
||||
+ "'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, "
|
||||
+ "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); "
|
||||
+ "'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 "
|
||||
@ -66,10 +72,16 @@ public class TeamTasksTool {
|
||||
+ "'retry' a failed/stale task back to pending (lead only; taskId). "
|
||||
+ "Only usable when you belong to an agent team.")
|
||||
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,
|
||||
@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,
|
||||
@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)
|
||||
String subject,
|
||||
@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) {
|
||||
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 workspaceId = conversation.getWorkspaceId();
|
||||
Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return "Error: you are not part of any agent team; team_tasks is unavailable.";
|
||||
@ -116,10 +132,14 @@ public class TeamTasksTool {
|
||||
|
||||
try {
|
||||
return switch (action == null ? "" : action) {
|
||||
case "start_run" -> startRun(team, agentId, isLead, workspaceId,
|
||||
conversationId, title, objective, ctx);
|
||||
case "list" -> renderBoard(team);
|
||||
case "get" -> renderDetail(team, parseId(taskId, "taskId"));
|
||||
case "create" -> createTask(team, agentId, isLead, subject, description,
|
||||
assigneeAgentId, blockedBy, priority, requireApproval, conversationId);
|
||||
case "create" -> createTask(team, agentId, isLead, workspaceId, runId,
|
||||
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 "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
|
||||
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 "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId"));
|
||||
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) {
|
||||
return "Error: " + e.getMessage();
|
||||
@ -140,7 +161,26 @@ public class TeamTasksTool {
|
||||
|
||||
// ==================== 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,
|
||||
Long workspaceId, String runId,
|
||||
String subject, String description, String assigneeAgentId,
|
||||
String blockedBy, Integer priority, Boolean requireApproval,
|
||||
String conversationId) {
|
||||
@ -148,8 +188,11 @@ public class TeamTasksTool {
|
||||
return "Error: only the team lead can create tasks. Report blockers or ask the "
|
||||
+ "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()
|
||||
.teamId(team.getId())
|
||||
.runId(parsedRunId)
|
||||
.subject(subject)
|
||||
.description(description)
|
||||
.assigneeAgentId(parseId(assigneeAgentId, "assigneeAgentId"))
|
||||
@ -160,15 +203,27 @@ public class TeamTasksTool {
|
||||
.leadConversationId(conversationId)
|
||||
.build());
|
||||
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()
|
||||
+ ") \"" + task.getSubject() + "\" assigned to " + agentName(task.getAssigneeAgentId())
|
||||
+ ". Status: " + task.getStatus()
|
||||
+ (TeamTaskStatus.BLOCKED.equals(task.getStatus())
|
||||
? " (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) {
|
||||
@ -333,6 +388,17 @@ public class TeamTasksTool {
|
||||
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) {
|
||||
if (agentId == null) {
|
||||
return "-";
|
||||
|
||||
@ -87,4 +87,8 @@ public final class ToolExecutionContext {
|
||||
}
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@ -49,6 +50,20 @@ class ChatOriginTest {
|
||||
"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
|
||||
void cronFactory_setsRequesterToSystem() {
|
||||
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.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
@ -13,10 +15,17 @@ import vip.mate.common.result.R;
|
||||
import vip.mate.config.WorkspaceAccessInterceptor;
|
||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||
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.event.TeamRunDispatchCommittedIntent;
|
||||
import vip.mate.team.service.TeamAnnounceService;
|
||||
import vip.mate.team.service.TeamDispatchService;
|
||||
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.TeamTaskService;
|
||||
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.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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.result.MockMvcResultMatchers.status;
|
||||
|
||||
@ -47,9 +58,12 @@ class TeamControllerTest {
|
||||
|
||||
private static final Long TEAM_ID = 1L;
|
||||
private static final Long TASK_ID = 100L;
|
||||
private static final Long RUN_ID = 200L;
|
||||
|
||||
@Mock private TeamService teamService;
|
||||
@Mock private TeamTaskService taskService;
|
||||
@Mock private TeamRunService runService;
|
||||
@Mock private ApplicationEventPublisher events;
|
||||
@Mock private TeamDispatchService dispatchService;
|
||||
@Mock private TeamAnnounceService announceService;
|
||||
@Mock private TeamEventChannel eventChannel;
|
||||
@ -58,15 +72,18 @@ class TeamControllerTest {
|
||||
@Mock private AuthService authService;
|
||||
|
||||
private TeamController controller;
|
||||
private TeamManualTaskService manualTaskService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new TeamController(teamService, taskService, dispatchService,
|
||||
manualTaskService = new TeamManualTaskService(runService, taskService, events);
|
||||
controller = new TeamController(teamService, taskService, manualTaskService, dispatchService,
|
||||
announceService, eventChannel, agentMapper);
|
||||
AgentTeamEntity team = new AgentTeamEntity();
|
||||
team.setId(TEAM_ID);
|
||||
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
|
||||
@ -84,6 +101,15 @@ class TeamControllerTest {
|
||||
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 ====================
|
||||
|
||||
@Test
|
||||
@ -141,9 +167,12 @@ class TeamControllerTest {
|
||||
|
||||
@Test
|
||||
void createTaskSurfacesUnknownAssigneeAsReadableFailure() {
|
||||
TeamRunEntity planning = run(TEAM_ID, 1L, TeamRunStatus.PLANNING);
|
||||
when(runService.requireRun(RUN_ID, 1L)).thenReturn(planning);
|
||||
when(taskService.createTask(any(TeamTaskCreateCommand.class)))
|
||||
.thenThrow(new IllegalArgumentException("assignee 9 is not a member of this team"));
|
||||
TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest();
|
||||
req.setRunId(RUN_ID);
|
||||
req.setSubject("do the thing");
|
||||
req.setAssigneeAgentId(9L);
|
||||
|
||||
@ -155,6 +184,95 @@ class TeamControllerTest {
|
||||
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
|
||||
void approveRejectsTaskFromAnotherTeamsBoard() {
|
||||
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;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@ -15,6 +17,12 @@ import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
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.mockito.ArgumentMatchers.any;
|
||||
@ -25,8 +33,8 @@ import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Pins the announce contract: settled results are batched per lead
|
||||
* conversation and delivered as ONE merged wake-up message; a busy lead defers
|
||||
* Pins the announce contract: settled results are batched per lead conversation
|
||||
* 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
|
||||
* synthesis / retry instructions the lead acts on.
|
||||
*/
|
||||
@ -34,6 +42,7 @@ class TeamAnnounceServiceTest {
|
||||
|
||||
private static final Long TEAM_ID = 10L;
|
||||
private static final Long LEAD_ID = 1L;
|
||||
private static final Long RUN_ID = 90L;
|
||||
private static final String LEAD_CONV = "lead-conv";
|
||||
|
||||
private TeamService teamService;
|
||||
@ -71,6 +80,7 @@ class TeamAnnounceServiceTest {
|
||||
TeamTaskEntity t = new TeamTaskEntity();
|
||||
t.setId(id);
|
||||
t.setTeamId(TEAM_ID);
|
||||
t.setRunId(RUN_ID);
|
||||
t.setTaskNumber(id.intValue());
|
||||
t.setSubject("task " + id);
|
||||
t.setStatus(status);
|
||||
@ -91,7 +101,7 @@ class TeamAnnounceServiceTest {
|
||||
service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "report done"));
|
||||
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);
|
||||
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("blocked: no docs"));
|
||||
// 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());
|
||||
|
||||
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
|
||||
@ -112,7 +131,7 @@ class TeamAnnounceServiceTest {
|
||||
when(runningConversations.isActive(LEAD_CONV)).thenReturn(true);
|
||||
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());
|
||||
}
|
||||
@ -124,7 +143,7 @@ class TeamAnnounceServiceTest {
|
||||
orphan.setLeadConversationId(null);
|
||||
|
||||
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());
|
||||
}
|
||||
@ -137,7 +156,7 @@ class TeamAnnounceServiceTest {
|
||||
.thenReturn(AgentService.ChatResult.contentOnly("综合汇报"));
|
||||
|
||||
service.announceTaskSettled(settled(1L, TeamTaskStatus.COMPLETED, "done"));
|
||||
service.drain(LEAD_CONV);
|
||||
service.drain(new TeamAnnounceService.BatchKey(LEAD_CONV, RUN_ID));
|
||||
|
||||
verify(streamTracker, timeout(3000))
|
||||
.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
|
||||
// carry an internal-note metadata type so the chat UI renders them as
|
||||
// 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))
|
||||
.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))
|
||||
.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
|
||||
@DisplayName("announcement text: single result keeps the singular form and the playbook")
|
||||
void announcementText() {
|
||||
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")));
|
||||
assertTrue(single.contains("A delegated team task has settled"));
|
||||
assertTrue(single.contains("member: 写手"));
|
||||
assertTrue(single.contains("ONE synthesized answer"));
|
||||
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("Delegation workflow (mandatory)"));
|
||||
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("agentId: " + MEMBER_ID));
|
||||
// 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.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
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.model.AgentTeamEntity;
|
||||
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.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
@ -40,10 +45,13 @@ class TeamPlanBridgeTest {
|
||||
private static final Long ANALYST_ID = 3L;
|
||||
private static final Long PLAN_ID = 77L;
|
||||
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 TeamTaskService taskService;
|
||||
private PlanningService planningService;
|
||||
private TeamRunService runService;
|
||||
private AgentMapper agentMapper;
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
private TeamPlanBridge bridge;
|
||||
@ -54,15 +62,17 @@ class TeamPlanBridgeTest {
|
||||
teamService = mock(TeamService.class);
|
||||
taskService = mock(TeamTaskService.class);
|
||||
planningService = mock(PlanningService.class);
|
||||
runService = mock(TeamRunService.class);
|
||||
agentMapper = mock(AgentMapper.class);
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
bridge = new TeamPlanBridge(teamService, taskService, planningService,
|
||||
bridge = new TeamPlanBridge(teamService, taskService, runService, planningService,
|
||||
agentMapper, eventPublisher);
|
||||
|
||||
team = new AgentTeamEntity();
|
||||
team.setId(TEAM_ID);
|
||||
team.setName("编队");
|
||||
team.setLeadAgentId(LEAD_ID);
|
||||
team.setWorkspaceId(WORKSPACE_ID);
|
||||
|
||||
when(teamService.listMembers(TEAM_ID)).thenReturn(List.of(
|
||||
member(LEAD_ID, TeamRole.LEAD),
|
||||
@ -91,6 +101,7 @@ class TeamPlanBridgeTest {
|
||||
TeamTaskEntity t = new TeamTaskEntity();
|
||||
t.setId(id);
|
||||
t.setTeamId(TEAM_ID);
|
||||
t.setRunId(RUN_ID);
|
||||
t.setTaskNumber(number);
|
||||
t.setSubject("task " + number);
|
||||
t.setStatus(status);
|
||||
@ -116,6 +127,13 @@ class TeamPlanBridgeTest {
|
||||
@Test
|
||||
@DisplayName("delegatePlan maps deps to blockedBy, stamps plan linkage, parks and nudges dispatch")
|
||||
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 -> {
|
||||
TeamTaskCreateCommand cmd = inv.getArgument(0);
|
||||
TeamTaskEntity created = new TeamTaskEntity();
|
||||
@ -143,14 +161,81 @@ class TeamPlanBridgeTest {
|
||||
assertTrue(second.getMetadata().contains("\"stepIndex\":1"));
|
||||
assertEquals(CONV, first.getLeadConversationId());
|
||||
assertEquals(LEAD_ID, first.getCreatedByAgentId());
|
||||
assertEquals(RUN_ID, first.getRunId());
|
||||
assertEquals(RUN_ID, second.getRunId());
|
||||
assertTrue(first.getDescription().contains("整体请求"));
|
||||
|
||||
verify(planningService).markPlanDelegated(PLAN_ID);
|
||||
verify(eventPublisher).publishEvent(new TeamTasksDelegatedEvent(TEAM_ID));
|
||||
ArgumentCaptor<TeamRunCreateCommand> runCaptor = ArgumentCaptor.forClass(TeamRunCreateCommand.class);
|
||||
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("前置"));
|
||||
}
|
||||
|
||||
@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 ====================
|
||||
|
||||
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;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
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;
|
||||
@ -8,8 +9,11 @@ import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
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.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
@ -20,6 +24,7 @@ import vip.mate.team.repository.TeamTaskEventMapper;
|
||||
import vip.mate.team.repository.TeamTaskMapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@ -36,11 +41,15 @@ class TeamTaskServiceTest {
|
||||
private static final Long TEAM_ID = 10L;
|
||||
private static final Long LEAD_ID = 1L;
|
||||
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 TeamTaskCommentMapper commentMapper;
|
||||
private TeamTaskEventMapper eventMapper;
|
||||
private TeamService teamService;
|
||||
private TeamRunProjectionScheduler projectionScheduler;
|
||||
private TeamRunService runService;
|
||||
private TeamTaskService service;
|
||||
|
||||
@BeforeAll
|
||||
@ -60,12 +69,16 @@ class TeamTaskServiceTest {
|
||||
commentMapper = mock(TeamTaskCommentMapper.class);
|
||||
eventMapper = mock(TeamTaskEventMapper.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();
|
||||
team.setId(TEAM_ID);
|
||||
team.setLeadAgentId(LEAD_ID);
|
||||
team.setStatus(TeamService.STATUS_ACTIVE);
|
||||
team.setWorkspaceId(WORKSPACE_ID);
|
||||
when(teamService.getTeam(TEAM_ID)).thenReturn(team);
|
||||
when(teamService.isMember(TEAM_ID, MEMBER_ID)).thenReturn(true);
|
||||
when(teamService.nextTaskNumber(TEAM_ID)).thenReturn(1);
|
||||
@ -87,6 +100,21 @@ class TeamTaskServiceTest {
|
||||
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 ====================
|
||||
|
||||
@Test
|
||||
@ -137,6 +165,60 @@ class TeamTaskServiceTest {
|
||||
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 ====================
|
||||
|
||||
@Test
|
||||
@ -207,6 +289,107 @@ class TeamTaskServiceTest {
|
||||
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 ====================
|
||||
|
||||
@Test
|
||||
@ -374,4 +557,33 @@ class TeamTaskServiceTest {
|
||||
assertTrue(service.listDeliverables(junk).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.Test;
|
||||
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.repository.AgentMapper;
|
||||
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;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.team.service.TeamDispatchService;
|
||||
import vip.mate.team.service.TeamEventChannel;
|
||||
import vip.mate.team.service.TeamService;
|
||||
import vip.mate.team.service.TeamRunService;
|
||||
import vip.mate.team.service.TeamTaskService;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
@ -39,9 +46,12 @@ class TeamTasksToolTest {
|
||||
private static final Long TEAM_ID = 10L;
|
||||
private static final Long LEAD_ID = 1L;
|
||||
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 TeamTaskService taskService;
|
||||
private TeamRunService runService;
|
||||
private TeamDispatchService dispatchService;
|
||||
private TeamEventChannel eventChannel;
|
||||
private ConversationService conversationService;
|
||||
@ -53,17 +63,19 @@ class TeamTasksToolTest {
|
||||
void setUp() {
|
||||
teamService = mock(TeamService.class);
|
||||
taskService = mock(TeamTaskService.class);
|
||||
runService = mock(TeamRunService.class);
|
||||
dispatchService = mock(TeamDispatchService.class);
|
||||
eventChannel = mock(TeamEventChannel.class);
|
||||
conversationService = mock(ConversationService.class);
|
||||
agentMapper = mock(AgentMapper.class);
|
||||
tool = new TeamTasksTool(teamService, taskService, dispatchService,
|
||||
tool = new TeamTasksTool(teamService, taskService, runService, dispatchService,
|
||||
eventChannel, conversationService, agentMapper);
|
||||
|
||||
team = new AgentTeamEntity();
|
||||
team.setId(TEAM_ID);
|
||||
team.setName("研发组");
|
||||
team.setLeadAgentId(LEAD_ID);
|
||||
team.setWorkspaceId(WORKSPACE_ID);
|
||||
|
||||
ToolExecutionContext.set(CONV, "admin");
|
||||
}
|
||||
@ -77,6 +89,7 @@ class TeamTasksToolTest {
|
||||
ConversationEntity conv = new ConversationEntity();
|
||||
conv.setConversationId(CONV);
|
||||
conv.setAgentId(agentId);
|
||||
conv.setWorkspaceId(WORKSPACE_ID);
|
||||
when(conversationService.findByConversationId(CONV)).thenReturn(conv);
|
||||
when(teamService.getTeamForAgent(agentId)).thenReturn(Optional.of(team));
|
||||
when(teamService.isLead(team, agentId)).thenReturn(agentId.equals(LEAD_ID));
|
||||
@ -94,8 +107,18 @@ class TeamTasksToolTest {
|
||||
}
|
||||
|
||||
private String invoke(String action, String taskId) {
|
||||
return tool.team_tasks(action, taskId, null, null, null, null, null,
|
||||
null, null, null, null, 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);
|
||||
}
|
||||
|
||||
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 ====================
|
||||
@ -113,6 +136,7 @@ class TeamTasksToolTest {
|
||||
ConversationEntity conv = new ConversationEntity();
|
||||
conv.setConversationId(CONV);
|
||||
conv.setAgentId(99L);
|
||||
conv.setWorkspaceId(WORKSPACE_ID);
|
||||
when(conversationService.findByConversationId(CONV)).thenReturn(conv);
|
||||
when(teamService.getTeamForAgent(99L)).thenReturn(Optional.empty());
|
||||
|
||||
@ -123,7 +147,21 @@ class TeamTasksToolTest {
|
||||
@DisplayName("unknown action lists the valid ones")
|
||||
void unknownAction() {
|
||||
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 ====================
|
||||
@ -133,8 +171,8 @@ class TeamTasksToolTest {
|
||||
void memberCannotCreate() {
|
||||
callerIs(MEMBER_ID);
|
||||
String out = tool.team_tasks("create", null, "subj", "desc",
|
||||
String.valueOf(MEMBER_ID), null, null, null, 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);
|
||||
assertTrue(out.contains("only the team lead can create"));
|
||||
verify(taskService, never()).createTask(any());
|
||||
}
|
||||
@ -150,21 +188,64 @@ class TeamTasksToolTest {
|
||||
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 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("lead create parses ids, wires the lead conversation and reports the assignee")
|
||||
void leadCreatePassesThrough() {
|
||||
callerIs(LEAD_ID);
|
||||
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
||||
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
||||
TeamTaskEntity created = task(50L, TeamTaskStatus.PENDING);
|
||||
when(taskService.createTask(any())).thenReturn(created);
|
||||
AgentEntity member = new AgentEntity();
|
||||
member.setName("写手");
|
||||
when(agentMapper.selectById(MEMBER_ID)).thenReturn(member);
|
||||
|
||||
String out = tool.team_tasks("create", null, "collect data", "step details",
|
||||
String.valueOf(MEMBER_ID), "11,12", 5, null, null, null, null, null, null,
|
||||
null, null, null);
|
||||
String out = tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
|
||||
"collect data", "step details", String.valueOf(MEMBER_ID), "11,12", 5,
|
||||
null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertTrue(out.startsWith("✓ Created task #3"));
|
||||
assertTrue(out.contains("写手"));
|
||||
@ -176,17 +257,20 @@ class TeamTasksToolTest {
|
||||
assertEquals(List.of(11L, 12L), cmd.getBlockedBy());
|
||||
assertEquals(LEAD_ID, cmd.getCreatedByAgentId());
|
||||
assertEquals(CONV, cmd.getLeadConversationId());
|
||||
verify(dispatchService).requestDispatch(TEAM_ID);
|
||||
assertEquals(RUN_ID, cmd.getRunId());
|
||||
verify(dispatchService, never()).requestDispatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creating a blocked task does not trigger a dispatch sweep")
|
||||
void blockedCreateDoesNotDispatch() {
|
||||
callerIs(LEAD_ID);
|
||||
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
||||
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
||||
TeamTaskEntity blocked = task(51L, TeamTaskStatus.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,
|
||||
null, null, null);
|
||||
|
||||
@ -197,9 +281,11 @@ class TeamTasksToolTest {
|
||||
@DisplayName("lead create passes requireApproval through to the command")
|
||||
void createPassesRequireApproval() {
|
||||
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));
|
||||
|
||||
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,
|
||||
null, null, null);
|
||||
|
||||
@ -209,6 +295,101 @@ class TeamTasksToolTest {
|
||||
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
|
||||
@DisplayName("lead cancel interrupts the running member conversation")
|
||||
void cancelInterruptsRun() {
|
||||
@ -231,9 +412,11 @@ class TeamTasksToolTest {
|
||||
callerIs(LEAD_ID);
|
||||
when(taskService.createTask(any()))
|
||||
.thenThrow(new IllegalArgumentException("assignee is required"));
|
||||
String out = tool.team_tasks("create", null, "s", null,
|
||||
String.valueOf(MEMBER_ID), null, null, null, null, null, null, null, null,
|
||||
null, null, null);
|
||||
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
||||
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
||||
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);
|
||||
}
|
||||
|
||||
@ -249,8 +432,8 @@ class TeamTasksToolTest {
|
||||
|
||||
assertTrue(invoke("complete", "5").startsWith("Error: result is required"));
|
||||
|
||||
String ok = tool.team_tasks("complete", "5", null, null, null, null, null,
|
||||
null, "done, see report", null, null, null, null, null, null, null);
|
||||
String ok = tool.team_tasks("complete", "5", 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)"));
|
||||
}
|
||||
|
||||
@ -262,8 +445,8 @@ class TeamTasksToolTest {
|
||||
when(taskService.addComment(eq(5L), eq(TeamTaskService.AUTHOR_AGENT),
|
||||
anyString(), eq("blocker"), anyString())).thenReturn(true);
|
||||
|
||||
String out = tool.team_tasks("comment", "5", null, null, null, null, null,
|
||||
null, null, null, null, "missing credentials", "blocker", null, null, null);
|
||||
String out = tool.team_tasks("comment", "5", null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, "missing credentials", "blocker", null, null, null);
|
||||
assertTrue(out.contains("stop working"));
|
||||
}
|
||||
|
||||
@ -273,8 +456,8 @@ class TeamTasksToolTest {
|
||||
callerIs(MEMBER_ID);
|
||||
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
||||
|
||||
String out = tool.team_tasks("attach", "5", null, null, null, null, null,
|
||||
null, 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,
|
||||
"report.docx", "/api/v1/files/generated/abc", null);
|
||||
|
||||
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 {
|
||||
id: string
|
||||
teamId: string
|
||||
runId?: string | null
|
||||
taskNumber: number
|
||||
subject: string
|
||||
description: string | null
|
||||
@ -924,6 +925,7 @@ export interface TeamTaskVO {
|
||||
task: TeamTask
|
||||
assigneeName: string | null
|
||||
ownerName: string | null
|
||||
runId?: string | null
|
||||
}
|
||||
|
||||
export interface TeamTaskComment {
|
||||
@ -977,6 +979,7 @@ export const teamApi = {
|
||||
data: {
|
||||
subject: string
|
||||
description?: string
|
||||
runId?: string
|
||||
assigneeAgentId: string
|
||||
priority?: number
|
||||
blockedBy?: string[]
|
||||
@ -994,6 +997,78 @@ export const teamApi = {
|
||||
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 ====================
|
||||
// One row in the cross-KB failure center. ids are strings (global Long→String
|
||||
// Jackson config) to avoid Snowflake precision loss.
|
||||
|
||||
@ -49,35 +49,46 @@
|
||||
</button>
|
||||
</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
|
||||
v-if="isCompressionSummary(msg)"
|
||||
:message="msg"
|
||||
v-else-if="isCompressionSummary(item.message)"
|
||||
:message="item.message"
|
||||
/>
|
||||
<!-- Cron-run 头部分隔卡(system 消息且以 📋 开头)—— 在
|
||||
tasks_<wsId> / IM 镜像会话里把"这是哪个 cron 跑的"清晰标出来。
|
||||
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>
|
||||
<span class="cron-divider__label">{{ msg.content }}</span>
|
||||
<span class="cron-divider__label">{{ item.message.content }}</span>
|
||||
<div class="cron-divider__line"></div>
|
||||
</div>
|
||||
<!-- Team task settlement note — user-role for the context pipeline,
|
||||
but rendered as a collapsed system strip instead of a user
|
||||
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
|
||||
v-else
|
||||
:message="msg"
|
||||
:is-last="index === messages.length - 1"
|
||||
:message="item.message"
|
||||
:is-last="item.messageIndex === messages.length - 1"
|
||||
:assistant-icon="assistantIcon"
|
||||
:user-icon="userIcon"
|
||||
:show-cursor="showCursorForMessage(msg)"
|
||||
@regenerate="$emit('regenerate', msg)"
|
||||
@rewind="$emit('rewind', msg)"
|
||||
@toggle-thinking="(expanded) => $emit('toggle-thinking', msg, expanded)"
|
||||
:show-cursor="showCursorForMessage(item.message)"
|
||||
@regenerate="$emit('regenerate', item.message)"
|
||||
@rewind="$emit('rewind', item.message)"
|
||||
@toggle-thinking="(expanded) => $emit('toggle-thinking', item.message, expanded)"
|
||||
@approve="(pendingId) => $emit('approve', pendingId)"
|
||||
@deny="(pendingId) => $emit('deny', pendingId)"
|
||||
/>
|
||||
@ -125,7 +136,12 @@ const { t } = useI18n()
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import CompressionSummary from './CompressionSummary.vue'
|
||||
import TeamAnnouncePanel from './TeamAnnouncePanel.vue'
|
||||
import TeamRunCard from '@/components/team-run/TeamRunCard.vue'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
@ -149,6 +165,10 @@ interface Props {
|
||||
hasMore?: 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>(), {
|
||||
@ -172,8 +192,22 @@ const emit = defineEmits<{
|
||||
approve: [pendingId: string]
|
||||
deny: [pendingId: string]
|
||||
'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) => {
|
||||
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.
|
||||
const isTeamAnnounce = (msg: Message) => {
|
||||
if (msg.role !== 'user') return false
|
||||
try {
|
||||
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] ')
|
||||
return parseTeamMessageMetadata(msg).isTeamAnnounce
|
||||
}
|
||||
|
||||
// 智能滚动
|
||||
@ -340,6 +370,11 @@ onUnmounted(() => {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.team-run-timeline-item {
|
||||
width: min(760px, calc(100% - 32px));
|
||||
margin: 8px auto;
|
||||
}
|
||||
|
||||
/* ==================== 空状态 / 欢迎屏 ==================== */
|
||||
.empty-state {
|
||||
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>
|
||||
|
||||
<!-- Lifecycle board: runs + goals across status columns -->
|
||||
<LiveBoard
|
||||
v-else-if="layout === 'board'"
|
||||
:runs="snapshot?.runs ?? []"
|
||||
:summary="snapshot?.summary ?? null"
|
||||
:goal-by-conv="goalByConv"
|
||||
:done-goals="doneGoals"
|
||||
:failed-goals="failedGoals"
|
||||
@open="openDetail"
|
||||
@stop="confirmStop"
|
||||
@recycle="confirmRecycle"
|
||||
/>
|
||||
<template v-else>
|
||||
<AgentRunGroups
|
||||
:groups="teamProjection.groups"
|
||||
:selected-run-id="selectedTeamRunId"
|
||||
:selected-task-id="selectedTeamTaskId"
|
||||
@open-run="openTeamRun"
|
||||
@open-worker="openTeamWorker"
|
||||
/>
|
||||
<p v-if="teamGroups.error.value" class="team-runs-error">{{ t('live.teamRuns.loadError') }}</p>
|
||||
<h2 v-if="teamProjection.groups.length && visibleRuns.length" class="other-live-title">{{ t('live.teamRuns.otherSessions') }}</h2>
|
||||
|
||||
<!-- Empty: nothing to see -->
|
||||
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
|
||||
<div class="empty-orb"></div>
|
||||
<div class="empty-line">{{ t('live.empty.allQuiet') }}</div>
|
||||
<div class="empty-hint">{{ t('live.empty.hint') }}</div>
|
||||
</div>
|
||||
<!-- Lifecycle board: non-team runs + goals across status columns -->
|
||||
<LiveBoard
|
||||
v-if="layout === 'board' && visibleRuns.length"
|
||||
:runs="visibleRuns"
|
||||
:summary="snapshot?.summary ?? null"
|
||||
:goal-by-conv="goalByConv"
|
||||
:done-goals="doneGoals"
|
||||
:failed-goals="failedGoals"
|
||||
@open="openDetail"
|
||||
@stop="confirmStop"
|
||||
@recycle="confirmRecycle"
|
||||
/>
|
||||
|
||||
<!-- Active runs -->
|
||||
<div v-else class="cards-grid">
|
||||
<div v-else-if="teamProjection.groups.length === 0 && visibleRuns.length === 0" class="empty-still">
|
||||
<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
|
||||
v-for="run in visibleRuns"
|
||||
:key="run.conversationId"
|
||||
@ -172,7 +182,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<LiveFocusPanel
|
||||
@ -188,16 +199,24 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue'
|
||||
import LiveBoard from '@/components/live/LiveBoard.vue'
|
||||
import AgentRunGroups from '@/components/live/AgentRunGroups.vue'
|
||||
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 { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api'
|
||||
import { buildTeamRunRoute } from '@/components/team-run/teamRunPresentation'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {
|
||||
avatarLetter,
|
||||
avatarBgStyle,
|
||||
@ -209,8 +228,22 @@ const {
|
||||
|
||||
type FilterKey = 'all' | 'working' | 'attention' | 'quiet'
|
||||
|
||||
const snapshot = ref<LiveSnapshot | null>(null)
|
||||
const isInitialLoading = ref(true)
|
||||
let teamGroups!: ReturnType<typeof useAgentRunGroups>
|
||||
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 drawerOpen = ref(false)
|
||||
const detail = ref<LiveRunCard | null>(null)
|
||||
@ -308,7 +341,7 @@ function tierOf(r: LiveRunCard): number {
|
||||
}
|
||||
|
||||
const visibleRuns = computed<LiveRunCard[]>(() => {
|
||||
const runs = snapshot.value?.runs ?? []
|
||||
const runs = teamProjection.value.ungrouped
|
||||
const filtered = (() => {
|
||||
switch (activeFilter.value) {
|
||||
case 'working': return runs.filter(isWorking)
|
||||
@ -411,22 +444,40 @@ function closeDetail() {
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res: any = await liveApi.snapshot()
|
||||
snapshot.value = (res?.data ?? res) as LiveSnapshot
|
||||
const accepted = await liveSnapshot.refresh()
|
||||
if (accepted) {
|
||||
if (detail.value && snapshot.value) {
|
||||
const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId)
|
||||
if (fresh) detail.value = fresh
|
||||
}
|
||||
// Keep the board's goal columns fresh on the same cadence as the snapshot.
|
||||
if (layout.value === 'board') loadGoals()
|
||||
} catch (e: any) {
|
||||
if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed'))
|
||||
} finally {
|
||||
isInitialLoading.value = false
|
||||
} else if (liveSnapshot.error.value) {
|
||||
const cause = liveSnapshot.error.value as { message?: string }
|
||||
mcToast.error(cause.message || t('live.errors.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
autoRefresh.value = !autoRefresh.value
|
||||
if (autoRefresh.value) {
|
||||
@ -520,6 +571,8 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
liveSnapshot.close()
|
||||
teamGroups.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -527,6 +580,8 @@ onBeforeUnmount(() => {
|
||||
.live-panel {
|
||||
--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 ===== */
|
||||
.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