fix(chat): pin composer, set reply language, and track model loading
This commit is contained in:
@@ -64,3 +64,112 @@ describe("generated report events", () => {
|
||||
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<void>((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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } 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";
|
||||
@@ -11,12 +11,95 @@ import { AppErrorBoundary } from "../src/components/AppErrorBoundary";
|
||||
|
||||
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
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 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("status")).toHaveTextContent("正在读取服务器模型配置"); expect(screen.queryByText(/尚未配置模型|重新输入 API Key|Model missing/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); });
|
||||
it("keeps Composer outside the scrolling message viewport and inside the Thread", async () => {
|
||||
const store = new HarnessClientStore();
|
||||
store.setMessages(Array.from({ length: 30 }, (_, index) => ({ id: `message-${index}`, role: "assistant", content: [{ type: "text", text: `回复 ${index}` }] })));
|
||||
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
|
||||
const input = screen.getByLabelText("消息输入");
|
||||
expect(input.closest(".thread-viewport")).toBeNull();
|
||||
const root = input.closest(".thread-root")!;
|
||||
expect(root.querySelector(".thread-viewport")).toContainElement(screen.getByText("回复 29"));
|
||||
expect(root.lastElementChild).toHaveClass("composer-footer");
|
||||
expect(root.lastElementChild).toContainElement(input);
|
||||
await userEvent.setup().click(screen.getByRole("button", { name: "chat" }));
|
||||
expect(input.closest(".mobile-panel")).toHaveClass("shown");
|
||||
});
|
||||
it("enables sending after an environment model loads, and preserves send and stop controls", async () => {
|
||||
const store = new HarnessClientStore();
|
||||
Object.assign(store.getSnapshot(), { sessionId: "ses_1", connection: "online" });
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const fetcher = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/model-config")) { await gate; return { ok: true, json: async () => ({ configured: true, source: "environment", keyConfigured: true }) }; }
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText("消息输入"), "请分析账户");
|
||||
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
|
||||
let refresh!: Promise<void>;
|
||||
act(() => { refresh = store.refreshModel(); });
|
||||
expect(screen.queryByText(/尚未配置模型|重新输入 API Key/)).not.toBeInTheDocument();
|
||||
await act(async () => { release(); await refresh; });
|
||||
expect(screen.getByRole("button", { name: "发送消息" })).toBeEnabled();
|
||||
await user.click(screen.getByRole("button", { name: "发送消息" }));
|
||||
expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/messages", expect.objectContaining({ method: "POST", body: JSON.stringify({ text: "请分析账户", attachmentIds: [] }) }));
|
||||
act(() => store.reduce(harnessEvent(1, "run.started", { modelId: "env-model" })));
|
||||
await user.click(screen.getByRole("button", { name: "停止运行" }));
|
||||
expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/cancel", expect.objectContaining({ method: "POST" }));
|
||||
});
|
||||
it("preserves attachment drag-and-drop upload and removal in the fixed Composer", async () => {
|
||||
const store = new HarnessClientStore();
|
||||
Object.assign(store.getSnapshot(), { sessionId: "ses_1", connection: "online", modelLoadState: "loaded", model: { configured: true, source: "environment", keyConfigured: true, supportsImages: true } });
|
||||
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ attachmentId: "att_1", name: "photo.png", mediaType: "image/png" }) });
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:attachment") });
|
||||
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: vi.fn() });
|
||||
const { container } = render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
|
||||
const user = userEvent.setup();
|
||||
expect(screen.getByRole("button", { name: "添加图片" })).toBeEnabled();
|
||||
const dropzone = container.querySelector(".dropzone")!;
|
||||
const dataTransfer = { types: ["Files"], files: [new File(["png"], "photo.png", { type: "image/png" })] };
|
||||
fireEvent.dragEnter(dropzone, { dataTransfer });
|
||||
fireEvent.drop(dropzone, { dataTransfer });
|
||||
await waitFor(() => expect(screen.getByText("photo.png")).toBeInTheDocument());
|
||||
expect(screen.getByText("photo.png").closest(".composer-footer")).not.toBeNull();
|
||||
expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/attachments", expect.objectContaining({ method: "POST", body: expect.any(FormData) }));
|
||||
await user.click(screen.getByRole("button", { name: "移除图片" }));
|
||||
await waitFor(() => expect(screen.queryByText("photo.png")).not.toBeInTheDocument());
|
||||
expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/attachments/att_1", expect.objectContaining({ method: "DELETE" }));
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:attachment");
|
||||
});
|
||||
it("shows a recoverable read error without claiming missing credentials", async () => {
|
||||
const store = new HarnessClientStore();
|
||||
Object.assign(store.getSnapshot(), { sessionId: "ses_1", connection: "online", model: { configured: true, source: "environment", keyConfigured: true } });
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("HTTP 503"))
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ configured: true, source: "environment", keyConfigured: true }) }));
|
||||
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
|
||||
const user = userEvent.setup(); await user.type(screen.getByLabelText("消息输入"), "任务");
|
||||
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
|
||||
await act(() => store.refreshModel());
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("服务器模型配置读取失败:HTTP 503");
|
||||
expect(screen.queryByText(/尚未配置模型|重新输入 API Key/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "重试读取模型配置" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "发送消息" })).toBeEnabled());
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
it("only offers missing-model Settings after the server confirms it is unconfigured", async () => {
|
||||
const store = new HarnessClientStore(); store.getSnapshot().modelLoadState = "loaded";
|
||||
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
|
||||
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
|
||||
await userEvent.setup().click(screen.getByRole("button", { name: /尚未配置模型/ }));
|
||||
expect(screen.getByLabelText("API Key")).toHaveAttribute("type", "password");
|
||||
});
|
||||
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("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: "Settings" })); 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().modelLoadState = "loaded"; 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: /模型已配置,但缺少 API Key/ })).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: "Settings" })); 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", () => {
|
||||
|
||||
Reference in New Issue
Block a user