mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
fix(agent): synchronize conversation preparation state
This commit is contained in:
@@ -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 }> = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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> : {};
|
||||
|
||||
Reference in New Issue
Block a user