feat(agent): add frontend action bridge

This commit is contained in:
2026-07-13 17:26:03 +08:00
parent 4418e99a5c
commit 6b45e7c40c
16 changed files with 447 additions and 14 deletions
@@ -0,0 +1,12 @@
import type { Plugin } from "@opencode-ai/plugin";
export const FRONTEND_ACTION_TOOLS = new Set(["zoom_to_map", "locate_features", "view_history", "view_scada"]);
export const frontendActionCallIdPlugin: Plugin = async () => ({
"tool.execute.before": async (input, output) => {
if (!FRONTEND_ACTION_TOOLS.has(input.tool)) return;
output.args = { ...(output.args ?? {}), __frontend_action_call_id: input.callID };
},
});
export default frontendActionCallIdPlugin;
+27
View File
@@ -0,0 +1,27 @@
type FrontendActionArgs = Record<string, unknown> & { __frontend_action_call_id?: string; reason?: string };
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export const executeFrontendAction = async (
name: string,
args: FrontendActionArgs,
context: { sessionID: string },
) => {
const callId = args.__frontend_action_call_id;
if (!callId) throw new Error("frontend action bridge did not inject the OpenCode call ID");
const { __frontend_action_call_id: _callId, reason, ...params } = args;
let response: Response;
try {
response = await fetch(`${internalBaseUrl}/internal/frontend-actions/request`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken },
body: JSON.stringify({ session_id: context.sessionID, call_id: callId, name, params, fallback_text: reason }),
});
} catch (error) {
throw new Error(`frontend action bridge unavailable: ${error instanceof Error ? error.message : String(error)}`);
}
const text = await response.text();
if (!response.ok) throw new Error(`frontend action bridge rejected request (${response.status}): ${text}`);
return text;
};
+5 -5
View File
@@ -1,4 +1,5 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js";
export default tool({ export default tool({
description: "在前端地图上定位并高亮指定的管网要素。", description: "在前端地图上定位并高亮指定的管网要素。",
@@ -9,14 +10,13 @@ export default tool({
"Why this map positioning action is needed for the user request.", "Why this map positioning action is needed for the user request.",
), ),
ids: tool.schema ids: tool.schema
.array(tool.schema.string()) .array(tool.schema.string().min(1)).min(1).max(100)
.describe("Feature ids to locate."), .describe("Feature ids to locate."),
feature_type: tool.schema feature_type: tool.schema
.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank"]) .enum(["junction", "pipe", "valve", "reservoir", "pump", "tank", "conduit", "orifice", "outfall"])
.describe("Type of feature to locate."), .describe("Type of feature to locate."),
}, },
async execute() { async execute(args, context) {
// 前端工具只负责生成 tool part,真正的地图动作由 Agent SSE 适配层转发给浏览器执行。 return executeFrontendAction("locate_features", args, context);
return "已在地图上定位到指定要素。";
}, },
}); });
+3 -3
View File
@@ -1,4 +1,5 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js";
export default tool({ export default tool({
description: "为选定的管网要素打开前端的历史记录或计算结果面板。", description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
@@ -23,8 +24,7 @@ export default tool({
.optional() .optional()
.describe("Optional ISO8601 end time."), .describe("Optional ISO8601 end time."),
}, },
async execute() { async execute(args, context) {
// 返回短确认即可;面板打开动作由前端根据 tool_call 参数完成。 return executeFrontendAction("view_history", args, context);
return "已打开计算结果面板。";
}, },
}); });
+3 -3
View File
@@ -1,4 +1,5 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js";
export default tool({ export default tool({
description: "打开前端的 SCADA 监测数据历史面板。", description: "打开前端的 SCADA 监测数据历史面板。",
@@ -23,8 +24,7 @@ export default tool({
.optional() .optional()
.describe("Optional ISO8601 end time."), .describe("Optional ISO8601 end time."),
}, },
async execute() { async execute(args, context) {
// SCADA 面板仍在浏览器侧执行,工具结果不承载实际监测数据。 return executeFrontendAction("view_scada", args, context);
return "已打开 SCADA 监测面板。";
}, },
}); });
+3 -2
View File
@@ -1,4 +1,5 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js";
export default tool({ export default tool({
description: description:
@@ -26,7 +27,7 @@ export default tool({
.optional() .optional()
.describe("Optional animation duration in milliseconds. Defaults to 1000."), .describe("Optional animation duration in milliseconds. Defaults to 1000."),
}, },
async execute() { async execute(args, context) {
return "已缩放到指定地图坐标。"; return executeFrontendAction("zoom_to_map", args, context);
}, },
}); });
+172
View File
@@ -0,0 +1,172 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { logger } from "../logger.js";
import { getFrontendActionDefinition } from "./registry.js";
import type { FrontendActionRequest, FrontendActionResult } from "./types.js";
export const frontendActionResultSchema = z.object({
version: z.literal("frontend-action-result@1"),
actionId: z.string().trim().min(1).max(128),
status: z.enum(["succeeded", "failed", "rejected", "cancelled", "expired"]),
output: z.unknown().optional(),
error: z.object({
code: z.string().trim().min(1).max(128),
message: z.string().trim().min(1).max(2_000),
}).strict().optional(),
completedAt: z.number().finite().positive(),
}).strict().superRefine((value, context) => {
if (value.status !== "succeeded" && !value.error) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "non-success results require an error" });
}
});
type PendingAction = {
request: FrontendActionRequest;
promise: Promise<FrontendActionResult>;
resolve: (result: FrontendActionResult) => void;
timer: ReturnType<typeof setTimeout>;
};
export type SubmitResultOutcome = { duplicate: boolean; result: FrontendActionResult };
export type FrontendActionSink = (request: FrontendActionRequest) => void;
export class FrontendActionError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
export class FrontendActionCoordinator {
private readonly pending = new Map<string, PendingAction>();
private readonly completed = new Map<string, { sessionId: string; result: FrontendActionResult }>();
private readonly sinks = new Map<string, FrontendActionSink>();
constructor(private readonly completedLimit = 512) {}
registerSink(sessionId: string, sink: FrontendActionSink) {
this.sinks.set(sessionId, sink);
const now = Date.now();
for (const pending of this.pending.values()) {
if (pending.request.sessionId === sessionId && pending.request.expiresAt > now) sink(pending.request);
}
return () => {
if (this.sinks.get(sessionId) === sink) this.sinks.delete(sessionId);
};
}
request(options: {
sessionId: string;
toolCallId: string;
name: string;
params: unknown;
fallbackText?: string;
signal?: AbortSignal;
}): Promise<FrontendActionResult> {
if (!options.toolCallId.trim()) throw new FrontendActionError(400, "frontend action call ID is required");
const definition = getFrontendActionDefinition(options.name);
if (!definition) throw new FrontendActionError(400, "unknown frontend action");
const parsedParams = definition.inputSchema.safeParse(options.params);
if (!parsedParams.success) throw new FrontendActionError(400, "invalid frontend action params");
const sink = this.sinks.get(options.sessionId);
if (!sink) throw new FrontendActionError(409, "frontend action session has no active browser sink");
const actionId = createHash("sha256")
.update(`${options.sessionId}\0${options.toolCallId}\0${options.name}`)
.digest("hex");
const completed = this.completed.get(actionId);
if (completed) return Promise.resolve(completed.result);
const existing = this.pending.get(actionId);
if (existing) return existing.promise;
const issuedAt = Date.now();
const request: FrontendActionRequest = {
version: "frontend-action@1",
actionId,
toolCallId: options.toolCallId,
sessionId: options.sessionId,
name: definition.manifest.id,
params: parsedParams.data,
risk: definition.manifest.risk,
replayPolicy: definition.manifest.risk === "view" ? "never" : "explicit",
issuedAt,
expiresAt: issuedAt + definition.manifest.timeoutMs,
fallbackText: options.fallbackText?.trim() || undefined,
};
let resolvePending!: (result: FrontendActionResult) => void;
const promise = new Promise<FrontendActionResult>((resolve) => { resolvePending = resolve; });
const timer = setTimeout(() => this.finish(request, {
version: "frontend-action-result@1",
actionId,
status: "expired",
error: { code: "ACTION_TIMEOUT", message: "browser action result was not received before expiry" },
completedAt: Date.now(),
}), definition.manifest.timeoutMs);
this.pending.set(actionId, { request, promise, resolve: resolvePending, timer });
options.signal?.addEventListener("abort", () => this.cancelAction(actionId), { once: true });
this.audit("proposed", request);
this.audit("validated", request);
sink(request);
this.audit("executing", request);
return promise;
}
submitResult(sessionId: string, value: unknown): SubmitResultOutcome {
const parsed = frontendActionResultSchema.safeParse(value);
if (!parsed.success) throw new FrontendActionError(400, "invalid frontend action result");
const result = parsed.data as FrontendActionResult;
const completed = this.completed.get(result.actionId);
if (completed) {
if (sessionId !== completed.sessionId) throw new FrontendActionError(403, "frontend action session mismatch");
if (JSON.stringify(completed.result) !== JSON.stringify(result)) throw new FrontendActionError(409, "frontend action result conflicts with terminal result");
return { duplicate: true, result: completed.result };
}
const pending = this.pending.get(result.actionId);
if (!pending) throw new FrontendActionError(404, "frontend action not found");
if (pending.request.sessionId !== sessionId) throw new FrontendActionError(403, "frontend action session mismatch");
const definition = getFrontendActionDefinition(pending.request.name);
if (result.status === "succeeded" && definition && !definition.outputSchema.safeParse(result.output).success) {
throw new FrontendActionError(400, "invalid frontend action output");
}
this.finish(pending.request, result);
return { duplicate: false, result };
}
cancelSession(sessionId: string) {
this.sinks.delete(sessionId);
for (const [actionId, pending] of this.pending) {
if (pending.request.sessionId === sessionId) this.cancelAction(actionId);
}
}
private cancelAction(actionId: string) {
const pending = this.pending.get(actionId);
if (!pending) return;
this.finish(pending.request, {
version: "frontend-action-result@1", actionId, status: "cancelled",
error: { code: "SESSION_CANCELLED", message: "session ended before the browser action completed" },
completedAt: Date.now(),
});
}
private finish(request: FrontendActionRequest, result: FrontendActionResult) {
const pending = this.pending.get(request.actionId);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(request.actionId);
this.completed.set(request.actionId, { sessionId: request.sessionId, result });
while (this.completed.size > this.completedLimit) {
const oldest = this.completed.keys().next().value as string | undefined;
if (!oldest) break;
this.completed.delete(oldest);
}
this.audit(result.status, request, result.error?.code);
pending.resolve(result);
}
private audit(stage: string, request: FrontendActionRequest, errorCode?: string) {
logger.info({ actionId: request.actionId, action: request.name, stage, sessionId: request.sessionId,
toolCallId: request.toolCallId, paramKeys: typeof request.params === "object" && request.params ? Object.keys(request.params) : [], errorCode },
"frontend action lifecycle");
}
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
import type { FrontendActionManifest } from "./types.js";
type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny };
const zoomInput = z.object({ x: z.number().finite(), y: z.number().finite(), source_crs: z.enum(["EPSG:3857", "EPSG:4326"]).optional(), zoom: z.number().finite().optional(), duration_ms: z.number().finite().nonnegative().optional() }).strict();
const id = z.string().trim().min(1).max(256);
const isoTime = z.string().datetime({ offset: true });
const timeRange = {
start_time: isoTime.optional(),
end_time: isoTime.optional(),
};
const locateInput = z.object({
ids: z.array(id).min(1).max(100),
feature_type: z.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank", "conduit", "orifice", "outfall"]),
}).strict();
const historyInput = z.object({
feature_infos: z.array(z.tuple([id, id])).min(1).max(100),
data_type: z.enum(["realtime", "scheme", "none"]),
...timeRange,
}).strict().refine((value) => !value.start_time || !value.end_time || Date.parse(value.start_time) <= Date.parse(value.end_time), "start_time must not be after end_time");
const scadaInput = z.object({
device_id: id.optional(),
device_ids: z.array(id).min(1).max(100).optional(),
...timeRange,
}).strict().refine((value) => Boolean(value.device_id) !== Boolean(value.device_ids), "provide exactly one of device_id or device_ids")
.refine((value) => !value.start_time || !value.end_time || Date.parse(value.start_time) <= Date.parse(value.end_time), "start_time must not be after end_time");
const definitions: Definition[] = [
{ manifest: { id: "zoom_to_map", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["map_overlay"] }, inputSchema: zoomInput, outputSchema: z.object({}).passthrough() },
{ manifest: { id: "locate_features", version: "frontend-action@1", risk: "view", timeoutMs: 15_000, supportedSurfaces: ["map_overlay"] }, inputSchema: locateInput, outputSchema: z.object({ locatedIds: z.array(id), missingIds: z.array(id), featureType: id, bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]) }).strict() },
{ manifest: { id: "view_history", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["side_panel", "canvas"] }, inputSchema: historyInput, outputSchema: z.object({ component: z.literal("HistoryPanel"), rendered: z.literal(true), itemCount: z.number().int().nonnegative() }).strict() },
{ manifest: { id: "view_scada", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["side_panel", "canvas"] }, inputSchema: scadaInput, outputSchema: z.object({ component: z.literal("ScadaPanel"), rendered: z.literal(true), itemCount: z.number().int().nonnegative() }).strict() },
];
export const frontendActionRegistry = definitions.map((item) => item.manifest);
export const getFrontendActionDefinition = (name: string) => definitions.find((item) => item.manifest.id === name);
export const frontendActionRegistryResponse = { schema_version: "frontend-action-registry@1" as const, actions: frontendActionRegistry };
+37
View File
@@ -0,0 +1,37 @@
export const FRONTEND_ACTION_VERSION = "frontend-action@1" as const;
export const FRONTEND_ACTION_RESULT_VERSION = "frontend-action-result@1" as const;
export type FrontendActionRisk = "view" | "reversible" | "side_effect";
export type FrontendActionStatus = "succeeded" | "failed" | "rejected" | "cancelled" | "expired";
export type FrontendActionRequest = {
version: typeof FRONTEND_ACTION_VERSION;
actionId: string;
toolCallId: string;
sessionId: string;
name: string;
params: unknown;
risk: FrontendActionRisk;
replayPolicy: "never" | "explicit";
issuedAt: number;
expiresAt: number;
fallbackText?: string;
replayOf?: string;
};
export type FrontendActionResult = {
version: typeof FRONTEND_ACTION_RESULT_VERSION;
actionId: string;
status: FrontendActionStatus;
output?: unknown;
error?: { code: string; message: string };
completedAt: number;
};
export type FrontendActionManifest = {
id: string;
version: typeof FRONTEND_ACTION_VERSION;
risk: FrontendActionRisk;
timeoutMs: number;
supportedSurfaces: ["map_overlay"] | ["side_panel", "canvas"];
};
+37
View File
@@ -23,9 +23,12 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
import { import {
type OpencodeRuntimeAdapter, type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js"; } from "../runtime/opencode.js";
import { getRuntimeSessionContext, setRuntimeSessionContext } from "../runtime/sessionContext.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { type SessionRecord } from "../sessions/metadataStore.js"; import { type SessionRecord } from "../sessions/metadataStore.js";
import { uiRegistryResponse } from "../uiEnvelope/registry.js"; import { uiRegistryResponse } from "../uiEnvelope/registry.js";
import { FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { frontendActionRegistryResponse } from "../frontendAction/registry.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js"; import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { import {
@@ -80,6 +83,7 @@ const payloadSchema = z.object({
message: "unsupported model", message: "unsupported model",
}).optional(), }).optional(),
approval_mode: z.enum(["request", "always"]).optional().default("request"), approval_mode: z.enum(["request", "always"]).optional().default("request"),
capabilities: z.array(z.string().max(64)).max(20).optional().default([]),
}); });
const createSessionPayloadSchema = z.object({ const createSessionPayloadSchema = z.object({
@@ -189,6 +193,7 @@ export const buildChatRouter = (
sessionTranscriptStore: SessionTranscriptStore, sessionTranscriptStore: SessionTranscriptStore,
learningOrchestrator: LearningOrchestrator, learningOrchestrator: LearningOrchestrator,
resultReferenceResolver: ResultReferenceResolver, resultReferenceResolver: ResultReferenceResolver,
frontendActionCoordinator: FrontendActionCoordinator,
) => { ) => {
const chatRouter = Router(); const chatRouter = Router();
@@ -203,6 +208,26 @@ export const buildChatRouter = (
res.json(uiRegistryResponse); res.json(uiRegistryResponse);
}); });
chatRouter.get("/frontend-action-registry", (_req, res) => {
res.json(frontendActionRegistryResponse);
});
chatRouter.post("/frontend-actions/:action_id/result", (req, res) => {
const sessionId = req.header("x-agent-session-id")?.trim() ?? "";
if (!sessionId || req.params.action_id !== req.body?.actionId) {
res.status(400).json({ message: "invalid frontend action result" });
return;
}
try {
const outcome = frontendActionCoordinator.submitResult(sessionId, req.body);
res.json({ accepted: true, duplicate: outcome.duplicate, result: outcome.result });
} catch (error) {
const message = error instanceof Error ? error.message : "frontend action result rejected";
const status = typeof error === "object" && error && "status" in error && typeof error.status === "number" ? error.status : 400;
res.status(status).json({ message });
}
});
chatRouter.post("/session", async (req, res) => { chatRouter.post("/session", async (req, res) => {
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {}); const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
if (!parsed.success) { if (!parsed.success) {
@@ -456,6 +481,7 @@ export const buildChatRouter = (
clientSessionId: sessionRecord.sessionId, clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId,
}); });
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
activeRuns.delete(sessionRecord.sessionId); activeRuns.delete(sessionRecord.sessionId);
lastRunStatuses.delete(sessionRecord.sessionId); lastRunStatuses.delete(sessionRecord.sessionId);
await sessionMetadataStore.remove(sessionRecord); await sessionMetadataStore.remove(sessionRecord);
@@ -469,6 +495,7 @@ export const buildChatRouter = (
sessionBridge, sessionBridge,
sessionMetadataStore, sessionMetadataStore,
sessionUiStateStore, sessionUiStateStore,
frontendActionCoordinator,
}); });
registerChatInteractionRoutes(chatRouter, { registerChatInteractionRoutes(chatRouter, {
@@ -624,6 +651,10 @@ export const buildChatRouter = (
traceId, traceId,
userId, userId,
}); });
setRuntimeSessionContext({
...(getRuntimeSessionContext(binding.sessionId)!),
capabilities: parsed.data.capabilities,
});
const { record: ensuredSessionRecord, created: sessionCreated } = const { record: ensuredSessionRecord, created: sessionCreated } =
await sessionMetadataStore.ensure({ await sessionMetadataStore.ensure({
actorKey, actorKey,
@@ -1023,6 +1054,10 @@ export const buildChatRouter = (
}); });
}; };
const unregisterFrontendActionSink = parsed.data.capabilities.includes("frontend-action@1")
? frontendActionCoordinator.registerSink(clientSessionId, (request) => publish("frontend_action", request as unknown as Record<string, unknown>))
: undefined;
try { try {
if (isPressureTrendDemoPrompt(promptText)) { if (isPressureTrendDemoPrompt(promptText)) {
const toolCall = createPressureTrendDemoToolCall(); const toolCall = createPressureTrendDemoToolCall();
@@ -1078,6 +1113,7 @@ export const buildChatRouter = (
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
signal: abortController.signal, signal: abortController.signal,
suppressLegacyFrontendActions: parsed.data.capabilities.includes("frontend-action@1"),
write: (event, data) => { write: (event, data) => {
publish(event, data); publish(event, data);
}, },
@@ -1148,6 +1184,7 @@ export const buildChatRouter = (
} }
} }
} finally { } finally {
unregisterFrontendActionSink?.();
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, ...message,
+4
View File
@@ -8,6 +8,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js"; import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { import {
type ActiveRun, type ActiveRun,
@@ -28,6 +29,7 @@ type RegisterAuxiliaryRoutesOptions = {
sessionBridge: ChatSessionBridge; sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore; sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore; sessionUiStateStore: SessionUiStateStore;
frontendActionCoordinator: FrontendActionCoordinator;
}; };
const toSessionUiStateContext = (sessionId: string) => ({ const toSessionUiStateContext = (sessionId: string) => ({
@@ -43,6 +45,7 @@ export const registerChatAuxiliaryRoutes = (
sessionBridge, sessionBridge,
sessionMetadataStore, sessionMetadataStore,
sessionUiStateStore, sessionUiStateStore,
frontendActionCoordinator,
}: RegisterAuxiliaryRoutesOptions, }: RegisterAuxiliaryRoutesOptions,
) => { ) => {
chatRouter.get("/render-ref/:render_ref", async (req, res) => { chatRouter.get("/render-ref/:render_ref", async (req, res) => {
@@ -93,6 +96,7 @@ export const registerChatAuxiliaryRoutes = (
} }
try { try {
frontendActionCoordinator.cancelSession(parsed.data.session_id);
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
const userId = authContext.userId; const userId = authContext.userId;
+3 -1
View File
@@ -74,6 +74,7 @@ type StreamPromptOptions = {
traceId?: string; traceId?: string;
projectId?: string; projectId?: string;
signal?: AbortSignal; signal?: AbortSignal;
suppressLegacyFrontendActions?: boolean;
write: (event: string, data: Record<string, unknown>) => void; write: (event: string, data: Record<string, unknown>) => void;
}; };
@@ -141,6 +142,7 @@ export const streamPromptResponse = async ({
traceId, traceId,
projectId, projectId,
signal, signal,
suppressLegacyFrontendActions = false,
write, write,
}: StreamPromptOptions): Promise<{ }: StreamPromptOptions): Promise<{
aborted: boolean; aborted: boolean;
@@ -828,7 +830,7 @@ export const streamPromptResponse = async ({
params: toolParams, params: toolParams,
reason, reason,
}); });
if (envelope) { if (envelope && !(suppressLegacyFrontendActions && ["zoom_to_map", "locate_features", "view_history", "view_scada"].includes(part.tool))) {
write("ui_envelope", { write("ui_envelope", {
session_id: clientSessionId, session_id: clientSessionId,
envelope_id: createEnvelopeId(part.tool), envelope_id: createEnvelopeId(part.tool),
+1
View File
@@ -9,6 +9,7 @@ export type RuntimeSessionContext = {
projectKey: string; projectKey: string;
sessionId: string; sessionId: string;
traceId: string; traceId: string;
capabilities?: string[];
}; };
const contexts = new Map<string, RuntimeSessionContext>(); const contexts = new Map<string, RuntimeSessionContext>();
+39
View File
@@ -16,6 +16,7 @@ import {
ResultReferenceStore, ResultReferenceStore,
} from "./results/store.js"; } from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js"; import { buildChatRouter } from "./routes/chat.js";
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { import {
getRuntimeSessionContext, getRuntimeSessionContext,
@@ -38,6 +39,7 @@ const learningOrchestrator = new LearningOrchestrator(
const resultReferenceStore = new ResultReferenceStore(); const resultReferenceStore = new ResultReferenceStore();
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
const frontendActionCoordinator = new FrontendActionCoordinator();
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。 // 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
@@ -45,6 +47,42 @@ process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors()); app.use(cors());
app.use(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
app.post("/internal/frontend-actions/request", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const runtimeSessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = runtimeSessionId ? getRuntimeSessionContext(runtimeSessionId) : null;
if (!context) {
res.status(404).json({ message: "session context not found" });
return;
}
if (!context.capabilities?.includes("frontend-action@1")) {
// Legacy clients still execute the existing UIEnvelope emitted from the tool call.
res.json({
version: "frontend-action-result@1",
status: "succeeded",
output: { legacy: true },
detail: "legacy client executes the action from its UIEnvelope",
});
return;
}
try {
const result = await frontendActionCoordinator.request({
sessionId: context.clientSessionId,
toolCallId: typeof req.body?.call_id === "string" ? req.body.call_id : "",
name: typeof req.body?.name === "string" ? req.body.name : "",
params: req.body?.params,
fallbackText: typeof req.body?.fallback_text === "string" ? req.body.fallback_text : undefined,
});
res.json(result);
} catch (error) {
const status = error instanceof FrontendActionError ? error.status : 500;
res.status(status).json({ message: error instanceof Error ? error.message : String(error) });
}
});
app.get("/health", async (_req, res) => { app.get("/health", async (_req, res) => {
try { try {
const runtime = await opencodeRuntime.health(); const runtime = await opencodeRuntime.health();
@@ -308,6 +346,7 @@ app.use(
sessionTranscriptStore, sessionTranscriptStore,
learningOrchestrator, learningOrchestrator,
resultReferenceResolver, resultReferenceResolver,
frontendActionCoordinator,
), ),
); );
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "bun:test";
import { frontendActionCallIdPlugin } from "../../.opencode/plugins/frontend-action-call-id.js";
describe("frontend action call ID plugin", () => {
it("overwrites forged IDs only for allowlisted tools", async () => {
const hooks = await frontendActionCallIdPlugin({} as never);
const before = hooks["tool.execute.before"];
if (!before) throw new Error("hook missing");
const actionOutput = { args: { x: 1, __frontend_action_call_id: "forged" } };
await before({ tool: "zoom_to_map", sessionID: "session", callID: "real" }, actionOutput);
expect(actionOutput.args.__frontend_action_call_id).toBe("real");
const ordinaryOutput = { args: { query: "water" } };
await before({ tool: "web_search", sessionID: "session", callID: "ignored" }, ordinaryOutput);
expect(ordinaryOutput.args).toEqual({ query: "water" });
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "bun:test";
import { FrontendActionCoordinator, FrontendActionError } from "../../src/frontendAction/coordinator.js";
import type { FrontendActionRequest } from "../../src/frontendAction/types.js";
const start = (coordinator: FrontendActionCoordinator, sessionId = "session-1") => {
let emitted: FrontendActionRequest | undefined;
coordinator.registerSink(sessionId, (request) => { emitted = request; });
const pending = coordinator.request({ sessionId, toolCallId: "call-1", name: "zoom_to_map", params: { x: 117.2, y: 39.1 } });
if (!emitted) throw new Error("request was not emitted");
return { emitted, pending };
};
describe("FrontendActionCoordinator", () => {
it("emits once and resolves with a validated browser result", async () => {
const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator);
const result = { version: "frontend-action-result@1" as const, actionId: emitted.actionId, status: "succeeded" as const, output: { zoom: 14 }, completedAt: Date.now() };
expect(coordinator.submitResult("session-1", result).duplicate).toBe(false);
expect((await pending).output).toEqual({ zoom: 14 });
expect(coordinator.submitResult("session-1", result).duplicate).toBe(true);
});
it("rejects conflicting terminal results and cross-session submissions", async () => {
const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator);
const result = { version: "frontend-action-result@1" as const, actionId: emitted.actionId, status: "succeeded" as const, output: {}, completedAt: Date.now() };
expect(() => coordinator.submitResult("other", result)).toThrow("session mismatch");
coordinator.submitResult("session-1", result);
await pending;
expect(() => coordinator.submitResult("session-1", { ...result, completedAt: result.completedAt + 1 })).toThrow("conflicts");
});
it("cancels all pending actions for a session", async () => {
const coordinator = new FrontendActionCoordinator();
const { pending } = start(coordinator);
coordinator.cancelSession("session-1");
expect((await pending).status).toBe("cancelled");
});
it("validates params and requires an active browser sink", () => {
const coordinator = new FrontendActionCoordinator();
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "zoom_to_map", params: { x: "bad", y: 1 } })).toThrow(FrontendActionError);
coordinator.registerSink("s", () => undefined);
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "unknown", params: {} })).toThrow("unknown frontend action");
});
});