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
+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,
});
}