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
+37
View File
@@ -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<string, unknown>))
: 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,
+4
View File
@@ -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;
+3 -1
View File
@@ -74,6 +74,7 @@ type StreamPromptOptions = {
traceId?: string;
projectId?: string;
signal?: AbortSignal;
suppressLegacyFrontendActions?: boolean;
write: (event: string, data: Record<string, unknown>) => 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),