Files
ss-tools/frontend/src/lib/models/__tests__/AgentChatModel.test.ts

1096 lines
46 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.

// #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