checkpoint before gfm ast parser design
This commit is contained in:
@@ -5,6 +5,11 @@ const mockValidateRequestBody = vi.fn();
|
||||
const mockIsConvexEnabled = vi.fn();
|
||||
const mockGetAuthedConvexClient = vi.fn();
|
||||
const mockStartMnoteCliAgentHostRun = vi.fn();
|
||||
const mockStartCodexJsonRun = vi.fn();
|
||||
const mockCodexMessagesToPrompt = vi.fn();
|
||||
const mockFindWorkspaceRoot = vi.fn();
|
||||
const mockStartHermesRun = vi.fn();
|
||||
const mockStreamHermesRunEvents = vi.fn();
|
||||
const mockRequireAuthContext = vi.fn();
|
||||
const mockGetConvexAuthedHttpClient = vi.fn();
|
||||
|
||||
@@ -51,6 +56,17 @@ vi.mock("@/lib/server/mnote-cli-agent-host", () => ({
|
||||
startMnoteCliAgentHostRun: mockStartMnoteCliAgentHostRun,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/codex/codexExec", () => ({
|
||||
codexMessagesToPrompt: mockCodexMessagesToPrompt,
|
||||
findWorkspaceRoot: mockFindWorkspaceRoot,
|
||||
startCodexJsonRun: mockStartCodexJsonRun,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
|
||||
startHermesRun: mockStartHermesRun,
|
||||
streamHermesRunEvents: mockStreamHermesRunEvents,
|
||||
}));
|
||||
|
||||
describe("/api/ai-agent/run route", () => {
|
||||
beforeEach(() => {
|
||||
mockSafeGetJsonBody.mockReset();
|
||||
@@ -58,8 +74,15 @@ describe("/api/ai-agent/run route", () => {
|
||||
mockIsConvexEnabled.mockReset();
|
||||
mockGetAuthedConvexClient.mockReset();
|
||||
mockStartMnoteCliAgentHostRun.mockReset();
|
||||
mockStartCodexJsonRun.mockReset();
|
||||
mockCodexMessagesToPrompt.mockReset();
|
||||
mockFindWorkspaceRoot.mockReset();
|
||||
mockStartHermesRun.mockReset();
|
||||
mockStreamHermesRunEvents.mockReset();
|
||||
mockRequireAuthContext.mockReset();
|
||||
mockGetConvexAuthedHttpClient.mockReset();
|
||||
mockCodexMessagesToPrompt.mockImplementation((messages) => messages.map((m: any) => m.content).join("\n"));
|
||||
mockFindWorkspaceRoot.mockResolvedValue("/mnt/Data1T/mnote");
|
||||
});
|
||||
|
||||
it("文档页在线请求应只进入 mnote-cli host", async () => {
|
||||
@@ -137,8 +160,8 @@ describe("/api/ai-agent/run route", () => {
|
||||
expect(text).toContain("mnote-cli");
|
||||
});
|
||||
|
||||
it.each(["codex", "hermes", "local", "ollama"] as const)(
|
||||
"provider=%s 也必须统一进入 mnote-cli host",
|
||||
it.each(["local", "ollama"] as const)(
|
||||
"provider=%s 继续进入 mnote-cli host",
|
||||
async (provider) => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
@@ -196,6 +219,127 @@ describe("/api/ai-agent/run route", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("provider=codex 应进入 Codex host 并返回 codex_session", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "#chat 继续检查" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "codex",
|
||||
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
mockStartCodexJsonRun.mockImplementation(({ onJsonLine }) => {
|
||||
onJsonLine?.({ type: "thread.started", thread_id: "codex-thread-1" });
|
||||
return {
|
||||
done: Promise.resolve({ ok: true, threadId: "codex-thread-1", text: "Codex 已回复" }),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
|
||||
expect(mockStartCodexJsonRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
|
||||
}),
|
||||
);
|
||||
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("codex");
|
||||
expect(text).toContain("event: codex_session");
|
||||
expect(text).toContain("codex-thread-1");
|
||||
expect(text).toContain("Codex 已回复");
|
||||
});
|
||||
|
||||
it("provider=hermes 应进入 Hermes API bridge", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "总结当前页面" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "hermes",
|
||||
sessionId: "hermes-session-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
mockStartHermesRun.mockResolvedValue({ runId: "hermes-run-1" });
|
||||
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
|
||||
await onEvent({ event: "message.delta", delta: "Hermes " });
|
||||
await onEvent({ event: "run.completed", output: "Hermes 已回复" });
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
|
||||
expect(mockStartHermesRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
session_id: "hermes-session-1",
|
||||
}),
|
||||
);
|
||||
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("hermes");
|
||||
expect(text).toContain("Hermes 已回复");
|
||||
});
|
||||
|
||||
it("provider=claudecode 未接桥时必须明确报错,不能静默进入 mnote-cli", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "claudecode",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(501);
|
||||
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
|
||||
expect(text).toContain("ClaudeCode");
|
||||
});
|
||||
|
||||
it("未登录时不应启动 mnote-cli host", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
|
||||
@@ -1,13 +1,130 @@
|
||||
import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils";
|
||||
import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec";
|
||||
import { startHermesRun, streamHermesRunEvents } from "@/lib/ai-agent/hermes/bridge";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
startMnoteCliAgentHostRun,
|
||||
type MnoteCliAgentRunPayload,
|
||||
} from "@/lib/server/mnote-cli-agent-host";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const toSseFrame = (event: string, data: unknown) => {
|
||||
const json = JSON.stringify(data ?? null);
|
||||
return `event: ${event}\ndata: ${json}\n\n`;
|
||||
};
|
||||
|
||||
const lastUserMessage = (payload: MnoteCliAgentRunPayload) => {
|
||||
const found = [...payload.messages].reverse().find((message) => message.role === "user");
|
||||
return String(found?.content ?? "");
|
||||
};
|
||||
|
||||
const codexSandboxForPayload = (payload: MnoteCliAgentRunPayload) =>
|
||||
/^\s*#dev\b/i.test(lastUserMessage(payload)) ? "workspace-write" : "read-only";
|
||||
|
||||
async function startCodexAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
|
||||
send("ready", { ok: true, bridgeOwner: "codex" });
|
||||
try {
|
||||
const cwd = await findWorkspaceRoot(process.cwd());
|
||||
const prompt = codexMessagesToPrompt(payload.messages);
|
||||
const run = startCodexJsonRun({
|
||||
cwd,
|
||||
sandbox: codexSandboxForPayload(payload),
|
||||
prompt,
|
||||
model: payload.options?.ai?.model,
|
||||
sessionId: payload.options?.ai?.sessionId,
|
||||
onJsonLine: (line) => {
|
||||
if (line.type === "thread.started" && typeof line.thread_id === "string" && line.thread_id.trim()) {
|
||||
send("codex_session", { sessionId: line.thread_id.trim() });
|
||||
}
|
||||
},
|
||||
});
|
||||
const result = await run.done;
|
||||
if (!result.ok) {
|
||||
send("error", { ok: false, message: result.error || "Codex 执行失败" });
|
||||
return;
|
||||
}
|
||||
const sessionId = result.threadId.trim();
|
||||
if (sessionId) send("codex_session", { sessionId });
|
||||
send("assistant_message", { text: result.text || "(无输出)" });
|
||||
send("completion", { ok: true, text: result.text || "(无输出)", steps: 1 });
|
||||
} catch (error) {
|
||||
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"x-mnote-ai-execution-owner": "codex",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function startHermesAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
|
||||
send("ready", { ok: true, bridgeOwner: "hermes" });
|
||||
try {
|
||||
const started = await startHermesRun({
|
||||
input: payload.messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})),
|
||||
conversation_history: payload.messages.slice(0, -1).map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})),
|
||||
session_id: payload.options?.ai?.sessionId,
|
||||
});
|
||||
let assistantText = "";
|
||||
await streamHermesRunEvents(started.runId, (event) => {
|
||||
if (event.event === "message.delta" && typeof event.delta === "string") {
|
||||
assistantText += event.delta;
|
||||
send("assistant_delta", { text: event.delta });
|
||||
}
|
||||
if (event.event === "run.completed") {
|
||||
const output = typeof event.output === "string" && event.output.trim() ? event.output.trim() : assistantText.trim();
|
||||
send("assistant_message", { text: output || "(无输出)" });
|
||||
send("completion", { ok: true, text: output || "(无输出)", steps: 1 });
|
||||
}
|
||||
if (event.event === "run.failed") {
|
||||
send("error", { ok: false, message: event.error || "Hermes 执行失败" });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"x-mnote-ai-execution-owner": "hermes",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request);
|
||||
if (!payload) {
|
||||
@@ -32,6 +149,25 @@ export async function POST(request: Request) {
|
||||
return errorResponses.unauthorized();
|
||||
}
|
||||
|
||||
const provider = String(payload.options?.ai?.provider ?? "").trim().toLowerCase();
|
||||
if (provider === "codex") {
|
||||
return startCodexAgentRun(payload);
|
||||
}
|
||||
if (provider === "hermes") {
|
||||
return startHermesAgentRun(payload);
|
||||
}
|
||||
if (provider === "claudecode") {
|
||||
return NextResponse.json(
|
||||
{ error: "ClaudeCode bridge 尚未接入,不能静默降级到 mnote-cli。" },
|
||||
{
|
||||
status: 501,
|
||||
headers: {
|
||||
"x-mnote-ai-execution-owner": "claudecode-unavailable",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return startMnoteCliAgentHostRun({
|
||||
request,
|
||||
userId,
|
||||
|
||||
Reference in New Issue
Block a user