Files
ss-tools/frontend/src/lib/models/AgentChatModel.svelte.ts
busya 4dce669844 fix(tests): 61 failed backend unit tests — async/await mocks, deadlock fix, SyntaxError repairs
Группы исправлений:
- Группа 1 (async/await misuse): MagicMock → AsyncMock для get_dashboards,
  export_dashboard, import_dashboard, sync_environment, get_run_detail,
  list_all_runs, create_task и др. — 23 теста
- Группа 2 (runner.run deadlock): добавлены моки get_async_job_runner +
  IdMappingService/AsyncSupersetClient в migration plugin + API tests
  для предотвращения вечной блокировки future.result() — 16 тестов
- Группа 3 (SyntaxError): исправлены 7 незакрытых скобок ')' в
  test_validation_tasks_comprehensive.py (QA-агент оставил AsyncMock
  без закрывающих скобок)
- Группа 4 (mock verification): logger mock error→explore, scheduler tests
  skipped (удалён из production), dataset mapper — 11 тестов
- Группа 5 (search/assistant): MagicMock → AsyncMock — 9 тестов
- Группа 6 (extractor parsing): AsyncMock для async методов — 9 тестов

Итого: 61 ранее FAILED → 274 passed, 4 skipped, 0 failed
2026-06-18 14:41:13 +03:00

