import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentResponseFinishedError, createAgentApiClient } from "./client"; describe("Agent prompt suggestions", () => { it("probes public readiness without loading identity or project credentials", async () => { const getAccessToken = vi.fn(async () => "private-token"); const getProjectId = vi.fn(async () => "project"); const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ok: true, ready: true })) .mockResolvedValueOnce(Response.json({ ok: true, ready: false })); vi.stubGlobal("fetch", fetcher); const client = createAgentApiClient("https://agent.local/prefix", { getAccessToken, getProjectId }); const signal = new AbortController().signal; await expect(client.getHealth(signal)).resolves.toBeUndefined(); expect(fetcher).toHaveBeenCalledWith("https://agent.local/prefix/health", { signal, cache: "no-store" }); expect(getAccessToken).not.toHaveBeenCalled(); expect(getProjectId).not.toHaveBeenCalled(); await expect(client.getHealth(signal)).rejects.toThrow("尚未就绪"); }); it("distinguishes a proven pre-admission idle response from ambiguous input conflicts", async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(Response.json({ code: "RESPONSE_FINISHED", message: "finished" }, { status: 409 })) .mockResolvedValueOnce(Response.json({ message: "unknown conflict" }, { status: 409 })); vi.stubGlobal("fetch", fetchMock); const client = createAgentApiClient("http://agent.local"); await expect(client.submitResponseInput("s", "next", "queue", `msg_${"1".repeat(32)}`)).rejects.toBeInstanceOf(AgentResponseFinishedError); const uncertain = client.submitResponseInput("s", "next", "queue", `msg_${"2".repeat(32)}`); await expect(uncertain).rejects.not.toBeInstanceOf(AgentResponseFinishedError); expect(fetchMock).toHaveBeenCalledTimes(2); }); it("uses owned topic endpoints and retains full-text hits from another topic", async () => { const topic = { id: "topic-a", name: "城南压力问题", createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" }; const fetchMock = vi.fn() .mockResolvedValueOnce(Response.json({ topics: [topic] })) .mockResolvedValueOnce(Response.json({ sessions: [{ id: "session-b", title: "另一段对话", topic_id: "topic-b", topic_name: "检修事件", search_snippet: "正文中的南桥阀门", purpose: "task_collaboration" }], has_more: false })) .mockResolvedValueOnce(Response.json({ session_id: "session-b", topic_id: "topic-a" })) .mockResolvedValueOnce(new Response(null, { status: 204 })); vi.stubGlobal("fetch", fetchMock); const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project-a" }); expect(await client.listTopics()).toEqual([topic]); const result = await client.listSessionPage({ q: "南桥阀门", includeCollaboration: true }); expect(result.sessions[0]).toMatchObject({ title: "另一段对话", topicId: "topic-b", topicName: "检修事件", searchSnippet: "正文中的南桥阀门", purpose: "task_collaboration" }); const query = new URL(String(fetchMock.mock.calls[1]?.[0])).searchParams; expect(query.get("q")).toBe("南桥阀门"); expect(query.has("topic_id")).toBe(false); expect(query.get("include_collaboration")).toBe("true"); await client.moveSessionToTopic("session-b", "topic-a"); expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({ topic_id: "topic-a" }); await client.deleteTopic("topic-a"); expect(String(fetchMock.mock.calls[3]?.[0])).toContain("/topics/topic-a"); for (const [, init] of fetchMock.mock.calls) { expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token"); expect(new Headers(init?.headers).get("X-Project-Id")).toBe("project-a"); } }); it("submits and edits pending input through the scoped Responses and session endpoints", async () => { const input = { id: `msg_${"1".repeat(32)}`, text: "补充压力测点", delivery: "steer", status: "pending" }; const fetchMock = vi.fn().mockResolvedValueOnce(Response.json(input)).mockResolvedValueOnce(Response.json({ items: [input] })) .mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" })) .mockResolvedValueOnce(Response.json({ ...input, status: "cancelled" })); vi.stubGlobal("fetch", fetchMock); const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" }); await expect(client.submitResponseInput("session-1", input.text, "steer", input.id)).resolves.toEqual(input); expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ input: input.text, conversation: "session-1", stream: false, tjwater: { delivery: "steer", input_id: input.id } }); await expect(client.listResponseInputs("session-1")).resolves.toEqual([input]); await expect(client.updateResponseInput("session-1", input.id, "queue")).resolves.toEqual({ ...input, delivery: "queue" }); await expect(client.cancelResponseInput("session-1", input.id)).resolves.toEqual({ ...input, status: "cancelled" }); expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("DELETE"); expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://agent.local/api/v1/responses"); expect(String(fetchMock.mock.calls[2]?.[0])).toContain(`/api/v1/session/session-1/inputs/${input.id}`); for (const [, request] of fetchMock.mock.calls) { expect(new Headers(request?.headers).get("Authorization")).toBe("Bearer token"); expect(new Headers(request?.headers).get("X-Project-Id")).toBe("project"); } }); it("uses the scoped Agent request and rejects invented activity references", async () => { const fetchMock = vi.fn().mockResolvedValue(Response.json({ suggestions: [{ question: "核对城南压力告警的证据", activity_id: "alert:pressure" }] })); vi.stubGlobal("fetch", fetchMock); const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" }); const activities = [{ id: "alert:pressure", kind: "alert" as const, title: "城南压力告警", occurred_at: "2026-10-09T07:00:00Z", origin: "backend" as const }]; const controller = new AbortController(); expect(await client.generatePromptSuggestions(activities, "test/model", controller.signal)).toEqual([{ question: "核对城南压力告警的证据", activityId: "alert:pressure" }]); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe("http://agent.local/api/v1/suggestions"); expect(new Headers(init.headers).get("Authorization")).toBe("Bearer token"); expect(new Headers(init.headers).get("X-Project-Id")).toBe("project"); expect(init.signal).toBe(controller.signal); fetchMock.mockResolvedValue(Response.json({ suggestions: [{ question: "引用其他项目", activity_id: "unknown" }] })); await expect(client.generatePromptSuggestions(activities, "test/model")).rejects.toThrow("Invalid Agent suggestion"); vi.unstubAllGlobals(); }); }); describe("Agent API client sessions", () => { it("forces renewal and sends only the refresh request id with verified context headers", async () => { const getAccessToken = vi.fn(async () => "TEST_NEW_TOKEN"); const fetchMock = vi.fn(async () => Response.json({ accepted: true }, { status: 202 })); vi.stubGlobal("fetch", fetchMock); await createAgentApiClient("http://agent.local", { getAccessToken, getProjectId: async () => "p" }).refreshCredential("s", "request-1"); expect(getAccessToken).toHaveBeenCalledWith(true); const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; expect(new Headers(init.headers).get("Authorization")).toBe("Bearer TEST_NEW_TOKEN"); expect(JSON.parse(String(init.body))).toEqual({ request_id: "request-1" }); }); afterEach(() => { vi.unstubAllGlobals(); }); it("lists normalized sessions in newest-first order", async () => { vi.stubGlobal( "fetch", vi.fn(async () => new Response( JSON.stringify({ sessions: [ { id: "old", title: "", created_at: 100, updated_at: 120 }, { id: "new", title: " 新会话 ", created_at: 200, updated_at: 210, run_status: "running" } ] }), { status: 200 } ) ) ); const sessions = await createAgentApiClient("http://agent.local").listSessions(); expect(sessions).toEqual([ { id: "new", title: "新会话", createdAt: 200, updatedAt: 210, status: undefined, parentSessionId: undefined, isStreaming: undefined, runStatus: "running", purpose: "ordinary_chat", taskId: undefined, trigger: undefined, topicId: null, topicName: undefined, searchSnippet: undefined }, { id: "old", title: "新对话", createdAt: 100, updatedAt: 120, status: undefined, parentSessionId: undefined, isStreaming: undefined, runStatus: undefined, purpose: "ordinary_chat", taskId: undefined, trigger: undefined, topicId: null, topicName: undefined, searchSnippet: undefined } ]); }); it("returns null when loading a missing session", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ message: "missing" }), { status: 404 }))); await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull(); }); it("loads a task execution history through its exact task and run binding", async () => { const fetchMock = vi.fn(async (..._args: Parameters) => Response.json({ id: "execution-session", session_id: "execution-session", title: "南桥管道爆管调查 · 执行记录", purpose: "task_execution", task_id: "50000000-0000-4000-8000-000000000001", created_at: "2026-10-30T01:00:00Z", updated_at: "2026-10-30T01:05:00Z", messages: [{ id: "assistant", role: "assistant", parts: [{ type: "text", text: "调查完成" }] }], is_streaming: false, run_status: "completed" })); vi.stubGlobal("fetch", fetchMock); const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" }); const loaded = await client.loadTaskExecutionSession( "50000000-0000-4000-8000-000000000001", "70000000-0000-4000-8000-000000000001", "execution-session" ); expect(loaded).toMatchObject({ sessionId: "execution-session", title: "南桥管道爆管调查 · 执行记录", purpose: "task_execution", taskId: "50000000-0000-4000-8000-000000000001", runStatus: "completed" }); const url = new URL(String(fetchMock.mock.calls[0]?.[0])); expect(url.pathname).toBe("/api/v1/task/50000000-0000-4000-8000-000000000001/execution-session"); expect(url.searchParams.get("run_id")).toBe("70000000-0000-4000-8000-000000000001"); expect(url.searchParams.get("session_id")).toBe("execution-session"); const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); expect(headers.get("Authorization")).toBe("Bearer token"); expect(headers.get("X-Project-Id")).toBe("project"); }); it("creates a debug branch bound to an exact task execution history", async () => { const fetchMock = vi.fn(async (..._args: Parameters) => Response.json({ session_id: "debug-session", title: "南桥管道爆管调查 · 调试", purpose: "task_collaboration", task_id: "50000000-0000-4000-8000-000000000001", parent_session_id: "execution-session", created: true }, { status: 201 })); vi.stubGlobal("fetch", fetchMock); const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" }); await expect(client.createTaskExecutionDebugSession( "50000000-0000-4000-8000-000000000001", "70000000-0000-4000-8000-000000000001", "execution-session" )).resolves.toMatchObject({ session_id: "debug-session", purpose: "task_collaboration" }); const [url, init] = fetchMock.mock.calls[0] ?? []; expect(new URL(String(url)).pathname).toBe("/api/v1/task/50000000-0000-4000-8000-000000000001/execution-session/debug"); expect(JSON.parse(String(init?.body))).toEqual({ run_id: "70000000-0000-4000-8000-000000000001", session_id: "execution-session" }); }); it("falls back when a candidate is not the agent service", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 })) .mockResolvedValueOnce( new Response( JSON.stringify({ schema_version: "agent-ui-registry@1", chart_grammars: [], components: [], actions: [] }), { status: 200 } ) ); vi.stubGlobal("fetch", fetchMock); await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({ schema_version: "agent-ui-registry@1", chart_grammars: [], components: [], actions: [] }); expect(fetchMock).toHaveBeenNthCalledWith( 1, "http://not-agent.local/api/v1/ui-registry", undefined ); expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined); }); it("sends title update payloads using backend field names", async () => { const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); vi.stubGlobal("fetch", fetchMock); await createAgentApiClient("http://agent.local").updateSessionTitle("session-1", " 标题 ", { isTitleManuallyEdited: true }); expect(fetchMock).toHaveBeenCalledWith( "http://agent.local/api/v1/session/session-1/title", expect.objectContaining({ method: "PATCH", body: JSON.stringify({ title: "标题", is_title_manually_edited: true }) }) ); }); it("adds the current Keycloak access token without dropping request headers", async () => { const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ sessions: [] }), { status: 200 }) ); vi.stubGlobal("fetch", fetchMock); const getAccessToken = vi.fn().mockResolvedValue("keycloak-token"); await createAgentApiClient("http://agent.local", { getAccessToken }).createSession(); expect(getAccessToken).toHaveBeenCalledOnce(); const init = fetchMock.mock.calls[0]?.[1]; if (!init) throw new Error("Expected Agent request init"); const headers = new Headers(init.headers); expect(headers.get("Authorization")).toBe("Bearer keycloak-token"); expect(headers.get("Content-Type")).toBe("application/json"); }); it("sends the authorized project context on Agent requests", async () => { const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ sessions: [] }), { status: 200 }) ); vi.stubGlobal("fetch", fetchMock); const getProjectId = vi.fn().mockResolvedValue("project-117"); await createAgentApiClient("http://agent.local", { getProjectId }).listSessions(); expect(getProjectId).toHaveBeenCalledOnce(); const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); expect(headers.get("X-Project-Id")).toBe("project-117"); }); it("streams session events from the backend SSE endpoint", async () => { vi.stubGlobal( "fetch", vi.fn(async () => new Response( [ 'event: state\ndata: {"session_id":"session-1","messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"检查压力"}]}],"is_streaming":true,"run_status":"running"}\n\n', 'event: token\ndata: {"session_id":"session-1","content":"处理中"}\n\n', 'event: done\ndata: {"session_id":"session-1","duration_ms":20}\n\n' ].join(""), { status: 200 } ) ) ); const events: unknown[] = []; await createAgentApiClient("http://agent.local").streamSession("session-1", { onEvent: (event) => events.push(event) }); expect(events).toEqual([ { type: "state", sessionId: "session-1", messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "检查压力" }] }], isStreaming: true, runStatus: "running" }, { type: "token", sessionId: "session-1", data: { session_id: "session-1", content: "处理中" } }, { type: "done", sessionId: "session-1", data: { session_id: "session-1", duration_ms: 20 } } ]); }); });