feat: initial commit
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
// @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<T extends HarnessEvent["type"]>(sequence: number, type: T, payload: Extract<HarnessEvent, { type: T }>["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: "<protected-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<void>((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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HarnessEvent } from "@agent-studio/shared";
|
||||
import { App } from "../src/App";
|
||||
import { HarnessRuntime } from "../src/assistant/HarnessRuntime";
|
||||
import { HarnessClientStore } from "../src/assistant/store";
|
||||
import { MockAdsMetricsUI, SaveReportDraftUI } from "../src/components/ToolUIs";
|
||||
import { AppErrorBoundary } from "../src/components/AppErrorBoundary";
|
||||
|
||||
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); });
|
||||
describe("web UI", () => {
|
||||
it("renders the Skills, Chat, and Timeline operating areas", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByLabelText("Skills Catalog")).toBeInTheDocument(); expect(screen.getByLabelText("Streaming Chat")).toBeInTheDocument(); expect(screen.getByLabelText("Event Timeline")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /尚未配置模型/ })).toBeInTheDocument(); });
|
||||
it("renders the mock metrics Tool UI", () => { render(<MockAdsMetricsUI {...toolProps("mock_ads_metrics", { accountId: "demo-account", days: 7 }, { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } })}/>); expect(screen.getByText("demo-account", { exact: false })).toBeInTheDocument(); expect(screen.getByText("ROAS")).toBeInTheDocument(); });
|
||||
it("renders approval actions inside save_report_draft", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><SaveReportDraftUI {...toolProps("save_report_draft", { content: "报告正文" }, undefined, { id: "apr_1" })}/></HarnessRuntime>); expect(screen.getByRole("button", { name: "批准一次" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument(); });
|
||||
it("opens settings with password input and does not use Web Storage", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: /尚未配置模型/ })); expect(screen.getByLabelText("API Key")).toHaveAttribute("type", "password"); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); });
|
||||
it("prompts for a new key when local model metadata was restored", () => { const store = new HarnessClientStore(); store.getSnapshot().model = { configured: true, source: "session", providerId: "saved", api: "openai-completions", modelId: "saved-model", supportsImages: false, keyConfigured: false }; render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByRole("button", { name: /模型配置已恢复/ })).toBeInTheDocument(); expect(screen.getByLabelText("消息输入")).toHaveAttribute("placeholder", "请重新输入 API Key"); });
|
||||
it("omits empty optional model fields when saving settings", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); const save = vi.spyOn(store, "saveModel").mockResolvedValue(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: /尚未配置模型/ })); await user.type(screen.getByLabelText("API Key"), "sk-fake"); await user.click(screen.getByRole("button", { name: "保存配置" })); expect(save).toHaveBeenCalledWith({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "sk-fake", modelId: "gpt-5.4", supportsImages: true }); });
|
||||
it("fills the classroom demo prompt without sending it", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: "填入 Demo Prompt" })); expect((screen.getByLabelText("消息输入") as HTMLTextAreaElement).value).toContain("ads-analysis"); expect(store.getSnapshot().events).toHaveLength(0); });
|
||||
it("shows a recoverable fallback instead of a black screen for render failures", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); const Broken = () => { throw new Error("tool renderer failed"); }; render(<AppErrorBoundary><Broken/></AppErrorBoundary>); expect(screen.getByRole("alert")).toHaveTextContent("界面渲染出现异常"); expect(screen.getByRole("button", { name: "重新加载页面" })).toBeInTheDocument(); });
|
||||
it("renders a duplicated Pi Tool sequence and approval without crashing", () => {
|
||||
const store = new HarnessClientStore();
|
||||
store.reduce(harnessEvent(1, "user.message", { messageId: "user_1", text: "demo", attachmentIds: [] }));
|
||||
store.reduce(harnessEvent(2, "run.started", { modelId: "fake" }));
|
||||
store.reduce(harnessEvent(3, "assistant.completed", { messageId: "assistant_1" }));
|
||||
store.reduce(harnessEvent(4, "tool.requested", { toolCallId: "read_1", toolName: "read", arguments: { path: "<protected-path>" } }));
|
||||
store.reduce(harnessEvent(5, "tool.requested", { toolCallId: "read_1", toolName: "read", arguments: { path: ".agents/skills/ads-analysis/SKILL.md" } }));
|
||||
store.reduce(harnessEvent(6, "tool.completed", { toolCallId: "read_1", toolName: "read", result: { status: "success" } }));
|
||||
store.reduce(harnessEvent(7, "tool.requested", { toolCallId: "metrics_1", toolName: "mock_ads_metrics", arguments: { accountId: "demo-account", days: 7 } }));
|
||||
store.reduce(harnessEvent(8, "tool.completed", { toolCallId: "metrics_1", toolName: "mock_ads_metrics", result: { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } } }));
|
||||
store.reduce(harnessEvent(9, "tool.requested", { toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" } }));
|
||||
store.reduce(harnessEvent(10, "approval.required", { approvalId: "apr_1", sessionId: "ses_1", runId: "run_1", toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" }, riskLevel: "medium", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), status: "pending" }));
|
||||
render(<AppErrorBoundary><HarnessRuntime store={store}><App/></HarnessRuntime></AppErrorBoundary>);
|
||||
expect(screen.queryByText("界面渲染出现异常")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("ROAS")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "允许保存报告草稿?" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function toolProps(toolName: string, args: Record<string, unknown>, result?: unknown, approval?: { id: string }) { return { type: "tool-call" as const, toolCallId: "t1", toolName, args, argsText: JSON.stringify(args), result, isError: false, status: { type: "complete" as const }, addResult: vi.fn(), resume: vi.fn(), respondToApproval: vi.fn(), ...(approval ? { approval } : {}) }; }
|
||||
function harnessEvent<T extends HarnessEvent["type"]>(sequence: number, type: T, payload: Extract<HarnessEvent, { type: T }>["payload"]): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId: "run_1", timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; }
|
||||
Reference in New Issue
Block a user