refactor: systematic tech debt cleanup (U1-U5) #8
|
|
@ -24,7 +24,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chatStore'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
import MessageShell from './messages/MessageShell.vue'
|
import MessageShell from './messages/MessageShell.vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useMessageRenderer } from './helpers/useMessageRenderer'
|
import { useMessageRenderer } from './helpers/useMessageRenderer'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chatStore'
|
||||||
import type { IChatMessage } from '@/api/types'
|
import type { IChatMessage } from '@/api/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chatStore'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ import {
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chatStore'
|
||||||
import TopNav from './TopNav.vue'
|
import TopNav from './TopNav.vue'
|
||||||
import TitleBar from './TitleBar.vue'
|
import TitleBar from './TitleBar.vue'
|
||||||
import SplitPane from './SplitPane.vue'
|
import SplitPane from './SplitPane.vue'
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ import {
|
||||||
RiseOutlined,
|
RiseOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chatStore'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ import {
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
TableOutlined,
|
TableOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chatStore'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import { FolderOpenOutlined } from '@ant-design/icons-vue'
|
||||||
import { Empty } from 'ant-design-vue'
|
import { Empty } from 'ant-design-vue'
|
||||||
import DocumentCard from '@/components/chat/messages/DocumentCard.vue'
|
import DocumentCard from '@/components/chat/messages/DocumentCard.vue'
|
||||||
import { useDocumentsStore } from '@/stores/documents'
|
import { useDocumentsStore } from '@/stores/documents'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chatStore'
|
||||||
|
|
||||||
const documentsStore = useDocumentsStore()
|
const documentsStore = useDocumentsStore()
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { ref, type Ref } from "vue";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import type { WsServerMessage } from "@/api/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which conversation an incoming WS message belongs to.
|
||||||
|
*
|
||||||
|
* The backend protocol currently does NOT tag server→client messages with
|
||||||
|
* `conversation_id`, so we route by recency: pick the most recently used
|
||||||
|
* pending conversation. This is a heuristic — it works as long as users
|
||||||
|
* don't fire two requests in quick succession across conversations. We
|
||||||
|
* bias toward the *current* view if it's still pending, which is what the
|
||||||
|
* user is watching right now.
|
||||||
|
*
|
||||||
|
* Exported as a pure function so it can be unit-tested without Pinia.
|
||||||
|
*/
|
||||||
|
export function resolveIncomingConvId(
|
||||||
|
currentConversationId: string | null,
|
||||||
|
pendingConversations: Set<string>,
|
||||||
|
pendingLastUsedAt: Map<string, number>,
|
||||||
|
): string {
|
||||||
|
if (currentConversationId && pendingConversations.has(currentConversationId)) {
|
||||||
|
return currentConversationId;
|
||||||
|
}
|
||||||
|
// Fall back to the most recently used pending conversation.
|
||||||
|
let best: string | null = null;
|
||||||
|
let bestTs = 0;
|
||||||
|
pendingLastUsedAt.forEach((ts, id) => {
|
||||||
|
if (pendingConversations.has(id) && ts > bestTs) {
|
||||||
|
best = id;
|
||||||
|
bestTs = ts;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return best ?? currentConversationId ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSocketOptions {
|
||||||
|
currentConversationId: Ref<string | null>;
|
||||||
|
pendingConversations: Ref<Set<string>>;
|
||||||
|
pendingLastUsedAt: Ref<Map<string, number>>;
|
||||||
|
/** Invoked for each parsed server message (→ dispatchWsEvent). */
|
||||||
|
onMessage: (data: WsServerMessage) => void;
|
||||||
|
/** Invoked after the socket reopens (→ _recoverTaskAfterReconnect). */
|
||||||
|
onReconnect: () => void | Promise<void>;
|
||||||
|
/** Invoked when the socket closes (→ clear stream-side state). */
|
||||||
|
onDisconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket lifecycle composable: connection, 30s heartbeat, and 3s
|
||||||
|
* auto-reconnect guarded by `_intentionalDisconnect` to prevent cascading
|
||||||
|
* reconnects after an explicit `disconnectWebSocket()`.
|
||||||
|
*/
|
||||||
|
export function useChatSocket(options: ChatSocketOptions) {
|
||||||
|
const isWsConnected = ref(false);
|
||||||
|
const ws = ref<WebSocket | null>(null);
|
||||||
|
let _heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let _intentionalDisconnect = false;
|
||||||
|
|
||||||
|
function connectWebSocket(): void {
|
||||||
|
// Problem 6: also skip if already CONNECTING to avoid orphan sockets
|
||||||
|
if (
|
||||||
|
ws.value &&
|
||||||
|
(ws.value.readyState === WebSocket.OPEN ||
|
||||||
|
ws.value.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_intentionalDisconnect = false;
|
||||||
|
const socket = apiClient.createWebSocket();
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
isWsConnected.value = true;
|
||||||
|
console.log("WebSocket connected");
|
||||||
|
// Start heartbeat: send ping every 30s to keep connection alive
|
||||||
|
if (_heartbeatTimer) clearInterval(_heartbeatTimer);
|
||||||
|
_heartbeatTimer = setInterval(() => {
|
||||||
|
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||||
|
ws.value.send(JSON.stringify({ type: "ping" }));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
// Check for running tasks to resume after reconnection
|
||||||
|
void options.onReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data as string) as WsServerMessage;
|
||||||
|
console.log("[Chat WS] Received:", data.type, data);
|
||||||
|
options.onMessage(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse WebSocket message:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
isWsConnected.value = false;
|
||||||
|
// P2 #21 fix: clear per-conversation pending state to prevent stuck
|
||||||
|
// loading state during disconnect. onReconnect will re-mark
|
||||||
|
// conversations pending if an active task is found.
|
||||||
|
options.pendingConversations.value = new Set();
|
||||||
|
options.pendingLastUsedAt.value = new Map();
|
||||||
|
// Notify stream side to clear stale streaming steps.
|
||||||
|
options.onDisconnect();
|
||||||
|
console.log("WebSocket disconnected");
|
||||||
|
if (_heartbeatTimer) {
|
||||||
|
clearInterval(_heartbeatTimer);
|
||||||
|
_heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
// Problem 1: do not auto-reconnect after an intentional disconnect
|
||||||
|
if (_intentionalDisconnect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Auto reconnect after 3 seconds
|
||||||
|
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||||
|
_reconnectTimer = setTimeout(() => {
|
||||||
|
if (!ws.value || ws.value.readyState === WebSocket.CLOSED) {
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
console.error("WebSocket error:", error);
|
||||||
|
isWsConnected.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.value = socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnect WebSocket and suppress auto-reconnect. */
|
||||||
|
function disconnectWebSocket(): void {
|
||||||
|
_intentionalDisconnect = true;
|
||||||
|
if (_reconnectTimer) {
|
||||||
|
clearTimeout(_reconnectTimer);
|
||||||
|
_reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (_heartbeatTimer) {
|
||||||
|
clearInterval(_heartbeatTimer);
|
||||||
|
_heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
if (ws.value) {
|
||||||
|
ws.value.close();
|
||||||
|
ws.value = null;
|
||||||
|
isWsConnected.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isWsConnected,
|
||||||
|
ws,
|
||||||
|
connectWebSocket,
|
||||||
|
disconnectWebSocket,
|
||||||
|
// Bound resolver using the latest option refs (for dispatchWsEvent ctx).
|
||||||
|
// Arrow form avoids shadowing the exported pure function above.
|
||||||
|
resolveIncomingConvId: () =>
|
||||||
|
resolveIncomingConvId(
|
||||||
|
options.currentConversationId.value,
|
||||||
|
options.pendingConversations.value,
|
||||||
|
options.pendingLastUsedAt.value,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,498 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import { useTeamStore } from "@/stores/team";
|
||||||
|
import { useDocumentsStore } from "@/stores/documents";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar";
|
||||||
|
import { useChatSocket } from "@/stores/chatSocket";
|
||||||
|
import { useChatStream } from "@/stores/chatStream";
|
||||||
|
import type {
|
||||||
|
IChatMessage,
|
||||||
|
IConversation,
|
||||||
|
WsClientMessage,
|
||||||
|
} from "@/api/types";
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = defineStore("chat", () => {
|
||||||
|
// --- State (chatStore-owned) ---
|
||||||
|
const conversations = ref<IConversation[]>([]);
|
||||||
|
const currentConversationId = ref<string | null>(null);
|
||||||
|
// Per-conversation in-flight tracking; isCurrentLoading derives from the
|
||||||
|
// current conversation being in this set, so other tabs remain usable.
|
||||||
|
const pendingConversations = ref<Set<string>>(new Set());
|
||||||
|
const pendingLastUsedAt = ref<Map<string, number>>(new Map());
|
||||||
|
let _is404Recovering = false;
|
||||||
|
|
||||||
|
// --- Message helpers (chatStore-owned, shared with chatStream) ---
|
||||||
|
function appendMessage(conversationId: string, message: IChatMessage): void {
|
||||||
|
const conv = conversations.value.find((c) => c.id === conversationId);
|
||||||
|
if (conv) {
|
||||||
|
if (!Array.isArray(conv.messages)) conv.messages = [];
|
||||||
|
conv.messages.push(message);
|
||||||
|
conv.updated_at = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMessage(
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
updates: Partial<IChatMessage>,
|
||||||
|
): void {
|
||||||
|
const conv = conversations.value.find((c) => c.id === conversationId);
|
||||||
|
if (!conv) return;
|
||||||
|
const msg = conv.messages.find((m) => m.id === messageId);
|
||||||
|
if (msg) Object.assign(msg, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markConversationPending(convId: string): void {
|
||||||
|
pendingConversations.value = new Set(pendingConversations.value).add(convId);
|
||||||
|
pendingLastUsedAt.value = new Map(pendingLastUsedAt.value).set(convId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function markConversationDone(convId: string): void {
|
||||||
|
const next = new Set(pendingConversations.value); next.delete(convId);
|
||||||
|
pendingConversations.value = next;
|
||||||
|
const last = new Map(pendingLastUsedAt.value); last.delete(convId);
|
||||||
|
pendingLastUsedAt.value = last;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lazy cross-store accessors (passed to chatStream as deps) ---
|
||||||
|
let _teamStore: ReturnType<typeof useTeamStore> | null = null;
|
||||||
|
let _calendarStore: ReturnType<typeof useCalendarStore> | null = null;
|
||||||
|
const _getTeamStore = () => (_teamStore ??= useTeamStore());
|
||||||
|
const _getCalendarStore = () => (_calendarStore ??= useCalendarStore());
|
||||||
|
const _getDocumentsStore = () => useDocumentsStore();
|
||||||
|
|
||||||
|
// --- chatStream: streaming state + dispatchWsEvent ---
|
||||||
|
const stream = useChatStream({
|
||||||
|
conversations,
|
||||||
|
currentConversationId,
|
||||||
|
appendMessage,
|
||||||
|
updateMessage,
|
||||||
|
markConversationDone,
|
||||||
|
resolveIncomingConvId: () => socket.resolveIncomingConvId(),
|
||||||
|
getTeamStore: _getTeamStore,
|
||||||
|
getCalendarStore: _getCalendarStore,
|
||||||
|
getDocumentsStore: _getDocumentsStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- chatSocket: WebSocket lifecycle + resolveIncomingConvId ---
|
||||||
|
const socket = useChatSocket({
|
||||||
|
currentConversationId,
|
||||||
|
pendingConversations,
|
||||||
|
pendingLastUsedAt,
|
||||||
|
onMessage: (data) => stream.dispatch(data),
|
||||||
|
onReconnect: () => _recoverTaskAfterReconnect(),
|
||||||
|
onDisconnect: () => stream.clearAllStreamState(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Getters (chatStore-owned) ---
|
||||||
|
const currentConversation = computed<IConversation | undefined>(() =>
|
||||||
|
conversations.value.find((c) => c.id === currentConversationId.value),
|
||||||
|
);
|
||||||
|
const currentMessages = computed<IChatMessage[]>(
|
||||||
|
() => currentConversation.value?.messages ?? [],
|
||||||
|
);
|
||||||
|
// `true` only when the current conversation is waiting on the agent.
|
||||||
|
const isCurrentLoading = computed<boolean>(() => {
|
||||||
|
const cid = currentConversationId.value;
|
||||||
|
return !!cid && pendingConversations.value.has(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
/** Load all conversations from the server */
|
||||||
|
async function loadConversations(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.getConversations();
|
||||||
|
conversations.value = data.map((conv: IConversation) => ({
|
||||||
|
id: conv.id,
|
||||||
|
title: conv.title || "对话",
|
||||||
|
messages: Array.isArray(conv.messages) ? conv.messages : [],
|
||||||
|
created_at: conv.created_at,
|
||||||
|
updated_at: conv.updated_at,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load conversations:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select a conversation by ID and load its messages */
|
||||||
|
async function selectConversation(id: string, force = false): Promise<void> {
|
||||||
|
currentConversationId.value = id;
|
||||||
|
// P2 #10: 会话隔离 — 切换会话时重置 collaborationState,避免跨会话数据泄漏。
|
||||||
|
stream.collaborationState.value = null;
|
||||||
|
|
||||||
|
const conv = conversations.value.find((c) => c.id === id);
|
||||||
|
// 本地临时会话尚未同步到服务端,跳过获取避免 404
|
||||||
|
if (
|
||||||
|
!conv?.is_local &&
|
||||||
|
(force || !conv || !conv.messages || conv.messages.length === 0)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const fullConv = await apiClient.getConversation(id);
|
||||||
|
if (conv) {
|
||||||
|
conv.messages = fullConv.messages || [];
|
||||||
|
// P0 #1 fix: never let the server's placeholder title ("对话")
|
||||||
|
// overwrite a real title we already have locally.
|
||||||
|
const serverTitle = fullConv.title || "";
|
||||||
|
const localTitle = conv.title || "";
|
||||||
|
const isServerPlaceholder =
|
||||||
|
serverTitle === "对话" || serverTitle.trim() === "";
|
||||||
|
const isLocalReal =
|
||||||
|
localTitle && localTitle !== "新对话" && localTitle !== "对话";
|
||||||
|
if (serverTitle && !isServerPlaceholder) conv.title = serverTitle;
|
||||||
|
else if (!isLocalReal) conv.title = serverTitle || localTitle || "对话";
|
||||||
|
conv.created_at = fullConv.created_at || conv.created_at;
|
||||||
|
conv.updated_at = fullConv.updated_at || conv.updated_at;
|
||||||
|
} else {
|
||||||
|
// P1 #7 fix: If the conversation is not in the local list, add it.
|
||||||
|
const serverTitle = fullConv.title || "新对话";
|
||||||
|
conversations.value.unshift({
|
||||||
|
id: fullConv.id || id,
|
||||||
|
title: serverTitle === "对话" ? "新对话" : serverTitle,
|
||||||
|
messages: fullConv.messages || [],
|
||||||
|
created_at: fullConv.created_at || new Date().toISOString(),
|
||||||
|
updated_at: fullConv.updated_at || new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const isNotFound = (error as { status?: number })?.status === 404;
|
||||||
|
if (isNotFound) {
|
||||||
|
conversations.value = conversations.value.filter((c) => c.id !== id);
|
||||||
|
stream.streamingStepsByConv.value.delete(id);
|
||||||
|
pendingConversations.value.delete(id);
|
||||||
|
pendingLastUsedAt.value.delete(id);
|
||||||
|
if (currentConversationId.value === id) {
|
||||||
|
currentConversationId.value = null;
|
||||||
|
stream.boardState.value = null;
|
||||||
|
stream.debateState.value = null;
|
||||||
|
// 自动切换到下一个可用会话,没有则新建(防止级联 404)
|
||||||
|
if (!_is404Recovering) {
|
||||||
|
_is404Recovering = true;
|
||||||
|
try {
|
||||||
|
if (conversations.value.length > 0) {
|
||||||
|
await selectConversation(conversations.value[0].id);
|
||||||
|
} else {
|
||||||
|
createConversation();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_is404Recovering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Failed to load conversation messages:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2 #10: 恢复 collaborationState — 从会话消息中查找 collaboration_graph
|
||||||
|
const restoredConv = conversations.value.find((c) => c.id === id);
|
||||||
|
const graphMsg = restoredConv?.messages
|
||||||
|
? [...restoredConv.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.message_type === "collaboration_graph" && m.collaboration_graph)
|
||||||
|
: undefined;
|
||||||
|
if (graphMsg?.collaboration_graph) {
|
||||||
|
stream.collaborationState.value = {
|
||||||
|
contracts: [...graphMsg.collaboration_graph.contracts],
|
||||||
|
notices: [...graphMsg.collaboration_graph.notices],
|
||||||
|
reviews: [...graphMsg.collaboration_graph.reviews],
|
||||||
|
risks: [...graphMsg.collaboration_graph.risks],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new empty conversation */
|
||||||
|
function createConversation(): void {
|
||||||
|
const newConversation: IConversation = {
|
||||||
|
id: generateId(),
|
||||||
|
title: "新对话",
|
||||||
|
messages: [],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
is_local: true,
|
||||||
|
};
|
||||||
|
conversations.value.unshift(newConversation);
|
||||||
|
currentConversationId.value = newConversation.id;
|
||||||
|
stream.clearConvSteps(newConversation.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a conversation (server + local state) */
|
||||||
|
async function deleteConversation(id: string): Promise<void> {
|
||||||
|
const conv = conversations.value.find((c) => c.id === id);
|
||||||
|
if (!conv?.is_local) {
|
||||||
|
try {
|
||||||
|
await apiClient.deleteConversation(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete conversation:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conversations.value = conversations.value.filter((c) => c.id !== id);
|
||||||
|
stream.streamingStepsByConv.value.delete(id);
|
||||||
|
pendingConversations.value.delete(id);
|
||||||
|
pendingLastUsedAt.value.delete(id);
|
||||||
|
markConversationDone(id);
|
||||||
|
if (currentConversationId.value === id) {
|
||||||
|
currentConversationId.value = null;
|
||||||
|
stream.boardState.value = null;
|
||||||
|
stream.debateState.value = null;
|
||||||
|
if (conversations.value.length > 0) {
|
||||||
|
await selectConversation(conversations.value[0].id);
|
||||||
|
} else {
|
||||||
|
createConversation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a user message + pending assistant placeholder; returns both. */
|
||||||
|
function _appendUserAndAssistant(
|
||||||
|
conversationId: string,
|
||||||
|
content: string,
|
||||||
|
): { userId: string; assistantId: string } {
|
||||||
|
const userId = generateId();
|
||||||
|
appendMessage(conversationId, {
|
||||||
|
id: userId,
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const assistantId = generateId();
|
||||||
|
appendMessage(conversationId, {
|
||||||
|
id: assistantId,
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
return { userId, assistantId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a message using REST API (fallback) */
|
||||||
|
async function sendMessage(
|
||||||
|
message: string,
|
||||||
|
sources?: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!currentConversationId.value) createConversation();
|
||||||
|
const conversationId = currentConversationId.value as string;
|
||||||
|
const { assistantId } = _appendUserAndAssistant(conversationId, message);
|
||||||
|
markConversationPending(conversationId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.chat({
|
||||||
|
message,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
sources,
|
||||||
|
});
|
||||||
|
updateMessage(conversationId, assistantId, {
|
||||||
|
content: response.message,
|
||||||
|
matched_skill: response.matched_skill,
|
||||||
|
routing_method: response.routing_method,
|
||||||
|
confidence: response.confidence,
|
||||||
|
task_id: response.task_id,
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
const conv = conversations.value.find((c) => c.id === conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.is_local = false;
|
||||||
|
if (conv.messages.length <= 2) {
|
||||||
|
conv.title =
|
||||||
|
message.length > 20 ? `${message.substring(0, 20)}...` : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateMessage(conversationId, assistantId, {
|
||||||
|
content: `请求失败: ${error instanceof Error ? error.message : "未知错误"}`,
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
markConversationDone(conversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a message via WebSocket for streaming */
|
||||||
|
async function sendWsMessage(
|
||||||
|
message: string,
|
||||||
|
sources?: string[],
|
||||||
|
model?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!currentConversationId.value) createConversation();
|
||||||
|
|
||||||
|
// Check WebSocket state BEFORE creating messages to avoid duplicates
|
||||||
|
if (!socket.ws.value || socket.ws.value.readyState !== WebSocket.OPEN) {
|
||||||
|
await sendMessage(message, sources);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationId = currentConversationId.value as string;
|
||||||
|
const { userId, assistantId } = _appendUserAndAssistant(
|
||||||
|
conversationId,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
markConversationPending(conversationId);
|
||||||
|
stream.clearConvSteps(conversationId);
|
||||||
|
|
||||||
|
// Problem 7: catch send() exceptions (e.g. connection closed mid-send)
|
||||||
|
try {
|
||||||
|
socket.ws.value.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "chat",
|
||||||
|
message,
|
||||||
|
sources,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
model,
|
||||||
|
} as WsClientMessage),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("WebSocket send failed, falling back to REST:", error);
|
||||||
|
const conv = conversations.value.find((c) => c.id === conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.messages = conv.messages.filter(
|
||||||
|
(m) => m.id !== userId && m.id !== assistantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
markConversationDone(conversationId);
|
||||||
|
await sendMessage(message, sources);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update conversation title from first user message
|
||||||
|
const conv = conversations.value.find((c) => c.id === conversationId);
|
||||||
|
if (conv && conv.title === "新对话") {
|
||||||
|
conv.title =
|
||||||
|
message.length > 20 ? `${message.substring(0, 20)}...` : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the in-flight generation by sending a `cancel` WS message. */
|
||||||
|
function stopGeneration(): void {
|
||||||
|
const cid = currentConversationId.value;
|
||||||
|
if (!socket.ws.value || socket.ws.value.readyState !== WebSocket.OPEN) {
|
||||||
|
if (cid) {
|
||||||
|
markConversationDone(cid);
|
||||||
|
stream.clearConvSteps(cid);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cancelMsg: WsClientMessage = { type: "cancel" };
|
||||||
|
socket.ws.value.send(JSON.stringify(cancelMsg));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send cancel message:", error);
|
||||||
|
if (cid) {
|
||||||
|
markConversationDone(cid);
|
||||||
|
stream.clearConvSteps(cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After WebSocket reconnects, check for running tasks and resume them. */
|
||||||
|
async function _recoverTaskAfterReconnect(): Promise<void> {
|
||||||
|
const cid = currentConversationId.value;
|
||||||
|
if (!cid) return;
|
||||||
|
try {
|
||||||
|
// Problem 2: query both 'running' and 'pending' tasks.
|
||||||
|
const [runningTasks, pendingTasks] = await Promise.all([
|
||||||
|
apiClient.listTasks("running"),
|
||||||
|
apiClient.listTasks("pending"),
|
||||||
|
]);
|
||||||
|
const activeTask = [...runningTasks, ...pendingTasks].find(
|
||||||
|
(t) => t.metadata?.conversation_id === cid,
|
||||||
|
);
|
||||||
|
const canResume =
|
||||||
|
activeTask &&
|
||||||
|
socket.ws.value &&
|
||||||
|
socket.ws.value.readyState === WebSocket.OPEN;
|
||||||
|
if (!canResume) {
|
||||||
|
// No active task — force reload conversation messages.
|
||||||
|
await selectConversation(cid, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// P1 #12 fix: Clear the last pending assistant message's accumulated
|
||||||
|
// content before resuming (replay would duplicate it otherwise).
|
||||||
|
const conv = conversations.value.find((c) => c.id === cid);
|
||||||
|
const lastPending = conv
|
||||||
|
? [...conv.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "assistant" && m.status === "pending")
|
||||||
|
: undefined;
|
||||||
|
if (lastPending) {
|
||||||
|
updateMessage(cid, lastPending.id, {
|
||||||
|
content: "",
|
||||||
|
thinking: "",
|
||||||
|
tool_calls: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
markConversationPending(cid);
|
||||||
|
try {
|
||||||
|
socket.ws.value?.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "resume",
|
||||||
|
task_id: activeTask!.task_id,
|
||||||
|
conversation_id: cid,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send resume message:", error);
|
||||||
|
markConversationDone(cid);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to recover task after reconnect:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resend the last user message in the current conversation */
|
||||||
|
async function resendLastUserMessage(): Promise<void> {
|
||||||
|
const conversationId = currentConversationId.value;
|
||||||
|
if (!conversationId) return;
|
||||||
|
if (pendingConversations.value.has(conversationId)) return;
|
||||||
|
const conv = conversations.value.find((c) => c.id === conversationId);
|
||||||
|
if (!conv) return;
|
||||||
|
const lastUserMsg = [...conv.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "user");
|
||||||
|
if (!lastUserMsg) return;
|
||||||
|
const content = lastUserMsg.content.trim();
|
||||||
|
if (!content) return;
|
||||||
|
await sendWsMessage(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversations,
|
||||||
|
currentConversationId,
|
||||||
|
isWsConnected: socket.isWsConnected,
|
||||||
|
ws: socket.ws,
|
||||||
|
pendingConversations,
|
||||||
|
// Stream-owned state (re-exported for component compat)
|
||||||
|
streamingStepsByConv: stream.streamingStepsByConv,
|
||||||
|
boardState: stream.boardState,
|
||||||
|
debateState: stream.debateState,
|
||||||
|
collaborationState: stream.collaborationState,
|
||||||
|
currentPhase: stream.currentPhase,
|
||||||
|
phaseViolations: stream.phaseViolations,
|
||||||
|
isPlanExec: stream.isPlanExec,
|
||||||
|
// Legacy aliases for backward compat
|
||||||
|
isLoading: isCurrentLoading,
|
||||||
|
streamingSteps: stream.currentStreamingSteps,
|
||||||
|
currentConversation,
|
||||||
|
currentMessages,
|
||||||
|
isCurrentLoading,
|
||||||
|
currentStreamingSteps: stream.currentStreamingSteps,
|
||||||
|
isBoardMode: stream.isBoardMode,
|
||||||
|
// Actions
|
||||||
|
loadConversations,
|
||||||
|
selectConversation,
|
||||||
|
createConversation,
|
||||||
|
deleteConversation,
|
||||||
|
sendMessage,
|
||||||
|
sendWsMessage,
|
||||||
|
resendLastUserMessage,
|
||||||
|
stopGeneration,
|
||||||
|
connectWebSocket: socket.connectWebSocket,
|
||||||
|
disconnectWebSocket: socket.disconnectWebSocket,
|
||||||
|
};
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -103,7 +103,7 @@ import {
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chatStore'
|
||||||
import ChatSidebar from '@/components/chat/ChatSidebar.vue'
|
import ChatSidebar from '@/components/chat/ChatSidebar.vue'
|
||||||
import ChatMessage from '@/components/chat/ChatMessage.vue'
|
import ChatMessage from '@/components/chat/ChatMessage.vue'
|
||||||
import ChatInput from '@/components/chat/ChatInput.vue'
|
import ChatInput from '@/components/chat/ChatInput.vue'
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ describe('chat store — PLAN_EXEC phase state (U4)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes currentPhase, phaseViolations, isPlanExec with initial values', async () => {
|
it('exposes currentPhase, phaseViolations, isPlanExec with initial values', async () => {
|
||||||
const { useChatStore } = await import('@/stores/chat')
|
const { useChatStore } = await import('@/stores/chatStore')
|
||||||
const store = useChatStore()
|
const store = useChatStore()
|
||||||
expect(store.currentPhase).toBeNull()
|
expect(store.currentPhase).toBeNull()
|
||||||
expect(store.phaseViolations).toEqual([])
|
expect(store.phaseViolations).toEqual([])
|
||||||
|
|
@ -48,7 +48,7 @@ describe('chat store — PLAN_EXEC phase state (U4)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('isPlanExec is true when currentPhase is set', async () => {
|
it('isPlanExec is true when currentPhase is set', async () => {
|
||||||
const { useChatStore } = await import('@/stores/chat')
|
const { useChatStore } = await import('@/stores/chatStore')
|
||||||
const store = useChatStore()
|
const store = useChatStore()
|
||||||
store.currentPhase = 'planning'
|
store.currentPhase = 'planning'
|
||||||
expect(store.isPlanExec).toBe(true)
|
expect(store.isPlanExec).toBe(true)
|
||||||
|
|
@ -59,7 +59,7 @@ describe('chat store — PLAN_EXEC phase state (U4)', () => {
|
||||||
// the cap is enforced inside the case handler, not as a setter.
|
// the cap is enforced inside the case handler, not as a setter.
|
||||||
// This test verifies the array is accessible; the cap-at-5 behavior
|
// This test verifies the array is accessible; the cap-at-5 behavior
|
||||||
// is exercised through handleWsMessage in the U5 E2E test.
|
// is exercised through handleWsMessage in the U5 E2E test.
|
||||||
const { useChatStore } = await import('@/stores/chat')
|
const { useChatStore } = await import('@/stores/chatStore')
|
||||||
const store = useChatStore()
|
const store = useChatStore()
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
store.phaseViolations = [
|
store.phaseViolations = [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for chatSocket (U5).
|
||||||
|
*
|
||||||
|
* Covers the exported pure function `resolveIncomingConvId` (heuristic
|
||||||
|
* conversation routing) and the `useChatSocket` composable's lifecycle
|
||||||
|
* behaviors (heartbeat setup, intentional disconnect suppression, reconnect
|
||||||
|
* scheduling). WebSocket injection is mocked at the apiClient level.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useChatSocket, resolveIncomingConvId } from '@/stores/chatSocket'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import type { WsServerMessage } from '@/api/types'
|
||||||
|
|
||||||
|
// happy-dom does not expose numeric WebSocket constants on the global, but
|
||||||
|
// chatSocket.ts references `WebSocket.OPEN` / `WebSocket.CONNECTING` /
|
||||||
|
// `WebSocket.CLOSED`. Install a minimal stub before any test runs.
|
||||||
|
;(globalThis as unknown as { WebSocket: unknown }).WebSocket = {
|
||||||
|
CONNECTING: 0,
|
||||||
|
OPEN: 1,
|
||||||
|
CLOSING: 2,
|
||||||
|
CLOSED: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mock apiClient.createWebSocket ────────────────────────────────────
|
||||||
|
|
||||||
|
const WS_CONNECTING = 0
|
||||||
|
const WS_OPEN = 1
|
||||||
|
const WS_CLOSED = 3
|
||||||
|
|
||||||
|
interface FakeSocket {
|
||||||
|
onopen: (() => void) | null
|
||||||
|
onmessage: ((e: { data: string }) => void) | null
|
||||||
|
onclose: (() => void) | null
|
||||||
|
onerror: ((e: unknown) => void) | null
|
||||||
|
readyState: number
|
||||||
|
send: ReturnType<typeof vi.fn>
|
||||||
|
close: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeSocket(): FakeSocket {
|
||||||
|
let state = WS_CONNECTING
|
||||||
|
return {
|
||||||
|
onopen: null,
|
||||||
|
onmessage: null,
|
||||||
|
onclose: null,
|
||||||
|
onerror: null,
|
||||||
|
readyState: state,
|
||||||
|
send: vi.fn(),
|
||||||
|
close: vi.fn(() => {
|
||||||
|
state = WS_CLOSED
|
||||||
|
// Reflect the new state on the returned object so callers see CLOSED.
|
||||||
|
;(fakeSocket as FakeSocket).readyState = WS_CLOSED
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fakeSocket: FakeSocket
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
apiClient: {
|
||||||
|
createWebSocket: vi.fn(() => {
|
||||||
|
fakeSocket = createFakeSocket()
|
||||||
|
return fakeSocket
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── resolveIncomingConvId (pure) ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveIncomingConvId (pure)', () => {
|
||||||
|
it('returns currentConversationId when it is pending', () => {
|
||||||
|
const id = resolveIncomingConvId(
|
||||||
|
'current',
|
||||||
|
new Set(['current', 'other']),
|
||||||
|
new Map([
|
||||||
|
['current', 100],
|
||||||
|
['other', 200],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(id).toBe('current')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to most-recently-used pending conversation', () => {
|
||||||
|
const id = resolveIncomingConvId(
|
||||||
|
null,
|
||||||
|
new Set(['a', 'b']),
|
||||||
|
new Map([
|
||||||
|
['a', 100],
|
||||||
|
['b', 300],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(id).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips conversations that are not currently pending', () => {
|
||||||
|
const id = resolveIncomingConvId(
|
||||||
|
null,
|
||||||
|
new Set(['a']),
|
||||||
|
new Map([
|
||||||
|
['a', 100],
|
||||||
|
['stale', 999],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(id).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns currentConversationId when no pending conv exists', () => {
|
||||||
|
const id = resolveIncomingConvId(
|
||||||
|
'current',
|
||||||
|
new Set(),
|
||||||
|
new Map([['old', 100]]),
|
||||||
|
)
|
||||||
|
expect(id).toBe('current')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string when nothing resolves', () => {
|
||||||
|
const id = resolveIncomingConvId(null, new Set(), new Map())
|
||||||
|
expect(id).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── useChatSocket lifecycle ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('useChatSocket', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
;(apiClient.createWebSocket as ReturnType<typeof vi.fn>).mockClear()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeSocket() {
|
||||||
|
const currentConversationId = ref<string | null>('c1')
|
||||||
|
const pendingConversations = ref<Set<string>>(new Set())
|
||||||
|
const pendingLastUsedAt = ref<Map<string, number>>(new Map())
|
||||||
|
const onMessage = vi.fn()
|
||||||
|
const onReconnect = vi.fn()
|
||||||
|
const onDisconnect = vi.fn()
|
||||||
|
|
||||||
|
const socket = useChatSocket({
|
||||||
|
currentConversationId,
|
||||||
|
pendingConversations,
|
||||||
|
pendingLastUsedAt,
|
||||||
|
onMessage,
|
||||||
|
onReconnect,
|
||||||
|
onDisconnect,
|
||||||
|
})
|
||||||
|
return { socket, currentConversationId, pendingConversations, pendingLastUsedAt, onMessage, onReconnect, onDisconnect }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('connectWebSocket: opens socket and fires onReconnect on open', () => {
|
||||||
|
const { socket, onReconnect } = makeSocket()
|
||||||
|
socket.connectWebSocket()
|
||||||
|
expect(fakeSocket.readyState).toBe(WS_CONNECTING)
|
||||||
|
fakeSocket.readyState = WS_OPEN
|
||||||
|
fakeSocket.onopen?.()
|
||||||
|
expect(socket.isWsConnected.value).toBe(true)
|
||||||
|
expect(onReconnect).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('heartbeat: sends ping every 30s while open', () => {
|
||||||
|
const { socket } = makeSocket()
|
||||||
|
socket.connectWebSocket()
|
||||||
|
fakeSocket.readyState = WS_OPEN
|
||||||
|
fakeSocket.onopen?.()
|
||||||
|
expect(fakeSocket.send).not.toHaveBeenCalled()
|
||||||
|
vi.advanceTimersByTime(30000)
|
||||||
|
expect(fakeSocket.send).toHaveBeenCalledWith(JSON.stringify({ type: 'ping' }))
|
||||||
|
vi.advanceTimersByTime(30000)
|
||||||
|
expect(fakeSocket.send).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onmessage: parses JSON and forwards to onMessage callback', () => {
|
||||||
|
const { socket, onMessage } = makeSocket()
|
||||||
|
socket.connectWebSocket()
|
||||||
|
fakeSocket.readyState = WS_OPEN
|
||||||
|
fakeSocket.onopen?.()
|
||||||
|
const event: WsServerMessage = { type: 'pong' }
|
||||||
|
fakeSocket.onmessage?.({ data: JSON.stringify(event) })
|
||||||
|
expect(onMessage).toHaveBeenCalledWith(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onmessage: swallows malformed JSON without crashing', () => {
|
||||||
|
const { socket, onMessage } = makeSocket()
|
||||||
|
socket.connectWebSocket()
|
||||||
|
fakeSocket.readyState = WS_OPEN
|
||||||
|
fakeSocket.onopen?.()
|
||||||
|
fakeSocket.onmessage?.({ data: 'not-json' })
|
||||||
|
expect(onMessage).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onclose: schedules reconnect after 3s when not intentional', () => {
|
||||||
|
const { socket, pendingConversations, pendingLastUsedAt, onDisconnect } = makeSocket()
|
||||||
|
socket.connectWebSocket()
|
||||||
|
fakeSocket.readyState = WS_OPEN
|
||||||
|
fakeSocket.onopen?.()
|
||||||
|
pendingConversations.value.add('c1')
|
||||||
|
pendingLastUsedAt.value.set('c1', 1)
|
||||||
|
|
||||||
|
fakeSocket.readyState = WS_CLOSED
|
||||||
|
fakeSocket.onclose?.()
|
||||||
|
|
||||||
|
expect(socket.isWsConnected.value).toBe(false)
|
||||||
|
expect(onDisconnect).toHaveBeenCalled()
|
||||||
|
// P2 #21 fix: pending state cleared on close so UI doesn't stay stuck.
|
||||||
|
expect(pendingConversations.value.has('c1')).toBe(false)
|
||||||
|
// Reconnect not yet — 3s delay.
|
||||||
|
const createWs = apiClient.createWebSocket as ReturnType<typeof vi.fn>
|
||||||
|
expect(createWs).toHaveBeenCalledTimes(1)
|
||||||
|
vi.advanceTimersByTime(3000)
|
||||||
|
expect(createWs).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disconnectWebSocket: suppresses auto-reconnect', () => {
|
||||||
|
const { socket } = makeSocket()
|
||||||
|
socket.connectWebSocket()
|
||||||
|
fakeSocket.readyState = WS_OPEN
|
||||||
|
fakeSocket.onopen?.()
|
||||||
|
|
||||||
|
socket.disconnectWebSocket()
|
||||||
|
expect(fakeSocket.close).toHaveBeenCalled()
|
||||||
|
expect(socket.isWsConnected.value).toBe(false)
|
||||||
|
|
||||||
|
const createWs = apiClient.createWebSocket as ReturnType<typeof vi.fn>
|
||||||
|
const callsBefore = createWs.mock.calls.length
|
||||||
|
// Even if onclose fires later, no reconnect should happen.
|
||||||
|
fakeSocket.readyState = WS_CLOSED
|
||||||
|
fakeSocket.onclose?.()
|
||||||
|
vi.advanceTimersByTime(10000)
|
||||||
|
expect(createWs.mock.calls.length).toBe(callsBefore)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connectWebSocket: skips when socket already OPEN', () => {
|
||||||
|
const { socket } = makeSocket()
|
||||||
|
socket.connectWebSocket()
|
||||||
|
fakeSocket.readyState = WS_OPEN
|
||||||
|
fakeSocket.onopen?.()
|
||||||
|
const firstSocket = fakeSocket
|
||||||
|
|
||||||
|
socket.connectWebSocket() // should be a no-op
|
||||||
|
expect(fakeSocket).toBe(firstSocket)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connectWebSocket: skips when socket is CONNECTING', () => {
|
||||||
|
const { socket } = makeSocket()
|
||||||
|
socket.connectWebSocket()
|
||||||
|
// fakeSocket starts in CONNECTING state
|
||||||
|
const firstSocket = fakeSocket
|
||||||
|
socket.connectWebSocket()
|
||||||
|
expect(fakeSocket).toBe(firstSocket)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,563 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for chatStream.dispatchWsEvent (U5).
|
||||||
|
*
|
||||||
|
* dispatchWsEvent is a pure function over ChatStreamState, so we construct
|
||||||
|
* a fixture state bag (no Pinia required) and assert state mutations
|
||||||
|
* directly. Covers the major WS event branches: connected, routing, step
|
||||||
|
* (token/thinking/tool_call/tool_result/final_answer), result, error,
|
||||||
|
* team_formed, expert_step, expert_result, plan_update, phase_changed,
|
||||||
|
* phase_violation, board_started.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
import {
|
||||||
|
dispatchWsEvent,
|
||||||
|
type ChatStreamState,
|
||||||
|
type IStreamingStep,
|
||||||
|
} from '@/stores/chatStream'
|
||||||
|
import type {
|
||||||
|
IChatMessage,
|
||||||
|
IConversation,
|
||||||
|
IExpertTeamState,
|
||||||
|
ITeamPlanPhase,
|
||||||
|
WsServerMessage,
|
||||||
|
} from '@/api/types'
|
||||||
|
|
||||||
|
// Mock ant-design-vue so phase_violation's dynamic import doesn't blow up.
|
||||||
|
vi.mock('ant-design-vue', () => ({
|
||||||
|
message: { warning: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock isDocumentMeta to true so tool_result document payloads are accepted.
|
||||||
|
vi.mock('@/api/documents', () => ({
|
||||||
|
isDocumentMeta: vi.fn(() => true),
|
||||||
|
}))
|
||||||
|
|
||||||
|
interface Fixture {
|
||||||
|
state: ChatStreamState
|
||||||
|
conversations: Ref<IConversation[]>
|
||||||
|
currentConversationId: Ref<string | null>
|
||||||
|
appendMessageSpy: ReturnType<typeof vi.fn>
|
||||||
|
updateMessageSpy: ReturnType<typeof vi.fn>
|
||||||
|
markConversationDoneSpy: ReturnType<typeof vi.fn>
|
||||||
|
teamStore: {
|
||||||
|
teamState: IExpertTeamState | null
|
||||||
|
setTeamState: ReturnType<typeof vi.fn>
|
||||||
|
updatePhases: ReturnType<typeof vi.fn>
|
||||||
|
updatePhaseStatus: ReturnType<typeof vi.fn>
|
||||||
|
clearTeam: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
calendarStore: { handleWsEvent: ReturnType<typeof vi.fn> }
|
||||||
|
documentsStore: { addDocument: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFixture(convId: string = 'conv-1'): Fixture {
|
||||||
|
const conversations = ref<IConversation[]>([
|
||||||
|
{
|
||||||
|
id: convId,
|
||||||
|
title: '新对话',
|
||||||
|
messages: [],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const currentConversationId = ref<string | null>(convId)
|
||||||
|
const appendMessageSpy = vi.fn((cid: string, msg: IChatMessage) => {
|
||||||
|
const conv = conversations.value.find((c) => c.id === cid)
|
||||||
|
if (conv) conv.messages.push(msg)
|
||||||
|
})
|
||||||
|
const updateMessageSpy = vi.fn(
|
||||||
|
(cid: string, mid: string, updates: Partial<IChatMessage>) => {
|
||||||
|
const conv = conversations.value.find((c) => c.id === cid)
|
||||||
|
if (!conv) return
|
||||||
|
const msg = conv.messages.find((m) => m.id === mid)
|
||||||
|
if (msg) Object.assign(msg, updates)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const markConversationDoneSpy = vi.fn()
|
||||||
|
|
||||||
|
const teamStore = {
|
||||||
|
teamState: null as IExpertTeamState | null,
|
||||||
|
setTeamState: vi.fn(),
|
||||||
|
updatePhases: vi.fn(),
|
||||||
|
updatePhaseStatus: vi.fn(),
|
||||||
|
clearTeam: vi.fn(),
|
||||||
|
}
|
||||||
|
const calendarStore = { handleWsEvent: vi.fn() }
|
||||||
|
const documentsStore = { addDocument: vi.fn() }
|
||||||
|
|
||||||
|
// The deps bag types expect full Pinia Store<...> instances; the fixture
|
||||||
|
// only needs the slice of methods dispatchWsEvent actually calls, so we
|
||||||
|
// cast through unknown to satisfy the type without pulling in the real
|
||||||
|
// stores (which would drag in their own deps and side effects).
|
||||||
|
const state: ChatStreamState = {
|
||||||
|
streamingStepsByConv: ref(new Map<string, IStreamingStep[]>()),
|
||||||
|
currentPhase: ref<string | null>(null),
|
||||||
|
phaseViolations: ref([]),
|
||||||
|
boardState: ref(null),
|
||||||
|
debateState: ref(null),
|
||||||
|
collaborationState: ref(null),
|
||||||
|
conversations,
|
||||||
|
currentConversationId,
|
||||||
|
appendMessage: appendMessageSpy,
|
||||||
|
updateMessage: updateMessageSpy,
|
||||||
|
markConversationDone: markConversationDoneSpy,
|
||||||
|
resolveIncomingConvId: () => currentConversationId.value ?? '',
|
||||||
|
getTeamStore: () => teamStore as unknown as ChatStreamState["getTeamStore"] extends () => infer R ? R : never,
|
||||||
|
getCalendarStore: () => calendarStore as unknown as ChatStreamState["getCalendarStore"] extends () => infer R ? R : never,
|
||||||
|
getDocumentsStore: () => documentsStore as unknown as ChatStreamState["getDocumentsStore"] extends () => infer R ? R : never,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
conversations,
|
||||||
|
currentConversationId,
|
||||||
|
appendMessageSpy,
|
||||||
|
updateMessageSpy,
|
||||||
|
markConversationDoneSpy,
|
||||||
|
teamStore,
|
||||||
|
calendarStore,
|
||||||
|
documentsStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a pending assistant placeholder so routing/step cases have a target. */
|
||||||
|
function seedAssistant(f: Fixture, id = 'a1'): IChatMessage {
|
||||||
|
const msg: IChatMessage = {
|
||||||
|
id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'pending',
|
||||||
|
}
|
||||||
|
f.conversations.value[0].messages.push(msg)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dispatchWsEvent', () => {
|
||||||
|
let f: Fixture
|
||||||
|
beforeEach(() => {
|
||||||
|
f = createFixture()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 1. connected ───────────────────────────────────────────────────
|
||||||
|
it('connected: marks local conv as synced and adopts server conversation_id', () => {
|
||||||
|
f.conversations.value[0].is_local = true
|
||||||
|
f.currentConversationId.value = 'local-1'
|
||||||
|
f.conversations.value[0].id = 'local-1'
|
||||||
|
|
||||||
|
dispatchWsEvent(
|
||||||
|
{ type: 'connected', conversation_id: 'server-1' },
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(f.conversations.value[0].is_local).toBe(false)
|
||||||
|
expect(f.conversations.value[0].id).toBe('server-1')
|
||||||
|
expect(f.currentConversationId.value).toBe('server-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 2. routing ─────────────────────────────────────────────────────
|
||||||
|
it('routing: tags last assistant message with skill/confidence/method', () => {
|
||||||
|
const a = seedAssistant(f)
|
||||||
|
dispatchWsEvent(
|
||||||
|
{ type: 'routing', skill: 'code_review', confidence: 0.92, method: 'react' },
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(a.matched_skill).toBe('code_review')
|
||||||
|
expect(a.confidence).toBe(0.92)
|
||||||
|
expect(a.routing_method).toBe('react')
|
||||||
|
// A "routing" streaming step should also be appended.
|
||||||
|
const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
|
||||||
|
expect(steps.some((s) => s.type === 'routing')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 3. step: token ─────────────────────────────────────────────────
|
||||||
|
it('step(token): creates a streaming step and accumulates counter', () => {
|
||||||
|
seedAssistant(f)
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'step',
|
||||||
|
data: {
|
||||||
|
event_type: 'token',
|
||||||
|
step: 1,
|
||||||
|
data: { delta: 'hello' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'step',
|
||||||
|
data: {
|
||||||
|
event_type: 'token',
|
||||||
|
step: 2,
|
||||||
|
data: { delta: 'world' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
|
||||||
|
const streamingSteps = steps.filter((s) => s.type === 'streaming')
|
||||||
|
expect(streamingSteps).toHaveLength(1)
|
||||||
|
expect(streamingSteps[0].counter).toBeGreaterThanOrEqual(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 4. step: thinking ──────────────────────────────────────────────
|
||||||
|
it('step(thinking): appends thinking step and accumulates thinking content', () => {
|
||||||
|
const a = seedAssistant(f)
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'step',
|
||||||
|
data: {
|
||||||
|
event_type: 'thinking',
|
||||||
|
step: 1,
|
||||||
|
data: { content: 'hmm' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'step',
|
||||||
|
data: {
|
||||||
|
event_type: 'thinking',
|
||||||
|
step: 2,
|
||||||
|
data: { content: ' hmm2' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(a.thinking).toBe('hmm hmm2')
|
||||||
|
const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
|
||||||
|
expect(steps.some((s) => s.type === 'thinking')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 5. step: tool_call + tool_result ───────────────────────────────
|
||||||
|
it('step(tool_call then tool_result): tracks tool_calls on assistant message', () => {
|
||||||
|
const a = seedAssistant(f)
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'step',
|
||||||
|
data: {
|
||||||
|
event_type: 'tool_call',
|
||||||
|
step: 1,
|
||||||
|
data: { tool_name: 'search', arguments: { q: 'x' } },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(a.tool_calls).toHaveLength(1)
|
||||||
|
expect(a.tool_calls?.[0].name).toBe('search')
|
||||||
|
expect(a.tool_calls?.[0].status).toBe('running')
|
||||||
|
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'step',
|
||||||
|
data: {
|
||||||
|
event_type: 'tool_result',
|
||||||
|
step: 2,
|
||||||
|
data: { tool_name: 'search', output: 'result', duration: 12 },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(a.tool_calls?.[0].status).toBe('completed')
|
||||||
|
expect(a.tool_calls?.[0].result).toBe('result')
|
||||||
|
expect(a.tool_calls?.[0].duration).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 6. result ──────────────────────────────────────────────────────
|
||||||
|
it('result: finalizes assistant content, clears steps, marks done', () => {
|
||||||
|
const a = seedAssistant(f)
|
||||||
|
dispatchWsEvent(
|
||||||
|
{ type: 'result', data: { message: 'final answer' } },
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(a.content).toBe('final answer')
|
||||||
|
expect(a.status).toBe('completed')
|
||||||
|
expect(f.markConversationDoneSpy).toHaveBeenCalledWith('conv-1')
|
||||||
|
expect(f.state.streamingStepsByConv.value.has('conv-1')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 7. error ───────────────────────────────────────────────────────
|
||||||
|
it('error: mutates last assistant to error state and marks done', () => {
|
||||||
|
const a = seedAssistant(f)
|
||||||
|
dispatchWsEvent(
|
||||||
|
{ type: 'error', data: { message: 'boom' } },
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(a.message_type).toBe('error')
|
||||||
|
expect(a.status).toBe('error')
|
||||||
|
expect(a.error_detail).toBe('boom')
|
||||||
|
expect(f.markConversationDoneSpy).toHaveBeenCalledWith('conv-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error: appends a new error message when no assistant placeholder exists', () => {
|
||||||
|
f.conversations.value[0].messages = []
|
||||||
|
dispatchWsEvent(
|
||||||
|
{ type: 'error', data: { message: 'no-assistant' } },
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(f.conversations.value[0].messages).toHaveLength(1)
|
||||||
|
const m = f.conversations.value[0].messages[0]
|
||||||
|
expect(m.message_type).toBe('error')
|
||||||
|
expect(m.error_detail).toBe('no-assistant')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 8. team_formed ─────────────────────────────────────────────────
|
||||||
|
it('team_formed: resets collaborationState and forwards to teamStore', () => {
|
||||||
|
f.state.collaborationState.value = {
|
||||||
|
contracts: [],
|
||||||
|
notices: [],
|
||||||
|
reviews: [],
|
||||||
|
risks: [],
|
||||||
|
}
|
||||||
|
const teamData: IExpertTeamState = {
|
||||||
|
team_id: 't1',
|
||||||
|
status: 'forming',
|
||||||
|
experts: [
|
||||||
|
{
|
||||||
|
id: 'e1',
|
||||||
|
name: 'Lead',
|
||||||
|
persona: '',
|
||||||
|
avatar: '',
|
||||||
|
color: '',
|
||||||
|
is_lead: true,
|
||||||
|
bound_skills: [],
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
plan_phases: [],
|
||||||
|
lead_expert: 'e1',
|
||||||
|
}
|
||||||
|
dispatchWsEvent({ type: 'team_formed', data: teamData }, f.state)
|
||||||
|
expect(f.state.collaborationState.value).toBeNull()
|
||||||
|
expect(f.teamStore.setTeamState).toHaveBeenCalledWith(teamData)
|
||||||
|
const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
|
||||||
|
expect(steps.some((s) => s.type === 'team_event')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 9. expert_step ─────────────────────────────────────────────────
|
||||||
|
it('expert_step: appends an expert-tagged assistant message and step', () => {
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'expert_step',
|
||||||
|
data: {
|
||||||
|
expert_id: 'e1',
|
||||||
|
expert_name: 'Alice',
|
||||||
|
expert_color: '#f00',
|
||||||
|
content: 'partial',
|
||||||
|
step: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
const msgs = f.conversations.value[0].messages
|
||||||
|
expect(msgs).toHaveLength(1)
|
||||||
|
expect(msgs[0].expert_id).toBe('e1')
|
||||||
|
expect(msgs[0].expert_name).toBe('Alice')
|
||||||
|
expect(msgs[0].content).toBe('partial')
|
||||||
|
// Same-expert follow-up accumulates into the existing pending message.
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'expert_step',
|
||||||
|
data: {
|
||||||
|
expert_id: 'e1',
|
||||||
|
expert_name: 'Alice',
|
||||||
|
expert_color: '#f00',
|
||||||
|
content: '+more',
|
||||||
|
step: '2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(msgs).toHaveLength(1)
|
||||||
|
expect(msgs[0].content).toBe('partial+more')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 10. expert_result ──────────────────────────────────────────────
|
||||||
|
it('expert_result: appends a completed expert-tagged message', () => {
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'expert_result',
|
||||||
|
data: {
|
||||||
|
expert_id: 'e1',
|
||||||
|
expert_name: 'Alice',
|
||||||
|
expert_color: '#f00',
|
||||||
|
content: 'done',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
const msgs = f.conversations.value[0].messages
|
||||||
|
expect(msgs).toHaveLength(1)
|
||||||
|
expect(msgs[0].status).toBe('completed')
|
||||||
|
expect(msgs[0].expert_id).toBe('e1')
|
||||||
|
expect(msgs[0].content).toBe('done')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 11. plan_update ────────────────────────────────────────────────
|
||||||
|
it('plan_update: forwards phases to teamStore and upserts plan_update message', () => {
|
||||||
|
const phases: ITeamPlanPhase[] = [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
name: 'Phase 1',
|
||||||
|
assigned_expert: 'e1',
|
||||||
|
depends_on: [],
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
dispatchWsEvent({ type: 'plan_update', data: { plan_phases: phases } }, f.state)
|
||||||
|
expect(f.teamStore.updatePhases).toHaveBeenCalledWith(phases)
|
||||||
|
const msgs = f.conversations.value[0].messages
|
||||||
|
expect(msgs).toHaveLength(1)
|
||||||
|
expect(msgs[0].message_type).toBe('plan_update')
|
||||||
|
expect(msgs[0].plan_phases).toStrictEqual(phases)
|
||||||
|
// A second plan_update should update the existing message in place.
|
||||||
|
dispatchWsEvent({ type: 'plan_update', data: { plan_phases: phases } }, f.state)
|
||||||
|
expect(msgs).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 12. plan_update with collaboration_contracts ───────────────────
|
||||||
|
it('plan_update: extracts collaboration_contracts into collaborationState', () => {
|
||||||
|
const phases: ITeamPlanPhase[] = [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
name: 'Phase 1',
|
||||||
|
assigned_expert: 'e1',
|
||||||
|
depends_on: [],
|
||||||
|
status: 'pending',
|
||||||
|
collaboration_contracts: [
|
||||||
|
{
|
||||||
|
from_expert: 'e1',
|
||||||
|
to_expert: 'e2',
|
||||||
|
content_description: 'interface spec',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
dispatchWsEvent({ type: 'plan_update', data: { plan_phases: phases } }, f.state)
|
||||||
|
expect(f.state.collaborationState.value).not.toBeNull()
|
||||||
|
expect(f.state.collaborationState.value?.contracts).toHaveLength(1)
|
||||||
|
expect(f.state.collaborationState.value?.contracts[0].phase_id).toBe('p1')
|
||||||
|
// A collaboration_graph message should also be upserted.
|
||||||
|
const graphMsg = f.conversations.value[0].messages.find(
|
||||||
|
(m) => m.message_type === 'collaboration_graph',
|
||||||
|
)
|
||||||
|
expect(graphMsg).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 13. phase_changed (PLAN_EXEC) ──────────────────────────────────
|
||||||
|
it('phase_changed: sets currentPhase and appends a milestone step', () => {
|
||||||
|
dispatchWsEvent(
|
||||||
|
{ type: 'phase_changed', data: { phase: 'planning', previous: 'init' } },
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(f.state.currentPhase.value).toBe('planning')
|
||||||
|
const steps = f.state.streamingStepsByConv.value.get('conv-1') ?? []
|
||||||
|
expect(steps.some((s) => s.type === 'milestone' && s.label === '阶段切换')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 14. phase_violation (PLAN_EXEC) ────────────────────────────────
|
||||||
|
it('phase_violation: records violation (capped at 5) and sets currentPhase', () => {
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'phase_violation',
|
||||||
|
data: {
|
||||||
|
current_phase: 'planning',
|
||||||
|
tool: `tool_${i}`,
|
||||||
|
message: `blocked ${i}`,
|
||||||
|
violation_kind: 'tool_not_allowed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
expect(f.state.currentPhase.value).toBe('planning')
|
||||||
|
expect(f.state.phaseViolations.value).toHaveLength(5)
|
||||||
|
// The most recent violation should be the last one we sent.
|
||||||
|
expect(f.state.phaseViolations.value[4].tool).toBe('tool_6')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 15. board_started ──────────────────────────────────────────────
|
||||||
|
it('board_started: initializes boardState and appends board_started message', () => {
|
||||||
|
dispatchWsEvent(
|
||||||
|
{
|
||||||
|
type: 'board_started',
|
||||||
|
data: {
|
||||||
|
team_id: 't1',
|
||||||
|
topic: 'roadmap',
|
||||||
|
experts: [
|
||||||
|
{
|
||||||
|
name: 'Mod',
|
||||||
|
avatar: '🦊',
|
||||||
|
color: '#f00',
|
||||||
|
is_moderator: true,
|
||||||
|
persona: 'moderator',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_rounds: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(f.state.boardState.value).not.toBeNull()
|
||||||
|
expect(f.state.boardState.value?.topic).toBe('roadmap')
|
||||||
|
expect(f.state.boardState.value?.current_round).toBe(0)
|
||||||
|
expect(f.state.boardState.value?.status).toBe('discussing')
|
||||||
|
const msgs = f.conversations.value[0].messages
|
||||||
|
expect(msgs.some((m) => m.message_type === 'board_started')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 16. team_dissolved ─────────────────────────────────────────────
|
||||||
|
it('team_dissolved: clears teamStore and collaborationState', () => {
|
||||||
|
f.state.collaborationState.value = {
|
||||||
|
contracts: [],
|
||||||
|
notices: [],
|
||||||
|
reviews: [],
|
||||||
|
risks: [],
|
||||||
|
}
|
||||||
|
dispatchWsEvent(
|
||||||
|
{ type: 'team_dissolved', data: { team_id: 't1' } },
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(f.teamStore.clearTeam).toHaveBeenCalled()
|
||||||
|
expect(f.state.collaborationState.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 17. calendar events route to calendarStore ─────────────────────
|
||||||
|
it('calendar_event_created: delegates to calendarStore.handleWsEvent', () => {
|
||||||
|
const event = {
|
||||||
|
type: 'calendar_event_created',
|
||||||
|
data: {
|
||||||
|
event: {
|
||||||
|
id: 'ev1',
|
||||||
|
title: 'standup',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as WsServerMessage
|
||||||
|
dispatchWsEvent(event, f.state)
|
||||||
|
expect(f.calendarStore.handleWsEvent).toHaveBeenCalledWith(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 18. resolveIncomingConvId fallback (no pending conv) ───────────
|
||||||
|
it('routing: skips mutation when conversation cannot be resolved', () => {
|
||||||
|
f.currentConversationId.value = null
|
||||||
|
f.state.resolveIncomingConvId = () => ''
|
||||||
|
const a = seedAssistant(f)
|
||||||
|
dispatchWsEvent(
|
||||||
|
{ type: 'routing', skill: 's', confidence: 0.5, method: 'm' },
|
||||||
|
f.state,
|
||||||
|
)
|
||||||
|
expect(a.matched_skill).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue