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,86 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import type { Map as MapLibreMap } from "maplibre-gl";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
import { useWorkbenchGeocoding } from "./use-workbench-geocoding";
vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn() }));
vi.mock("../map/geocoding-location", () => ({ locateGeocodingResult: vi.fn(), markGeocodingLocation: vi.fn() }));
const location: PlaceSearchItem = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.44, latitude: 31.22, category: "道路名", district: "奉贤区" };
const other = { ...location, id: "place-2", address: "奉贤区南桥路", longitude: 121.35, latitude: 30.77 };
const results: PlaceSearchResults = { items: [location, other], total: 2, truncated: false, region: "奉贤区" };
const remove = vi.fn();
const options = {
active: true, projectId: "project-1", mapRef: { current: {} as MapLibreMap }, mapReady: true,
getPadding: () => ({ top: 48, right: 400, bottom: 48, left: 48 }), reducedMotion: true, onLocate: vi.fn()
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(markGeocodingLocation).mockReturnValue({ remove } as unknown as ReturnType<typeof markGeocodingLocation>);
});
afterEach(cleanup);
describe("place search selection lifecycle", () => {
it("does not move until selection and replaces the previous marker", async () => {
vi.mocked(searchPlaces).mockResolvedValue(results);
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options });
act(() => result.current.changeAddress("南桥路"));
await act(() => result.current.search());
expect(result.current.results).toEqual(results);
expect(result.current.result).toBeNull();
expect(result.current.choosing).toBe(true);
expect(markGeocodingLocation).not.toHaveBeenCalled();
act(() => result.current.select(location.id));
expect(result.current.choosing).toBe(false);
expect(locateGeocodingResult).toHaveBeenCalledWith(options.mapRef.current, location, options.getPadding(), true);
act(() => result.current.select(other.id));
expect(result.current.result).toEqual(other);
expect(remove).toHaveBeenCalledOnce();
expect(markGeocodingLocation).toHaveBeenLastCalledWith(options.mapRef.current, other);
act(() => result.current.changeSelection());
expect(result.current.choosing).toBe(true);
expect(result.current.result).toEqual(other);
const moves = vi.mocked(locateGeocodingResult).mock.calls.length;
act(() => result.current.select(other.id));
expect(result.current.choosing).toBe(false);
expect(locateGeocodingResult).toHaveBeenCalledTimes(moves + 1);
rerender({ ...options, active: false });
expect(result.current.result).toEqual(other);
act(() => result.current.clear());
expect(remove).toHaveBeenCalledTimes(2);
expect(result.current.results).toBeNull();
});
it.each(["edit", "close", "project"])("ignores a late search after %s", async (action) => {
let resolve!: (value: PlaceSearchResults) => void;
vi.mocked(searchPlaces).mockReturnValue(new Promise((done) => { resolve = done; }));
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options });
act(() => result.current.changeAddress("旧地址"));
let pending!: Promise<void>;
act(() => { pending = result.current.search(); });
const signal = vi.mocked(searchPlaces).mock.calls[0][3];
if (action === "edit") act(() => result.current.changeAddress("新地址"));
if (action === "close") rerender({ ...options, active: false });
if (action === "project") rerender({ ...options, projectId: "project-2" });
expect(signal?.aborted).toBe(true);
await act(async () => { resolve(results); await pending; });
expect(result.current.results).toBeNull();
expect(result.current.result).toBeNull();
expect(result.current.pending).toBe(false);
});
it("recovers from errors and clears the map when editing the query", async () => {
vi.mocked(searchPlaces).mockRejectedValueOnce(new Error("查询暂不可用")).mockResolvedValueOnce(results);
const { result, unmount } = renderHook(() => useWorkbenchGeocoding(options));
act(() => result.current.changeAddress("南桥路"));
await act(() => result.current.search());
expect(result.current.error).toBe("查询暂不可用");
await act(() => result.current.search());
expect(result.current.error).toBeNull();
act(() => result.current.select(location.id));
act(() => result.current.changeAddress("新地址"));
expect(remove).toHaveBeenCalledOnce();
expect(result.current.result).toBeNull();
expect(result.current.results).toBeNull();
unmount();
});
});