// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import type { HarnessEvent } from "@agent-studio/shared"; import { HarnessClientStore } from "../src/assistant/store"; function event(sequence: number, type: T, payload: Extract["payload"], runId = "run_1"): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId, timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; } afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe("HarnessClientStore reducer", () => { it("merges streaming deltas into one assistant text part", () => { const store = new HarnessClientStore(); store.reduce(event(1, "assistant.delta", { messageId: "msg_a", delta: "你" })); store.reduce(event(2, "assistant.delta", { messageId: "msg_a", delta: "好" })); expect(store.getSnapshot().messages[0]?.content).toEqual([{ type: "text", text: "你好" }]); }); it("de-duplicates replayed events by id and sequence", () => { const store = new HarnessClientStore(); const value = event(1, "run.started", { modelId: "fake" }); store.reduce(value); store.reduce(value); expect(store.getSnapshot().events).toHaveLength(1); }); it("maps tool and approval state by toolCallId", () => { const store = new HarnessClientStore(); store.reduce(event(1, "tool.requested", { toolCallId: "t1", toolName: "save_report_draft", arguments: { content: "x" } })); store.reduce(event(2, "approval.required", { approvalId: "a1", sessionId: "ses_1", runId: "run_1", toolCallId: "t1", toolName: "save_report_draft", arguments: {}, riskLevel: "medium", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 1000).toISOString(), status: "pending" })); store.reduce(event(3, "approval.resolved", { approvalId: "a1", toolCallId: "t1", status: "approved", resolvedAt: new Date().toISOString() })); const content = store.getSnapshot().messages[0]?.content; expect(typeof content === "string" ? undefined : content?.[0]).toMatchObject({ type: "tool-call", toolCallId: "t1", approval: { approved: true } }); }); it("upserts duplicate tool requests by toolCallId", () => { const store = new HarnessClientStore(); store.reduce(event(1, "tool.requested", { toolCallId: "t1", toolName: "read", arguments: { path: "" } })); store.reduce(event(2, "tool.requested", { toolCallId: "t1", toolName: "read", arguments: { path: ".agents/skills/ads-analysis/SKILL.md" } })); const content = store.getSnapshot().messages[0]?.content; expect(typeof content === "string" ? [] : content?.filter((part) => part.type === "tool-call")).toHaveLength(1); expect(typeof content === "string" ? undefined : content?.[0]).toMatchObject({ toolCallId: "t1", args: { path: ".agents/skills/ads-analysis/SKILL.md" } }); }); it("maps cancelled and failed runs to incomplete status", () => { const store = new HarnessClientStore(); store.reduce(event(1, "assistant.delta", { messageId: "m", delta: "x" })); store.reduce(event(2, "run.cancelled", { reason: "user" })); expect(store.getSnapshot().messages[0]?.status).toMatchObject({ type: "incomplete", reason: "cancelled" }); }); it("blocks image messages locally for text-only models", async () => { const store = new HarnessClientStore(); const snapshot = store.getSnapshot(); snapshot.sessionId = "ses_1"; snapshot.model = { configured: true, source: "session", providerId: "x", modelId: "x", supportsImages: false, keyConfigured: true }; vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ attachmentId: "att_uploaded", sessionId: "ses_1", name: "x.png", mediaType: "image/png", byteSize: 3, width: 1, height: 1, status: "ready" }) })); vi.stubGlobal("URL", { ...URL, createObjectURL: () => "blob:x", revokeObjectURL: vi.fn() }); const pending = await store.attachmentAdapter.add({ file: new File(["png"], "x.png", { type: "image/png" }) }); if (!("id" in pending)) throw new Error("unexpected generator"); await expect(store.onNew({ id: "ignored", role: "user", parentId: null, sourceId: null, createdAt: new Date(), content: [{ type: "text", text: "x" }], attachments: [{ id: pending.id, type: "image", name: "x", status: { type: "complete" }, content: [{ type: "image", image: "blob:x" }] }], metadata: { custom: {} }, runConfig: undefined })).rejects.toThrow("不支持图片"); expect(fetch).toHaveBeenCalledTimes(1); vi.unstubAllGlobals(); }); it("persists model metadata without the API key", async () => { const store = new HarnessClientStore(); store.getSnapshot().sessionId = "ses_1"; vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ configured: true, source: "session", providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true, keyConfigured: true, keyHint: "••••cret" }) })); await store.saveModel({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "top-secret", modelId: "gpt", supportsImages: true }); const raw = localStorage.getItem("agent-studio-mini:model-connection:v1"); expect(raw).not.toContain("top-secret"); expect(JSON.parse(raw!)).toEqual({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", modelId: "gpt", supportsImages: true }); }); it("restores saved metadata into a new Session without credentials", async () => { localStorage.setItem("agent-studio-mini:model-connection:v1", JSON.stringify({ providerId: "saved", api: "openai-completions", baseUrl: "https://example.com/v1", modelId: "saved-model", supportsImages: false })); const requests: Array<{ url: string; init?: RequestInit }> = []; vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); requests.push({ url, init }); const body = url === "/api/skills" ? { skills: [] } : url === "/api/sessions" ? { sessionId: "ses_new" } : init?.method === "PUT" ? { configured: true, source: "session", providerId: "saved", api: "openai-completions", baseUrl: "https://example.com/v1", modelId: "saved-model", supportsImages: false, keyConfigured: false } : { configured: false, source: "missing", keyConfigured: false }; return { ok: true, json: async () => body }; })); class FakeEventSource { static readonly CLOSED = 2; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener(): void {} close(): void {} } vi.stubGlobal("EventSource", FakeEventSource); const store = new HarnessClientStore(); await store.initialize(); expect(store.getSnapshot().model).toMatchObject({ modelId: "saved-model", keyConfigured: false }); const restored = requests.find((item) => item.init?.method === "PUT"); expect(restored?.init?.body).not.toContain("apiKey"); }); it("drops corrupted persisted configuration without breaking initialization", async () => { localStorage.setItem("agent-studio-mini:model-connection:v1", "not-json"); vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { const url = String(input); const body = url === "/api/skills" ? { skills: [] } : url === "/api/sessions" ? { sessionId: "ses_new" } : { configured: false, source: "missing", keyConfigured: false }; return { ok: true, json: async () => body }; })); class FakeEventSource { static readonly CLOSED = 2; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener(): void {} close(): void {} } vi.stubGlobal("EventSource", FakeEventSource); const store = new HarnessClientStore(); await store.initialize(); expect(localStorage.getItem("agent-studio-mini:model-connection:v1")).toBeNull(); expect(store.getSnapshot().model.configured).toBe(false); }); it("waits for an in-flight Session before saving the model", async () => { let releaseSession!: () => void; const sessionGate = new Promise((resolve) => { releaseSession = resolve; }); const requests: Array<{ url: string; init?: RequestInit }> = []; vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); requests.push({ url, init }); if (url === "/api/sessions" && init?.method === "POST") { await sessionGate; return { ok: true, json: async () => ({ sessionId: "ses_delayed" }) }; } if (url === "/api/skills") return { ok: true, json: async () => ({ skills: [] }) }; if (init?.method === "PUT") return { ok: true, json: async () => ({ configured: true, source: "session", providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true, keyConfigured: true, keyHint: "••••cret" }) }; return { ok: true, json: async () => ({ configured: false, source: "missing", keyConfigured: false }) }; })); class FakeEventSource { static readonly CLOSED = 2; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener(): void {} close(): void {} } vi.stubGlobal("EventSource", FakeEventSource); const store = new HarnessClientStore(); const initializing = store.initialize(); const saving = store.saveModel({ providerId: "openai", api: "openai-responses", apiKey: "top-secret", modelId: "gpt", supportsImages: true }); expect(requests.filter((item) => item.init?.method === "POST")).toHaveLength(1); expect(requests.some((item) => item.init?.method === "PUT")).toBe(false); releaseSession(); await Promise.all([initializing, saving]); expect(requests.find((item) => item.init?.method === "PUT")?.url).toBe("/api/sessions/ses_delayed/model-config"); expect(store.getSnapshot().model).toMatchObject({ configured: true, keyConfigured: true }); }); it("reports the Session initialization failure instead of a false save success", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); const store = new HarnessClientStore(); await expect(store.saveModel({ providerId: "openai", api: "openai-responses", apiKey: "top-secret", modelId: "gpt", supportsImages: true })).rejects.toThrow("Session 初始化失败:Failed to fetch"); }); }); describe("generated report events", () => { it("deduplicates images and merges provisional tool messages with the correct assistant", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["png"], { type: "image/png" }), { headers: { "content-type": "image/png" } }))); Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:report-image") }); const store = new HarnessClientStore(); const base = { sessionId: "ses_report", runId: "run_report", timestamp: new Date().toISOString() }; const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc"; const url = `/api/reports/${accountKey}/${reportDate}/${reportId}`; const artifact = { accountKey, reportDate, reportId, previewUrl: `${url}/png`, downloads: { json: `${url}/json`, html: `${url}/html`, png: `${url}/png` } }; store.reduce({ ...base, eventId: "report_1", sequence: 1, type: "tool.requested", payload: { toolCallId: "render", toolName: "render_swads_daily_report", arguments: {} } }); store.reduce({ ...base, eventId: "report_2", sequence: 2, type: "assistant.delta", payload: { messageId: "real_message", delta: "分析" } }); const event: HarnessEvent = { ...base, eventId: "report_3", sequence: 3, type: "report.generated", payload: { assistantMessageId: "real_message", artifact } }; store.reduce(event); store.reduce(event); store.reduce({ ...event, eventId: "report_4", sequence: 4 }); await vi.waitFor(() => expect(store.getSnapshot().messages[0]?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })]))); const messages = store.getSnapshot().messages; expect(messages).toHaveLength(1); expect(messages[0]?.id).toBe("real_message"); expect(messages[0]?.content).toEqual(expect.arrayContaining([{ type: "image", image: "blob:report-image", filename: "SW Ads 2026-09-06" }])); expect(Array.isArray(messages[0]?.content) && messages[0].content.filter((part) => part.type === "image")).toHaveLength(1); }); }); const environmentModel = { configured: true, source: "environment", providerId: "openai", modelId: "env-model", keyConfigured: true } as const; const savedMetadata = { providerId: "saved", api: "openai-completions", modelId: "saved-model", supportsImages: false }; class ModelEventSource { static readonly CLOSED = 2; static current: ModelEventSource; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; constructor() { ModelEventSource.current = this; } addEventListener(): void {} close(): void {} } function response(body: unknown) { return { ok: true, json: async () => body }; } function gate() { let release!: () => void; const promise = new Promise((resolve) => { release = resolve; }); return { promise, release }; } describe("model configuration loading", () => { it("loads environment defaults on initialization and Reset before considering browser metadata", async () => { localStorage.setItem("agent-studio-mini:model-connection:v1", JSON.stringify(savedMetadata)); const sessionGate = gate(), modelGate = gate(), deleteGate = gate(), resetModelGate = gate(); let sessions = 0; const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); if (url === "/api/sessions") { await sessionGate.promise; return response({ sessionId: `ses_${++sessions}` }); } if (init?.method === "DELETE") { await deleteGate.promise; return response({}); } if (url === "/api/skills") return response({ skills: [] }); await (sessions === 1 ? modelGate : resetModelGate).promise; return response(environmentModel); }); vi.stubGlobal("fetch", fetcher); vi.stubGlobal("EventSource", ModelEventSource); const store = new HarnessClientStore(); expect(store.getSnapshot().modelLoadState).toBe("loading"); const initializing = store.initialize(); expect(store.getSnapshot().modelLoadState).toBe("loading"); sessionGate.release(); await vi.waitFor(() => expect(store.getSnapshot().sessionId).toBe("ses_1")); ModelEventSource.current.onopen?.(); expect(store.getSnapshot()).toMatchObject({ connection: "online", modelLoadState: "loading" }); modelGate.release(); await initializing; expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", model: environmentModel }); const resetting = store.reset(); expect(store.getSnapshot().modelLoadState).toBe("loading"); deleteGate.release(); await vi.waitFor(() => expect(store.getSnapshot().sessionId).toBe("ses_2")); expect(store.getSnapshot().modelLoadState).toBe("loading"); resetModelGate.release(); await resetting; ModelEventSource.current.onopen?.(); expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", model: environmentModel, connection: "online" }); expect(fetcher.mock.calls.some(([, init]) => init?.method === "PUT")).toBe(false); expect(fetcher.mock.calls.some(([url]) => String(url).endsWith("/test"))).toBe(false); }); it("keeps model errors across SSE open and retries without restoring metadata over unknown server config", async () => { localStorage.setItem("agent-studio-mini:model-connection:v1", JSON.stringify(savedMetadata)); let fail = true; const fetcher = vi.fn(async (input: string | URL | Request) => { const url = String(input); if (url === "/api/sessions") return response({ sessionId: "ses_1" }); if (url === "/api/skills") return response({ skills: [] }); if (fail) throw new Error("model endpoint unavailable"); return response(environmentModel); }); vi.stubGlobal("fetch", fetcher); vi.stubGlobal("EventSource", ModelEventSource); const store = new HarnessClientStore(); await store.initialize(); ModelEventSource.current.onopen?.(); expect(store.getSnapshot()).toMatchObject({ modelLoadState: "error", modelLoadError: "服务器模型配置读取失败:model endpoint unavailable", connection: "online" }); expect(localStorage.getItem("agent-studio-mini:model-connection:v1")).not.toBeNull(); fail = false; await store.retryModel(); expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", modelLoadError: undefined, model: environmentModel }); expect(fetcher).toHaveBeenCalledTimes(4); }); it("restores only non-sensitive metadata after a successful retry confirms no server model", async () => { localStorage.setItem("agent-studio-mini:model-connection:v1", JSON.stringify(savedMetadata)); const store = new HarnessClientStore(); store.getSnapshot().sessionId = "ses_1"; const fetcher = vi.fn().mockRejectedValueOnce(new Error("unavailable")) .mockResolvedValueOnce(response({ configured: false, source: "missing", keyConfigured: false })) .mockResolvedValueOnce(response({ configured: true, source: "session", keyConfigured: false })); vi.stubGlobal("fetch", fetcher); await store.refreshModel(); expect(store.getSnapshot().modelLoadState).toBe("error"); await store.retryModel(); expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", model: { configured: true, keyConfigured: false } }); expect(JSON.parse(fetcher.mock.calls[2]![1].body)).toEqual(savedMetadata); expect(sessionStorage.length).toBe(0); }); it("marks a successful save loaded and refreshes authoritative state after clearing credentials", async () => { const store = new HarnessClientStore(); store.getSnapshot().sessionId = "ses_1"; vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("unavailable")) .mockResolvedValueOnce(response(environmentModel)) .mockResolvedValueOnce(response({})) .mockResolvedValueOnce(response({ ...environmentModel, keyConfigured: false }))); await store.refreshModel(); expect(store.getSnapshot().modelLoadState).toBe("error"); await store.saveModel({ providerId: "openai", api: "openai-responses", modelId: "env-model", supportsImages: false, apiKey: "secret" }); expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", modelLoadError: undefined }); await store.clearCredentials(); expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", model: { configured: true, keyConfigured: false } }); expect(JSON.stringify(localStorage)).not.toContain("secret"); }); it("ignores an old Session model response arriving after Reset", async () => { const store = new HarnessClientStore(); store.getSnapshot().sessionId = "ses_old"; const oldGate = gate(); vi.stubGlobal("EventSource", ModelEventSource); vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); if (init?.method === "DELETE") return response({}); if (url === "/api/sessions") return response({ sessionId: "ses_new" }); if (url.includes("ses_old")) { await oldGate.promise; return response({ configured: false, source: "missing", keyConfigured: false }); } return response(environmentModel); })); const oldRefresh = store.refreshModel(); await store.reset(); oldGate.release(); await oldRefresh; expect(store.getSnapshot()).toMatchObject({ sessionId: "ses_new", modelLoadState: "loaded", model: environmentModel }); }); });