fix(desktop): keep sidebar ACP chats on one agent process

Follow-up messages were starting a new agent every time, so each reply waited through process startup and a new session before the model was called.
This commit is contained in:
tianma-if committed 2026-10-21 23:03:42 +08:00
1 parent ccb9ccafe3
commit 942dadc00e
9 files changed
+682 -52

No files matched your search

+317 -45
View File
@@ -30,6 +30,11 @@ const AUTH_REQUIRED_CODE = -32000;
const MAX_ATTACHMENTS = 8;
const MAX_ATTACHMENT_CHARS = 8_000_000;
const HANDSHAKE_TIMEOUT_MS = 20_000;
const WARM_SESSION_LIMIT = 2;
const WARM_IDLE_MS = 10 * 60_000;
const WARM_CANCEL_GRACE_MS = 5_000;
const HISTORY_TEXT_LIMIT = 16_000;
const SESSION_KEY_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
const WORKSPACE_MARKER = `${path.sep}edgeever-acp-`;
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
@@ -365,11 +370,14 @@ const rejectAttachment = (attachment, reason) => ({
reason,
});
export function buildPromptContent({ prompt, contextText, attachments, promptCapabilities = {}, promptFirst = false } = {}) {
export function buildPromptContent({ prompt, contextText, historyText, attachments, promptCapabilities = {}, promptFirst = false } = {}) {
const blocks = [];
const rejectedAttachments = [];
const contextBlocks = [];
if (typeof contextText === "string" && contextText.length > 0) contextBlocks.push({ type: "text", text: contextText });
if (typeof historyText === "string" && historyText.length > 0) contextBlocks.push({ type: "text", text: historyText });
if (promptFirst) blocks.push({ type: "text", text: typeof prompt === "string" ? prompt : "" });
if (typeof contextText === "string" && contextText.length > 0) blocks.push({ type: "text", text: contextText });
blocks.push(...contextBlocks);
if (!promptFirst) blocks.push({ type: "text", text: typeof prompt === "string" ? prompt : "" });
const list = Array.isArray(attachments) ? attachments : [];
list.forEach((attachment, index) => {
@@ -740,7 +748,8 @@ const createEdgeEverAcpClient = (requestId, emit, allowPermissions, onAuthStatus
return allowPermissions ? automaticAcpPermissionResponse(params) : { outcome: { outcome: "cancelled" } };
},
sessionUpdate(params) {
for (const event of eventsFromSessionUpdate(requestId, params)) emit(event);
const id = typeof requestId === "function" ? requestId() : requestId;
for (const event of eventsFromSessionUpdate(id, params)) emit(event);
},
readTextFile() {
throw new Error("filesystem access is not available");
@@ -827,6 +836,11 @@ export function createAcpHostRuntime(options = {}) {
const commandDeps = options;
const manager = options.adapterManager ?? (options.adapterStore ? createAcpAdapterManager({ root: options.adapterStore, executablePath: options.executablePath, fetchImpl: options.adapterFetchImpl }) : null);
const active = new Map();
const warmSessions = new Map();
const preparing = new Map();
const warmLimit = Number.isInteger(options.warmSessionLimit) && options.warmSessionLimit > 0 ? options.warmSessionLimit : WARM_SESSION_LIMIT;
const warmIdleMs = Number.isInteger(options.warmIdleMs) && options.warmIdleMs >= 0 ? options.warmIdleMs : WARM_IDLE_MS;
const warmCancelGraceMs = Number.isInteger(options.warmCancelGraceMs) && options.warmCancelGraceMs >= 0 ? options.warmCancelGraceMs : WARM_CANCEL_GRACE_MS;
const updateFailures = new Map();
const installingIds = new Set();
const latestStatus = new Map();
@@ -854,6 +868,11 @@ export function createAcpHostRuntime(options = {}) {
};
const connect = async (command, requestId, emit, signal, authMethodId, mcpServers = [], allowPermissions = false, workBuddyAuth = null, verifyWorkBuddy = false) => {
const sink = emit && typeof emit === "object" && typeof emit.emit === "function"
? emit
: { emit: typeof emit === "function" ? emit : () => {} };
const notifyConnection = (event) => sink.emit(event);
const liveRequestId = () => (typeof requestId === "function" ? requestId() : requestId);
const cwd = await createAcpWorkspace(mkdtempImpl, options.tmpRoot);
let child = null;
let authMethods = [];
@@ -876,7 +895,7 @@ export function createAcpHostRuntime(options = {}) {
// Correlate transient Windows consoles with adapter connections without
// logging command arguments, environment variables or credential paths.
const processDiagnostic = (event, details = {}) => {
try { options.onProcessDiagnostic?.({ event, requestId, pid: child.pid, ...details }); } catch { /* diagnostics are optional */ }
try { options.onProcessDiagnostic?.({ event, requestId: liveRequestId(), pid: child.pid, ...details }); } catch { /* diagnostics are optional */ }
};
processDiagnostic("spawned");
child.once("exit", (code, signal) => processDiagnostic("exited", { code, signal }));
@@ -910,7 +929,7 @@ export function createAcpHostRuntime(options = {}) {
});
child.stderr?.on("end", () => { recordStderr(stderrBuffer); stderrBuffer = ""; });
const stream = acpNdJsonStream(child);
const connection = new ClientSideConnection(() => createEdgeEverAcpClient(requestId, emit, allowPermissions, onAuthStatus), stream);
const connection = new ClientSideConnection(() => createEdgeEverAcpClient(liveRequestId, notifyConnection, allowPermissions, onAuthStatus), stream);
void connection.closed?.catch(() => {});
const initialized = await connection.initialize(acpInitializeParams(version));
authMethods = publicAuthMethods(initialized);
@@ -988,7 +1007,106 @@ export function createAcpHostRuntime(options = {}) {
}
};
const sessionKeyOf = (input) => {
if (input.sessionKey == null || input.sessionKey === "") return null;
if (typeof input.sessionKey !== "string" || !SESSION_KEY_PATTERN.test(input.sessionKey)) {
throw new Error("invalid_session");
}
return input.sessionKey;
};
const commandFingerprint = (command) => (
typeof command === "string" ? command : `${command?.command ?? ""}\0${(command?.args ?? []).join("\0")}`
);
const discardConnected = async (connected, mcpBridge) => {
const stopped = connected?.stop?.() ?? Promise.resolve();
const closedBridge = mcpBridge?.close?.() ?? Promise.resolve();
try { await stopped; } catch { /* The process may already be gone. */ }
if (connected?.cwd) await cleanupWorkspace(connected.cwd);
try { await closedBridge; } catch { /* The bridge may already be closed. */ }
};
const retireWarm = async (warm) => {
if (!warm || warm.retired) return;
warm.retired = true;
warm.busy = false;
clearTimeout(warm.idleTimer);
for (const [key, value] of warmSessions) if (value === warm) warmSessions.delete(key);
await discardConnected(warm, warm.mcpBridge);
};
const armIdle = (warm) => {
clearTimeout(warm.idleTimer);
warm.lastUsed = Date.now();
if (!(warmIdleMs > 0)) return;
warm.idleTimer = setTimeout(() => {
if (warm.busy) armIdle(warm);
else void retireWarm(warm);
}, warmIdleMs);
warm.idleTimer.unref?.();
};
const retireSiblingSessions = async (keepKey, sessionKey) => {
const suffix = `\0${sessionKey}`;
const siblings = [...warmSessions.entries()].filter(([key, warm]) => key !== keepKey && key.endsWith(suffix) && !warm.busy && !warm.retired);
for (const [, warm] of siblings) await retireWarm(warm);
};
// One handshake is shared by a sidebar prepare and the first prompt. The
// session stays up for later turns instead of spawning a process per message.
const startWarmSession = (key, spec) => {
const requestRef = { id: "", open: false, notify: () => {} };
const job = (async () => {
await ensureWarmCapacity();
let mcpBridge = null;
try {
if (spec.wantsNotes && spec.access) mcpBridge = await (options.startMcpBridge ?? startAcpMcpBridge)(spec.access);
const sink = { emit: (event) => { if (requestRef.open) requestRef.notify(event); } };
const connected = await withHandshakeTimeout((signal) => connect(
spec.command, () => requestRef.id, sink, signal, undefined,
mcpBridge ? [mcpServerFor(mcpBridge)] : [], true, null, spec.verifyWorkBuddy,
));
spec.rememberPromptStatus({
state: "available",
authMethods: connected.authMethods,
promptCapabilities: connected.promptCapabilities,
});
const warm = {
...connected,
requestRef,
mcpBridge,
busy: false,
prompted: false,
retired: false,
lastUsed: Date.now(),
idleTimer: null,
};
warmSessions.set(key, warm);
connected.child?.once("exit", () => { void retireWarm(warm); });
armIdle(warm);
return warm;
} catch (error) {
const failure = classifyAcpFailure(error);
spec.rememberPromptStatus({
...failureFields(failure),
...(failure.state === "needs_login" ? { authMethods: error.authMethods ?? [] } : {}),
});
try { await mcpBridge?.close(); } catch { /* The bridge may already be closed. */ }
throw error;
}
})();
preparing.set(key, job);
job.finally(() => { if (preparing.get(key) === job) preparing.delete(key); }).catch(() => {});
return job;
};
const ensureWarmCapacity = async () => {
while (warmSessions.size >= warmLimit) {
const idle = [...warmSessions.values()].filter((warm) => !warm.busy && !warm.retired);
if (!idle.length) return;
idle.sort((left, right) => left.lastUsed - right.lastUsed);
await retireWarm(idle[0]);
}
};
return {
async releaseWarmSessions() {
await Promise.all([...warmSessions.values()].map((warm) => retireWarm(warm)));
},
clearAdapterStatus(id) { latestStatus.delete(id); },
listAdapters(configuredPaths = {}) {
return ["codex", "claudeCode", "antigravity", "openClaw", "hermesAgent", "grokBuild", "deepseekHarness", "piAgent", "workbuddyCn", "workbuddyIntl"].map((id) => {
@@ -1136,6 +1254,59 @@ export function createAcpHostRuntime(options = {}) {
}
},
// Open the selected chat's agent before the user sends. Every desktop
// client uses this; it is not tied to one machine or one agent.
async prepare(input) {
if (!input || typeof input !== "object" || !Object.hasOwn(ADAPTERS, input.adapterId)) return { ready: false };
let sessionKey;
try { sessionKey = sessionKeyOf(input); } catch { return { ready: false }; }
if (!sessionKey) return { ready: false };
const resolved = resolveCommand({ id: input.adapterId, path: input.path });
if (!resolved.ok) return { ready: false };
const customPath = input.adapterId === "antigravity" && typeof input.path === "string" ? input.path.trim() : "";
const rememberPromptStatus = (fields) => latestStatus.set(input.adapterId, {
...adapterShell(input.adapterId),
...fields,
...(customPath ? { customPath } : {}),
});
const wantsNotes = Boolean(options.mcpAccess) && input.noteAccess !== false && input.adapterId !== "openClaw";
let access = null;
if (wantsNotes) {
try { access = await options.mcpAccess(); } catch { return { ready: false }; }
}
const key = [
input.adapterId,
commandFingerprint(resolved.command),
customPath,
wantsNotes ? "notes" : "isolated",
wantsNotes ? `${access?.baseUrl ?? ""}\0${access?.accountId ?? ""}` : "",
sessionKey,
].join("\0");
let warm = warmSessions.get(key) ?? null;
if (warm && (warm.retired || warm.child?.exitCode != null || warm.child?.signalCode != null)) {
await retireWarm(warm);
warm = null;
}
if (warm?.busy || warm) {
if (warm && !warm.busy) armIdle(warm);
return { ready: true };
}
try {
await (preparing.get(key) ?? startWarmSession(key, {
command: resolved.command,
wantsNotes,
access,
verifyWorkBuddy: input.adapterId === "workbuddyCn" || input.adapterId === "workbuddyIntl",
rememberPromptStatus,
}));
return { ready: true };
} catch {
return { ready: false };
}
},
// Sidebar chats reuse one process and ACP session. Follow-up turns skip
// startup and the replayed transcript. Calls without sessionKey stay one-shot.
async prompt(input, emit = () => {}) {
if (!input || typeof input !== "object" || typeof input.prompt !== "string") throw new Error("invalid_prompt");
if (!Object.hasOwn(ADAPTERS, input.adapterId)) throw new Error("unknown_adapter");
@@ -1145,6 +1316,12 @@ export function createAcpHostRuntime(options = {}) {
notify({ requestId, type: "error", message });
return { requestId };
};
let sessionKey;
try {
sessionKey = sessionKeyOf(input);
} catch {
return fail("invalid_session");
}
const resolved = resolveCommand({ id: input.adapterId, path: input.path });
if (!resolved.ok) return fail(promptFailureMessage(resolved));
const customPath = input.adapterId === "antigravity" && typeof input.path === "string" ? input.path.trim() : "";
@@ -1153,91 +1330,174 @@ export function createAcpHostRuntime(options = {}) {
...fields,
...(customPath ? { customPath } : {}),
});
let connected;
let mcpBridge;
try {
// OpenClaw's ACP Gateway bridge rejects session-scoped MCP servers.
if (options.mcpAccess && input.noteAccess !== false && input.adapterId !== "openClaw") {
const access = await options.mcpAccess();
mcpBridge = await (options.startMcpBridge ?? startAcpMcpBridge)(access);
// OpenClaw's ACP Gateway bridge rejects session-scoped MCP servers.
const wantsNotes = Boolean(options.mcpAccess) && input.noteAccess !== false && input.adapterId !== "openClaw";
let access = null;
if (wantsNotes) {
try {
access = await options.mcpAccess();
} catch (error) {
return fail(promptFailureMessage(classifyAcpFailure(error)));
}
} catch (error) {
return fail(promptFailureMessage(classifyAcpFailure(error)));
}
try {
connected = await withHandshakeTimeout((signal) => connect(
resolved.command, requestId, notify, signal, undefined,
mcpBridge ? [mcpServerFor(mcpBridge)] : [], true, null, input.adapterId === "workbuddyCn" || input.adapterId === "workbuddyIntl",
));
rememberPromptStatus({
state: "available",
authMethods: connected.authMethods,
promptCapabilities: connected.promptCapabilities,
const accountKey = wantsNotes ? `${access?.baseUrl ?? ""}\0${access?.accountId ?? ""}` : "";
const key = sessionKey ? [
input.adapterId,
commandFingerprint(resolved.command),
customPath,
wantsNotes ? "notes" : "isolated",
accountKey,
sessionKey,
].join("\0") : null;
let warm = key ? warmSessions.get(key) ?? null : null;
if (warm && (warm.retired || warm.child?.exitCode != null || warm.child?.signalCode != null)) {
await retireWarm(warm);
warm = null;
}
if (warm?.busy) return fail("session_busy");
// A read-only turn must not continue inside the note-writing process.
// Dropping the other process makes the next normal turn replay the transcript.
if (key) await retireSiblingSessions(key, sessionKey);
const verifyWorkBuddy = input.adapterId === "workbuddyCn" || input.adapterId === "workbuddyIntl";
const claim = (session) => {
session.requestRef.id = requestId;
session.requestRef.open = true;
session.requestRef.notify = notify;
session.busy = true;
clearTimeout(session.idleTimer);
};
if (!warm && key) {
const pending = preparing.get(key) ?? startWarmSession(key, {
command: resolved.command, wantsNotes, access, verifyWorkBuddy, rememberPromptStatus,
});
} catch (error) {
const failure = classifyAcpFailure(error);
rememberPromptStatus({
...failureFields(failure),
...(failure.state === "needs_login" ? { authMethods: error.authMethods ?? [] } : {}),
});
await mcpBridge?.close();
return fail(promptFailureMessage(failure));
try {
warm = await pending;
} catch (error) {
return fail(promptFailureMessage(classifyAcpFailure(error)));
}
if (warm.retired || warm.child?.exitCode != null || warm.child?.signalCode != null) {
await retireWarm(warm);
return fail("connection_failed");
}
if (warm.busy) return fail("session_busy");
}
let connected = warm;
let mcpBridge = warm?.mcpBridge ?? null;
// prompted is set only after a turn is delivered. Preparing a process
// still sends the transcript with the first real question.
const resumeConversation = warm?.prompted === true;
if (warm) {
claim(warm);
try { options.onProcessDiagnostic?.({ event: resumeConversation ? "reused" : "ready", requestId, pid: warm.child?.pid }); } catch { /* diagnostics are optional */ }
} else {
try {
if (wantsNotes && access) mcpBridge = await (options.startMcpBridge ?? startAcpMcpBridge)(access);
} catch (error) {
return fail(promptFailureMessage(classifyAcpFailure(error)));
}
const spawnedRequest = { id: requestId, open: true, notify };
const sink = { emit: (event) => { if (spawnedRequest.open) spawnedRequest.notify(event); } };
try {
connected = await withHandshakeTimeout((signal) => connect(
resolved.command, () => spawnedRequest.id, sink, signal, undefined,
mcpBridge ? [mcpServerFor(mcpBridge)] : [], true, null, verifyWorkBuddy,
));
rememberPromptStatus({
state: "available",
authMethods: connected.authMethods,
promptCapabilities: connected.promptCapabilities,
});
} catch (error) {
const failure = classifyAcpFailure(error);
rememberPromptStatus({
...failureFields(failure),
...(failure.state === "needs_login" ? { authMethods: error.authMethods ?? [] } : {}),
});
try { await mcpBridge?.close(); } catch { /* The bridge may already be closed. */ }
return fail(promptFailureMessage(failure));
}
connected.requestRef = spawnedRequest;
}
const requestRef = warm ? warm.requestRef : connected.requestRef;
let content;
try {
const historyText = !resumeConversation && typeof input.historyText === "string" ? input.historyText.slice(-HISTORY_TEXT_LIMIT) : "";
content = buildPromptContent({
prompt: input.prompt,
contextText: input.contextText,
historyText,
attachments: input.attachments,
promptCapabilities: connected.promptCapabilities,
// Codex derives its thread title from the start of the first user turn.
promptFirst: input.adapterId === "codex",
});
if (mcpBridge) {
if (mcpBridge && !resumeConversation) {
content.blocks.splice(input.adapterId === "codex" ? 1 : 0, 0, {
type: "text",
text: "For EdgeEver note operations in this conversation, use only the session-provided MCP server named edgeever-current-workspace. It is connected to the account currently signed in to EdgeEver. Ignore any EdgeEver MCP server from your persistent configuration, which may target a different instance or account. For a request to generate a poster or 海报笔记, first call get_poster_capabilities and read templates and browserDesignTemplates. Select by zhUse/enUse, the requested style and target platform (fit). Respect a named template. Use a basic templates ID for create_poster_memo.template. Full browserDesignTemplates IDs go only in request_poster_render.templateId, even when IDs overlap. For a new poster, apply the chosen full design during initial rendering if appropriate; an existing poster requires an explicit template-change request. With sections, basic styles share grid/list layout and differ mainly in palette. Then use create_poster_memo with concise copy; it produces the actual editable poster type. Never manually encode poster source, inspect local files to discover the format, or substitute poster-style Markdown. If the tool is unavailable or fails, report that clearly. To query posters, use search_memos or list_memos with poster=true rather than searching the title for 海报. For detailed design, read get_poster_capabilities for valid fields, fonts, platform sizes and operations. Read and edit a poster with get_poster then update_poster. apply_template replaces the layout and must be requested. get_poster_preview and export_poster provide saved previews and editable source, not fresh rendering. Request new PNG rendering or a full browser template with request_poster_render and expectedRevision; an online desktop/Web client handles it. Query get_poster_render_job no faster than every 5 seconds; only completed means rendered, then get_poster_preview. Pending means waiting for a client; report that honestly. Delete exact queried IDs with trash_memos; restore them with restore_memos. When you create a note, include a clickable Markdown link to it in your final answer: [note title](#memo=exact_memo_id_from_tool_result). Do not show the note ID as plain text or use an https://edgeever.ai/memo/ URL; that URL opens an external website instead of the current workspace.",
});
}
} catch (error) {
await connected.stop();
await cleanupWorkspace(connected.cwd);
await mcpBridge?.close();
requestRef.open = false;
if (warm) {
warm.busy = false;
armIdle(warm);
} else {
await discardConnected(connected, mcpBridge);
}
return fail(promptFailureMessage(classifyAcpFailure(error)));
}
let resolveSettled;
const whenSettled = new Promise((resolve) => { resolveSettled = resolve; });
const session = {
cancelled: false,
settled: false,
...connected,
keep: Boolean(warm),
warm,
connected,
mcpBridge,
connection: connected.connection,
sessionId: connected.sessionId,
requestRef,
whenSettled,
stop: connected.stop,
};
active.set(requestId, session);
const finish = (event) => {
const finish = (event, { drop }) => {
if (session.settled) return;
session.settled = true;
requestRef.open = false;
notify(event);
active.delete(requestId);
void session.stop().then(() => cleanupWorkspace(session.cwd));
void session.mcpBridge?.close();
if (warm && !drop && !warm.retired) {
warm.busy = false;
armIdle(warm);
} else if (warm) {
void retireWarm(warm);
} else {
void discardConnected(connected, mcpBridge);
}
resolveSettled();
};
if (warm) warm.prompted = true;
void connected.connection.prompt({ sessionId: connected.sessionId, prompt: content.blocks }).then((result) => {
if (session.cancelled) return finish({ requestId, type: "done" });
if (session.cancelled) return finish({ requestId, type: "done" }, { drop: false });
const failure = connected.authenticationRequired() ? "needs_login" : promptResultFailure(result);
if (failure === "needs_login") {
rememberPromptStatus({ state: "needs_login", authMethods: connected.authMethods });
}
finish(failure ? { requestId, type: "error", message: failure } : { requestId, type: "done" });
finish(failure ? { requestId, type: "error", message: failure } : { requestId, type: "done" }, { drop: Boolean(failure) });
}).catch((error) => {
if (session.cancelled) finish({ requestId, type: "done" });
if (session.cancelled) finish({ requestId, type: "done" }, { drop: warm?.retired === true });
else {
const failure = connected.authenticationRequired() ? { state: "needs_login" } : classifyAcpFailure(error);
if (failure.state === "needs_login") {
rememberPromptStatus({ state: "needs_login", authMethods: connected.authMethods });
}
finish({ requestId, type: "error", message: promptFailureMessage(failure) });
finish({ requestId, type: "error", message: promptFailureMessage(failure) }, { drop: true });
}
});
return { requestId, rejectedAttachments: content.rejectedAttachments };
@@ -1250,7 +1510,15 @@ export function createAcpHostRuntime(options = {}) {
try {
if (session.connection && session.sessionId) await session.connection.cancel({ sessionId: session.sessionId });
} catch {
// The process may already be gone. Killing it is enough.
// The process may already be gone. Killing it is enough for a one-shot call.
}
if (session.keep) {
let timer;
const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), warmCancelGraceMs); });
const outcome = await Promise.race([session.whenSettled.then(() => "settled"), timedOut]);
clearTimeout(timer);
if (outcome === "timeout") await retireWarm(session.warm);
return { ok: true };
}
await session.stop();
void session.mcpBridge?.close();
@@ -1312,6 +1580,10 @@ export function registerAcpIpc(ipcMain, runtime = createAcpHostRuntime(), {
if (!allowInstall(event.sender, event)) throw new Error("adapter_auth_forbidden");
return runtime.authenticateAdapter(await withRecordedPath(input));
});
ipcMain.handle("desktop:acp-prepare", async (event, input) => {
if (!allowPrompt(event.sender, event)) throw new Error("acp_prepare_forbidden");
return runtime.prepare(await withRecordedPath(input));
});
ipcMain.handle("desktop:acp-prompt", async (event, input) => {
if (!allowPrompt(event.sender, event)) throw new Error("acp_prompt_forbidden");
return runtime.prompt(await withRecordedPath(input), (acpEvent) => send(event.sender, acpEvent));
+317 -4
View File
@@ -88,13 +88,14 @@ const collector = () => {
const fakeAgentSource = ({ reportPath, secretPath, allowImage, allowEmbedded, hold, requireAuth, advertiseAuth, promptRefusal, hangAuth, hangSession, failSessionBeforeLogin, workBuddyAccountApi, workBuddyLoginFailure, workBuddyUnreadable, workBuddyUnreadableAfterLogin, authMethodId = "browser", authStatusKind, advertiseAuthStatus = false, authStatusDelay = 20, stderrText, promptAuthStatusKind }) => `#!/usr/bin/env bun
import * as acp from ${JSON.stringify(sdkHref)};
import { writeFileSync, readFileSync } from "node:fs";
import { writeFileSync, readFileSync, appendFileSync } from "node:fs";
const reportPath = ${JSON.stringify(reportPath)};
const secretPath = ${JSON.stringify(secretPath)};
const allowImage = ${allowImage ? "true" : "false"};
const allowEmbedded = ${allowEmbedded ? "true" : "false"};
const hold = ${hold ? "true" : "false"};
let held = false;
const requireAuth = ${requireAuth ? "true" : "false"};
const advertiseAuth = ${advertiseAuth ? "true" : "false"};
const promptRefusal = ${promptRefusal ? JSON.stringify(promptRefusal) : "null"};
@@ -197,6 +198,8 @@ acp.agent({ name: "edgeever-fake-agent" })
.onRequest("session/new", (ctx) => {
if (${failSessionBeforeLogin ? "true" : "false"} && !authenticated) throw new acp.RequestError(-32603, "connection_failed");
if (requireAuth && !authenticated) throw new acp.RequestError(-32000, "auth_required");
report.sessionCount = (report.sessionCount ?? 0) + 1;
appendFileSync(reportPath + ".sessions", String(process.pid) + "\\n");
report.newSession = { cwd: ctx.params.cwd, mcpServers: ctx.params.mcpServers };
save();
if (${hangSession ? "true" : "false"}) return new Promise(() => {});
@@ -206,6 +209,7 @@ acp.agent({ name: "edgeever-fake-agent" })
if (${promptAuthStatusKind ? "true" : "false"}) {
await ctx.client.notify("_auth/status_update", { authStatus: { kind: ${JSON.stringify(promptAuthStatusKind ?? "none")}, label: "Fixture" } });
}
report.prompts = [...(report.prompts ?? []), ctx.params.prompt];
report.prompt = ctx.params.prompt;
save();
if (promptRefusal) return promptRefusal;
@@ -221,7 +225,7 @@ acp.agent({ name: "edgeever-fake-agent" })
sessionId: ctx.params.sessionId,
update: { sessionUpdate: "tool_call", toolCallId: "call_1", name: "search", title: "Search notes", status: "pending" },
});
if (!hold) {
if (!(hold && !held)) {
try {
const result = await ctx.client.request("fs/read_text_file", { sessionId: ctx.params.sessionId, path: secretPath });
report.readResult = result?.content ?? result ?? null;
@@ -237,6 +241,7 @@ acp.agent({ name: "edgeever-fake-agent" })
save();
return { stopReason: "end_turn" };
}
held = true;
await released;
return { stopReason: "cancelled" };
})
@@ -253,6 +258,16 @@ const writeFakeAgent = async (directory, options) => {
return scriptPath;
};
const processAlive = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error?.code === "ESRCH") return false;
throw error;
}
};
const waitUntilExited = async (pid) => {
const started = Date.now();
while (Date.now() - started < 3000) {
@@ -541,10 +556,11 @@ describe("ACP prompt blocks", () => {
});
test("puts a Codex user's request before context for a useful thread title", () => {
const content = buildPromptContent({ prompt: "分析资金往来", contextText: "Current note and earlier turns", promptFirst: true });
const content = buildPromptContent({ prompt: "分析资金往来", contextText: "Current note and earlier turns", historyText: "Earlier turns", promptFirst: true });
expect(content.blocks).toEqual([
{ type: "text", text: "分析资金往来" },
{ type: "text", text: "Current note and earlier turns" },
{ type: "text", text: "Earlier turns" },
]);
});
@@ -1416,6 +1432,301 @@ describe("ACP stdio session", () => {
await rm(directory, { recursive: true, force: true });
}
}, 15_000);
const chat = "11111111-1111-4111-8111-111111111111";
const otherChat = "22222222-2222-4222-8222-222222222222";
sessionTest("reuses one ACP process for the same chat and omits replayed history", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-"));
const reportPath = path.join(directory, "report.json");
let closed = false;
const runtime = createAcpHostRuntime({
mcpAccess: () => ({ baseUrl: "https://notes.example", sessionToken: "login-secret", accountId: "account-a" }),
startMcpBridge: () => ({
url: "http://127.0.0.1:12345",
secret: "temporary-secret",
close: async () => { closed = true; },
}),
});
try {
const scriptPath = await writeFakeAgent(directory, {
reportPath,
secretPath: path.join(directory, "secret.txt"),
allowImage: false,
allowEmbedded: false,
hold: false,
});
const prompt = (text, events) => runtime.prompt({
adapterId: "antigravity",
path: scriptPath,
prompt: text,
sessionKey: chat,
contextText: "Current note",
historyText: "Earlier turns",
}, events.emit);
const firstEvents = collector();
const first = await prompt("hello", firstEvents);
await firstEvents.waitFor((event) => event.type === "done" && event.requestId === first.requestId);
const pid = Number(await readFile(`${reportPath}.pid`, "utf8"));
expect(processAlive(pid)).toBe(true);
expect(closed).toBe(false);
const secondEvents = collector();
const second = await prompt("again", secondEvents);
const delta = await secondEvents.waitFor((event) => event.type === "text-delta");
expect(delta).toEqual({ requestId: second.requestId, type: "text-delta", text: "hello from agent" });
expect(secondEvents.events.some((event) => event.requestId === first.requestId)).toBe(false);
await secondEvents.waitFor((event) => event.type === "done");
expect(Number(await readFile(`${reportPath}.pid`, "utf8"))).toBe(pid);
expect(processAlive(pid)).toBe(true);
const report = await readReport(reportPath);
expect(report.sessionCount).toBe(1);
expect(report.prompts).toHaveLength(2);
expect(JSON.stringify(report.prompts[0])).toContain("edgeever-current-workspace");
expect(JSON.stringify(report.prompts[0])).toContain("Earlier turns");
expect(JSON.stringify(report.prompts[1])).not.toContain("edgeever-current-workspace");
expect(JSON.stringify(report.prompts[1])).not.toContain("Earlier turns");
expect(JSON.stringify(report.prompts[1])).toContain("Current note");
expect(closed).toBe(false);
await runtime.releaseWarmSessions();
await waitUntilExited(pid);
expect(closed).toBe(true);
} finally {
await runtime.releaseWarmSessions();
await rm(directory, { recursive: true, force: true });
}
}, 15_000);
sessionTest("keeps a cancelled chat process and rejects an overlapping prompt", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-cancel-"));
const reportPath = path.join(directory, "report.json");
const runtime = createAcpHostRuntime();
try {
const scriptPath = await writeFakeAgent(directory, {
reportPath,
secretPath: path.join(directory, "secret.txt"),
allowImage: false,
allowEmbedded: false,
hold: true,
});
const events = collector();
const first = await runtime.prompt({
adapterId: "antigravity",
path: scriptPath,
prompt: "wait",
sessionKey: chat,
historyText: "Earlier turns",
}, events.emit);
await events.waitFor((event) => event.type === "text-delta");
const overlap = collector();
const second = await runtime.prompt({
adapterId: "antigravity",
path: scriptPath,
prompt: "too soon",
sessionKey: chat,
}, overlap.emit);
expect(await overlap.waitFor((event) => event.type === "error")).toEqual({ requestId: second.requestId, type: "error", message: "session_busy" });
expect(await runtime.cancel(first.requestId)).toEqual({ ok: true });
await events.waitFor((event) => event.type === "done");
const pid = Number(await readFile(`${reportPath}.pid`, "utf8"));
expect(processAlive(pid)).toBe(true);
const continued = collector();
const third = await runtime.prompt({
adapterId: "antigravity",
path: scriptPath,
prompt: "continue",
sessionKey: chat,
historyText: "Earlier turns",
}, continued.emit);
expect((await continued.waitFor((event) => event.type === "text-delta")).requestId).toBe(third.requestId);
await continued.waitFor((event) => event.type === "done");
const report = await readReport(reportPath);
expect(report.sessionCount).toBe(1);
expect(report.prompts).toHaveLength(2);
expect(JSON.stringify(report.prompts[1])).not.toContain("Earlier turns");
expect(processAlive(pid)).toBe(true);
} finally {
await runtime.releaseWarmSessions();
await rm(directory, { recursive: true, force: true });
}
}, 15_000);
sessionTest("starts a separate process for another chat or another account", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-reset-"));
const reportPath = path.join(directory, "report.json");
let accountId = "account-a";
const runtime = createAcpHostRuntime({
warmSessionLimit: 3,
mcpAccess: () => ({ baseUrl: "https://notes.example", sessionToken: "login-secret", accountId }),
startMcpBridge: () => ({ url: "http://127.0.0.1:12345", secret: "temporary-secret", close: async () => {} }),
});
try {
const scriptPath = await writeFakeAgent(directory, {
reportPath,
secretPath: path.join(directory, "secret.txt"),
allowImage: false,
allowEmbedded: false,
hold: false,
});
const send = async (sessionKey) => {
const events = collector();
await runtime.prompt({
adapterId: "antigravity",
path: scriptPath,
prompt: "hello",
sessionKey,
historyText: "Earlier turns",
}, events.emit);
await events.waitFor((event) => event.type === "done");
return Number(await readFile(`${reportPath}.pid`, "utf8"));
};
const firstPid = await send(chat);
const secondPid = await send(otherChat);
expect(secondPid).not.toBe(firstPid);
expect(processAlive(firstPid)).toBe(true);
expect(processAlive(secondPid)).toBe(true);
accountId = "account-b";
const switchedPid = await send(chat);
expect(switchedPid).not.toBe(firstPid);
expect(processAlive(firstPid)).toBe(false);
expect(processAlive(secondPid)).toBe(true);
expect(JSON.stringify((await readReport(reportPath)).prompts.at(-1))).toContain("Earlier turns");
} finally {
await runtime.releaseWarmSessions();
await rm(directory, { recursive: true, force: true });
}
}, 15_000);
sessionTest("starts over after the chat process has been idle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-idle-"));
const reportPath = path.join(directory, "report.json");
const runtime = createAcpHostRuntime({ warmIdleMs: 80 });
try {
const scriptPath = await writeFakeAgent(directory, {
reportPath,
secretPath: path.join(directory, "secret.txt"),
allowImage: false,
allowEmbedded: false,
hold: false,
});
const send = async () => {
const events = collector();
await runtime.prompt({
adapterId: "antigravity",
path: scriptPath,
prompt: "hello",
sessionKey: chat,
historyText: "Earlier turns",
}, events.emit);
await events.waitFor((event) => event.type === "done");
return Number(await readFile(`${reportPath}.pid`, "utf8"));
};
const firstPid = await send();
await new Promise((resolve) => setTimeout(resolve, 200));
await waitUntilExited(firstPid);
const secondPid = await send();
expect(secondPid).not.toBe(firstPid);
expect(JSON.stringify((await readReport(reportPath)).prompt)).toContain("Earlier turns");
} finally {
await runtime.releaseWarmSessions();
await rm(directory, { recursive: true, force: true });
}
}, 15_000);
sessionTest("a read-only turn does not continue inside the note-writing process", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-readonly-"));
const reportPath = path.join(directory, "report.json");
const runtime = createAcpHostRuntime({
mcpAccess: () => ({ baseUrl: "https://notes.example", sessionToken: "login-secret", accountId: "account-a" }),
startMcpBridge: () => ({ url: "http://127.0.0.1:12345", secret: "temporary-secret", close: async () => {} }),
});
try {
const scriptPath = await writeFakeAgent(directory, {
reportPath,
secretPath: path.join(directory, "secret.txt"),
allowImage: false,
allowEmbedded: false,
hold: false,
});
const send = async ({ noteAccess = true } = {}) => {
const events = collector();
await runtime.prompt({
adapterId: "antigravity",
path: scriptPath,
prompt: "hello",
sessionKey: chat,
historyText: "Earlier turns",
noteAccess,
}, events.emit);
await events.waitFor((event) => event.type === "done");
return Number(await readFile(`${reportPath}.pid`, "utf8"));
};
const writingPid = await send();
const readingPid = await send({ noteAccess: false });
expect(readingPid).not.toBe(writingPid);
expect(processAlive(writingPid)).toBe(false);
expect(JSON.stringify((await readReport(reportPath)).newSession.mcpServers)).toBe("[]");
const resumedPid = await send();
expect(resumedPid).not.toBe(readingPid);
expect(processAlive(readingPid)).toBe(false);
expect(JSON.stringify((await readReport(reportPath)).prompt)).toContain("Earlier turns");
expect(JSON.stringify((await readReport(reportPath)).newSession.mcpServers)).toContain("edgeever-current-workspace");
} finally {
await runtime.releaseWarmSessions();
await rm(directory, { recursive: true, force: true });
}
}, 15_000);
sessionTest("starts one ACP process for a prepared chat and keeps it for the next turn", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-prepare-"));
const reportPath = path.join(directory, "report.json");
const runtime = createAcpHostRuntime();
try {
const scriptPath = await writeFakeAgent(directory, {
reportPath,
secretPath: path.join(directory, "secret.txt"),
allowImage: false,
allowEmbedded: false,
hold: false,
});
const input = {
adapterId: "antigravity",
path: scriptPath,
sessionKey: chat,
historyText: "Earlier turns",
noteAccess: false,
};
const preparing = runtime.prepare(input);
const events = collector();
const sent = runtime.prompt({ ...input, prompt: "hello" }, events.emit);
expect(await preparing).toEqual({ ready: true });
await sent;
await events.waitFor((event) => event.type === "done");
expect((await readFile(`${reportPath}.sessions`, "utf8")).trim().split("\n")).toEqual([
String(Number(await readFile(`${reportPath}.pid`, "utf8"))),
]);
const report = await readReport(reportPath);
expect(report.sessionCount).toBe(1);
expect(JSON.stringify(report.prompt)).toContain("Earlier turns");
const again = collector();
await runtime.prompt({ ...input, prompt: "next" }, again.emit);
await again.waitFor((event) => event.type === "done");
const continued = await readReport(reportPath);
expect(continued.sessionCount).toBe(1);
expect(continued.prompts).toHaveLength(2);
expect(JSON.stringify(continued.prompts[1])).not.toContain("Earlier turns");
expect((await readFile(`${reportPath}.sessions`, "utf8")).trim().split("\n")).toHaveLength(1);
} finally {
await runtime.releaseWarmSessions();
await rm(directory, { recursive: true, force: true });
}
}, 15_000);
sessionTest("rejects a session key that is not a chat id before starting an agent", async () => {
const runtime = createAcpHostRuntime();
const events = collector();
const result = await runtime.prompt({ adapterId: "codex", prompt: "hello", sessionKey: "../secret" }, events.emit);
expect(await events.waitFor((event) => event.type === "error")).toEqual({ requestId: result.requestId, type: "error", message: "invalid_session" });
});
});
test("desktop preload and main process expose the ACP host", async () => {
@@ -1425,16 +1736,18 @@ test("desktop preload and main process expose the ACP host", async () => {
Bun.file(new URL("./acp-host.mjs", import.meta.url)).text(),
]);
expect(main).toContain("registerAcpIpc(ipcMain, createAcpHostRuntime({");
expect(main).toContain("await releaseDesktopAcpSessions().catch(() => {});");
expect(preload).toContain('installAcpAdapter: (id) => ipcRenderer.invoke("desktop:acp-install", id)');
expect(preload).toContain('authenticateAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-authenticate", input)');
expect(host).toContain('ipcMain.handle("desktop:acp-prompt"');
expect(preload).toContain('listAcpAdapters: () => ipcRenderer.invoke("desktop:acp-list")');
expect(preload).toContain('probeAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-probe", input)');
expect(preload).toContain('prepareAcp: (input) => ipcRenderer.invoke("desktop:acp-prepare", input)');
expect(preload).toContain('promptAcp: (input) => ipcRenderer.invoke("desktop:acp-prompt", input)');
expect(preload).toContain('cancelAcp: (requestId) => ipcRenderer.invoke("desktop:acp-cancel", requestId)');
expect(preload).toContain('ipcRenderer.on("desktop:acp-event"');
const handlers = new Map();
registerAcpIpc({ handle: (channel, handler) => handlers.set(channel, handler) });
expect([...handlers.keys()]).toEqual(["desktop:acp-list", "desktop:acp-choose-path", "desktop:acp-probe", "desktop:acp-install", "desktop:acp-authenticate", "desktop:acp-prompt", "desktop:acp-cancel"]);
expect([...handlers.keys()]).toEqual(["desktop:acp-list", "desktop:acp-choose-path", "desktop:acp-probe", "desktop:acp-install", "desktop:acp-authenticate", "desktop:acp-prepare", "desktop:acp-prompt", "desktop:acp-cancel"]);
await expect(handlers.get("desktop:acp-prompt")({ sender: {} }, { prompt: "test" })).rejects.toThrow("acp_prompt_forbidden");
});
+3
View File
@@ -146,6 +146,7 @@ let windowsDownloadedUpdateVerified = false;
let sidecarScopeKey = "anonymous";
let activeAccountId = null;
let shutdownCleanupStarted = false;
let releaseDesktopAcpSessions = async () => {};
let sidecarRestartTimer = null;
let sidecarRestartAttempts = 0;
let sidecarRestartInFlight = false;
@@ -1873,6 +1874,7 @@ const startApplication = async () => {
return validateTrustedAcpPath(selection.filePaths[0], { excludedRoot: app.getPath("userData") });
},
});
releaseDesktopAcpSessions = () => acpRuntime.releaseWarmSessions();
await acpRuntime.pruneAdapters().catch(() => {});
const refreshAdapters = () => { void acpRuntime.installDetected().catch(() => []).then(() => acpRuntime.updateInstalled()).catch(() => {}); };
const firstAdapterRefresh = setTimeout(refreshAdapters, 10_000);
@@ -2263,6 +2265,7 @@ app.on("before-quit", (event) => {
}
tray?.destroy();
void (async () => {
await releaseDesktopAcpSessions().catch(() => {});
await stopSidecar();
await unlink(crashMarkerPath()).catch(() => {});
await writeDiagnostic("session.quit");
@@ -20,6 +20,7 @@ test("ACP ignores caller paths on all execution channels and preserves main-owne
listAdapters: () => [{ id: "antigravity" }],
probeAdapter: (input) => calls.push(input),
authenticateAdapter: (input) => calls.push(input),
prepare: (input) => calls.push(input),
prompt: (input) => calls.push(input),
};
const register = () => {
@@ -32,7 +33,7 @@ test("ACP ignores caller paths on all execution channels and preserves main-owne
});
return handlers;
};
const channels = ["desktop:acp-probe", "desktop:acp-authenticate", "desktop:acp-prompt"];
const channels = ["desktop:acp-probe", "desktop:acp-authenticate", "desktop:acp-prepare", "desktop:acp-prompt"];
const handlers = register();
for (const channel of channels) {
const input = { id: "antigravity", adapterId: "antigravity", path: "/attacker/payload" };
+1
View File
@@ -68,6 +68,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
probeAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-probe", input),
installAcpAdapter: (id) => ipcRenderer.invoke("desktop:acp-install", id),
authenticateAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-authenticate", input),
prepareAcp: (input) => ipcRenderer.invoke("desktop:acp-prepare", input),
promptAcp: (input) => ipcRenderer.invoke("desktop:acp-prompt", input),
cancelAcp: (requestId) => ipcRenderer.invoke("desktop:acp-cancel", requestId),
onAcpEvent: (callback) => {
@@ -55,6 +55,7 @@ import {
AI_SIDEBAR_THREAD_KEY,
AI_SIDEBAR_WIDTH_KEY,
cancelDesktopAcp,
prepareDesktopAcp,
promptDesktopAcp,
readAiSidebarAdapter,
readAiSidebarSource,
@@ -1053,6 +1054,25 @@ function AiSidebarSession({
};
}, [open, busy]);
useEffect(() => {
if (!open || source !== "local") return;
let cancelled = false;
const timer = window.setTimeout(() => {
if (cancelled || locked.current) return;
const adapter = readLocalAdapter();
if (!adapter) return;
void prepareDesktopAcp({
adapterId: adapter.id,
...(adapter.path ? { path: adapter.path } : {}),
sessionKey: localThreadId,
}).catch(() => undefined);
}, 400);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [open, source, localAdapterId, localThreadId]);
useEffect(() => {
if (!companionAvailable || source === "local") {
setLoading(false);
@@ -1207,7 +1227,9 @@ function AiSidebarSession({
...(adapter.path ? { path: adapter.path } : {}),
prompt: text,
...(options?.readOnly ? { noteAccess: false } : {}),
...((noteContext || transcript) ? { contextText: [noteContext, transcript].filter(Boolean).join("\n\n") } : {}),
sessionKey: activeLocalThreadId,
...(noteContext ? { contextText: noteContext } : {}),
...(transcript ? { historyText: transcript } : {}),
...(snapshot.length ? {
attachments: snapshot.map((item) => ({
filename: item.filename,
+10
View File
@@ -78,6 +78,10 @@ export type DesktopAcpPromptInput = {
path?: string;
prompt: string;
contextText?: string;
// Replayed only when this chat has no live ACP process. A live session already contains it.
historyText?: string;
// Keeps one agent process for this sidebar chat. Omit it for a one-shot call.
sessionKey?: string;
attachments?: DesktopAcpAttachment[];
// Infographic edits apply a validated proposal in the editor. They must not
// also grant the agent write access to the note document.
@@ -201,6 +205,12 @@ export const authenticateDesktopAcpAdapter = async (input: { id: DesktopAcpAdapt
return desktop.authenticateAcpAdapter(input);
};
export const prepareDesktopAcp = async (input: Omit<DesktopAcpPromptInput, "prompt" | "contextText" | "historyText" | "attachments">) => {
const desktop = bridge();
if (!desktop?.prepareAcp || !input.sessionKey) return { ready: false as const };
return desktop.prepareAcp(input);
};
export const promptDesktopAcp = async (input: DesktopAcpPromptInput): Promise<DesktopAcpPromptResult> => {
const desktop = bridge();
if (!desktop?.promptAcp) throw new Error("desktop_acp_unavailable");
+1 -1
View File
@@ -214,7 +214,7 @@ const clipLine = (value: string) => {
return text.length > LOCAL_AGENT_TRANSCRIPT_LINE ? `${text.slice(0, LOCAL_AGENT_TRANSCRIPT_LINE)}…` : text;
};
// One-shot ACP calls do not keep the agent process. The current thread's earlier replies are the session.
// Sent only when the desktop has to start a new ACP process. A live chat already has this history.
export const localAgentTranscript = (
turns: readonly { threadId: string; message: string; response: string; status: string; createdAt: string }[],
threadId: string,
+8
View File
@@ -152,11 +152,19 @@ interface EdgeEverDesktopBridge {
detail?: string;
authMethods?: Array<{ id: string; name: string }>;
}>;
prepareAcp?(input: {
adapterId: "codex" | "claudeCode" | "antigravity" | "openClaw" | "hermesAgent" | "grokBuild" | "deepseekHarness" | "piAgent" | "workbuddyCn" | "workbuddyIntl";
path?: string;
sessionKey?: string;
noteAccess?: boolean;
}): Promise<{ ready: boolean }>;
promptAcp?(input: {
adapterId: "codex" | "claudeCode" | "antigravity" | "openClaw" | "hermesAgent" | "grokBuild" | "deepseekHarness" | "piAgent" | "workbuddyCn" | "workbuddyIntl";
path?: string;
prompt: string;
contextText?: string;
historyText?: string;
sessionKey?: string;
attachments?: Array<{ filename: string; mediaType: string; dataBase64: string }>;
noteAccess?: boolean;
}): Promise<{ requestId: string; rejectedAttachments?: Array<{ filename: string; reason: string }> }>;