feat(agent): add frontend action bridge
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user