359 lines
17 KiB
TypeScript
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 }
|
|
}
|
|
]);
|
|
});
|
|
});
|