868 lines
37 KiB
TypeScript
868 lines
37 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(() => {});
|
||
});
|
||
// #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("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("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("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.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("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", () => {
|
||
const saveSpy = vi.spyOn(model["storage"], "save").mockImplementation(() => {});
|
||
model.currentConversationId = "conv-1";
|
||
model.messages = [];
|
||
model["_persistMessages"]();
|
||
expect(saveSpy).toHaveBeenCalled();
|
||
saveSpy.mockRestore();
|
||
});
|
||
|
||
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";
|
||
const clearSpy = vi.spyOn(model["storage"], "clear");
|
||
await model.deleteConversation("c1");
|
||
expect(clearSpy).toHaveBeenCalledWith("c1");
|
||
clearSpy.mockRestore();
|
||
});
|
||
|
||
it("createConversation saves to localStorage when messages exist", () => {
|
||
const saveSpy = vi.spyOn(model["storage"], "save").mockImplementation(() => {});
|
||
model.currentConversationId = "c1";
|
||
model.messages = [{ id: "m1", conversation_id: "c1", role: "user", text: "hi", toolCalls: [], created_at: "" }];
|
||
model.createConversation();
|
||
expect(saveSpy).toHaveBeenCalled();
|
||
saveSpy.mockRestore();
|
||
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 }) }) });
|
||
const sendSpy = vi.spyOn(model as any, "_sendNow");
|
||
await model["_drainQueue"]();
|
||
expect(sendSpy).toHaveBeenCalled();
|
||
sendSpy.mockRestore();
|
||
});
|
||
|
||
it("_drainQueue processing guard on re-entry", async () => {
|
||
model["_processingQueue"] = true;
|
||
const drainSpy = vi.spyOn(model as any, "_drainQueue");
|
||
// This should just return early at line 206
|
||
await model["_drainQueue"]();
|
||
expect(model["_processingQueue"]).toBe(true);
|
||
drainSpy.mockRestore();
|
||
});
|
||
|
||
it("_sendNow streamDone false path", async () => {
|
||
const processSpy = vi.spyOn(model["streamProcessor"], "processStream").mockReturnValue(new Promise(() => {}));
|
||
const watcherSpy = vi.spyOn(model["streamProcessor"], "streamCloseWatcher").mockResolvedValue(false);
|
||
model._client!.submit = vi.fn().mockReturnValue({
|
||
[Symbol.asyncIterator]: () => ({ next: vi.fn(), cancel: vi.fn(), return: vi.fn() }),
|
||
cancel: vi.fn(), return: vi.fn(),
|
||
});
|
||
model._client!.stream_status = { open: true };
|
||
await model["_sendNow"]("hello");
|
||
expect(model.streamingState).toBe("idle");
|
||
processSpy.mockRestore();
|
||
watcherSpy.mockRestore();
|
||
});
|
||
|
||
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
|