checkpoint before gfm ast parser design

This commit is contained in:
lix-2026
2026-05-08 00:41:03 +08:00
parent e8ba12e461
commit c620b9e40c
41 changed files with 12270 additions and 263 deletions
@@ -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,