Files
next-tjwater-frontend/src/features/agent/api/client.test.ts
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

359 lines
17 KiB
TypeScript

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<typeof fetch>) => 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<typeof fetch>) => 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 }
}
]);
});
});