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:
1 parent
ccb9ccafe3
commit
942dadc00e
9 files changed
+682
-52
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+8
@@ -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 }> }>;
|
||||
|
||||
Reference in new issue
Block a user