362 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend/src/lib/models/AgentChatModel.svelte.ts
// #region AgentChat.Model [C:4] [TYPE Model] [SEMANTICS agent-chat,model,gradio,langgraph]
// @defgroup AgentChat State model for Gradio-powered agent chat — submit() streaming, structured metadata, LangGraph HITL resume, no WebSocket.
// @INVARIANT Streaming state gates input: input disabled in streaming, awaiting_confirmation, disconnected states.
// @INVARIANT Connection state gates send: send rejected when connectionState !== "connected".
// @INVARIANT HITL resume: confirmation via second submit() with additional_inputs=[conversationId, "confirm"|"deny"]. Primary stream TERMINATES on confirm_required, second stream resumes from checkpoint.
// @INVARIANT Optimistic cancel: submission.cancel() stops generation, partial text preserved.
// @INVARIANT Conversation list loaded from FastAPI REST — Gradio serves only live agent streaming.
// @INVARIANT DECOMPOSITION GATE (630→~340 lines): Stream processing → AgentChat.StreamProcessor; Connection lifecycle → AgentChat.ConnectionManager; Persistence → AgentChat.LocalStorage; Types → AgentChatTypes.
// @STATE idle | streaming | awaiting_confirmation | error | disconnected | disconnected_permanent
// @ACTION sendMessage(text, files?) — submit("/chat", {text, files}, [conversationId, null]), iterate stream events, process metadata.
// @ACTION cancelGeneration() — submission.cancel(), optimistic UI reset.
// @ACTION resumeConfirm(action) — second submit("/chat", {text:"confirm"}, [conversationId, action]) for HITL resume.
// @ACTION loadConversations(reset?) — fetch list from REST, infinite scroll.
// @ACTION loadHistory(conversationId?) — fetch messages from REST.
// @ACTION deleteConversation(id) — optimistic archive, rollback on failure.
// @ACTION createConversation() — clear state, start new.
// @ACTION retryConnection() — delegates to ConnectionManager.retryConnection().
// @RELATION BINDS_TO -> [AssistantChatStore]
// @RELATION DEPENDS_ON -> [AgentChat.ConnectionManager]
// @RELATION DEPENDS_ON -> [AgentChat.StreamProcessor]
// @RELATION DEPENDS_ON -> [AgentChat.LocalStorage]
// @RELATION DEPENDS_ON -> [AgentChatTypes]
// @RATIONALE Model-first: extracted from AssistantChatPanel.svelte (1029 lines). Decomposed further per DECOMPOSITION GATE into sub-helpers (ConnectionManager, StreamProcessor, LocalStorage) to stay under 400-line limit.
// @REJECTED Inline $state in AssistantChatPanel.svelte — rejected because component already exceeds 400-line guideline.
// @REJECTED WebSocket-based model — rejected with custom WebSocket protocol.
// @REJECTED Active/follower multi-tab — rejected in favor of client-side gate.
import { type Client as GradioClient } from "@gradio/client";
import {
assistantChatStore,
setAssistantConversationId,
} from "$lib/stores/assistantChat.svelte.js";
import {
getAssistantConversations,
getAssistantHistory,
deleteAssistantConversation,
} from "$lib/api/assistant.js";
import { addToast } from "$lib/toasts.svelte.js";
import { log } from "$lib/cot-logger";
import { type ConnectionManagerCallbacks, ConnectionManager } from "./AgentChat.ConnectionManager.svelte.js";
import { type StreamProcessorHost, StreamProcessor } from "./AgentChat.StreamProcessor.svelte.js";
import { LocalStorageManager } from "./AgentChat.LocalStorage.js";
import type {
StreamingState, ConnectionState, StreamMetadata, ToolCall, AgentMessage, Conversation,
} from "./AgentChatTypes.js";
// ═══ Main model ═════════════════════════════════════════════════
export class AgentChatModel {
// ── Atoms ──────────────────────────────────────────────────────
messages: AgentMessage[] = $state([]);
conversations: Conversation[] = $state([]);
currentConversationId: string | null = $state(null);
streamingState: StreamingState = $state("idle");
connectionState: ConnectionState = $state("connected");
error: string | null = $state(null);
partialText: string = $state("");
partialTokens: string[] = $state([]);
activeToolCalls: ToolCall[] = $state([]);
isLoadingHistory: boolean = $state(false);
inputText: string = $state("");
pendingThreadId: string | null = $state(null);
// ── Private fields ─────────────────────────────────────────────
_client: GradioClient | null = null; // non-private for legacy Object.assign usage
private _submission: ReturnType<GradioClient["submit"]> | null = null;
private _conversationsPage: number = 1;
private _conversationsHasNext: boolean = $state(false);
private _historyPage: number = 1;
private _historyHasNext: boolean = $state(false);
private _messageQueue: Array<{ text: string; files?: File[] }> = $state([]);
private _processingQueue: boolean = false;
// ── Sub-helpers ────────────────────────────────────────────────
readonly connection: ConnectionManager;
private readonly streamProcessor: StreamProcessor;
private readonly storage: LocalStorageManager;
// ── Derived ────────────────────────────────────────────────────
isStreaming = $derived(
this.streamingState === "streaming" ||
this.streamingState === "awaiting_confirmation",
);
isInputLocked = $derived(
this.isStreaming ||
this.streamingState === "disconnected" ||
this.streamingState === "disconnected_permanent",
);
connectionDotColor = $derived(
this.connectionState === "connected" ? "success"
: this.connectionState === "disconnected" ? "warning"
: "destructive",
);
queuePosition = $derived(this._messageQueue.length);
conversationsHasNext = $derived(this._conversationsHasNext);
constructor() {
const connectionCbs: ConnectionManagerCallbacks = {
onConnected: (client) => { this._client = client; this.connectionState = "connected"; },
onDisconnected: () => { this.connectionState = "disconnected"; },
onDisconnectedPermanent: () => { this.connectionState = "disconnected_permanent"; },
onStreamingStateChange: (s) => { this.streamingState = s; },
};
this.connection = new ConnectionManager(connectionCbs);
this.streamProcessor = new StreamProcessor(this as unknown as StreamProcessorHost);
this.storage = new LocalStorageManager();
}
// ── Actions — P1 (streaming) ───────────────────────────────────
async sendMessage(text: string, files?: File[]): Promise<void> {
if (!text.trim() && (!files || files.length === 0)) return;
if (this.connectionState !== "connected") return;
if (this.streamingState !== "idle") {
this._messageQueue = [...this._messageQueue, { text, files }];
log("AgentChat.Model", "REASON", "Message queued", { queueSize: this._messageQueue.length });
return;
}
await this._sendNow(text, files);
}
private async _sendNow(text: string, files?: File[]): Promise<void> {
if (this._processingQueue) return;
const convId = this.currentConversationId;
this.streamingState = "streaming";
this.error = null;
this.partialText = "";
this.partialTokens = [];
this.activeToolCalls = [];
log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) });
try {
this._submission = this._client!.submit("chat",
[{ text, files }, null, convId, null],
);
// Stream watcher: polls Client.stream_status.open. When SSE stream closes
// (close_stream → abort controller), we call submission.return() to
// terminate the iterator. If stream doesn't close in 120s — absolute fallback.
const streamDone = await Promise.race([
this.streamProcessor.processStream(this._submission, convId),
this.streamProcessor.streamCloseWatcher(this._client, 120_000),
]);
if (streamDone === false) {
try { this._submission?.return?.(); } catch { /* ignore */ }
}
this.streamingState = "idle";
this._submission = null;
this._persistMessages();
await this._drainQueue();
} catch (e: unknown) {
this.streamingState = "error";
this.error = e instanceof Error ? e.message : "Stream failed";
this._persistMessages();
log("AgentChat.Model", "EXPLORE", "Stream failed", {}, this.error);
}
}
private async _drainQueue(): Promise<void> {
if (this._processingQueue) return;
this._processingQueue = true;
try {
while (this._messageQueue.length > 0 && this.streamingState === "idle") {
const next = this._messageQueue[0];
this._messageQueue = this._messageQueue.slice(1);
log("AgentChat.Model", "REASON", "Processing queued message", { remaining: this._messageQueue.length });
await this._sendNow(next.text, next.files);
}
} finally {
this._processingQueue = false;
}
}
cancelGeneration(): void {
if (this.streamingState === "idle") return;
this._submission?.cancel();
this.streamingState = "idle";
this._submission = null;
log("AgentChat.Model", "REASON", "Generation cancelled");
}
async resumeConfirm(action: "confirm" | "deny"): Promise<void> {
if (this.streamingState !== "awaiting_confirmation") return;
if (!this.pendingThreadId || !this.currentConversationId) return;
log("AgentChat.Model", "REASON", `HITL resume: ${action}`, { threadId: this.pendingThreadId });
try {
const submission = this._client!.submit("chat",
[{ text: action === "confirm" ? "confirm" : "deny", files: null }, null, this.currentConversationId, action],
);
await this.streamProcessor.processStream(submission, this.currentConversationId);
this.streamingState = "idle";
this.pendingThreadId = null;
this._persistMessages();
} catch (e: unknown) {
this.streamingState = "error";
this.error = e instanceof Error ? e.message : "Resume failed";
log("AgentChat.Model", "EXPLORE", "HITL resume failed", {}, this.error);
}
}
// ── Actions — P2 (data + lifecycle) ────────────────────────────
async loadConversations(reset: boolean = false): Promise<void> {
log("AgentChat.Model", "REASON", "Loading conversations", { reset });
try {
if (reset) this._conversationsPage = 1;
const res = await getAssistantConversations(this._conversationsPage, 20, false, "");
const items = res.items || [];
this.conversations = reset
? items.map((item: Record<string, unknown>) => ({
id: item.conversation_id ?? item.id ?? "",
title: item.title ?? "",
updated_at: item.updated_at ?? "",
message_count: item.message_count ?? 0,
}))
: [...this.conversations, ...items.map((item: Record<string, unknown>) => ({
id: item.conversation_id ?? item.id ?? "",
title: item.title ?? "",
updated_at: item.updated_at ?? "",
message_count: item.message_count ?? 0,
}))];
this._conversationsHasNext = Boolean(res.has_next);
this._conversationsPage++;
} catch (e: unknown) {
this.error = e instanceof Error ? e.message : "Failed to load conversations";
log("AgentChat.Model", "EXPLORE", "Load conversations failed", {}, this.error);
}
}
async searchConversations(query: string): Promise<void> {
log("AgentChat.Model", "REASON", "Searching conversations", { query });
try {
this._conversationsPage = 1;
const res = await getAssistantConversations(1, 20, false, query);
this.conversations = (res.items || []).map((item: Record<string, unknown>) => ({
id: item.conversation_id ?? item.id ?? "",
title: item.title ?? "",
updated_at: item.updated_at ?? "",
message_count: item.message_count ?? 0,
}));
this._conversationsHasNext = Boolean(res.has_next);
this._conversationsPage++;
} catch (e: unknown) {
this.error = e instanceof Error ? e.message : "Failed to search conversations";
log("AgentChat.Model", "EXPLORE", "Search conversations failed", {}, this.error);
}
}
async loadHistory(conversationId: string | null = null): Promise<void> {
this.isLoadingHistory = true;
this.error = null;
log("AgentChat.Model", "REASON", "Loading history", { conversationId });
try {
const targetId = conversationId ?? this.currentConversationId;
if (!targetId) { this.isLoadingHistory = false; return; }
if (this._loadFromStorage(targetId)) { this.isLoadingHistory = false; return; }
const res = await getAssistantHistory(1, 30, targetId);
this.messages = (res.items || []).map((msg: Record<string, unknown>) => ({
id: (msg.message_id as string) ?? (msg.id as string) ?? "",
conversation_id: msg.conversation_id as string ?? "",
role: msg.role as string ?? "assistant",
text: msg.text as string ?? "",
metadata: msg.metadata as StreamMetadata,
toolCalls: (msg.tool_calls as ToolCall[]) ?? [],
created_at: msg.created_at as string ?? "",
}));
if (res.conversation_id && !this.currentConversationId) {
setAssistantConversationId(res.conversation_id as string);
this.currentConversationId = res.conversation_id as string;
}
} catch (e: unknown) {
this.error = e instanceof Error ? e.message : "Failed to load history";
log("AgentChat.Model", "EXPLORE", "Load history failed", {}, this.error);
} finally {
this.isLoadingHistory = false;
}
}
async retryConnection(): Promise<void> {
return this.connection.retryConnection();
}
createConversation(): void {
if (this.currentConversationId && this.messages.length > 0) {
this._saveToStorage();
}
this.messages = [];
this.currentConversationId = null;
this.streamingState = "idle";
this.error = null;
this.partialText = "";
this.activeToolCalls = [];
this.pendingThreadId = null;
this._historyPage = 1;
this._historyHasNext = false;
log("AgentChat.Model", "REASON", "New conversation created");
}
async deleteConversation(id: string): Promise<void> {
const prevConversations = [...this.conversations];
this.conversations = this.conversations.filter((c) => c.id !== id);
this.storage.clear(id);
log("AgentChat.Model", "REASON", "Archiving conversation (optimistic)", { id });
try {
await deleteAssistantConversation(id);
addToast("Диалог архивирован", "success");
if (this.currentConversationId === id) this.createConversation();
} catch (e: unknown) {
this.conversations = prevConversations;
this.error = e instanceof Error ? e.message : "Failed to archive conversation";
addToast("Не удалось архивировать диалог", "error");
log("AgentChat.Model", "EXPLORE", "Archive failed, rolled back", {}, this.error);
}
}
/** Extract conversation_id from stream metadata or event data */
_captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void {
this.captureConversationId(meta, convIdFromEvent);
}
/** Public alias for StreamProcessorHost interface contract */
captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void {
const newId = meta?.thread_id || convIdFromEvent || null;
if (newId && !this.currentConversationId) {
this.currentConversationId = newId;
if (this.currentConversationId) setAssistantConversationId(this.currentConversationId);
this._saveToStorage();
}
}
/** Save messages after streaming completes */
_persistMessages(): void {
if (this.currentConversationId) this._saveToStorage();
}
// ── Private — storage helpers ──────────────────────────────────
private _saveToStorage(): void {
if (this.currentConversationId) {
this.storage.save(this.currentConversationId, this.messages, this.partialText);
}
}
private _loadFromStorage(conversationId: string): boolean {
const data = this.storage.load(conversationId);
if (data) {
this.messages = data.messages;
this.currentConversationId = data.conversationId;
return true;
}
return false;
}
}
// #endregion AgentChat.Model