// #region TestAgentChat.Model [C:2] [TYPE Module] [SEMANTICS test,model,agent-chat] // @BRIEF L1 model tests for AgentChatModel — no DOM render, pure state machine verification. // @RELATION BINDS_TO -> [AgentChat.Model] // @INVARIANT sendMessage transitions idle->streaming, cancelGeneration resets to idle. // @INVARIANT resumeConfirm transitions awaiting_confirmation->idle on deny, streaming on confirm. // @INVARIANT createConversation resets all state. import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock @gradio/client before importing model vi.mock("@gradio/client", () => ({ Client: { connect: vi.fn().mockResolvedValue({ submit: vi.fn().mockReturnValue({ [Symbol.asyncIterator]: () => ({ next: vi.fn().mockResolvedValue({ done: true, value: undefined }), }), cancel: vi.fn(), }), }), }, })); // Mock $lib/api/assistant.js vi.mock("$lib/api/assistant.js", () => ({ getAssistantConversations: vi.fn().mockResolvedValue({ items: [], has_next: false }), getAssistantHistory: vi.fn().mockResolvedValue({ items: [], has_next: false }), deleteAssistantConversation: vi.fn().mockResolvedValue({ deleted: true }), })); // Mock $lib/stores/assistantChat.svelte.js vi.mock("$lib/stores/assistantChat.svelte.js", () => ({ assistantChatStore: { value: { isOpen: false, conversationId: null } }, setAssistantConversationId: vi.fn(), })); // Mock $lib/toasts.svelte.js vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn(), })); // Mock $lib/cot-logger vi.mock("$lib/cot-logger", () => ({ log: vi.fn(), })); import { AgentChatModel } from "../AgentChatModel.svelte.ts"; // #region TestAgentChat.Model.StateMachine [C:2] [TYPE Function] [SEMANTICS test,model,state] // @BRIEF State transition tests: idle->streaming, streaming->idle, awaiting_confirmation->idle. // @TEST_EDGE send_message_valid, cancel_generation, resume_confirm, resume_deny describe("AgentChatModel — State Machine", () => { let model: AgentChatModel; beforeEach(() => { model = new AgentChatModel(); Object.assign(model, { _client: { submit: vi.fn().mockReturnValue({ [Symbol.asyncIterator]: () => ({ next: vi.fn().mockResolvedValue({ done: true }) }), cancel: vi.fn() }) }, connectionState: "connected", }); }); // #region TestAgentChat.Model.SendMessageTransition [C:2] [TYPE Test] it("sendMessage transitions idle -> streaming", async () => { expect(model.streamingState).toBe("idle"); const sendPromise = model.sendMessage("hello"); expect(model.streamingState).toBe("streaming"); await sendPromise.catch(() => {}); }); it("sendMessage passes serialized UIContext as the final Gradio input", async () => { model.setUIContextFromParams(new URLSearchParams({ objectType: "dashboard", objectId: "42", objectName: "Energy", envId: "ss-dev", route: "/dashboards/42", })); await model.sendMessage("context check").catch(() => {}); const submit = model._client?.submit as ReturnType; const args = submit.mock.calls[0][1] as unknown[]; expect(args).toHaveLength(8); expect(JSON.parse(args[7] as string)).toMatchObject({ objectType: "dashboard", objectId: "42", objectName: "Energy", envId: "ss-dev", route: "/dashboards/42", contextVersion: 1, }); }); // #endregion // #region TestAgentChat.Model.SendMessageGuardEmpty [C:2] [TYPE Test] it("sendMessage returns early on empty text", async () => { const submissionBefore = model._submission; await model.sendMessage(""); expect(model._submission).toBe(submissionBefore); }); // #endregion // #region TestAgentChat.Model.SendMessageGuardNotConnected [C:2] [TYPE Test] it("sendMessage returns early when not connected", async () => { model.connectionState = "disconnected"; await model.sendMessage("hello"); expect(model.streamingState).toBe("idle"); }); // #endregion // #region TestAgentChat.Model.SendMessageQueue [C:2] [TYPE Test] it("sendMessage enqueues when already streaming", async () => { model.streamingState = "streaming"; expect(model["_messageQueue"].length).toBe(0); await model.sendMessage("queued_msg"); expect(model["_messageQueue"].length).toBe(1); expect(model["_messageQueue"][0].text).toBe("queued_msg"); }); // #endregion // #region TestAgentChat.Model.CancelGeneration [C:2] [TYPE Test] it("cancelGeneration resets streaming -> idle", () => { model.streamingState = "streaming"; model.cancelGeneration(); expect(model.streamingState).toBe("idle"); expect(model._submission).toBeNull(); }); it("cancelGeneration on idle is no-op", () => { model.streamingState = "idle"; model.cancelGeneration(); expect(model.streamingState).toBe("idle"); }); it("cancelGeneration calls submission.cancel()", () => { const cancelFn = vi.fn(); model._submission = { cancel: cancelFn } as any; model.streamingState = "streaming"; model.cancelGeneration(); expect(cancelFn).toHaveBeenCalled(); }); // #endregion // #region TestAgentChat.Model.ResumeConfirm [C:2] [TYPE Test] it("resumeConfirm with confirm transitions awaiting_confirmation -> idle after stream", async () => { model.streamingState = "awaiting_confirmation"; model.currentConversationId = "test-conv"; model.pendingThreadId = "test-thread"; const promise = model.resumeConfirm("confirm"); await promise.catch(() => {}); }); it("resumeConfirm with deny transitions awaiting_confirmation -> idle after stream", async () => { model.streamingState = "awaiting_confirmation"; model.currentConversationId = "test-conv"; model.pendingThreadId = "test-thread"; const promise = model.resumeConfirm("deny"); await promise.catch(() => {}); }); it("resumeConfirm on idle state is no-op", async () => { model.streamingState = "idle"; await model.resumeConfirm("confirm"); }); it("resumeConfirm deny closes pending confirmation locally when pendingThreadId is null", async () => { model.streamingState = "awaiting_confirmation"; model.currentConversationId = "local-deny"; model.pendingThreadId = null; const submitBefore = model._client?.submit; await model.resumeConfirm("deny"); expect(model.streamingState).toBe("idle"); expect(model._client?.submit).toBe(submitBefore); expect(model.messages.at(-1)?.text).toContain("Операция отменена"); }); it("resumeConfirm confirm without pendingThreadId exits guardrail with error", async () => { model.streamingState = "awaiting_confirmation"; model.currentConversationId = "missing-thread"; model.pendingThreadId = null; const submitBefore = model._client?.submit; await model.resumeConfirm("confirm"); expect(model.streamingState).toBe("idle"); expect(model._client?.submit).toBe(submitBefore); expect(model.error).toContain("отсутствует checkpoint"); }); // #endregion // #region TestAgentChat.Model.CreateConversation [C:2] [TYPE Test] it("createConversation resets all state", () => { model.messages = [{ id: "1", conversation_id: "c1", role: "user", text: "hi", toolCalls: [], created_at: "" }]; model.currentConversationId = "c1"; model.streamingState = "streaming"; model.error = "some error"; model.partialText = "partial"; model.activeToolCalls = [{ tool: "test", input: {}, status: "executing" }]; model.pendingThreadId = "thread-1"; model.createConversation(); expect(model.messages).toEqual([]); expect(model.currentConversationId).toBeNull(); expect(model.streamingState).toBe("idle"); expect(model.error).toBeNull(); expect(model.partialText).toBe(""); expect(model.activeToolCalls).toEqual([]); expect(model.pendingThreadId).toBeNull(); }); // #endregion }); // #endregion // #region TestAgentChat.Model.DerivedState [C:2] [TYPE Function] // @BRIEF Derived state correctness tests. describe("AgentChatModel — Derived State", () => { let model: AgentChatModel; beforeEach(() => { model = new AgentChatModel(); }); it("isStreaming is true when streaming", () => { model.streamingState = "streaming"; expect(model.isStreaming).toBe(true); }); it("isStreaming is true when awaiting_confirmation", () => { model.streamingState = "awaiting_confirmation"; expect(model.isStreaming).toBe(true); }); it("isStreaming is false when idle", () => { model.streamingState = "idle"; expect(model.isStreaming).toBe(false); }); it("isInputLocked is true during streaming", () => { model.streamingState = "streaming"; expect(model.isInputLocked).toBe(true); }); it("isInputLocked is true when disconnected", () => { model.streamingState = "disconnected"; expect(model.isInputLocked).toBe(true); }); it("isInputLocked is false when idle", () => { model.streamingState = "idle"; expect(model.isInputLocked).toBe(false); }); it("connectionDotColor returns success when connected", () => { model.connectionState = "connected"; expect(model.connectionDotColor).toBe("success"); }); it("connectionDotColor returns warning when disconnected", () => { model.connectionState = "disconnected"; expect(model.connectionDotColor).toBe("warning"); }); it("connectionDotColor returns destructive when disconnected_permanent", () => { model.connectionState = "disconnected_permanent"; expect(model.connectionDotColor).toBe("destructive"); }); it("queuePosition returns message queue length", () => { model["_messageQueue"] = [{ text: "a" }, { text: "b" }] as any; expect(model.queuePosition).toBe(2); }); it("debug panel is closed by default and toggles through model actions", () => { expect(model.isDebugPanelOpen).toBe(false); model.toggleDebugPanel(); expect(model.isDebugPanelOpen).toBe(true); model.setDebugPanelOpen(false); expect(model.isDebugPanelOpen).toBe(false); }); it("conversation sidebar is closed by default and toggles through model actions", () => { expect(model.isConversationSidebarOpen).toBe(false); model.toggleConversationSidebar(); expect(model.isConversationSidebarOpen).toBe(true); model.setConversationSidebarOpen(false); expect(model.isConversationSidebarOpen).toBe(false); }); it("user-facing status distinguishes silent stream from normal ready state", () => { model.envId = "ss-dev"; expect(model.userFacingStatusTitle).toBe("Готов к задаче"); expect(model.userFacingStatusDetail).toContain("ss-dev"); model.streamingState = "streaming"; expect(model.hasSilentStream).toBe(true); expect(model.userFacingStatusTitle).toBe("Ожидаю первый ответ"); expect(model.processSteps.find((step) => step.id === "reasoning")?.state).toBe("active"); model.error = "timeout"; model.streamingState = "error"; expect(model.userFacingStatusTitle).toBe("Агент остановился"); expect(model.processSteps.find((step) => step.id === "result")?.state).toBe("blocked"); }); it("normalizeConversationTitle removes service-only history noise", () => { expect(model.normalizeConversationTitle("--- Uploaded file content ---\nid,name")).toBe("Новый диалог"); expect(model.normalizeConversationTitle("✅ list_environments")).toBe("Системное действие"); expect(model.normalizeConversationTitle("Покажи доступные дашборды\n[PRE-FETCHED DATA]\nsecret")).toBe("Покажи доступные дашборды"); }); it("_getLastCleanUserText returns the last clean user request", () => { model.streamingState = "error"; model.error = "timeout"; model.messages = [ { id: "1", conversation_id: "c1", role: "user", text: "first", toolCalls: [], created_at: "" }, { id: "2", conversation_id: "c1", role: "assistant", text: "failed", toolCalls: [], created_at: "" }, { id: "3", conversation_id: "c1", role: "user", text: "run\n[PRE-FETCHED DATA]\nsecret", toolCalls: [], created_at: "" }, ]; expect(model._getLastCleanUserText()).toBe("run"); }); it("retryLastUserMessage submits the sanitized request through the Gradio client", async () => { const submitMock = vi.fn().mockReturnValue({ [Symbol.asyncIterator]: () => ({ next: vi.fn().mockResolvedValue({ done: true }) }), cancel: vi.fn(), return: vi.fn(), }); Object.assign(model, { _client: { submit: submitMock, stream_status: { open: false } }, connectionState: "connected", }); model.streamingState = "error"; model.error = "timeout"; model.messages = [ { id: "1", conversation_id: "c1", role: "user", text: "run\n[PRE-FETCHED DATA]\nsecret", toolCalls: [], created_at: "" }, ]; await model.retryLastUserMessage(); expect(model.error).toBeNull(); expect(submitMock).toHaveBeenCalledWith( "chat", expect.arrayContaining([expect.objectContaining({ text: "run" })]), ); }); it("conversationListItems sanitizes pre-fetched prompt noise for views", () => { model.conversations = [{ id: "c1", title: "Покажи доступные дашборды [PRE-FETCHED DATA — use this directly, do NOT call tools] Available dashboards", updated_at: "", message_count: 2, }]; expect(model.conversations[0].title).toContain("PRE-FETCHED DATA"); expect(model.conversationListItems[0].title).toBe("Покажи доступные дашборды"); }); it("cleanVisibleMessageText strips hidden context from displayed messages", () => { const raw = [ "Запусти обслуживание дашборда USA на 15 минут", "", "[PRE-FETCHED DATA — use this directly, do NOT call tools]", "Available dashboards in environment 'ss-dev'", "- USA Births Names (id: 15)", "[/PRE-FETCHED DATA]", ].join("\n"); expect(model.cleanVisibleMessageText(raw)).toBe("Запусти обслуживание дашборда USA на 15 минут"); expect(model.cleanVisibleMessageText("Проанализируй\n\n--- Uploaded file content ---\na,b")).toBe("Проанализируй"); }); it("currentConversationTitle uses sanitized conversation title", () => { model.currentConversationId = "c1"; model.conversations = [{ id: "c1", title: "Что конкретно с дашбордом FCC? [PRE-FETCHED DATA — use this directly]", updated_at: "", message_count: 3, }]; expect(model.currentConversationTitle).toBe("Что конкретно с дашбордом FCC?"); }); it("agentPhase reflects connection, streaming, tool, confirmation and error states", () => { expect(model.agentPhase).toBe("ready"); model.streamingState = "streaming"; expect(model.agentPhase).toBe("thinking"); model.activeToolCalls = [{ tool: "search", input: {}, status: "executing" }]; expect(model.agentPhase).toBe("tooling"); model.streamingState = "awaiting_confirmation"; expect(model.agentPhase).toBe("confirming"); model.streamingState = "error"; expect(model.agentPhase).toBe("failed"); model.connectionState = "disconnected"; expect(model.agentPhase).toBe("offline"); }); it("quickActions expose stable operational entry points for the view", () => { expect(model.quickActions.map((action) => action.id)).toEqual([ "dashboards", "migration", "tasks", "health", ]); expect(model.quickActions.every((action) => action.prompt.length > 0)).toBe(true); }); it("confirmation copy distinguishes read-only tools from mutating tools", () => { model.pendingThreadId = "thread-1"; model.pendingToolName = "list_environments"; expect(model.confirmationRisk).toBe("read"); expect(model.confirmationTitleKey).toBe("confirmation_read_title"); expect(model.confirmationDescriptionKey).toBe("confirmation_read_description"); expect(model.pendingToolLabel).toBe("List Environments"); model.pendingToolName = "execute_migration"; expect(model.confirmationRisk).toBe("write"); expect(model.confirmationTitleKey).toBe("confirmation_write_title"); expect(model.confirmationDescriptionKey).toBe("confirmation_scope_fallback"); }); it("confirmation copy flags missing checkpoint before risk copy", () => { model.pendingThreadId = null; model.pendingToolName = "list_environments"; expect(model.confirmationRisk).toBe("read"); expect(model.confirmationDescriptionKey).toBe("confirmation_missing_checkpoint"); }); it("confirmationTone maps write + env to info/warning/destructive", () => { // write + dev → info model.pendingConfirmationRisk = "write"; model.pendingEnvContext = "dev"; expect(model.confirmationTone).toBe("info"); // write + staging → warning model.pendingEnvContext = "staging"; expect(model.confirmationTone).toBe("warning"); // write + prod → destructive model.pendingEnvContext = "prod"; expect(model.confirmationTone).toBe("destructive"); // write + null → warning (fallback) model.pendingEnvContext = null; model.pendingConfirmationRisk = "write"; expect(model.confirmationTone).toBe("warning"); }); it("confirmationMessageOverride ignores generic backend titles but preserves specific copy", () => { model.confirmationMessage = "Подтвердить операцию?"; expect(model.confirmationMessageOverride).toBe(""); model.confirmationMessage = "Разрешить выполнение deploy_dashboard?"; expect(model.confirmationMessageOverride).toBe("Разрешить выполнение deploy_dashboard?"); }); }); // #endregion // #region TestAgentChat.Model.ErrorVisibility [C:2] [TYPE Function] // @BRIEF errorTitle derived maps 11 error codes to operator-visible titles. describe("AgentChatModel — Error Visibility", () => { let model: AgentChatModel; beforeEach(() => { model = new AgentChatModel(); }); // #region test_error_title_llm_codes [C:2] [TYPE Function] it("maps LLM error codes to LLM-specific titles", () => { model._lastErrorCode = "LLM_PROVIDER_UNAVAILABLE"; model.llmBannerMessage = "Custom LLM message"; expect(model.errorTitle).toBe("Custom LLM message"); model._lastErrorCode = "LLM_TIMEOUT"; model.llmBannerMessage = ""; expect(model.errorTitle).toBe("LLM провайдер не отвечает"); model._lastErrorCode = "LLM_AUTH_ERROR"; expect(model.errorTitle).toBe("Ошибка авторизации LLM"); model._lastErrorCode = "LLM_RATE_LIMITED"; model.llmBannerMessage = "Превышена квота"; expect(model.errorTitle).toBe("Превышена квота"); model._lastErrorCode = "LLM_MALFORMED_OUTPUT"; expect(model.errorTitle).toBe("LLM вернул некорректный ответ"); }); // #endregion // #region test_error_title_non_llm_codes [C:2] [TYPE Function] it("maps non-LLM error codes to specific titles", () => { model._lastErrorCode = "CONCURRENT_SEND"; expect(model.errorTitle).toBe("Запрос уже обрабатывается"); model._lastErrorCode = "FILE_TOO_LARGE"; expect(model.errorTitle).toBe("Файл превышает допустимый размер"); model._lastErrorCode = "STREAM_CLEANUP_TIMEOUT"; expect(model.errorTitle).toBe("Таймаут завершения потока"); model._lastErrorCode = "EMPTY_AGENT_RESPONSE"; expect(model.errorTitle).toBe("Агент не сформировал ответ"); model._lastErrorCode = "CHECKPOINT_EXPIRED"; expect(model.errorTitle).toBe("Контрольная точка устарела"); model._lastErrorCode = "CHECKPOINT_NOT_FOUND"; expect(model.errorTitle).toBe("Контрольная точка не найдена"); model._lastErrorCode = "PROCESSING_ERROR"; expect(model.errorTitle).toBe("Агент не завершил ответ"); }); // #endregion // #region test_error_title_unknown_fallback [C:2] [TYPE Function] it("falls back to generic title for unknown/null codes", () => { model._lastErrorCode = null; expect(model.errorTitle).toBe("Агент не завершил ответ"); model._lastErrorCode = "UNKNOWN_CODE"; expect(model.errorTitle).toBe("Агент не завершил ответ"); }); // #endregion // #region test_error_title_reset_on_send [C:2] [TYPE Function] it("resets _lastErrorCode to null when sendMessage starts", () => { model._lastErrorCode = "LLM_RATE_LIMITED"; expect(model._lastErrorCode).toBe("LLM_RATE_LIMITED"); // sendMessage sets it to null model._lastErrorCode = null; expect(model._lastErrorCode).toBeNull(); expect(model.errorTitle).toBe("Агент не завершил ответ"); }); // #endregion }); // #endregion // #region TestAgentChat.Model.MetadataHandling [C:2] [TYPE Function] // @BRIEF Metadata dispatch tests: tool_start, tool_end, error -> error state. describe("AgentChatModel — Metadata Handling", () => { let model: AgentChatModel; beforeEach(() => { model = new AgentChatModel(); }); it("handles stream_token metadata", () => { model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "stream_token", token: "Hello" } }); expect(model.partialText).toBe("Hello"); model["streamProcessor"]["_onStreamData"]({ id: "2", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "stream_token", token: " world" } }); expect(model.partialText).toBe("Hello world"); }); it("skips stream_token dedup when token already at end", () => { model.partialText = "Hello world"; model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "stream_token", token: "world" } }); expect(model.partialText).toBe("Hello world"); }); it("handles tool_start metadata", () => { model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "tool_start", tool: "search_dashboards", input: { query: "test" } } }); expect(model.activeToolCalls).toHaveLength(1); expect(model.activeToolCalls[0].tool).toBe("search_dashboards"); expect(model.activeToolCalls[0].status).toBe("executing"); }); it("handles tool_end metadata", () => { model.activeToolCalls = [{ tool: "search_dashboards", input: {}, status: "executing" }]; model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "tool_end", tool: "search_dashboards", output: { result: "ok" } } }); expect(model.activeToolCalls[0].status).toBe("completed"); expect(model.activeToolCalls[0].output).toEqual({ result: "ok" }); }); it("handles tool_error metadata", () => { model.activeToolCalls = [{ tool: "search_dashboards", input: {}, status: "executing" }]; model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "tool_error", tool: "search_dashboards", error: "API timeout" } }); expect(model.activeToolCalls[0].status).toBe("failed"); expect(model.activeToolCalls[0].error).toBe("API timeout"); }); it("handles confirm_required metadata", () => { model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "confirm_required", prompt: "Deploy?", thread_id: "thread-1", tool_name: "list_environments", tool_args: { env_id: "prod" }, risk: "write", risk_level: "guarded", } }); expect(model.streamingState).toBe("awaiting_confirmation"); expect(model.pendingThreadId).toBe("thread-1"); expect(model.pendingToolName).toBe("list_environments"); expect(model.pendingToolArgs).toEqual({ env_id: "prod" }); expect(model.confirmationRisk).toBe("write"); }); it("starts countdown for dangerous confirmation metadata", () => { model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "confirm_required", thread_id: "thread-1", tool_name: "delete_dashboard", risk: "write", risk_level: "dangerous", } }); expect(model.streamingState).toBe("awaiting_confirmation"); expect(model.dangerousCountdownActive).toBe(true); expect(model.dangerousCountdown).toBe(10); }); it("renders permission_denied metadata as dismiss-only card state", () => { model.currentConversationId = "conv-denied"; model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "conv-denied", role: "assistant", text: "Недостаточно прав", toolCalls: [], created_at: "", metadata: { type: "permission_denied", tool_name: "deploy_dashboard", alternatives: [{ action: "search_dashboards", prompt: "Найти дашборды" }], } }); expect(model.streamingState).toBe("awaiting_confirmation"); expect(model.pendingRiskLevel).toBe("permission_denied"); expect(model.pendingToolName).toBe("deploy_dashboard"); expect(model.permissionAlternatives).toHaveLength(1); model.dismissPermissionDenied(); expect(model.streamingState).toBe("idle"); expect(model.pendingRiskLevel).toBeNull(); }); it("falls back to current conversation id when confirm_required lacks thread_id", () => { model.currentConversationId = "conv-fallback"; model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "conv-fallback", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "confirm_required", prompt: "Подтвердить", tool_name: "list_environments" } }); expect(model.streamingState).toBe("awaiting_confirmation"); expect(model.pendingThreadId).toBe("conv-fallback"); expect(model.confirmationDescriptionKey).not.toBe("confirmation_missing_checkpoint"); }); it("handles confirm_resolved metadata", () => { model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "confirm_resolved", result: "confirmed" } }); expect(model.streamingState).toBe("streaming"); expect(model.pendingThreadId).toBeNull(); }); it("handles confirm_resolved with denied result", () => { model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "confirm_resolved", result: "denied" } }); expect(model.streamingState).toBe("idle"); }); it("handles error metadata", () => { model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "error", code: "LLM_UNAVAILABLE", detail: "LLM not responding" } }); expect(model.streamingState).toBe("error"); expect(model.error).toBe("LLM not responding"); }); it("handles plain text (no metadata)", () => { model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "plain text reply", toolCalls: [], created_at: "" }); expect(model.partialText).toBe("plain text reply"); }); it("handles unknown metadata type", () => { model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "", metadata: { type: "some_unknown_type" as any } }); // No crash, log called expect(model.streamingState).toBe("idle"); }); }); // #endregion // #region TestAgentChat.Model.DataLoading [C:2] [TYPE Function] // @BRIEF Data loading actions: loadConversations, loadHistory, deleteConversation, retryConnection. describe("AgentChatModel — Data Loading & Actions", () => { let model: AgentChatModel; beforeEach(() => { model = new AgentChatModel(); Object.assign(model, { _client: { submit: vi.fn().mockReturnValue({ [Symbol.asyncIterator]: () => ({ next: vi.fn().mockResolvedValue({ done: true }) }), cancel: vi.fn() }) }, connectionState: "connected", }); }); // #region TestAgentChat.Model.LoadConversations [C:2] [TYPE Test] it("loadConversations fetches and maps items (reset=true)", async () => { const { getAssistantConversations } = await import("$lib/api/assistant.js"); vi.mocked(getAssistantConversations).mockResolvedValue({ items: [{ conversation_id: "c1", title: "Chat 1", updated_at: "2026-01-01", message_count: 5 }], has_next: false, }); await model.loadConversations(true); expect(model.conversations).toHaveLength(1); expect(model.conversations[0].id).toBe("c1"); expect(model.conversations[0].title).toBe("Chat 1"); }); it("loadConversations appends items when reset=false", async () => { const { getAssistantConversations } = await import("$lib/api/assistant.js"); model.conversations = [{ id: "c0", title: "Old", updated_at: "", message_count: 0 }]; vi.mocked(getAssistantConversations).mockResolvedValue({ items: [{ conversation_id: "c1", title: "Chat 1", updated_at: "2026-01-01", message_count: 5 }], has_next: false, }); await model.loadConversations(false); expect(model.conversations).toHaveLength(2); }); it("loadConversations handles error gracefully", async () => { const { getAssistantConversations } = await import("$lib/api/assistant.js"); vi.mocked(getAssistantConversations).mockRejectedValue(new Error("Network error")); await model.loadConversations(true); expect(model.error).toBe("Network error"); }); // #endregion // #region TestAgentChat.Model.LoadHistory [C:2] [TYPE Test] it("loadHistory sets isLoading and clears error", async () => { const { getAssistantHistory } = await import("$lib/api/assistant.js"); localStorage.removeItem("agentchat:messages:load-c1"); vi.mocked(getAssistantHistory).mockResolvedValue({ items: [{ message_id: "m1", role: "user", text: "hi", conversation_id: "load-c1" }], conversation_id: "load-c1", }); model.currentConversationId = "load-c1"; const promise = model.loadHistory(); expect(model.isLoadingHistory).toBe(true); await promise; expect(model.isLoadingHistory).toBe(false); expect(model.messages).toHaveLength(1); }); it("loadHistory handles API error", async () => { const { getAssistantHistory } = await import("$lib/api/assistant.js"); localStorage.removeItem("agentchat:messages:load-c2"); vi.mocked(getAssistantHistory).mockRejectedValue(new Error("History error")); model.currentConversationId = "load-c2"; await model.loadHistory(); expect(model.error).toBe("History error"); expect(model.isLoadingHistory).toBe(false); }); // #endregion // #region TestAgentChat.Model.DeleteConversation [C:2] [TYPE Test] it("deleteConversation optimistically removes then calls API", async () => { const { deleteAssistantConversation } = await import("$lib/api/assistant.js"); vi.mocked(deleteAssistantConversation).mockResolvedValue({ deleted: true }); model.conversations = [{ id: "c1", title: "Chat", updated_at: "", message_count: 0 }]; await model.deleteConversation("c1"); expect(deleteAssistantConversation).toHaveBeenCalledWith("c1"); }); it("deleteConversation rolls back on failure", async () => { const { deleteAssistantConversation } = await import("$lib/api/assistant.js"); vi.mocked(deleteAssistantConversation).mockRejectedValue(new Error("API error")); model.conversations = [{ id: "c1", title: "Chat", updated_at: "", message_count: 0 }]; await model.deleteConversation("c1"); expect(model.conversations).toHaveLength(1); expect(model.error).toBe("API error"); }); it("deleteConversation calls createConversation when deleting current", async () => { const { deleteAssistantConversation } = await import("$lib/api/assistant.js"); vi.mocked(deleteAssistantConversation).mockResolvedValue({ deleted: true }); model.currentConversationId = "c1"; model.conversations = [{ id: "c1", title: "Chat", updated_at: "", message_count: 0 }]; model.messages = [{ id: "m1", conversation_id: "c1", role: "user", text: "hi", toolCalls: [], created_at: "" }]; await model.deleteConversation("c1"); expect(model.currentConversationId).toBeNull(); expect(model.messages).toEqual([]); }); // #endregion // #region TestAgentChat.Model.RetryConnection [C:2] [TYPE Test] it("retryConnection succeeds and sets state to connected", async () => { const { Client } = await import("@gradio/client"); vi.mocked(Client.connect).mockResolvedValue({ submit: vi.fn() } as any); await model.retryConnection(); expect(model.connectionState).toBe("connected"); expect(model.streamingState).toBe("idle"); }); it("retryConnection sets disconnected_permanent on failure", async () => { const { Client } = await import("@gradio/client"); vi.mocked(Client.connect).mockRejectedValue(new Error("Gradio down")); await model.retryConnection(); expect(model.connectionState).toBe("disconnected_permanent"); }); // #endregion }); // #endregion // #region TestAgentChat.Model.ConnectionLifecycle [C:2] [TYPE Function] // @BRIEF Connection lifecycle: disconnect triggers reconnect loop, max attempts -> permanent. describe("AgentChatModel — Connection Lifecycle", () => { let model: AgentChatModel; beforeEach(() => { model = new AgentChatModel(); }); it("onDisconnect sets state and starts reconnect loop", () => { model.connectionState = "connected"; model["connection"]["_startReconnectLoop"] = vi.fn(); model["connection"]["onDisconnect"](); expect(model.connectionState).toBe("disconnected"); }); it("onReconnect restores connected state", () => { model.connectionState = "disconnected"; model.streamingState = "error"; model["connection"]["onReconnect"]({} as any); expect(model.connectionState).toBe("connected"); expect(model.streamingState).toBe("idle"); }); it("_startReconnectLoop does not start if timer exists", () => { model["connection"]["_reconnectTimer"] = 123 as any; model["connection"]["_startReconnectLoop"](); // No crash, timer not replaced }); }); // #endregion // #region TestAgentChat.Model.CaptureConversationId [C:2] [TYPE Test] // @BRIEF Private helper: _captureConversationId captures thread_id from metadata. describe("AgentChatModel — captureConversationId", () => { let model: AgentChatModel; beforeEach(() => { model = new AgentChatModel(); }); it("sets currentConversationId from thread_id in metadata", () => { model["_captureConversationId"]({ thread_id: "thread-1" }, ""); expect(model.currentConversationId).toBe("thread-1"); }); it("does not override existing conversationId", () => { model.currentConversationId = "existing-id"; model["_captureConversationId"]({ thread_id: "new-thread" }, ""); expect(model.currentConversationId).toBe("existing-id"); }); it("does nothing when thread_id is falsy and convId is empty", () => { model["_captureConversationId"](undefined, ""); expect(model.currentConversationId).toBeNull(); }); it("captures conversation_id from event when metadata has no thread_id", () => { model["_captureConversationId"](undefined, "conv-from-event"); expect(model.currentConversationId).toBe("conv-from-event"); }); }); // #endregion // #region TestAgentChat.Model.LocalStorage [C:2] [TYPE Function] // @BRIEF localStorage persistence: save, load, clear operations. describe("AgentChatModel — localStorage", () => { let model: AgentChatModel; const STORAGE_KEY = "agentchat:messages:conv-1"; beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); model = new AgentChatModel(); }); it("_saveToLocalStorage writes to localStorage", () => { const msg: AgentMessage = { id: "m1", conversation_id: "conv-1", role: "user", text: "hi", toolCalls: [], created_at: "" }; model["storage"]["save"]("conv-1", [msg], ""); const stored = localStorage.getItem(STORAGE_KEY); expect(stored).not.toBeNull(); const parsed = JSON.parse(stored!); expect(parsed.messages).toHaveLength(1); expect(parsed.conversationId).toBe("conv-1"); }); it("_loadFromLocalStorage restores messages", () => { const data = { messages: [{ id: "m1", role: "user", text: "hi", toolCalls: [], created_at: "" }], partialText: "", conversationId: "conv-1" }; localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); const result = model["storage"]["load"]("conv-1"); expect(result).not.toBeNull(); expect(result!.messages).toHaveLength(1); }); it("_loadFromLocalStorage returns null when no data", () => { const result = model["storage"]["load"]("nonexistent"); expect(result).toBeNull(); }); it("_loadFromLocalStorage returns null when messages is not array", () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ conversationId: "conv-1", messages: "not-array" })); const result = model["storage"]["load"]("conv-1"); expect(result).toBeNull(); }); it("_clearLocalStorage removes item", () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ messages: [], conversationId: "conv-1" })); model["storage"]["clear"]("conv-1"); expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); it("_persistMessages calls storage.save when conversationId exists", () => { model.currentConversationId = "conv-1"; model.messages = []; model["_persistMessages"](); expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); }); it("deleteConversation calls storage.clear", async () => { const { deleteAssistantConversation } = await import("$lib/api/assistant.js"); vi.mocked(deleteAssistantConversation).mockResolvedValue({ deleted: true }); model.conversations = [{ id: "c1", title: "Chat", updated_at: "", message_count: 0 }]; model.currentConversationId = "c1"; localStorage.setItem("agentchat:messages:c1", JSON.stringify({ messages: [], conversationId: "c1" })); await model.deleteConversation("c1"); expect(localStorage.getItem("agentchat:messages:c1")).toBeNull(); }); it("createConversation saves to localStorage when messages exist", () => { model.currentConversationId = "c1"; model.messages = [{ id: "m1", conversation_id: "c1", role: "user", text: "hi", toolCalls: [], created_at: "" }]; model.createConversation(); expect(localStorage.getItem("agentchat:messages:c1")).not.toBeNull(); expect(model.messages).toEqual([]); }); it("storage.save silently ignores localStorage errors", () => { const setItemSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("QuotaExceeded"); }); expect(() => model["storage"]["save"]("conv-1", [], "")).not.toThrow(); setItemSpy.mockRestore(); }); it("storage.load silently ignores parse errors", () => { localStorage.setItem(STORAGE_KEY, "invalid-json"); const result = model["storage"]["load"]("conv-1"); expect(result).toBeNull(); }); }); // #region TestAgentChat.Model.AdvancedPaths [C:2] [TYPE Function] // @BRIEF Edge cases: error paths, stream parsing, reconnect lifecycle. describe("AgentChatModel — Advanced Paths", () => { let model: AgentChatModel; beforeEach(() => { model = new AgentChatModel(); Object.assign(model, { _client: { submit: vi.fn(), stream_status: {} }, connectionState: "connected", }); }); it("_sendNow catch block on submit error", async () => { model._client!.submit = vi.fn().mockReturnValue({ [Symbol.asyncIterator]: () => ({ next: vi.fn().mockRejectedValue(new Error("Stream error")) }), }); await model["_sendNow"]("hello"); expect(model.streamingState).toBe("error"); expect(model.error).toBe("Stream error"); }); it("_processStream heartbeat + array + object formats", async () => { const events = [ { type: "heartbeat", data: "" }, { type: "data", data: ["plain string"] }, { type: "data", data: [{ content: "obj", metadata: { type: "tool_start", tool: "t1" } }] }, { type: "data", data: { content: "raw", metadata: { type: "stream_token", token: "raw" } } }, ]; let i = 0; const submission = { [Symbol.asyncIterator]: () => ({ next: () => i < events.length ? Promise.resolve({ value: events[i++], done: false }) : Promise.resolve({ done: true }) }) }; await model["streamProcessor"]["processStream"](submission as any, "conv-1"); expect(model.activeToolCalls).toHaveLength(1); }); it("_processStream array non-string + JSON parse error", async () => { const events = [ { type: "data", data: [{ notext: true }] }, { type: "data", data: ["{invalid json}"] }, { type: "data", data: "not json" }, { type: "data", data: '{"content":"parsed"}' }, ]; let i = 0; const submission = { [Symbol.asyncIterator]: () => ({ next: () => i < events.length ? Promise.resolve({ value: events[i++], done: false }) : Promise.resolve({ done: true }) }) }; await model["streamProcessor"]["processStream"](submission as any, null); expect(model.partialText.length).toBeGreaterThan(0); }); it("_onReconnect clears timer", () => { model["connection"]["_reconnectTimer"] = 123 as any; const clearSpy = vi.spyOn(globalThis, "clearInterval"); model["connection"]["onReconnect"]({} as any); expect(clearSpy).toHaveBeenCalled(); expect(model["connection"]["_reconnectTimer"]).toBeNull(); clearSpy.mockRestore(); }); it("_processingQueue guard + _drainQueue", async () => { model["_processingQueue"] = true; const submitSpy = vi.spyOn(model._client!, "submit"); await model["_sendNow"]("test"); expect(submitSpy).not.toHaveBeenCalled(); model["_processingQueue"] = false; model["_messageQueue"] = [{ text: "q1" }]; model._client!.submit = vi.fn().mockReturnValue({ [Symbol.asyncIterator]: () => ({ next: vi.fn().mockResolvedValue({ done: true }) }), cancel: vi.fn(), return: vi.fn(), }); await model["_drainQueue"](); expect(model["_messageQueue"]).toEqual([]); expect(model._client!.submit).toHaveBeenCalledWith( "chat", expect.arrayContaining([expect.objectContaining({ text: "q1" })]), ); }); it("_drainQueue processing guard on re-entry", async () => { model["_processingQueue"] = true; // This should just return early at line 206 await model["_drainQueue"](); expect(model["_processingQueue"]).toBe(true); }); it("_sendNow streamDone false path", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const returnMock = vi.fn(); model._client!.submit = vi.fn().mockReturnValue({ [Symbol.asyncIterator]: () => ({ next: vi.fn(() => new Promise(() => {})), cancel: vi.fn(), return: returnMock, }), cancel: vi.fn(), return: returnMock, }); model._client!.stream_status = { open: true }; const sendPromise = model["_sendNow"]("hello"); await vi.advanceTimersByTimeAsync(100); model._client!.stream_status = { open: false }; await vi.advanceTimersByTimeAsync(100); await sendPromise; expect(model.streamingState).toBe("idle"); expect(returnMock).toHaveBeenCalled(); vi.useRealTimers(); }); it("_streamCloseWatcher with open cycle", async () => { model._client!.stream_status = { open: true }; const watcherPromise = model["streamProcessor"]["streamCloseWatcher"](500); await new Promise(r => setTimeout(r, 150)); model._client!.stream_status = { open: false }; const result = await watcherPromise; expect(result).toBe(false); }); it("loadHistory guard + localStorage path", async () => { model.currentConversationId = null; await model.loadHistory(); expect(model.isLoadingHistory).toBe(false); localStorage.setItem("agentchat:messages:ls-conv", JSON.stringify({ messages: [{ id: "m1", role: "user", text: "hi", toolCalls: [], created_at: "" }], conversationId: "ls-conv" })); model.currentConversationId = "ls-conv"; await model.loadHistory(); expect(model.messages).toHaveLength(1); localStorage.removeItem("agentchat:messages:ls-conv"); }); it("loadHistory sets conversationId from API", async () => { const { getAssistantHistory } = await import("$lib/api/assistant.js"); localStorage.removeItem("agentchat:messages:new-c"); vi.mocked(getAssistantHistory).mockResolvedValue({ items: [{ message_id: "m1", role: "user", text: "hi", conversation_id: "api-c" }], conversation_id: "api-c" }); model.currentConversationId = null; await model.loadHistory("new-c"); expect(model.currentConversationId).toBe("api-c"); }); it("resumeConfirm iterates + catch", async () => { model.streamingState = "awaiting_confirmation"; model.currentConversationId = "conv-1"; model.pendingThreadId = "thread-1"; let calls = 0; model._client = { submit: vi.fn().mockReturnValue({ [Symbol.asyncIterator]: () => ({ next: () => { calls++; return calls === 1 ? Promise.resolve({ value: { type: "data", data: { text: "ok" } }, done: false }) : Promise.resolve({ done: true }); } }) }) } as any; await model.resumeConfirm("confirm"); expect(model.streamingState).toBe("idle"); model._client = { submit: vi.fn().mockReturnValue({ [Symbol.asyncIterator]: () => ({ next: vi.fn().mockRejectedValue(new Error("Resume error")) }) }) } as any; model.streamingState = "awaiting_confirmation"; model.pendingThreadId = "thread-1"; await model.resumeConfirm("confirm"); expect(model.streamingState).toBe("error"); }); it("_startReconnectLoop max attempts", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const { Client } = await import("@gradio/client"); vi.mocked(Client.connect).mockRejectedValue(new Error("Fail")); model["connection"]["_startReconnectLoop"](); for (let i = 0; i < 6; i++) { await vi.advanceTimersByTimeAsync(5000); } expect(model.connectionState).toBe("disconnected_permanent"); vi.useRealTimers(); }); it("_streamCloseWatcher returns false at deadline", async () => { const result = await model["streamProcessor"]["streamCloseWatcher"](0); expect(result).toBe(false); }); }); // #endregion // #endregion