1096 lines
46 KiB
TypeScript
1096 lines
46 KiB
TypeScript
// #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<typeof vi.fn>;
|
||
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
|