feat(api): expose REST-only agent routes
Agent CI/CD / docker-image (push) Failing after 35s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s

This commit is contained in:
2026-07-30 20:38:52 +08:00
parent 2415f75841
commit 94529cb141
29 changed files with 3525 additions and 378 deletions
+38 -30
View File
@@ -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",
+4 -4
View File
@@ -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",
+34 -163
View File
@@ -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,
});
}
+55
View File
@@ -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;
};