fix(agent): synchronize conversation preparation state

This commit is contained in:
yu
2026-08-05 10:40:10 +08:00
parent 46f29f70ca
commit 9bccd0ff1a
13 changed files with 511 additions and 124 deletions
@@ -150,6 +150,41 @@ test("skills/changed 作为站点级事件单独广播", () => {
assert.deepEqual(events, [{ type: "skills_changed", payload: {} }]);
});
test("MCP 启动状态区分空对话预热与正常 turn 运行", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const starting = client.startThread("D:\\site", "request", true);
const request = writes.find((item) => item.method === "thread/start");
assert.ok(request);
testClient.handleNotification("mcpServer/startupStatus/updated", { threadId: "thread-1", name: "notion", status: "starting" });
testClient.handle({ id: request.id, result: { thread: { id: "thread-1" } } });
await new Promise((resolve) => setImmediate(resolve));
const statusRequest = writes.find((item) => item.method === "mcpServerStatus/list");
assert.ok(statusRequest);
testClient.handleNotification("mcpServer/startupStatus/updated", { threadId: "thread-1", name: "notion", status: "failed" });
testClient.handle({ id: statusRequest.id, result: { data: [{ name: "notion", authStatus: "notLoggedIn", tools: [], resources: [], resourceTemplates: [] }], nextCursor: null } });
await starting;
const running = client.startTurn("thread-1", "test", [], "request");
const turnRequest = writes.find((item) => item.method === "turn/start");
assert.ok(turnRequest);
testClient.handleNotification("mcpServer/startupStatus/updated", { threadId: "thread-1", name: "notion", status: "ready" });
testClient.handle({ id: turnRequest.id, result: { turn: { id: "turn-1" } } });
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
await running;
assert.deepEqual(events.filter((event) => event.type === "agent_bootstrap"), [
{ type: "agent_bootstrap", payload: { type: "mcp.startup", phase: "preheat", threadId: "thread-1", name: "notion", status: "starting", error: undefined, failureReason: undefined } },
{ type: "agent_bootstrap", payload: { type: "mcp.startup", phase: "preheat", threadId: "thread-1", name: "notion", status: "failed", error: undefined, failureReason: undefined } },
{ type: "agent_bootstrap", payload: { type: "mcp.complete", phase: "preheat", threadId: "thread-1", services: [{ name: "notion", authStatus: "notLoggedIn" }] } },
{ type: "agent_bootstrap", payload: { type: "mcp.startup", phase: "runtime", threadId: "thread-1", name: "notion", status: "ready", error: undefined, failureReason: undefined } },
]);
});
test("静默 Skill 草稿 turn 只返回结构化结果,不广播也不写历史", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
+40 -9
View File
@@ -57,6 +57,8 @@ export class CodexAppClient {
private structuredOutputByTurn = new Map<string, string>();
private pendingSilentThreadStarts = new Set<symbol>();
private pendingThreadStartedNotifications: JsonRecord[] = [];
private pendingPreheatThreadStarts = 0;
private preheatingThreadIds = new Set<string>();
private failing = false;
private failureMessage = "";
@@ -99,10 +101,22 @@ export class CodexAppClient {
}
/** 创建新的 Codex 线程。 */
async startThread(cwd?: string, permissionMode: AgentPermissionMode = "request") {
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
return thread;
async startThread(cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
if (preheat) this.pendingPreheatThreadStarts += 1;
let threadId = "";
try {
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
threadId = thread.id;
if (preheat) {
this.preheatingThreadIds.add(threadId);
await this.completeMcpPreheat(threadId);
}
return thread;
} finally {
if (preheat) this.pendingPreheatThreadStarts -= 1;
if (threadId) this.preheatingThreadIds.delete(threadId);
}
}
/** 创建不会持久化或向网页广播的草稿线程。 */
@@ -116,10 +130,24 @@ export class CodexAppClient {
}
/** 恢复已有 Codex 线程。 */
async resumeThread(threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
return thread;
async resumeThread(threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
if (preheat) this.pendingPreheatThreadStarts += 1;
try {
if (preheat) this.preheatingThreadIds.add(threadId);
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
if (preheat) await this.completeMcpPreheat(thread.id);
return thread;
} finally {
if (preheat) this.pendingPreheatThreadStarts -= 1;
if (preheat) this.preheatingThreadIds.delete(threadId);
}
}
/** 以 app-server 的权威 MCP 清单响应作为预热完成边界。 */
private async completeMcpPreheat(threadId: string) {
const result = await this.request("mcpServerStatus/list", { threadId, limit: 100, detail: "toolsAndAuthOnly" });
this.emit("agent_bootstrap", { type: "mcp.complete", phase: "preheat", threadId, services: result.data.map(({ name, authStatus }) => ({ name, authStatus })) });
}
/** 查询 Codex 线程列表。 */
@@ -187,6 +215,7 @@ export class CodexAppClient {
/** 启动一个 Codex turn 并等待完成通知。 */
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void, skill?: CodexSkillSelector, messageText?: string, outputSchema?: JsonRecord) {
this.preheatingThreadIds.delete(threadId);
this.currentThreadId = threadId;
this.currentTurnId = "";
this.lastUsage = null;
@@ -347,9 +376,11 @@ export class CodexAppClient {
}
if (method === "mcpServer/startupStatus/updated") {
const value = params as unknown as CodexNotificationParams<"mcpServer/startupStatus/updated">;
const threadId = value.threadId || this.currentThreadId;
this.emit("agent_bootstrap", {
type: "mcp.startup",
threadId: value.threadId || this.currentThreadId,
phase: this.pendingPreheatThreadStarts > 0 || this.preheatingThreadIds.has(threadId) ? "preheat" : "runtime",
threadId,
name: value.name,
status: value.status,
error: value.error,
+5
View File
@@ -7,6 +7,7 @@ export type CodexItem = JsonRecord & { id: string; type: string; text?: string }
export type CodexPlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
export type CodexPlanUpdate = { threadId: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[]; turnStatus?: string };
export type CodexMcpStartupStatus = { threadId: string | null; name: string; status: "starting" | "ready" | "failed" | "cancelled"; error: string | null; failureReason: "reauthenticationRequired" | null };
export type CodexMcpServerStatus = { name: string; authStatus: "unsupported" | "notLoggedIn" | "bearerToken" | "oAuth" } & JsonRecord;
export type CodexReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
export type CodexModel = JsonRecord & {
id: string;
@@ -103,6 +104,10 @@ type CodexRequestSpec = {
params: { limit: number; includeHidden: boolean };
result: { data: CodexModel[]; nextCursor: string | null };
};
"mcpServerStatus/list": {
params: { cursor?: string | null; limit?: number | null; detail?: "full" | "toolsAndAuthOnly" | null; threadId?: string | null };
result: { data: CodexMcpServerStatus[]; nextCursor: string | null };
};
"skills/list": {
params: { cwds: string[]; forceReload?: boolean };
result: { data: CodexSkillsListEntry[] };
+6 -6
View File
@@ -84,17 +84,17 @@ export async function resolveCodexApproval(requestId: string, decision: string)
}
/** 创建新的 Codex 线程并记录当前线程 ID。 */
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request") {
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
const app = await getCodexApp(emit);
const thread = await app.startThread(cwd, permissionMode);
const thread = await app.startThread(cwd, permissionMode, preheat);
loadedThreadId = String(field(thread, "id") || "");
return thread;
}
/** 恢复指定 Codex 线程并返回聊天历史。 */
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
const app = await getCodexApp(emit);
const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true);
const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true, preheat);
const history = await loadCodexHistory(emit, threadId, cwd);
const supplementalItems = await codexEventHistory.readThread(threadId);
return { thread, messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
@@ -433,8 +433,8 @@ async function loadCodexHistory(emit: AgentEmit, threadId: string, cwd?: string)
}
/** 恢复线程并统一校验工作空间与进程内活动线程。 */
async function resumeLoadedThread(app: CodexAppClient, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", updateLoaded = true) {
const thread = await app.resumeThread(threadId, cwd, permissionMode);
async function resumeLoadedThread(app: CodexAppClient, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", updateLoaded = true, preheat = false) {
const thread = await app.resumeThread(threadId, cwd, permissionMode, preheat);
assertThreadWorkspace(thread, cwd);
if (updateLoaded) loadedThreadId = String(field(thread, "id") || threadId);
return thread;
+46 -1
View File
@@ -214,7 +214,7 @@ test("shared thread events are broadcast with the active thread id", (t) => {
});
test("new clients receive the current Codex state and later updates", (t) => {
const session = new CanvasSession();
const session = new CanvasSession("thread-2");
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
session.trackCodexEvent("codex_approval", { requestId: "approval-1", threadId: "thread-2" });
const client = connect(session, "first", "thread-2");
@@ -223,6 +223,7 @@ test("new clients receive the current Codex state and later updates", (t) => {
const hello = client.event("hello");
assert.equal(field(hello, "protocolVersion"), 5);
assert.deepEqual(field(hello, "workspace"), { activeThreadId: "thread-2" });
assert.deepEqual(field(hello, "conversation"), { revision: 1, conversationId: "thread-2", threadId: "thread-2", status: "ready", mcpStatuses: {} });
assert.deepEqual(field(hello, "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
assert.deepEqual(field(hello, "pendingApprovals"), [{ requestId: "approval-1", threadId: "thread-2" }]);
@@ -236,6 +237,50 @@ test("new clients receive the current Codex state and later updates", (t) => {
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
});
test("对话 revision 单调递增且 MCP 全部进入终态前保持 preparing", () => {
const session = new CanvasSession();
const revisions = [session.conversationStateSnapshot.revision];
revisions.push(session.beginConversation({ sourceClientId: "first" }).revision);
revisions.push(session.updateConversationMcp("late-service", "starting").revision);
revisions.push(session.completeConversationMcpInventory([{ name: "infinite-canvas", authStatus: "unsupported" }]).revision);
const pending = session.completeConversationPreparation("thread-1");
revisions.push(pending.revision);
assert.equal(pending.status, "preparing");
const ready = session.updateConversationMcp("late-service", "ready");
revisions.push(ready.revision);
assert.equal(ready.status, "ready");
assert.equal(ready.threadId, "thread-1");
revisions.slice(1).forEach((revision, index) => assert.ok(revision > revisions[index]));
});
test("可选 MCP 失败进入 warning,画布 MCP 失败进入 failed", () => {
const optionalFailure = new CanvasSession();
optionalFailure.beginConversation();
optionalFailure.completeConversationMcpInventory([
{ name: "infinite-canvas", authStatus: "unsupported" },
{ name: "notion", authStatus: "notLoggedIn" },
]);
const warning = optionalFailure.completeConversationPreparation("thread-1");
assert.equal(warning.status, "warning");
assert.equal(warning.mcpStatuses.notion.status, "failed");
const requiredFailure = new CanvasSession();
requiredFailure.beginConversation();
requiredFailure.completeConversationMcpInventory([{ name: "infinite-canvas", authStatus: "notLoggedIn" }]);
const failed = requiredFailure.completeConversationPreparation("thread-2");
assert.equal(failed.status, "failed");
assert.match(failed.error || "", /Infinite Canvas MCP/);
const requiredMissing = new CanvasSession();
requiredMissing.beginConversation();
requiredMissing.completeConversationMcpInventory([{ name: "notion", authStatus: "unsupported" }]);
const missing = requiredMissing.completeConversationPreparation("thread-3");
assert.equal(missing.status, "failed");
assert.match(missing.error || "", /Infinite Canvas MCP/);
});
test("Codex 写操作在多窗口之间互斥且不能与运行 turn 并发", () => {
const session = new CanvasSession();
assert.equal(session.beginCodexMutation(), true);
+125 -2
View File
@@ -12,6 +12,17 @@ type PendingRequest = { clientId: string; resolve: (value: unknown) => void; rej
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
type ReplayEvent = { type: string; payload: Record<string, unknown> };
export type CodexState = { busy: boolean; threadId: string; turnId: string };
export type McpStartupState = "starting" | "ready" | "failed" | "cancelled";
export type ConversationState = {
revision: number;
conversationId: string;
threadId: string;
status: "idle" | "preparing" | "ready" | "warning" | "running" | "failed";
mcpStatuses: Record<string, { status: McpStartupState; error?: string | null; failureReason?: string | null }>;
sourceClientId?: string;
error?: string;
};
type McpInventoryItem = { name: string; authStatus?: string };
export const AGENT_PROTOCOL_VERSION = 5;
const SITE_TOOLS = new Set<ToolName>([
@@ -42,6 +53,19 @@ export class CanvasSession {
private boundClientId = "";
private focusSequence = 0;
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
private conversationState: ConversationState;
private conversationInventoryComplete = false;
private preparedConversationThreadId = "";
constructor(activeThreadId = "") {
this.conversationState = {
revision: 1,
conversationId: activeThreadId || crypto.randomUUID(),
threadId: activeThreadId,
status: activeThreadId ? "ready" : "idle",
mcpStatuses: {},
};
}
/** 获取当前目标网页的画布状态。 */
private get canvasState() {
@@ -55,7 +79,7 @@ export class CanvasSession {
/** 返回 Canvas Agent 当前连接状态。 */
health() {
return { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
return { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy, conversation: this.conversationStateSnapshot };
}
/** 返回 Codex 是否正在执行任务。 */
@@ -72,6 +96,98 @@ export class CanvasSession {
return { ...this.codexState };
}
/** 返回站点级对话的权威快照。 */
get conversationStateSnapshot(): ConversationState {
return { ...this.conversationState, mcpStatuses: { ...this.conversationState.mcpStatuses } };
}
/** 原子开始一次新建或恢复对话流程。 */
beginConversation(options: { threadId?: string; conversationId?: string; sourceClientId?: string } = {}) {
this.conversationInventoryComplete = false;
this.preparedConversationThreadId = "";
return this.updateConversation({
conversationId: options.conversationId || options.threadId || crypto.randomUUID(),
threadId: options.threadId || "",
status: "preparing",
mcpStatuses: {},
sourceClientId: options.sourceClientId,
error: undefined,
});
}
/** 记录预热阶段单个 MCP 服务的启动状态。 */
updateConversationMcp(name: string, status: McpStartupState, error?: string | null, failureReason?: string | null) {
if (!name || this.conversationState.status !== "preparing") return this.conversationStateSnapshot;
const snapshot = this.updateConversation({
mcpStatuses: { ...this.conversationState.mcpStatuses, [name]: { status, error, failureReason } },
});
return this.conversationInventoryComplete && this.preparedConversationThreadId
? this.completeConversationPreparation(this.preparedConversationThreadId)
: snapshot;
}
/** 用 app-server 的完整 MCP 清单补齐未发送逐项通知的服务。 */
completeConversationMcpInventory(services: McpInventoryItem[]) {
if (this.conversationState.status !== "preparing") return this.conversationStateSnapshot;
const mcpStatuses = { ...this.conversationState.mcpStatuses };
services.filter((item) => item.name).forEach((item) => {
const current = mcpStatuses[item.name];
if (current?.status === "failed" || current?.status === "cancelled") return;
mcpStatuses[item.name] = item.authStatus === "notLoggedIn"
? { status: "failed", error: "MCP 服务未登录", failureReason: "reauthenticationRequired" }
: { status: "ready" };
});
this.conversationInventoryComplete = true;
return this.updateConversation({ mcpStatuses });
}
/** MCP 清单读取结束后提交线程和最终可发送状态。 */
completeConversationPreparation(threadId: string) {
this.preparedConversationThreadId = threadId;
const statuses = this.conversationState.mcpStatuses;
const hasPending = !this.conversationInventoryComplete || Object.values(statuses).some((item) => item.status === "starting");
const requiredFailure = statuses["infinite-canvas"]?.status !== "ready";
const hasFailure = Object.values(statuses).some((item) => item.status === "failed" || item.status === "cancelled");
const requiredFailureDetail = statuses["infinite-canvas"]?.error;
return this.updateConversation({
threadId,
status: hasPending ? "preparing" : requiredFailure ? "failed" : hasFailure ? "warning" : "ready",
error: requiredFailure ? `Infinite Canvas MCP 初始化失败${requiredFailureDetail ? `${requiredFailureDetail}` : ""}` : undefined,
});
}
/** 将创建线程或读取 MCP 清单的失败保存为不可发送状态。 */
failConversationPreparation(error: string) {
this.conversationInventoryComplete = false;
this.preparedConversationThreadId = "";
return this.updateConversation({ status: "failed", error });
}
/** 切换到无需重新预热的既有状态,例如删除当前对话后的空状态。 */
activateConversation(threadId: string, sourceClientId?: string) {
this.conversationInventoryComplete = false;
this.preparedConversationThreadId = "";
return this.updateConversation({
conversationId: threadId || crypto.randomUUID(),
threadId,
status: threadId ? "ready" : "idle",
mcpStatuses: {},
sourceClientId,
error: undefined,
});
}
markConversationRunning(threadId: string) {
if (!threadId || threadId !== this.conversationState.threadId || !["ready", "warning"].includes(this.conversationState.status)) return this.conversationStateSnapshot;
return this.updateConversation({ status: "running" });
}
finishConversationRun(threadId: string) {
if (!threadId || threadId !== this.conversationState.threadId) return this.conversationStateSnapshot;
const hasFailure = Object.values(this.conversationState.mcpStatuses).some((item) => item.status === "failed" || item.status === "cancelled");
return this.updateConversation({ status: hasFailure ? "warning" : "ready" });
}
/** 判断网页客户端是否仍连接到当前 Agent。 */
hasClient(clientId: string) {
return this.clients.has(clientId);
@@ -163,7 +279,7 @@ export class CanvasSession {
this.clientFocusOrder.set(clientId, ++this.focusSequence);
}
}
sendEvent(res, "hello", { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, clientId, workspace: { activeThreadId }, codex: this.codexState, pendingApprovals: this.codexPendingApprovals });
sendEvent(res, "hello", { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, clientId, workspace: { activeThreadId }, conversation: this.conversationStateSnapshot, codex: this.codexState, pendingApprovals: this.codexPendingApprovals });
if (!statusOnly && activeThreadId && this.codexState.threadId === activeThreadId) this.codexReplayEvents.forEach((event) => sendEvent(res, event.type, event.payload));
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
res.on("close", () => {
@@ -182,6 +298,13 @@ export class CanvasSession {
});
}
private updateConversation(patch: Partial<Omit<ConversationState, "revision">>) {
this.conversationState = { ...this.conversationState, ...patch, revision: this.conversationState.revision + 1 };
const snapshot = this.conversationStateSnapshot;
this.emitAll("conversation_changed", snapshot);
return snapshot;
}
/** 保存指定网页上报的最新画布快照。 */
updateState(body: unknown, clientId?: string) {
const targetClientId = clientId || this.activeClientId;
+112 -45
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import express, { type NextFunction, type Request, type Response } from "express";
import { runClaudeTurn } from "../agent/claude.js";
import { archiveCodexThread, CodexSkillLookupError, configureCodexSkill, generateCodexSkillDraft, interruptCodexTurn, listCodexModels, listCodexSkills, listCodexThreads, readCodexThread, resolveCodexApproval, resolveCodexSkill, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
import { archiveCodexThread, CodexSkillLookupError, configureCodexSkill, generateCodexSkillDraft, interruptCodexTurn, isRecoverableThreadError, listCodexModels, listCodexSkills, listCodexThreads, readCodexThread, resolveCodexApproval, resolveCodexSkill, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
import type { CodexReasoningEffort, CodexSkillSelector } from "../agent/codex-protocol.js";
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
import { AGENT_PROTOCOL_VERSION, CanvasSession } from "../canvas/session.js";
@@ -20,8 +20,9 @@ export function startHttpServer() {
config.url = `http://127.0.0.1:${port}`;
saveConfig(config);
const session = new CanvasSession();
const skillStore = new SkillStore(ensureSiteWorkspace(config).workspacePath);
const initialWorkspace = ensureSiteWorkspace(config);
const session = new CanvasSession(initialWorkspace.activeThreadId || "");
const skillStore = new SkillStore(initialWorkspace.workspacePath);
/** 将 Agent 事件广播到所属线程或全部网页。 */
const emit = (type: string, payload: unknown) => {
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
@@ -29,6 +30,10 @@ export function startHttpServer() {
session.emitAll(type, value);
return;
}
if (type === "agent_bootstrap" && value.phase === "preheat") {
if (value.type === "mcp.startup") session.updateConversationMcp(String(value.name || ""), startupStatus(value.status), String(value.error || "") || null, String(value.failureReason || "") || null);
if (value.type === "mcp.complete") session.completeConversationMcpInventory(mcpInventory(value.services));
}
const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
const threadId = String(value.threadId || value.thread_id || scope.threadId || ensureSiteWorkspace(config).activeThreadId || "");
const turnId = String(value.turnId || value.turn_id || scope.turnId || "");
@@ -43,10 +48,11 @@ export function startHttpServer() {
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
};
/** 保存并广播当前站点工作空间的活跃线程。 */
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}, preserveConversation = false) => {
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
if (!preserveConversation) session.activateConversation(activeThreadId, String(payload.sourceClientId || "") || undefined);
if (!session.codexBusy && session.codexThreadId !== activeThreadId) session.setCodexState({ threadId: activeThreadId, turnId: "" });
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId, conversation: session.conversationStateSnapshot });
return workspace;
};
let draftThreadStart: ReturnType<typeof startCodexThread> | null = null;
@@ -54,19 +60,45 @@ export function startHttpServer() {
const prepareDraftThread = (clientId: string, permission: AgentPermissionMode) => {
if (draftThreadStart) return draftThreadStart;
const workspace = ensureSiteWorkspace(config);
emit("agent_bootstrap", { type: "codex.preparing", sourceClientId: clientId });
const start = startCodexThread(emit, workspace.workspacePath, permission);
draftThreadStart = start;
void start.then((thread) => {
if (draftThreadStart !== start) return;
draftThreadStart = null;
const threadId = String((thread as Record<string, unknown>).id || "");
if (threadId && !ensureSiteWorkspace(config).activeThreadId) setActiveThread(threadId, { emptyThread: true, draftThread: true, sourceClientId: clientId });
}).catch((error) => {
if (draftThreadStart === start) draftThreadStart = null;
emit("agent_bootstrap", { type: "codex.prepare_failed", sourceClientId: clientId, error: error instanceof Error ? error.message : String(error) });
});
return start;
let prepared!: ReturnType<typeof startCodexThread>;
prepared = (async () => {
emit("agent_bootstrap", { type: "codex.preparing", sourceClientId: clientId });
try {
const thread = await startCodexThread(emit, workspace.workspacePath, permission, true);
if (draftThreadStart !== prepared) return thread;
const threadId = String((thread as Record<string, unknown>).id || "");
if (threadId && !ensureSiteWorkspace(config).activeThreadId) {
session.completeConversationPreparation(threadId);
setActiveThread(threadId, { emptyThread: true, draftThread: true, sourceClientId: clientId }, true);
}
return thread;
} catch (error) {
if (draftThreadStart === prepared) {
const text = error instanceof Error ? error.message : String(error);
session.failConversationPreparation(text);
emit("agent_bootstrap", { type: "codex.prepare_failed", sourceClientId: clientId, error: text });
}
throw error;
} finally {
if (draftThreadStart === prepared) draftThreadStart = null;
}
})();
draftThreadStart = prepared;
return prepared;
};
/** 恢复已有线程并等待完整 MCP 清单,供启动恢复和手动切换共用。 */
const prepareExistingThread = async (threadId: string, clientId = "", permission: AgentPermissionMode = "request") => {
const workspace = ensureSiteWorkspace(config);
session.beginConversation({ conversationId: threadId, threadId, sourceClientId: clientId || undefined });
emit("agent_bootstrap", { type: "codex.preparing", threadId, sourceClientId: clientId || undefined });
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permission, true);
session.completeConversationPreparation(threadId);
return result;
};
const failPreparedConversation = (error: unknown, threadId: string, clientId = "") => {
const text = error instanceof Error ? error.message : String(error);
session.failConversationPreparation(text);
emit("agent_bootstrap", { type: "codex.prepare_failed", threadId, sourceClientId: clientId || undefined, error: text });
};
const app = express();
app.disable("x-powered-by");
@@ -133,7 +165,7 @@ export function startHttpServer() {
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
app.get("/agent/codex/workspace", (_req, res) => {
const workspace = ensureSiteWorkspace(config);
res.json({ ok: true, workspace });
res.json({ ok: true, workspace, conversation: session.conversationStateSnapshot });
});
app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) })));
app.get("/agent/codex/skills", route(async (req, res) => {
@@ -204,25 +236,26 @@ export function startHttpServer() {
app.get("/agent/codex/threads", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
res.json({ ok: true, workspace, ...result });
res.json({ ok: true, workspace, conversation: session.conversationStateSnapshot, ...result });
}));
app.post("/agent/codex/threads/new", codexMutation(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
const activeThreadId = String((thread as Record<string, unknown>).id || "");
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true, sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
}));
app.post("/agent/codex/threads/reset", codexMutation((req, res) => {
const clientId = String(req.body?.clientId || "");
const workspace = setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId });
void prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
res.json({ ok: true, workspace });
session.beginConversation({ sourceClientId: clientId });
setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId }, true);
const thread = await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
res.json({ ok: true, workspace: ensureSiteWorkspace(config), conversation: session.conversationStateSnapshot, thread: summarizeCodexThread(thread), messages: [] });
}));
app.post("/agent/codex/threads/reset", codexMutation(async (req, res) => {
const clientId = String(req.body?.clientId || "");
session.beginConversation({ sourceClientId: clientId });
setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId }, true);
await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
res.json({ ok: true, workspace: ensureSiteWorkspace(config), conversation: session.conversationStateSnapshot });
}));
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
res.json({ ok: true, workspace, conversation: session.conversationStateSnapshot, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
}));
app.post("/agent/codex/history/ack", (req, res) => {
const threadId = String(req.body?.threadId || "");
@@ -231,18 +264,23 @@ export function startHttpServer() {
res.json({ ok: true });
});
app.post("/agent/codex/threads/:threadId/resume", codexMutation(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
const nextWorkspace = setActiveThread(threadId, { sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true, workspace: nextWorkspace, ...result });
const clientId = String(req.body?.clientId || "");
try {
const result = await prepareExistingThread(threadId, clientId, permissionMode(req.body?.permissionMode));
const nextWorkspace = setActiveThread(threadId, { sourceClientId: clientId }, true);
res.json({ ok: true, workspace: nextWorkspace, conversation: session.conversationStateSnapshot, ...result });
} catch (error) {
failPreparedConversation(error, threadId, clientId);
throw error;
}
}));
app.post("/agent/codex/threads/:threadId/delete", codexMutation(async (req, res) => {
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
await archiveCodexThread(emit, threadId, workspace.workspacePath);
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "", { sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true });
const nextWorkspace = setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "", { sourceClientId: String(req.body?.clientId || "") });
res.json({ ok: true, workspace: nextWorkspace, conversation: session.conversationStateSnapshot });
}));
app.post("/agent/codex/turn", codexMutation(async (req, res) => {
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
@@ -253,7 +291,15 @@ export function startHttpServer() {
if (!clientId || !session.hasClient(clientId)) return res.status(409).json({ ok: false, error: "发起任务的网页已断开,请重新连接后再试" });
const requestedThreadId = String(req.body?.threadId || "");
const activeThreadId = workspace.activeThreadId || "";
if (requestedThreadId !== activeThreadId) return res.status(409).json({ ok: false, error: "当前会话已在其他页面切换,请同步后重试" });
const conversation = session.conversationStateSnapshot;
const requestedConversationId = String(req.body?.conversationId || "");
const expectedRevision = Number(req.body?.expectedRevision || 0);
if (requestedThreadId !== activeThreadId || conversation.threadId !== activeThreadId || (requestedConversationId && requestedConversationId !== conversation.conversationId) || (expectedRevision && expectedRevision !== conversation.revision)) {
return res.status(409).json({ ok: false, code: "CONVERSATION_STALE", error: "当前会话已切换,已同步最新状态,请确认后重试", state: conversation });
}
if (!activeThreadId || !["ready", "warning"].includes(conversation.status)) {
return res.status(409).json({ ok: false, code: "CONVERSATION_NOT_READY", error: "Codex 对话仍在初始化,请等待 MCP 加载完成", state: conversation });
}
const model = String(req.body?.model || "") || undefined;
const effort = reasoningEffort(req.body?.effort);
const skill = req.body?.skill === undefined ? undefined : await resolveCodexSkill(emit, workspace.workspacePath, skillSelector(req.body.skill), true);
@@ -262,15 +308,10 @@ export function startHttpServer() {
let threadId = activeThreadId;
logger.info("Codex turn accepted", { threadId: req.body?.threadId, model: model || "default", reasoningEffort: effort || "default", promptLength: prompt.length, attachmentCount: attachments.length });
session.bindClient(clientId);
session.markConversationRunning(threadId);
session.setCodexState({ busy: true, threadId, turnId: "" });
try {
let turnId = "";
if (!threadId) {
const thread = await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
threadId = String((thread as Record<string, unknown>).id || "");
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
}
session.setCodexState({ busy: true, threadId, turnId: "" });
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
session.emitThread("chat_message", threadId, {
sourceClientId: clientId,
@@ -307,6 +348,7 @@ export function startHttpServer() {
threadId = actualThreadId;
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
}
session.markConversationRunning(threadId);
session.setCodexState({ busy: true, threadId, turnId: "" });
if (threadChanged) {
session.emitThread("chat_message", threadId, {
@@ -333,12 +375,14 @@ export function startHttpServer() {
session.clearTurnAttachments(clientId);
if (clientId) session.releaseClient(clientId);
session.setCodexState({ busy: false, threadId, turnId });
session.finishConversationRun(threadId);
},
});
res.json({ ok: true, threadId });
} catch (error) {
session.releaseClient(clientId);
session.setCodexState({ busy: false, threadId, turnId: "" });
session.finishConversationRun(threadId);
throw error;
}
}));
@@ -346,7 +390,7 @@ export function startHttpServer() {
/** 将 Codex 写操作串行化,避免多窗口在异步请求期间交叉修改会话。 */
function codexMutation(handler: (req: Request, res: Response) => unknown | Promise<unknown>) {
return route(async (req, res) => {
if (!session.beginCodexMutation()) return res.status(409).json({ ok: false, error: "Codex 正在运行或正在切换会话,请稍后重试" });
if (!session.beginCodexMutation()) return res.status(409).json({ ok: false, code: "CONVERSATION_BUSY", error: "Codex 正在运行或正在切换会话,请稍后重试", state: session.conversationStateSnapshot });
try {
return await handler(req, res);
} finally {
@@ -385,6 +429,15 @@ export function startHttpServer() {
console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
if (logger.enabled) console.log(`Debug log: ${logger.filePath}`);
logger.info("Canvas Agent started", { url: config.url, workspace: ensureSiteWorkspace(config).workspacePath, debugLog: logger.filePath });
const activeThreadId = initialWorkspace.activeThreadId || "";
if (activeThreadId && session.beginCodexMutation()) {
void prepareExistingThread(activeThreadId).catch(async (error) => {
if (!isRecoverableThreadError(error)) return failPreparedConversation(error, activeThreadId);
session.beginConversation();
setActiveThread("", { emptyThread: true, draftThread: true }, true);
await prepareDraftThread("", "request");
}).finally(() => session.endCodexMutation()).catch(() => undefined);
}
});
}
@@ -406,6 +459,20 @@ function reasoningEffort(value: unknown): CodexReasoningEffort | undefined {
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" || value === "ultra" ? value : undefined;
}
function startupStatus(value: unknown): "starting" | "ready" | "failed" | "cancelled" {
return value === "starting" || value === "ready" || value === "failed" ? value : "cancelled";
}
function mcpInventory(value: unknown) {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const server = item as Record<string, unknown>;
const name = String(server.name || "");
return name ? [{ name, authStatus: String(server.authStatus || "") || undefined }] : [];
});
}
/** 读取浏览器提交的 Skill 选择器;真实路径随后必须通过原生列表校验。 */
function skillSelector(value: unknown): CodexSkillSelector {
const selector = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};