feat(agent): add typed server API tools

This commit is contained in:
2026-07-15 10:44:07 +08:00
parent f3f873e3c4
commit 8a21908785
30 changed files with 3119 additions and 15 deletions
+5 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { FRONTEND_ACTION_TOOLS, frontendActionCallIdPlugin } from "../../.opencode/plugins/frontend-action-call-id.js";
import { FRONTEND_ACTION_TOOLS, frontendActionCallIdPlugin, server } from "../../.opencode/plugins/frontend-action-call-id.js";
import { frontendActionRegistry } from "../../src/frontendAction/registry.js";
const frontendActionToolNames = () => readdirSync(".opencode/tools")
@@ -17,6 +17,10 @@ describe("frontend action call ID plugin", () => {
expect(config.plugin).toContain("./.opencode/plugins/frontend-action-call-id.ts");
});
it("exposes the server export required by OpenCode", () => {
expect(server).toBe(frontendActionCallIdPlugin);
});
it("overwrites forged IDs only for allowlisted tools", async () => {
const hooks = await frontendActionCallIdPlugin({} as never);
const before = hooks["tool.execute.before"];
+43
View File
@@ -44,4 +44,47 @@ describe("FrontendActionCoordinator", () => {
coordinator.registerSink("s", () => undefined);
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "unknown", params: {} })).toThrow("unknown frontend action");
});
it("requires a valid time range when opening history data", async () => {
const coordinator = new FrontendActionCoordinator();
let emitted: FrontendActionRequest | undefined;
coordinator.registerSink("s", (request) => { emitted = request; });
expect(() => coordinator.request({
sessionId: "s",
toolCallId: "c",
name: "view_history",
params: { feature_infos: [["node-1", "junction"]], data_type: "realtime" },
})).toThrow("invalid frontend action params");
expect(() => coordinator.request({
sessionId: "s",
toolCallId: "c",
name: "view_history",
params: {
feature_infos: [["node-1", "junction"]],
data_type: "realtime",
start_time: "2026-07-14T00:00:00Z",
end_time: "2026-07-14T00:00:00Z",
},
})).toThrow("invalid frontend action params");
const pending = coordinator.request({
sessionId: "s",
toolCallId: "c",
name: "view_history",
params: {
feature_infos: [["node-1", "junction"]],
data_type: "realtime",
start_time: "2026-07-14T00:00:00Z",
end_time: "2026-07-14T01:00:00Z",
},
});
if (!emitted) throw new Error("request was not emitted");
coordinator.submitResult("s", {
version: "frontend-action-result@1",
actionId: emitted.actionId,
status: "succeeded",
output: { component: "HistoryPanel", rendered: true, itemCount: 1 },
completedAt: Date.now(),
});
await expect(pending).resolves.toMatchObject({ status: "succeeded" });
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test";
import { ServerApiGateway } from "../../src/serverApi/gateway.js";
const context = {
actorKey: "user",
clientSessionId: "session",
projectId: "1c60fd8c-89fb-47ca-84f1-66ec10f5bf73",
projectKey: "project",
sessionId: "runtime",
traceId: "trace",
};
describe("ServerApiGateway project context", () => {
test("uses the metadata path and accepts project_id responses", async () => {
let requestedPath = "";
const fetchImpl = (async (input: URL | RequestInfo) => {
requestedPath = new URL(input.toString()).pathname;
return Response.json({
project_id: context.projectId,
code: "lingang",
name: "临港排水",
description: null,
status: "active",
project_role: "owner",
gs_workspace: "lingang",
map_extent: { xmin: 120.0, ymin: 30.0, xmax: 122.0, ymax: 32.0 },
});
}) as typeof fetch;
const gateway = new ServerApiGateway({} as never, fetchImpl);
const result = await gateway.execute("get_project_context", {}, context);
expect(requestedPath).toBe("/api/v1/meta/project");
expect(result).toMatchObject({
ok: true,
data: { project_id: context.projectId, status: "active", project_role: "owner" },
});
});
test("rejects the removed id response shape", async () => {
const fetchImpl = (async () =>
Response.json({ id: context.projectId, code: "lingang", name: "临港排水" })) as unknown as typeof fetch;
const gateway = new ServerApiGateway({} as never, fetchImpl);
const result = await gateway.execute("get_project_context", {}, context);
expect(result).toMatchObject({
ok: false,
error: { code: "UPSTREAM_UNAVAILABLE" },
});
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { domainToolSchemas } from "../../src/serverApi/schemas.js";
import { createIdempotencyKey } from "../../src/serverApi/gateway.js";
const context = {
actorKey: "user", clientSessionId: "session", projectId: "1c60fd8c-89fb-47ca-84f1-66ec10f5bf73",
projectKey: "project", sessionId: "runtime", traceId: "trace",
};
describe("domain tool validation", () => {
test("rejects unknown metrics and reversed time", () => {
const base = { device_ids: ["940468c1-87cc-478f-b9f5-537ffbdb9025"], observed_from: "2026-07-14T02:00:00Z", observed_to: "2026-07-14T01:00:00Z", granularity: "5m" };
expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["pressure"] }).success).toBeFalse();
expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["flow_mean"] }).success).toBeFalse();
});
test("rejects invalid UUID before dispatch", () => {
expect(domainToolSchemas.start_simulation.safeParse({ model_version_id: "bad", parameters: {} }).success).toBeFalse();
});
test("idempotency key ignores object key order", () => {
expect(createIdempotencyKey(context, "start_simulation", { b: 2, a: 1 })).toBe(createIdempotencyKey(context, "start_simulation", { a: 1, b: 2 }));
});
});