diff --git a/.opencode/plugins/frontend-action-call-id.ts b/.opencode/plugins/frontend-action-call-id.ts new file mode 100644 index 0000000..e11e638 --- /dev/null +++ b/.opencode/plugins/frontend-action-call-id.ts @@ -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; diff --git a/.opencode/tools/frontend_action.ts b/.opencode/tools/frontend_action.ts new file mode 100644 index 0000000..604f2cb --- /dev/null +++ b/.opencode/tools/frontend_action.ts @@ -0,0 +1,27 @@ +type FrontendActionArgs = Record & { __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; +}; diff --git a/.opencode/tools/locate_features.ts b/.opencode/tools/locate_features.ts index 1ff3b49..6731897 100644 --- a/.opencode/tools/locate_features.ts +++ b/.opencode/tools/locate_features.ts @@ -1,4 +1,5 @@ import { tool } from "@opencode-ai/plugin"; +import { executeFrontendAction } from "./frontend_action.js"; export default tool({ description: "在前端地图上定位并高亮指定的管网要素。", @@ -9,14 +10,13 @@ export default tool({ "Why this map positioning action is needed for the user request.", ), ids: tool.schema - .array(tool.schema.string()) + .array(tool.schema.string().min(1)).min(1).max(100) .describe("Feature ids to locate."), 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."), }, - async execute() { - // 前端工具只负责生成 tool part,真正的地图动作由 Agent SSE 适配层转发给浏览器执行。 - return "已在地图上定位到指定要素。"; + async execute(args, context) { + return executeFrontendAction("locate_features", args, context); }, }); diff --git a/.opencode/tools/view_history.ts b/.opencode/tools/view_history.ts index 29c68a9..cf01721 100644 --- a/.opencode/tools/view_history.ts +++ b/.opencode/tools/view_history.ts @@ -1,4 +1,5 @@ import { tool } from "@opencode-ai/plugin"; +import { executeFrontendAction } from "./frontend_action.js"; export default tool({ description: "为选定的管网要素打开前端的历史记录或计算结果面板。", @@ -23,8 +24,7 @@ export default tool({ .optional() .describe("Optional ISO8601 end time."), }, - async execute() { - // 返回短确认即可;面板打开动作由前端根据 tool_call 参数完成。 - return "已打开计算结果面板。"; + async execute(args, context) { + return executeFrontendAction("view_history", args, context); }, }); diff --git a/.opencode/tools/view_scada.ts b/.opencode/tools/view_scada.ts index ad05355..4e6ce4e 100644 --- a/.opencode/tools/view_scada.ts +++ b/.opencode/tools/view_scada.ts @@ -1,4 +1,5 @@ import { tool } from "@opencode-ai/plugin"; +import { executeFrontendAction } from "./frontend_action.js"; export default tool({ description: "打开前端的 SCADA 监测数据历史面板。", @@ -23,8 +24,7 @@ export default tool({ .optional() .describe("Optional ISO8601 end time."), }, - async execute() { - // SCADA 面板仍在浏览器侧执行,工具结果不承载实际监测数据。 - return "已打开 SCADA 监测面板。"; + async execute(args, context) { + return executeFrontendAction("view_scada", args, context); }, }); diff --git a/.opencode/tools/zoom_to_map.ts b/.opencode/tools/zoom_to_map.ts index 986533b..a98efb1 100644 --- a/.opencode/tools/zoom_to_map.ts +++ b/.opencode/tools/zoom_to_map.ts @@ -1,4 +1,5 @@ import { tool } from "@opencode-ai/plugin"; +import { executeFrontendAction } from "./frontend_action.js"; export default tool({ description: @@ -26,7 +27,7 @@ export default tool({ .optional() .describe("Optional animation duration in milliseconds. Defaults to 1000."), }, - async execute() { - return "已缩放到指定地图坐标。"; + async execute(args, context) { + return executeFrontendAction("zoom_to_map", args, context); }, }); diff --git a/src/frontendAction/coordinator.ts b/src/frontendAction/coordinator.ts new file mode 100644 index 0000000..27866e2 --- /dev/null +++ b/src/frontendAction/coordinator.ts @@ -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; + resolve: (result: FrontendActionResult) => void; + timer: ReturnType; +}; + +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(); + private readonly completed = new Map(); + private readonly sinks = new Map(); + + 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 { + 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((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"); + } +} diff --git a/src/frontendAction/registry.ts b/src/frontendAction/registry.ts new file mode 100644 index 0000000..d140a70 --- /dev/null +++ b/src/frontendAction/registry.ts @@ -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 }; diff --git a/src/frontendAction/types.ts b/src/frontendAction/types.ts new file mode 100644 index 0000000..94acb74 --- /dev/null +++ b/src/frontendAction/types.ts @@ -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"]; +}; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index fb9c455..b884549 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -23,9 +23,12 @@ import { type ResultReferenceResolver } from "../results/resolver.js"; import { type OpencodeRuntimeAdapter, } from "../runtime/opencode.js"; +import { getRuntimeSessionContext, setRuntimeSessionContext } from "../runtime/sessionContext.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type SessionRecord } from "../sessions/metadataStore.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 { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { @@ -80,6 +83,7 @@ const payloadSchema = z.object({ message: "unsupported model", }).optional(), approval_mode: z.enum(["request", "always"]).optional().default("request"), + capabilities: z.array(z.string().max(64)).max(20).optional().default([]), }); const createSessionPayloadSchema = z.object({ @@ -189,6 +193,7 @@ export const buildChatRouter = ( sessionTranscriptStore: SessionTranscriptStore, learningOrchestrator: LearningOrchestrator, resultReferenceResolver: ResultReferenceResolver, + frontendActionCoordinator: FrontendActionCoordinator, ) => { const chatRouter = Router(); @@ -203,6 +208,26 @@ export const buildChatRouter = ( 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) => { const parsed = createSessionPayloadSchema.safeParse(req.body ?? {}); if (!parsed.success) { @@ -456,6 +481,7 @@ export const buildChatRouter = ( clientSessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId, }); + frontendActionCoordinator.cancelSession(sessionRecord.sessionId); activeRuns.delete(sessionRecord.sessionId); lastRunStatuses.delete(sessionRecord.sessionId); await sessionMetadataStore.remove(sessionRecord); @@ -469,6 +495,7 @@ export const buildChatRouter = ( sessionBridge, sessionMetadataStore, sessionUiStateStore, + frontendActionCoordinator, }); registerChatInteractionRoutes(chatRouter, { @@ -624,6 +651,10 @@ export const buildChatRouter = ( traceId, userId, }); + setRuntimeSessionContext({ + ...(getRuntimeSessionContext(binding.sessionId)!), + capabilities: parsed.data.capabilities, + }); const { record: ensuredSessionRecord, created: sessionCreated } = await sessionMetadataStore.ensure({ 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)) + : undefined; + try { if (isPressureTrendDemoPrompt(promptText)) { const toolCall = createPressureTrendDemoToolCall(); @@ -1078,6 +1113,7 @@ export const buildChatRouter = ( traceId: requestContext.traceId, projectId: requestContext.projectId, signal: abortController.signal, + suppressLegacyFrontendActions: parsed.data.capabilities.includes("frontend-action@1"), write: (event, data) => { publish(event, data); }, @@ -1148,6 +1184,7 @@ export const buildChatRouter = ( } } } finally { + unregisterFrontendActionSink?.(); if (abortController.signal.aborted) { activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ ...message, diff --git a/src/routes/chatAuxiliaryRoutes.ts b/src/routes/chatAuxiliaryRoutes.ts index 5164f36..da8c207 100644 --- a/src/routes/chatAuxiliaryRoutes.ts +++ b/src/routes/chatAuxiliaryRoutes.ts @@ -8,6 +8,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js"; import { RESULT_REFERENCE_KIND } from "../results/store.js"; import { type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; +import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { type ActiveRun, @@ -28,6 +29,7 @@ type RegisterAuxiliaryRoutesOptions = { sessionBridge: ChatSessionBridge; sessionMetadataStore: SessionMetadataStore; sessionUiStateStore: SessionUiStateStore; + frontendActionCoordinator: FrontendActionCoordinator; }; const toSessionUiStateContext = (sessionId: string) => ({ @@ -43,6 +45,7 @@ export const registerChatAuxiliaryRoutes = ( sessionBridge, sessionMetadataStore, sessionUiStateStore, + frontendActionCoordinator, }: RegisterAuxiliaryRoutesOptions, ) => { chatRouter.get("/render-ref/:render_ref", async (req, res) => { @@ -93,6 +96,7 @@ export const registerChatAuxiliaryRoutes = ( } try { + frontendActionCoordinator.cancelSession(parsed.data.session_id); const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index f86a928..662475d 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -74,6 +74,7 @@ type StreamPromptOptions = { traceId?: string; projectId?: string; signal?: AbortSignal; + suppressLegacyFrontendActions?: boolean; write: (event: string, data: Record) => void; }; @@ -141,6 +142,7 @@ export const streamPromptResponse = async ({ traceId, projectId, signal, + suppressLegacyFrontendActions = false, write, }: StreamPromptOptions): Promise<{ aborted: boolean; @@ -828,7 +830,7 @@ export const streamPromptResponse = async ({ params: toolParams, reason, }); - if (envelope) { + if (envelope && !(suppressLegacyFrontendActions && ["zoom_to_map", "locate_features", "view_history", "view_scada"].includes(part.tool))) { write("ui_envelope", { session_id: clientSessionId, envelope_id: createEnvelopeId(part.tool), diff --git a/src/runtime/sessionContext.ts b/src/runtime/sessionContext.ts index 97d21fb..b70da06 100644 --- a/src/runtime/sessionContext.ts +++ b/src/runtime/sessionContext.ts @@ -9,6 +9,7 @@ export type RuntimeSessionContext = { projectKey: string; sessionId: string; traceId: string; + capabilities?: string[]; }; const contexts = new Map(); diff --git a/src/server.ts b/src/server.ts index 7bcfd9e..6d44715 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,7 @@ import { ResultReferenceStore, } from "./results/store.js"; import { buildChatRouter } from "./routes/chat.js"; +import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js"; import { opencodeRuntime } from "./runtime/opencode.js"; import { getRuntimeSessionContext, @@ -38,6 +39,7 @@ const learningOrchestrator = new LearningOrchestrator( const resultReferenceStore = new ResultReferenceStore(); const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); +const frontendActionCoordinator = new FrontendActionCoordinator(); // 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。 process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; @@ -45,6 +47,42 @@ process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; app.use(cors()); 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) => { try { const runtime = await opencodeRuntime.health(); @@ -308,6 +346,7 @@ app.use( sessionTranscriptStore, learningOrchestrator, resultReferenceResolver, + frontendActionCoordinator, ), ); diff --git a/tests/frontendAction/callIdPlugin.test.ts b/tests/frontendAction/callIdPlugin.test.ts new file mode 100644 index 0000000..8659c50 --- /dev/null +++ b/tests/frontendAction/callIdPlugin.test.ts @@ -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" }); + }); +}); diff --git a/tests/frontendAction/coordinator.test.ts b/tests/frontendAction/coordinator.test.ts new file mode 100644 index 0000000..c67494f --- /dev/null +++ b/tests/frontendAction/coordinator.test.ts @@ -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"); + }); +});