feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running turns active while users steer or queue follow-ups and edit pending delivery. Refresh the model catalog before generation and preserve approval mode selection. Integrate scoped investigation messaging/presentation, geographic place selection, current Agent composer styles and supporting workbench contracts. Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { createAgentApiClient } from "./client";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("reads mailbox pages without sending or consuming messages and validates the contract", async () => {
|
||||
const message = { message_id: "m1", task_id: "t1", run_id: "r1", source_session_id: "task-session", target_session_id: "chat-session", direction: "to_chat", text: "现场记录已核对", status: "pending", in_reply_to: null, created_at: "2026-10-13T00:00:00Z", source_owner_id: "private-owner" };
|
||||
const fetchMock = vi.fn().mockResolvedValue(Response.json({ items: [message], next_cursor: "m1" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "p1" });
|
||||
const page = await client.listAgentMessages("chat-session", { cursor: "previous" });
|
||||
expect(page.items[0]).not.toHaveProperty("source_owner_id");
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://agent.local/api/v1/session/chat-session/agent-messages?limit=50&cursor=previous");
|
||||
expect(init.method).toBeUndefined();
|
||||
expect(init.body).toBeUndefined();
|
||||
expect(new Headers(init.headers).get("X-Project-Id")).toBe("p1");
|
||||
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, target_session_id: null, direction: "to_task" }], next_cursor: null }));
|
||||
expect((await client.listAgentMessages("chat-session")).items[0].target_session_id).toBeNull();
|
||||
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, status: "invented" }], next_cursor: null }));
|
||||
await expect(client.listAgentMessages("chat-session")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("accepts inbox notifications only for the subscribed session", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response([
|
||||
'event: agent_message\ndata: {"session_id":"other"}\n\n',
|
||||
'event: agent_message\ndata: {"session_id":"main"}\n\n',
|
||||
': heartbeat\n\n'
|
||||
].join(""))));
|
||||
const onMessage = vi.fn();
|
||||
await createAgentApiClient("http://agent.local").streamAgentMessages("main", { onMessage });
|
||||
expect(onMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const agentMessageSchema = z.object({
|
||||
message_id: z.string().min(1),
|
||||
task_id: z.string().min(1),
|
||||
run_id: z.string().min(1),
|
||||
source_session_id: z.string().min(1),
|
||||
target_session_id: z.string().min(1).nullable(),
|
||||
direction: z.enum(["to_task", "to_chat"]),
|
||||
text: z.string(),
|
||||
status: z.enum(["pending", "enqueued", "delivered", "failed"]),
|
||||
in_reply_to: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
failure_detail: z.string().nullable().optional()
|
||||
});
|
||||
export const agentMessagePageSchema = z.object({
|
||||
items: z.array(agentMessageSchema),
|
||||
next_cursor: z.string().nullable()
|
||||
});
|
||||
export type AgentMailboxMessage = z.infer<typeof agentMessageSchema>;
|
||||
export type AgentMessagePage = z.infer<typeof agentMessagePageSchema>;
|
||||
@@ -1,7 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAgentApiClient } from "./client";
|
||||
|
||||
describe("Agent prompt suggestions", () => {
|
||||
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" }));
|
||||
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" });
|
||||
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();
|
||||
});
|
||||
@@ -85,10 +132,10 @@ describe("Agent API client sessions", () => {
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://not-agent.local/api/v1/agent/chat/ui-registry",
|
||||
"http://not-agent.local/api/v1/ui-registry",
|
||||
undefined
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/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 () => {
|
||||
@@ -100,7 +147,7 @@ describe("Agent API client sessions", () => {
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://agent.local/api/v1/agent/chat/session/session-1/title",
|
||||
"http://agent.local/api/v1/session/session-1/title",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { env } from "@/shared/config/env";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages";
|
||||
|
||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
|
||||
@@ -37,6 +38,9 @@ export type AgentModelsResponse = {
|
||||
models: AgentModelOption[];
|
||||
};
|
||||
|
||||
export type AgentInputDelivery = "steer" | "queue";
|
||||
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
|
||||
|
||||
export type AgentChatSession = {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
@@ -70,6 +74,21 @@ export type AgentChatSessionSummary = {
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
export type AgentRecentActivity = {
|
||||
id: string;
|
||||
kind: "alert" | "task" | "analysis" | "session";
|
||||
title: string;
|
||||
occurred_at: string;
|
||||
summary?: string;
|
||||
status?: string;
|
||||
origin: "backend" | "agent" | "demo";
|
||||
};
|
||||
|
||||
export type AgentPromptSuggestion = {
|
||||
question: string;
|
||||
activityId: string | null;
|
||||
};
|
||||
|
||||
export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
||||
sessionId: string;
|
||||
isTitleManuallyEdited: boolean;
|
||||
@@ -94,7 +113,12 @@ export type AgentSessionStreamDataEventType =
|
||||
| "session_title"
|
||||
| "done"
|
||||
| "error"
|
||||
| "auth_required";
|
||||
| "auth_required"
|
||||
| "credential_refresh_required"
|
||||
| "credential_refreshed"
|
||||
| "credential_refresh_failed"
|
||||
| "agent_message"
|
||||
| "user_input";
|
||||
|
||||
export type AgentSessionStreamEvent =
|
||||
| {
|
||||
@@ -112,6 +136,12 @@ export type AgentSessionStreamEvent =
|
||||
};
|
||||
|
||||
export type AgentApiClient = {
|
||||
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
|
||||
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
|
||||
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
|
||||
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
|
||||
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
|
||||
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
|
||||
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
|
||||
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
|
||||
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
|
||||
@@ -143,17 +173,18 @@ export type AgentApiClient = {
|
||||
options: { sessionId: string; answers: string[][] }
|
||||
) => Promise<void>;
|
||||
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
|
||||
refreshCredential: (sessionId: string, requestId: string) => Promise<void>;
|
||||
abort: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type AgentApiClientOptions = {
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getProjectId?: () => Promise<string>;
|
||||
getProjectId?: () => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
|
||||
|
||||
const CHAT_PATH = "/api/v1/agent/chat";
|
||||
const API_PATH = "/api/v1";
|
||||
|
||||
export function createAgentApiClient(
|
||||
baseUrls: string | string[] = AGENT_API_BASE_URLS,
|
||||
@@ -187,6 +218,44 @@ export function createAgentApiClient(
|
||||
};
|
||||
|
||||
return {
|
||||
async submitResponseInput(sessionId, text, delivery, id) {
|
||||
return requestJson<AgentResponseInput>("/responses", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
|
||||
});
|
||||
},
|
||||
async listResponseInputs(sessionId) {
|
||||
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
|
||||
return result.items;
|
||||
},
|
||||
async updateResponseInput(sessionId, id, delivery) {
|
||||
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
|
||||
});
|
||||
},
|
||||
async generatePromptSuggestions(activities, model, signal) {
|
||||
const result = await requestJson<unknown>("/suggestions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ activities, model }),
|
||||
signal,
|
||||
});
|
||||
if (!isRecord(result) || !Array.isArray(result.suggestions) || result.suggestions.length < 1 || result.suggestions.length > 3) {
|
||||
throw new Error("Invalid Agent suggestions");
|
||||
}
|
||||
const ids = new Set(activities.map((item) => item.id));
|
||||
const questions = new Set<string>();
|
||||
return result.suggestions.map((item): AgentPromptSuggestion => {
|
||||
if (!isRecord(item) || typeof item.question !== "string" || !item.question.trim() || item.question.length > 180 ||
|
||||
(item.activity_id !== null && (typeof item.activity_id !== "string" || !ids.has(item.activity_id)))) {
|
||||
throw new Error("Invalid Agent suggestion");
|
||||
}
|
||||
const question = item.question.trim();
|
||||
if (questions.has(question)) throw new Error("Duplicate Agent suggestion");
|
||||
questions.add(question);
|
||||
return { question, activityId: item.activity_id as string | null };
|
||||
});
|
||||
},
|
||||
async createSession(options) {
|
||||
return requestJson<AgentChatSession>("/session", {
|
||||
method: "POST",
|
||||
@@ -254,6 +323,22 @@ export function createAgentApiClient(
|
||||
await readSessionSseStream(response, options.onEvent);
|
||||
},
|
||||
|
||||
async listAgentMessages(sessionId, options) {
|
||||
const query = new URLSearchParams({ limit: "50" });
|
||||
if (options?.cursor) query.set("cursor", options.cursor);
|
||||
return agentMessagePageSchema.parse(await requestJson<unknown>(
|
||||
`/session/${encodeURIComponent(sessionId)}/agent-messages?${query}`, { signal: options?.signal }
|
||||
));
|
||||
},
|
||||
|
||||
async streamAgentMessages(sessionId, options) {
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}/agent-messages/stream`, { signal: options.signal });
|
||||
if (!response.ok) throw new Error(getResponseErrorMessage(await response.json(), response.status));
|
||||
await readSessionSseStream(response, (event) => {
|
||||
if (event.type === "agent_message" && event.sessionId === sessionId) options.onMessage();
|
||||
});
|
||||
},
|
||||
|
||||
async updateSessionTitle(sessionId, title, options) {
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) {
|
||||
@@ -329,6 +414,13 @@ export function createAgentApiClient(
|
||||
});
|
||||
},
|
||||
|
||||
async refreshCredential(sessionId, requestId) {
|
||||
await options.getAccessToken?.(true);
|
||||
await requestJson(`/session/${encodeURIComponent(sessionId)}/credentials`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_id: requestId })
|
||||
});
|
||||
},
|
||||
async abort(sessionId) {
|
||||
await requestJson<unknown>("/abort", {
|
||||
method: "POST",
|
||||
@@ -346,7 +438,7 @@ async function fetchWithFallback(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getProjectId?: () => Promise<string>
|
||||
getProjectId?: () => Promise<string | undefined>
|
||||
) {
|
||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||
let lastError: unknown;
|
||||
@@ -361,7 +453,7 @@ async function fetchWithFallback(
|
||||
|
||||
for (const baseUrl of orderedBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
|
||||
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
|
||||
if (response.ok) {
|
||||
setActiveBaseUrl(baseUrl);
|
||||
return response;
|
||||
@@ -603,7 +695,12 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
|
||||
value === "session_title" ||
|
||||
value === "done" ||
|
||||
value === "error" ||
|
||||
value === "credential_refresh_required" ||
|
||||
value === "credential_refreshed" ||
|
||||
value === "credential_refresh_failed" ||
|
||||
value === "auth_required"
|
||||
|| value === "agent_message"
|
||||
|| value === "user_input"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage, UIMessageChunk } from "ai";
|
||||
import { createResponseRequest, responsesToUiStream } from "./responses-transport";
|
||||
|
||||
const bytes = (events: Record<string, unknown>[], partial = false) => {
|
||||
const text = events.map((event) => `event: ${String(event.type)}\r\ndata: ${JSON.stringify(event)}\r\n\r\n`).join("");
|
||||
const encoded = new TextEncoder().encode(text);
|
||||
return new ReadableStream<Uint8Array>({ start(controller) {
|
||||
if (partial) for (let i = 0; i < encoded.length; i += 7) controller.enqueue(encoded.slice(i, i + 7));
|
||||
else controller.enqueue(encoded);
|
||||
controller.close();
|
||||
} });
|
||||
};
|
||||
const collect = async (stream: ReadableStream<UIMessageChunk>) => {
|
||||
const parts: UIMessageChunk[] = [];
|
||||
const reader = stream.getReader();
|
||||
while (true) { const next = await reader.read(); if (next.done) break; parts.push(next.value); }
|
||||
return parts;
|
||||
};
|
||||
const events = (): Record<string, unknown>[] => [
|
||||
{ type: "response.created", sequence_number: 0, response: { id: "resp_1" } },
|
||||
{ type: "response.output_item.added", sequence_number: 1, item: { type: "message", id: "msg_1", role: "assistant", content: [] } },
|
||||
{ type: "response.content_part.added", sequence_number: 2, item_id: "msg_1", content_index: 0, part: { type: "output_text", text: "" } },
|
||||
{ type: "response.output_text.delta", sequence_number: 3, item_id: "msg_1", content_index: 0, delta: "压力待核验" },
|
||||
{ type: "tjwater.permission_request", sequence_number: 4, response_id: "resp_1", data: { request_id: "permission_1" } },
|
||||
{ type: "response.output_text.done", sequence_number: 5, item_id: "msg_1", content_index: 0, text: "压力待核验" },
|
||||
{ type: "response.completed", sequence_number: 6, response: { id: "resp_1", status: "completed" } },
|
||||
];
|
||||
|
||||
describe("Responses transport", () => {
|
||||
it("sends only the latest user input and keeps business options explicit", () => {
|
||||
const messages = [{ id: "old", role: "assistant", parts: [{ type: "text", text: "旧回答" }] }, { id: "new", role: "user", parts: [{ type: "text", text: "继续核查" }] }] as UIMessage[];
|
||||
expect(createResponseRequest(messages, { conversation: "owned", approvalMode: "always" })).toEqual({
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "继续核查" }] }], stream: true, conversation: "owned",
|
||||
tjwater: { approval_mode: "always", capabilities: [] },
|
||||
});
|
||||
});
|
||||
it("decodes split UTF-8 and CRLF while retaining approval events", async () => {
|
||||
expect(await collect(responsesToUiStream(bytes(events(), true)))).toEqual([
|
||||
{ type: "start", messageId: "msg_1" }, { type: "text-start", id: "msg_1:0" },
|
||||
{ type: "text-delta", id: "msg_1:0", delta: "压力待核验" },
|
||||
{ type: "data-permission_request", data: { request_id: "permission_1" } },
|
||||
{ type: "text-end", id: "msg_1:0" }, { type: "finish", finishReason: "stop" },
|
||||
]);
|
||||
});
|
||||
it("reports premature disconnects, mismatched owners and invalid event order", async () => {
|
||||
await expect(collect(responsesToUiStream(bytes(events().slice(0, 5))))).rejects.toThrow("断开");
|
||||
const wrong = events(); wrong[4] = { ...wrong[4], response_id: "other" };
|
||||
await expect(collect(responsesToUiStream(bytes(wrong)))).rejects.toThrow("归属");
|
||||
await expect(collect(responsesToUiStream(bytes([events()[0]!, events()[3]!])))).rejects.toThrow();
|
||||
});
|
||||
it("turns failed and cancelled Responses into visible errors", async () => {
|
||||
const failed = events();
|
||||
failed[6] = { type: "response.failed", sequence_number: 6, response: { id: "resp_1", error: { message: "数据源不可用" } } };
|
||||
expect((await collect(responsesToUiStream(bytes(failed)))).at(-2)).toEqual({ type: "error", errorText: "数据源不可用" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai";
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export function createResponseRequest(messages: UIMessage[], options: {
|
||||
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
|
||||
triggerContext?: unknown; taskContext?: unknown;
|
||||
}) {
|
||||
const latest = [...messages].reverse().find((message) => message.role === "user");
|
||||
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
|
||||
if (!text) throw new Error("Responses 请求需要用户文本");
|
||||
return {
|
||||
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
|
||||
...(options.model ? { model: options.model } : {}),
|
||||
...(options.conversation ? { conversation: options.conversation } : {}),
|
||||
tjwater: { approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
|
||||
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
|
||||
...(options.taskContext ? { task_context: options.taskContext } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep UI rendering independent of the public Responses wire protocol. */
|
||||
export function responsesToUiStream(stream: ReadableStream<Uint8Array>): ReadableStream<UIMessageChunk> {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let responseId: string | undefined;
|
||||
let messageStarted = false;
|
||||
let messageId: string | undefined;
|
||||
let terminal = false;
|
||||
let sequence = -1;
|
||||
const texts = new Set<string>();
|
||||
const parse = (block: string, controller: TransformStreamDefaultController<UIMessageChunk>) => {
|
||||
if (block.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
|
||||
const lines = block.split(/\r?\n/);
|
||||
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
||||
if (!data) return;
|
||||
if (terminal) throw new Error("Responses 流在结束后仍返回事件");
|
||||
const value: unknown = JSON.parse(data);
|
||||
if (!record(value) || typeof value.type !== "string" || !Number.isInteger(value.sequence_number) || (value.sequence_number as number) <= sequence) throw new Error("Responses 流事件格式不正确");
|
||||
sequence = value.sequence_number as number;
|
||||
const named = lines.find((line) => line.startsWith("event:"))?.slice(6).trim();
|
||||
if (named && named !== value.type) throw new Error("Responses 流事件类型不一致");
|
||||
if (value.type === "response.created") {
|
||||
if (responseId || !record(value.response) || typeof value.response.id !== "string") throw new Error("Responses 创建事件不正确");
|
||||
responseId = value.response.id;
|
||||
return;
|
||||
}
|
||||
if (!responseId) throw new Error("Responses 流缺少创建事件");
|
||||
const textId = () => {
|
||||
if (value.item_id !== messageId || !Number.isInteger(value.content_index) || (value.content_index as number) < 0) throw new Error("Responses 文本归属不正确");
|
||||
return `${messageId}:${String(value.content_index)}`;
|
||||
};
|
||||
if (value.type === "response.output_item.added" && record(value.item) && value.item.type === "message") {
|
||||
if (messageStarted || typeof value.item.id !== "string") throw new Error("Responses 回答消息不正确");
|
||||
messageStarted = true;
|
||||
messageId = value.item.id;
|
||||
controller.enqueue({ type: "start", messageId: value.item.id });
|
||||
} else if (value.type === "response.content_part.added" && record(value.part) && value.part.type === "output_text") {
|
||||
if (!messageStarted) throw new Error("Responses 流缺少回答消息");
|
||||
texts.add(textId());
|
||||
controller.enqueue({ type: "text-start", id: textId() });
|
||||
} else if (value.type === "response.output_text.delta") {
|
||||
if (!texts.has(textId()) || typeof value.delta !== "string") throw new Error("Responses 文本事件顺序不正确");
|
||||
controller.enqueue({ type: "text-delta", id: textId(), delta: value.delta });
|
||||
} else if (value.type === "response.output_text.done") {
|
||||
if (!texts.delete(textId())) throw new Error("Responses 文本结束事件不正确");
|
||||
controller.enqueue({ type: "text-end", id: textId() });
|
||||
} else if (value.type.startsWith("tjwater.")) {
|
||||
if (value.response_id !== responseId || !/^tjwater\.[a-z][a-z0-9_]*$/.test(value.type)) throw new Error("业务流事件归属不正确");
|
||||
controller.enqueue({ type: `data-${value.type.slice(8)}`, data: value.data,
|
||||
...(typeof value.id === "string" ? { id: value.id } : {}), ...(value.transient === true ? { transient: true } : {}) } as UIMessageChunk);
|
||||
} else if (["response.completed", "response.failed", "response.incomplete"].includes(value.type)) {
|
||||
if (!record(value.response) || value.response.id !== responseId || texts.size) throw new Error("Responses 结束事件不正确");
|
||||
terminal = true;
|
||||
if (!messageStarted) controller.enqueue({ type: "start", messageId: responseId });
|
||||
if (value.type !== "response.completed") {
|
||||
const failure = value.response.error;
|
||||
controller.enqueue({ type: "error", errorText: record(failure) && typeof failure.message === "string" ? failure.message : "请求未完成或已中止" });
|
||||
}
|
||||
controller.enqueue({ type: "finish", finishReason: value.type === "response.completed" ? "stop" : "error" });
|
||||
}
|
||||
};
|
||||
const flushBlocks = (controller: TransformStreamDefaultController<UIMessageChunk>) => {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = /\r?\n\r?\n/.exec(buffer))) {
|
||||
const block = buffer.slice(0, match.index);
|
||||
buffer = buffer.slice(match.index + match[0].length);
|
||||
parse(block, controller);
|
||||
}
|
||||
if (buffer.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
|
||||
};
|
||||
return stream.pipeThrough(new TransformStream<Uint8Array, UIMessageChunk>({
|
||||
transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); flushBlocks(controller); },
|
||||
flush(controller) {
|
||||
buffer += decoder.decode();
|
||||
flushBlocks(controller);
|
||||
if (buffer.trim()) parse(buffer, controller);
|
||||
if (!terminal) throw new Error("Responses 流在完成前断开,请重新打开会话查看状态");
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export class ResponsesChatTransport<Message extends UIMessage> extends HttpChatTransport<Message> {
|
||||
protected processResponseStream(stream: ReadableStream<Uint8Array>) {
|
||||
return responsesToUiStream(stream);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentApiClient } from "./client";
|
||||
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
|
||||
import { fetchAgentBootstrap, fetchAgentSessions, resolveAgentModelSelection } from "./swr";
|
||||
|
||||
describe("Agent API SWR helpers", () => {
|
||||
it("preserves valid model choices and resolves removed choices against the current catalog", () => {
|
||||
const models = [
|
||||
{ id: "primary/fast", label: "快速", description: "Primary", icon: "bolt" as const },
|
||||
{ id: "secondary/expert", label: "专家", description: "Secondary", icon: "sparkle" as const }
|
||||
];
|
||||
const catalog = { defaultModel: "secondary/expert", models };
|
||||
expect(resolveAgentModelSelection(catalog, "primary/fast")).toBe("primary/fast");
|
||||
expect(resolveAgentModelSelection(catalog, "retired/fast")).toBe("secondary/expert");
|
||||
expect(resolveAgentModelSelection(catalog)).toBe("secondary/expert");
|
||||
expect(resolveAgentModelSelection({ ...catalog, defaultModel: "missing/model" }, "retired/fast")).toBe("primary/fast");
|
||||
expect(resolveAgentModelSelection({ defaultModel: "primary/fast", models: [] }, "primary/fast")).toBe("");
|
||||
});
|
||||
|
||||
it("loads bootstrap data with a normalized UI registry", async () => {
|
||||
const client = {
|
||||
getUiRegistry: vi.fn(async () => ({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type {
|
||||
AgentApiClient,
|
||||
AgentChatSessionSummary,
|
||||
AgentModelOption
|
||||
AgentModelOption,
|
||||
AgentModelsResponse
|
||||
} from "./client";
|
||||
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
|
||||
import { parseFrontendActionRegistry, type FrontendActionRegistry } from "../frontend-action";
|
||||
@@ -16,6 +17,12 @@ export type AgentBootstrapData = {
|
||||
defaultModel: string;
|
||||
};
|
||||
|
||||
export function resolveAgentModelSelection(catalog: AgentModelsResponse, current = ""): string {
|
||||
if (catalog.models.some((model) => model.id === current)) return current;
|
||||
if (catalog.models.some((model) => model.id === catalog.defaultModel)) return catalog.defaultModel;
|
||||
return catalog.models[0]?.id ?? "";
|
||||
}
|
||||
|
||||
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
|
||||
const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([
|
||||
client.getUiRegistry(),
|
||||
|
||||
Reference in New Issue
Block a user