feat(api): expose REST-only agent routes
This commit is contained in:
+38
-30
@@ -131,7 +131,7 @@ export const buildChatRouter = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.post("/session", async (req, res) => {
|
||||
chatRouter.post("/sessions", async (req, res) => {
|
||||
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
@@ -194,7 +194,7 @@ export const buildChatRouter = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:session_id", async (req, res) => {
|
||||
chatRouter.get("/sessions/:session_id", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
@@ -238,7 +238,7 @@ export const buildChatRouter = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:session_id/stream", async (req, res) => {
|
||||
chatRouter.get("/sessions/:session_id/runs/current/events", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
@@ -303,7 +303,7 @@ export const buildChatRouter = (
|
||||
res.on("close", cleanup);
|
||||
});
|
||||
|
||||
chatRouter.patch("/session/:session_id/title", async (req, res) => {
|
||||
chatRouter.patch("/sessions/:session_id", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const title =
|
||||
typeof req.body?.title === "string" ? req.body.title.trim() : "";
|
||||
@@ -349,7 +349,7 @@ export const buildChatRouter = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.delete("/session/:session_id", async (req, res) => {
|
||||
chatRouter.delete("/sessions/:session_id", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
@@ -395,8 +395,11 @@ export const buildChatRouter = (
|
||||
sessionUiStateStore,
|
||||
});
|
||||
|
||||
chatRouter.post("/fork", async (req, res) => {
|
||||
const parsed = forkPayloadSchema.safeParse(req.body);
|
||||
chatRouter.post("/sessions/:session_id/forks", async (req, res) => {
|
||||
const parsed = forkPayloadSchema.safeParse({
|
||||
...req.body,
|
||||
session_id: req.params.session_id,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -425,6 +428,10 @@ export const buildChatRouter = (
|
||||
sourceSessionId,
|
||||
)
|
||||
: null;
|
||||
if (!sourceSessionId || !sourceSessionRecord) {
|
||||
res.status(404).json({ message: "source session not found" });
|
||||
return;
|
||||
}
|
||||
const forkSession = await runtime.createSession();
|
||||
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
@@ -436,27 +443,25 @@ export const buildChatRouter = (
|
||||
});
|
||||
const nextSessionId = targetSessionRecord.sessionId;
|
||||
|
||||
if (sourceSessionId) {
|
||||
await sessionTranscriptStore.cloneThread(
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: sourceSessionId,
|
||||
projectKey,
|
||||
sessionId: sourceSessionId,
|
||||
},
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: nextSessionId,
|
||||
projectKey,
|
||||
sessionId: nextSessionId,
|
||||
},
|
||||
parsed.data.keep_message_count,
|
||||
);
|
||||
}
|
||||
const sourceState = sourceSessionRecord
|
||||
? await sessionUiStateStore.read(toSessionUiStateContext(sourceSessionRecord))
|
||||
: null;
|
||||
const forkTitle = sourceSessionRecord?.title
|
||||
await sessionTranscriptStore.cloneThread(
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: sourceSessionId,
|
||||
projectKey,
|
||||
sessionId: sourceSessionId,
|
||||
},
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: nextSessionId,
|
||||
projectKey,
|
||||
sessionId: nextSessionId,
|
||||
},
|
||||
parsed.data.keep_message_count,
|
||||
);
|
||||
const sourceState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sourceSessionRecord),
|
||||
);
|
||||
const forkTitle = sourceSessionRecord.title
|
||||
? `${sourceSessionRecord.title} 副本`
|
||||
: "新对话副本";
|
||||
const titledTargetSessionRecord = await sessionMetadataStore.touch(
|
||||
@@ -495,8 +500,11 @@ export const buildChatRouter = (
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/stream", async (req, res) => {
|
||||
const parsed = payloadSchema.safeParse(req.body);
|
||||
chatRouter.post("/sessions/:session_id/runs", async (req, res) => {
|
||||
const parsed = payloadSchema.safeParse({
|
||||
...req.body,
|
||||
session_id: req.params.session_id,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
updateLastAssistantMessage,
|
||||
} from "./chatUiState.js";
|
||||
|
||||
const abortPayloadSchema = z.object({
|
||||
const abortParamsSchema = z.object({
|
||||
session_id: z.string().max(128),
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ export const registerChatAuxiliaryRoutes = (
|
||||
sessionUiStateStore,
|
||||
}: RegisterAuxiliaryRoutesOptions,
|
||||
) => {
|
||||
chatRouter.get("/render-ref/:render_ref", async (req, res) => {
|
||||
chatRouter.get("/render-references/:render_ref", async (req, res) => {
|
||||
const renderRef = req.params.render_ref?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const userId = authContext.userId;
|
||||
@@ -82,8 +82,8 @@ export const registerChatAuxiliaryRoutes = (
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
chatRouter.post("/abort", async (req, res) => {
|
||||
const parsed = abortPayloadSchema.safeParse(req.body);
|
||||
chatRouter.delete("/sessions/:session_id/runs/current", async (req, res) => {
|
||||
const parsed = abortParamsSchema.safeParse(req.params);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
|
||||
@@ -15,20 +15,17 @@ import {
|
||||
} from "./chatUiState.js";
|
||||
|
||||
const permissionReplyPayloadSchema = z.object({
|
||||
session_id: z.string().max(128),
|
||||
request_id: z.string().min(1),
|
||||
reply: z.enum(["once", "always", "reject"]),
|
||||
message: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
const questionReplyPayloadSchema = z.object({
|
||||
session_id: z.string().max(128),
|
||||
request_id: z.string().min(1),
|
||||
action: z.enum(["reply", "reject"]).default("reply"),
|
||||
answers: z.array(z.array(z.string().max(2000))).default([]),
|
||||
});
|
||||
|
||||
const questionRejectPayloadSchema = z.object({
|
||||
session_id: z.string().max(128),
|
||||
});
|
||||
|
||||
type RegisterInteractionRoutesOptions = {
|
||||
activeRuns: Map<string, ActiveRun>;
|
||||
runtime: OpencodeRuntimeAdapter;
|
||||
@@ -49,13 +46,8 @@ export const registerChatInteractionRoutes = (
|
||||
sessionUiStateStore,
|
||||
}: RegisterInteractionRoutesOptions,
|
||||
) => {
|
||||
chatRouter.post("/permission/:request_id/reply", async (req, res) => {
|
||||
const requestId = req.params.request_id?.trim();
|
||||
chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => {
|
||||
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
return;
|
||||
}
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -70,9 +62,10 @@ export const registerChatInteractionRoutes = (
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestId = parsed.data.request_id;
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
req.params.session_id,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
@@ -174,13 +167,8 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/question/:request_id/reply", async (req, res) => {
|
||||
const requestId = req.params.request_id?.trim();
|
||||
chatRouter.post("/sessions/:session_id/question-responses", async (req, res) => {
|
||||
const parsed = questionReplyPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
return;
|
||||
}
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -195,9 +183,10 @@ export const registerChatInteractionRoutes = (
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestId = parsed.data.request_id;
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
req.params.session_id,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
@@ -227,11 +216,18 @@ export const registerChatInteractionRoutes = (
|
||||
};
|
||||
|
||||
try {
|
||||
await runtime.replyQuestion({
|
||||
requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
answers: parsed.data.answers,
|
||||
});
|
||||
if (parsed.data.action === "reject") {
|
||||
await runtime.rejectQuestion({
|
||||
requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
} else {
|
||||
await runtime.replyQuestion({
|
||||
requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
answers: parsed.data.answers,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
@@ -242,7 +238,7 @@ export const registerChatInteractionRoutes = (
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "failed to reply question",
|
||||
: `failed to ${parsed.data.action} question`,
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
@@ -252,7 +248,7 @@ export const registerChatInteractionRoutes = (
|
||||
);
|
||||
});
|
||||
res.status(502).json({
|
||||
message: "question reply failed",
|
||||
message: `question ${parsed.data.action} failed`,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
@@ -264,8 +260,9 @@ export const registerChatInteractionRoutes = (
|
||||
requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "answered",
|
||||
answers: parsed.data.answers,
|
||||
status: parsed.data.action === "reject" ? "rejected" : "answered",
|
||||
answers:
|
||||
parsed.data.action === "reject" ? question.answers : parsed.data.answers,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}),
|
||||
@@ -280,7 +277,9 @@ export const registerChatInteractionRoutes = (
|
||||
subscriber.write("question_response", {
|
||||
session_id: pendingQuestion.session_id,
|
||||
request_id: requestId,
|
||||
answers: parsed.data.answers,
|
||||
...(parsed.data.action === "reject"
|
||||
? { rejected: true }
|
||||
: { answers: parsed.data.answers }),
|
||||
});
|
||||
}
|
||||
if (
|
||||
@@ -294,143 +293,15 @@ export const registerChatInteractionRoutes = (
|
||||
res.status(202).json({
|
||||
session_id: pendingQuestion.session_id,
|
||||
request_id: requestId,
|
||||
answers: parsed.data.answers,
|
||||
...(parsed.data.action === "reject"
|
||||
? { rejected: true }
|
||||
: { answers: parsed.data.answers }),
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ err: error }, "question reply route failed");
|
||||
logger.error({ err: error }, "question response route failed");
|
||||
res.status(500).json({
|
||||
message: "question reply route failed",
|
||||
detail,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/question/:request_id/reject", async (req, res) => {
|
||||
const requestId = req.params.request_id?.trim();
|
||||
const parsed = questionRejectPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
return;
|
||||
}
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
detail: parsed.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
);
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const run = activeRuns.get(sessionRecord.sessionId);
|
||||
if (!run) {
|
||||
res.status(409).json({ message: "session is not waiting for questions" });
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingQuestion = run.pendingQuestions.get(requestId);
|
||||
if (!pendingQuestion) {
|
||||
res.status(404).json({ message: "question request not found" });
|
||||
return;
|
||||
}
|
||||
const persistQuestionState = async () => {
|
||||
const currentState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord.sessionId),
|
||||
);
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await runtime.rejectQuestion({
|
||||
requestId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "error",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "failed to reject question",
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
"failed to persist question error state",
|
||||
);
|
||||
});
|
||||
res.status(502).json({
|
||||
message: "question reject failed",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
run.pendingQuestions.delete(requestId);
|
||||
run.messages = updateLastAssistantQuestion(
|
||||
run.messages,
|
||||
requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: "rejected",
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}),
|
||||
);
|
||||
await persistQuestionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
"failed to persist question reject state",
|
||||
);
|
||||
});
|
||||
for (const subscriber of run.subscribers) {
|
||||
subscriber.write("question_response", {
|
||||
session_id: pendingQuestion.session_id,
|
||||
request_id: requestId,
|
||||
rejected: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
run.status !== "running" &&
|
||||
run.pendingPermissions.size === 0 &&
|
||||
run.pendingQuestions.size === 0
|
||||
) {
|
||||
activeRuns.delete(sessionRecord.sessionId);
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
session_id: pendingQuestion.session_id,
|
||||
request_id: requestId,
|
||||
rejected: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ err: error }, "question reject route failed");
|
||||
res.status(500).json({
|
||||
message: "question reject route failed",
|
||||
message: "question response route failed",
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
|
||||
const problemDetails = (req: Request, status: number, body: unknown) => {
|
||||
const record =
|
||||
typeof body === "object" && body !== null
|
||||
? (body as Record<string, unknown>)
|
||||
: {};
|
||||
const detail =
|
||||
typeof record.detail === "string"
|
||||
? record.detail
|
||||
: typeof record.message === "string"
|
||||
? record.message
|
||||
: `Request failed with status ${status}`;
|
||||
const codeByStatus: Record<number, string> = {
|
||||
400: "invalid_request",
|
||||
401: "unauthenticated",
|
||||
403: "forbidden",
|
||||
404: "not_found",
|
||||
409: "conflict",
|
||||
422: "validation_error",
|
||||
503: "dependency_unavailable",
|
||||
};
|
||||
const code = codeByStatus[status] ?? "request_error";
|
||||
return {
|
||||
type: `https://tjwater.example/problems/${code.replaceAll("_", "-")}`,
|
||||
title: code.replaceAll("_", " "),
|
||||
status,
|
||||
detail,
|
||||
instance: req.originalUrl.split("?")[0],
|
||||
code,
|
||||
trace_id: req.header("x-request-id") ?? randomUUID(),
|
||||
errors: record.detail && typeof record.detail === "object" ? [record.detail] : [],
|
||||
};
|
||||
};
|
||||
|
||||
export const buildAgentPublicRouter = (chatRouter: Router) => {
|
||||
const router = Router();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const json = res.json.bind(res);
|
||||
res.json = ((body: unknown) => {
|
||||
if (res.statusCode >= 400) {
|
||||
res.type("application/problem+json");
|
||||
return json(problemDetails(req, res.statusCode, body));
|
||||
}
|
||||
return json(body);
|
||||
}) as Response["json"];
|
||||
next();
|
||||
});
|
||||
|
||||
router.use(chatRouter);
|
||||
|
||||
return router;
|
||||
};
|
||||
Reference in New Issue
Block a user