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,142 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { InvestigationPresentationCommand, InvestigationPresentationCursor } from "../api/investigation-presentation";
|
||||
import type { WorkbenchEvent } from "../api/workbench-sync-client";
|
||||
import { InvestigationPresentationFollower } from "./investigation-presentation-follower";
|
||||
import { WorkbenchEventChannel } from "./workbench-event-channel";
|
||||
|
||||
const taskId = "50000000-0000-4000-8000-000000000001";
|
||||
const runId = "55000000-0000-4000-8000-000000000001";
|
||||
const nextRunId = "55000000-0000-4000-8000-000000000002";
|
||||
const highlight: InvestigationPresentationCommand = { action: "highlight_features", layer: "scada", feature_ids: ["MP01"], effect: "pulse" };
|
||||
const openChart: InvestigationPresentationCommand = {
|
||||
action: "open_chart", chart_id: "pressure", evidence_refs: ["reading-1"],
|
||||
chart: { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4] }] }
|
||||
};
|
||||
const closeChart: InvestigationPresentationCommand = { action: "close_chart", chart_id: "pressure" };
|
||||
|
||||
function cursor(eventCursor: number, generation = 1): InvestigationPresentationCursor {
|
||||
return { task_id: taskId, execution_generation: generation, event_cursor: eventCursor,
|
||||
active_run: { run_id: generation === 1 ? runId : nextRunId, generation, status: "running" } };
|
||||
}
|
||||
|
||||
function event(sequence: number, command = highlight, generation = 1): WorkbenchEvent {
|
||||
return { schema: "workbench-event@1", event_seq: sequence, event_id: "60000000-0000-4000-8000-000000000001",
|
||||
project_id: "10000000-0000-4000-8000-000000000001", topic: "operational.presentation.requested",
|
||||
aggregate_type: "operational_presentation", aggregate_id: taskId, aggregate_revision: 1,
|
||||
occurred_at: "2026-10-10T01:00:00Z", metadata: {
|
||||
task_id: taskId, run_id: generation === 1 ? runId : nextRunId, execution_generation: generation, command
|
||||
} };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function setup(overrides: Partial<ConstructorParameters<typeof InvestigationPresentationFollower>[0]> = {}) {
|
||||
const channel = new WorkbenchEventChannel();
|
||||
const execute = vi.fn(async (_command: InvestigationPresentationCommand, _signal: AbortSignal) => {});
|
||||
const fetchCursor = vi.fn(async (_signal: AbortSignal) => cursor(10));
|
||||
const fetchIdentity = vi.fn(async (_signal: AbortSignal) => cursor(10));
|
||||
const clear = vi.fn();
|
||||
const options = { taskId, subscribe: channel.subscribe, execute, fetchCursor, fetchIdentity,
|
||||
clear, stopPulse: vi.fn(), onStatus: vi.fn() };
|
||||
const follower = new InvestigationPresentationFollower({ ...options, ...overrides });
|
||||
return { channel, follower, ...options };
|
||||
}
|
||||
|
||||
describe("live investigation presentation following", () => {
|
||||
it("listens before fetching the baseline and preserves events arriving during cursor lookup", async () => {
|
||||
const baseline = deferred<InvestigationPresentationCursor>();
|
||||
const order: string[] = [];
|
||||
const test = setup();
|
||||
test.channel.publish({ type: "open" });
|
||||
const fetchCursor = vi.fn(() => {
|
||||
order.push("cursor");
|
||||
test.channel.publish({ type: "events", events: [event(8), event(11)] });
|
||||
return baseline.promise;
|
||||
});
|
||||
const follower = new InvestigationPresentationFollower({ ...test,
|
||||
subscribe: (listener) => { order.push("listen"); return test.channel.subscribe(listener); }, fetchCursor });
|
||||
follower.start();
|
||||
try {
|
||||
expect(order).toEqual(["listen", "cursor"]);
|
||||
expect(test.execute).not.toHaveBeenCalled();
|
||||
baseline.resolve(cursor(10));
|
||||
await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1));
|
||||
expect(test.execute.mock.calls[0]?.[0]).toEqual(highlight);
|
||||
} finally { follower.stop(); }
|
||||
});
|
||||
|
||||
it("executes same-batch chart commands in sequence and waits for open before close", async () => {
|
||||
const painted = deferred<void>();
|
||||
const execute = vi.fn((command: InvestigationPresentationCommand) => command.action === "open_chart" ? painted.promise : Promise.resolve());
|
||||
const test = setup({ execute });
|
||||
test.follower.start();
|
||||
test.channel.publish({ type: "open" });
|
||||
try {
|
||||
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
|
||||
test.channel.publish({ type: "events", events: [event(12, closeChart), event(11, openChart)] });
|
||||
expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart"]);
|
||||
painted.resolve();
|
||||
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart", "close_chart"]));
|
||||
} finally { painted.resolve(); test.follower.stop(); }
|
||||
});
|
||||
|
||||
it("verifies a new generation without losing its first command or fetching a new baseline", async () => {
|
||||
const fetchIdentity = vi.fn(async () => cursor(10, 2));
|
||||
const test = setup({ fetchIdentity });
|
||||
test.follower.start();
|
||||
test.channel.publish({ type: "open" });
|
||||
try {
|
||||
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
|
||||
test.channel.publish({ type: "events", events: [event(11, highlight, 2)] });
|
||||
await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1));
|
||||
expect(fetchIdentity).toHaveBeenCalledTimes(1);
|
||||
expect(test.fetchCursor).toHaveBeenCalledTimes(1);
|
||||
expect(test.execute.mock.calls[0]?.[0]).toEqual(highlight);
|
||||
} finally { test.follower.stop(); }
|
||||
});
|
||||
|
||||
it("retains a command while identity lookup is stale and applies it once the task snapshot catches up", async () => {
|
||||
const test = setup();
|
||||
test.follower.start();
|
||||
test.channel.publish({ type: "open" });
|
||||
try {
|
||||
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
|
||||
test.channel.publish({ type: "events", events: [event(11, highlight, 2)] });
|
||||
await vi.waitFor(() => expect(test.fetchIdentity).toHaveBeenCalledTimes(1));
|
||||
expect(test.execute).not.toHaveBeenCalled();
|
||||
test.follower.updateIdentity(cursor(10, 2));
|
||||
await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1));
|
||||
expect(test.fetchCursor).toHaveBeenCalledTimes(1);
|
||||
} finally { test.follower.stop(); }
|
||||
});
|
||||
|
||||
it("aborts on disconnect and uses a fresh reconnect baseline without replaying old commands", async () => {
|
||||
const oldDisplay = deferred<void>();
|
||||
const execute = vi.fn(async (_command: InvestigationPresentationCommand, _signal: AbortSignal) => {})
|
||||
.mockImplementationOnce(() => oldDisplay.promise);
|
||||
const fetchCursor = vi.fn(async () => cursor(10)).mockResolvedValueOnce(cursor(10)).mockResolvedValueOnce(cursor(20));
|
||||
const test = setup({ execute, fetchCursor });
|
||||
test.follower.start();
|
||||
test.channel.publish({ type: "open" });
|
||||
try {
|
||||
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
|
||||
test.channel.publish({ type: "events", events: [event(11)] });
|
||||
const oldSignal = execute.mock.calls[0]?.[1];
|
||||
test.channel.publish({ type: "disconnect" });
|
||||
expect(oldSignal?.aborted).toBe(true);
|
||||
const clearCount = test.clear.mock.calls.length;
|
||||
test.channel.publish({ type: "events", events: [event(12, openChart)] });
|
||||
test.channel.publish({ type: "open" });
|
||||
await vi.waitFor(() => expect(fetchCursor).toHaveBeenCalledTimes(2));
|
||||
test.channel.publish({ type: "events", events: [event(12, openChart), event(20, openChart), event(21, closeChart)] });
|
||||
oldDisplay.resolve();
|
||||
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["highlight_features", "close_chart"]));
|
||||
expect(test.clear.mock.calls.length).toBeGreaterThan(clearCount);
|
||||
expect(execute.mock.calls[1]?.[1].aborted).toBe(false);
|
||||
} finally { oldDisplay.resolve(); test.follower.stop(); }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user