fix(chat): pin composer, set reply language, and track model loading

This commit is contained in:
Jeffrey Wu
2026-09-07 12:00:27 +08:00
parent 5c61371d7e
commit f4e3f41c2c
9 changed files with 299 additions and 36 deletions
+109
View File
@@ -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 });
});
});