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:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { searchPlaces } from "./place-search-client";
const item = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.441891, latitude: 31.220188, category: "道路名", district: "奉贤区" };
const results = { items: [item], total: 1, truncated: false, region: "奉贤区" };
afterEach(() => vi.restoreAllMocks());
describe("Fengxian place search client", () => {
it("uses the new endpoint with trimmed keywords, identity and project headers", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(results));
expect(await searchPlaces(" 南桥路 ", "project-1", async () => "read-token", undefined)).toEqual(results);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://127.0.0.1:8000/api/v1/place-search-requests");
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body as string)).toEqual({ keyword: "南桥路" });
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer read-token");
expect(headers.get("X-Project-Id")).toBe("project-1");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("accepts empty search results", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ...results, items: [], total: 0 }));
expect((await searchPlaces("未知位置", "project-1")).items).toEqual([]);
});
it("accepts at most ten Fengxian candidates", async () => {
const capped = { ...results, items: Array.from({ length: 10 }, (_, index) => ({ ...item, id: `place-${index}` })), total: 10, truncated: true };
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(capped));
expect(await searchPlaces("南桥路", "project-1")).toEqual(capped);
});
it.each([
{ ...results, items: [{ ...item, longitude: 181 }] },
{ ...results, items: [{ ...item, latitude: 91 }] },
{ ...results, items: [item, item] },
{ ...results, region: "北京市" },
{ ...results, items: [{ ...item, district: "徐汇区" }] },
{ ...results, total: 0 },
{ ...results, total: 11 },
{ ...results, items: Array.from({ length: 11 }, (_, index) => ({ ...item, id: `place-${index}` })) },
{ status: "0", location: { lon: "121", lat: "31" } }
])("rejects malformed, mismatched or legacy responses: %j", async (payload) => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload));
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("未获得有效的地名搜索结果");
});
it("does not fetch blank keywords", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
await expect(searchPlaces(" ", "project-1")).rejects.toThrow("请输入");
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([403, 502, 503, 504])("preserves server failures for HTTP %s", async (status) => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ detail: "查询暂不可用" }, { status }));
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("查询暂不可用");
});
it("does not fetch after cancellation during token refresh", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
const controller = new AbortController();
await expect(searchPlaces("南桥路", "project-1", async () => {
controller.abort(); return "read-token";
}, controller.signal)).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
});