Skip to content

Commit 9c9b0f3

Browse files
committed
Fix chat scrollback and runtime fallback
1 parent 73fe42c commit 9c9b0f3

12 files changed

Lines changed: 370 additions & 17 deletions

File tree

apps/ade-cli/src/cli.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
resolveAdeCodeModulePath,
2020
resolveRoots,
2121
shouldAutoRegisterProjectForPlan,
22+
shouldBlockManualMachineRuntimeSpawn,
2223
shouldEnforceMachineRuntimeBuildCompatibility,
2324
shouldAttemptDesktopSocketConnection,
2425
summarizeExecution,
@@ -345,6 +346,19 @@ describe("ADE CLI", () => {
345346
expect(isEphemeralRuntimeSocketPath("tcp://127.0.0.1:8765")).toBe(false);
346347
});
347348

349+
it("blocks manual service-socket runtime spawn when service mutation is disabled", () => {
350+
expect(shouldBlockManualMachineRuntimeSpawn("/Users/example/.ade-beta/sock/ade.sock", {
351+
ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1",
352+
})).toBe(true);
353+
expect(shouldBlockManualMachineRuntimeSpawn("/Users/example/.ade-beta/sock/ade.sock", {})).toBe(false);
354+
expect(shouldBlockManualMachineRuntimeSpawn("tcp://127.0.0.1:9999", {
355+
ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1",
356+
})).toBe(false);
357+
expect(shouldBlockManualMachineRuntimeSpawn(path.join(os.tmpdir(), "ade-code-test", "ade.sock"), {
358+
ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1",
359+
})).toBe(false);
360+
});
361+
348362
it("parses runtime idle expiry with a minimum clamp", () => {
349363
expect(readRuntimeIdleExitMs({ ADE_RUNTIME_IDLE_EXIT_MS: "30000" } as NodeJS.ProcessEnv)).toBe(30_000);
350364
expect(readRuntimeIdleExitMs({ ADE_RUNTIME_IDLE_EXIT_MS: "100" } as NodeJS.ProcessEnv)).toBe(5_000);

apps/ade-cli/src/cli.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12621,6 +12621,22 @@ function shouldRepairMachineRuntimeServiceBeforeSpawn(
1262112621
&& !isEphemeralRuntimeSocketPath(socketPath);
1262212622
}
1262312623

12624+
export function shouldBlockManualMachineRuntimeSpawn(
12625+
socketPath: string,
12626+
env: NodeJS.ProcessEnv = process.env,
12627+
): boolean {
12628+
return env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL === "1"
12629+
&& !socketPath.startsWith("tcp://")
12630+
&& !isAdeRuntimeNamedPipePath(socketPath)
12631+
&& !isEphemeralRuntimeSocketPath(socketPath);
12632+
}
12633+
12634+
function manualMachineRuntimeSpawnBlockedError(socketPath: string): Error {
12635+
return new Error(
12636+
`ADE runtime is unavailable at ${socketPath}, and ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 forbids starting a manual replacement for this service-managed socket.`,
12637+
);
12638+
}
12639+
1262412640
async function repairMachineRuntimeServiceConnection(args: {
1262512641
socketPath: string;
1262612642
options: GlobalOptions;
@@ -12771,6 +12787,10 @@ async function connectMachineRuntimeDaemon(
1277112787
client.close();
1277212788
throw selfShutdownBlock;
1277312789
}
12790+
if (shouldBlockManualMachineRuntimeSpawn(socketPath)) {
12791+
client.close();
12792+
throw manualMachineRuntimeSpawnBlockedError(socketPath);
12793+
}
1277412794
await shutdownMachineRuntimeDaemon(client);
1277512795
const repaired = await repairServiceConnection();
1277612796
if (repaired) return repaired;
@@ -12816,6 +12836,9 @@ async function connectMachineRuntimeDaemon(
1281612836
if (!allowSpawn) throw firstError;
1281712837
const repaired = await repairServiceConnection();
1281812838
if (repaired) return repaired;
12839+
if (shouldBlockManualMachineRuntimeSpawn(socketPath)) {
12840+
throw manualMachineRuntimeSpawnBlockedError(socketPath);
12841+
}
1281912842
const spawned = await spawnMachineRuntimeDaemon(socketPath, options);
1282012843
if (!spawned) throw firstError;
1282112844
try {

apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ function makePayload(action: string, args: Record<string, unknown> = {}): SyncCo
66
return { commandId: "cmd-1", action, args };
77
}
88

9-
function createService() {
9+
function createService(options?: {
10+
agentChatService?: Record<string, unknown>;
11+
}) {
1012
const ptyService = {
1113
resumeSession: vi.fn().mockResolvedValue({
1214
sessionId: "session-1",
@@ -20,6 +22,7 @@ function createService() {
2022
ptyService,
2123
sessionService: {},
2224
fileService: {},
25+
...(options?.agentChatService ? { agentChatService: options.agentChatService } : {}),
2326
logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
2427
} as any);
2528
return { service, ptyService };
@@ -75,4 +78,41 @@ describe("createSyncRemoteCommandService", () => {
7578
sessionId: "session-1",
7679
});
7780
});
81+
82+
it("routes the canonical chat history page command to the chat service", async () => {
83+
const getChatEventHistoryPage = vi.fn().mockReturnValue({
84+
sessionId: "chat-1",
85+
events: [],
86+
startOffset: 128,
87+
hasMore: true,
88+
sessionFound: true,
89+
});
90+
const { service } = createService({
91+
agentChatService: { getChatEventHistoryPage },
92+
});
93+
94+
expect(service.getDescriptor("chat.getChatEventHistoryPage")).toEqual({
95+
action: "chat.getChatEventHistoryPage",
96+
scope: "project",
97+
policy: { viewerAllowed: true },
98+
});
99+
100+
const result = await service.execute(makePayload("chat.getChatEventHistoryPage", {
101+
sessionId: "chat-1",
102+
beforeOffset: 4096,
103+
maxBytes: 65_536,
104+
}));
105+
106+
expect(getChatEventHistoryPage).toHaveBeenCalledWith("chat-1", {
107+
beforeOffset: 4096,
108+
maxBytes: 65_536,
109+
});
110+
expect(result).toEqual({
111+
sessionId: "chat-1",
112+
events: [],
113+
startOffset: 128,
114+
hasMore: true,
115+
sessionFound: true,
116+
});
117+
});
78118
});

apps/ade-cli/src/services/sync/syncRemoteCommandService.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,12 +2380,9 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio
23802380
nextCursor: hasMore ? String(oldestReturnedIndex) : null,
23812381
};
23822382
});
2383-
// Byte-offset transcript pagination for chat event envelopes (scroll-back
2384-
// beyond the hydrated tail). Mirrors the desktop `chat.getChatEventHistoryPage`
2385-
// action; cursor protocol lives on agentChatService.getChatEventHistoryPage.
2386-
register("agentChat.getEventHistoryPage", { viewerAllowed: true }, async (payload) => {
2383+
const getChatEventHistoryPage = async (payload: Record<string, unknown>) => {
23872384
const agentChatService = requireService(args.agentChatService, "Agent chat service not available.");
2388-
const sessionId = requireString(payload.sessionId, "agentChat.getEventHistoryPage requires sessionId.");
2385+
const sessionId = requireString(payload.sessionId, "chat.getChatEventHistoryPage requires sessionId.");
23892386
const beforeOffset = typeof payload.beforeOffset === "number" && Number.isFinite(payload.beforeOffset)
23902387
? payload.beforeOffset
23912388
: 0;
@@ -2396,7 +2393,13 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio
23962393
beforeOffset,
23972394
...(maxBytes != null ? { maxBytes } : {}),
23982395
});
2399-
});
2396+
};
2397+
// Byte-offset transcript pagination for chat event envelopes (scroll-back
2398+
// beyond the hydrated tail). The canonical action mirrors the desktop/TUI
2399+
// ADE action surface; the legacy agentChat.* name remains for older mobile
2400+
// clients that learned the first sync-only spelling.
2401+
register("chat.getChatEventHistoryPage", { viewerAllowed: true }, getChatEventHistoryPage);
2402+
register("agentChat.getEventHistoryPage", { viewerAllowed: true }, getChatEventHistoryPage);
24002403
register("chat.create", { viewerAllowed: true, queueable: true }, async (payload) => {
24012404
const agentChatService = requireService(args.agentChatService, "Agent chat service not available.");
24022405
const parsed = parseAgentChatCreateArgs(payload);

apps/desktop/src/main/services/chat/agentChatService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19774,6 +19774,7 @@ export function createAgentChatService(args: {
1977419774
poolKey,
1977519775
projectRoot,
1977619776
workspacePath: managed.laneWorktreePath,
19777+
baseEnv: buildAgentRuntimeEnv(managed),
1977719778
modelSdkId: launchModelSdkId,
1977819779
...(launchModelParams?.length ? { modelParams: launchModelParams } : {}),
1977919780
apiKey,

apps/desktop/src/main/services/chat/cursorSdkPool.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { EventEmitter } from "node:events";
2+
import fs from "node:fs";
23
import os from "node:os";
34
import path from "node:path";
45
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -102,11 +103,20 @@ describe("Cursor SDK pool paths", () => {
102103
});
103104

104105
it("builds a worker environment with real HOME parity and ADE socket metadata", () => {
106+
const cliBinDir = path.join(os.tmpdir(), `ade-cli-bin-${Date.now()}-${Math.random()}`);
107+
const cliEntry = path.join(os.tmpdir(), `ade-cli-entry-${Date.now()}-${Math.random()}`, "cli.cjs");
108+
fs.mkdirSync(cliBinDir, { recursive: true });
109+
fs.mkdirSync(path.dirname(cliEntry), { recursive: true });
110+
const adeCommand = path.join(cliBinDir, process.platform === "win32" ? "ade.cmd" : "ade");
111+
fs.writeFileSync(adeCommand, "");
112+
fs.writeFileSync(cliEntry, "");
105113
const env = buildCursorSdkWorkerEnv({
106114
baseEnv: {
107115
HOME: "/synthetic",
108116
USERPROFILE: "/synthetic-profile",
109117
PATH: "/bin",
118+
ADE_CLI_ENTRY_PATH: cliEntry,
119+
ADE_CLI_BIN_DIR: cliBinDir,
110120
CURSOR_API_KEY: "cursor-secret",
111121
CURSOR_AUTH_TOKEN: "cursor-token",
112122
},
@@ -121,6 +131,10 @@ describe("Cursor SDK pool paths", () => {
121131
expect(env.USERPROFILE).toBe("/Users/admin");
122132
expect(env.CURSOR_API_KEY).toBeUndefined();
123133
expect(env.CURSOR_AUTH_TOKEN).toBeUndefined();
134+
expect(env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL).toBe("1");
135+
expect(env.ADE_CLI_BIN_DIR).toBe(cliBinDir);
136+
expect(env.ADE_CLI_PATH).toBe(adeCommand);
137+
expect(env.PATH?.split(path.delimiter)[0]).toBe(cliBinDir);
124138
expect(env.ADE_CURSOR_SDK_SOCKET).toBe("/tmp/ade-cursor-sdk/socket.sock");
125139
expect(env.ADE_CURSOR_SDK_LANE_ROOT).toBe("/repo/.ade/worktrees/lane");
126140
expect(env.ADE_CURSOR_SDK_SESSION_ID).toBe("session-1");

apps/desktop/src/main/services/chat/cursorSdkPool.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,121 @@ function sanitizeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
120120
return env;
121121
}
122122

123+
function pathEnvKey(env: NodeJS.ProcessEnv): string {
124+
if (process.platform !== "win32") return "PATH";
125+
if (env.PATH !== undefined) return "PATH";
126+
if (env.Path !== undefined) return "Path";
127+
return "PATH";
128+
}
129+
130+
function prependPathDir(env: NodeJS.ProcessEnv, dir: string | null | undefined): void {
131+
if (!dir?.trim()) return;
132+
try {
133+
if (!fs.statSync(dir).isDirectory()) return;
134+
} catch {
135+
return;
136+
}
137+
const key = pathEnvKey(env);
138+
const current = env[key]?.trim();
139+
const parts = current ? current.split(path.delimiter) : [];
140+
if (parts.some((part) => path.resolve(part) === path.resolve(dir))) return;
141+
env[key] = current ? `${dir}${path.delimiter}${current}` : dir;
142+
}
143+
144+
function prependPathList(existing: string | undefined, root: string | null): string | undefined {
145+
if (!root) return existing;
146+
try {
147+
if (!fs.statSync(root).isDirectory()) return existing;
148+
} catch {
149+
return existing;
150+
}
151+
const parts = (existing ?? "").split(path.delimiter).filter(Boolean);
152+
if (parts.some((part) => path.resolve(part) === path.resolve(root))) return existing;
153+
return [root, ...parts].join(path.delimiter);
154+
}
155+
156+
function existingFilePath(candidate: string | null | undefined): string | null {
157+
const trimmed = candidate?.trim();
158+
if (!trimmed) return null;
159+
try {
160+
const resolved = path.resolve(trimmed);
161+
return fs.statSync(resolved).isFile() ? resolved : null;
162+
} catch {
163+
return null;
164+
}
165+
}
166+
167+
function existingDirPath(candidate: string | null | undefined): string | null {
168+
const trimmed = candidate?.trim();
169+
if (!trimmed) return null;
170+
try {
171+
const resolved = path.resolve(trimmed);
172+
return fs.statSync(resolved).isDirectory() ? resolved : null;
173+
} catch {
174+
return null;
175+
}
176+
}
177+
178+
function commandFileName(name: string): string {
179+
return process.platform === "win32" ? `${name}.cmd` : name;
180+
}
181+
182+
function adeCommandNameCandidates(env: NodeJS.ProcessEnv): string[] {
183+
const names = [
184+
env.ADE_CLI_PATH ? path.basename(env.ADE_CLI_PATH, process.platform === "win32" ? ".cmd" : "") : "",
185+
env.ADE_CLI_INSTALL_NAME,
186+
env.ADE_PACKAGE_CHANNEL === "alpha" || env.ADE_PACKAGE_CHANNEL === "beta"
187+
? `ade-${env.ADE_PACKAGE_CHANNEL}`
188+
: "",
189+
"ade",
190+
"ade-dev",
191+
];
192+
return Array.from(new Set(names.map((name) => name?.trim()).filter((name): name is string => Boolean(name))));
193+
}
194+
195+
function findAdeCommandInBinDir(binDir: string | null, env: NodeJS.ProcessEnv): string | null {
196+
if (!binDir) return null;
197+
for (const name of adeCommandNameCandidates(env)) {
198+
const candidate = existingFilePath(path.join(binDir, commandFileName(name)));
199+
if (!candidate) continue;
200+
return candidate;
201+
}
202+
return null;
203+
}
204+
205+
function inferAdeCliBinDirFromEntry(cliEntry: string | null): string | null {
206+
if (!cliEntry) return null;
207+
return existingDirPath(path.join(path.dirname(cliEntry), "bin"));
208+
}
209+
210+
function inferAdeCliEntryFromBinDir(binDir: string | null): string | null {
211+
if (!binDir) return null;
212+
return existingFilePath(path.resolve(binDir, "..", "cli.cjs"));
213+
}
214+
215+
function applyCurrentAdeCliEnv(env: NodeJS.ProcessEnv): void {
216+
const envCliEntry = existingFilePath(env.ADE_CLI_ENTRY_PATH);
217+
const argvCliEntry = existingFilePath(typeof process.argv[1] === "string" ? process.argv[1] : null);
218+
const binDir = existingDirPath(env.ADE_CLI_BIN_DIR)
219+
?? inferAdeCliBinDirFromEntry(envCliEntry)
220+
?? inferAdeCliBinDirFromEntry(argvCliEntry);
221+
if (binDir) {
222+
env.ADE_CLI_BIN_DIR = binDir;
223+
prependPathDir(env, binDir);
224+
const commandPath = findAdeCommandInBinDir(binDir, env);
225+
if (commandPath) env.ADE_CLI_PATH = commandPath;
226+
}
227+
const cliEntry = inferAdeCliEntryFromBinDir(binDir) ?? envCliEntry ?? argvCliEntry;
228+
if (cliEntry) env.ADE_CLI_ENTRY_PATH = cliEntry;
229+
else delete env.ADE_CLI_ENTRY_PATH;
230+
const bundledSkillsRoot = binDir
231+
? path.resolve(binDir, "..", "..", "agent-skills")
232+
: cliEntry
233+
? path.resolve(path.dirname(cliEntry), "..", "agent-skills")
234+
: null;
235+
env.ADE_AGENT_SKILLS_DIRS = prependPathList(env.ADE_AGENT_SKILLS_DIRS, bundledSkillsRoot);
236+
}
237+
123238
function ensurePrivateDirectory(dir: string): void {
124239
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
125240
let fd: number | null = null;
@@ -183,21 +298,25 @@ export function buildCursorSdkWorkerEnv(args: {
183298
workspacePath: string;
184299
sessionId: string;
185300
}): NodeJS.ProcessEnv {
186-
return {
301+
const env: NodeJS.ProcessEnv = {
187302
...sanitizeEnv(args.baseEnv ?? process.env),
188303
HOME: args.userHomeDir,
189304
USERPROFILE: args.userHomeDir,
305+
ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1",
190306
ADE_CURSOR_SDK_SOCKET: args.socketPath,
191307
ADE_CURSOR_SDK_LANE_ROOT: args.workspacePath,
192308
ADE_CURSOR_SDK_SESSION_ID: args.sessionId,
193309
ADE_CURSOR_SDK_STATE_ROOT: args.stateRoot,
194310
};
311+
applyCurrentAdeCliEnv(env);
312+
return env;
195313
}
196314

197315
export async function acquireCursorSdkConnection(args: {
198316
poolKey: string;
199317
projectRoot: string;
200318
workspacePath: string;
319+
baseEnv?: NodeJS.ProcessEnv;
201320
modelSdkId: string;
202321
modelParams?: CursorSdkModelParameterValue[];
203322
apiKey?: string | null;
@@ -258,6 +377,7 @@ async function createCursorSdkConnection(args: Parameters<typeof acquireCursorSd
258377
const child = fork(workerPath, [], {
259378
cwd: args.workspacePath,
260379
env: buildCursorSdkWorkerEnv({
380+
baseEnv: args.baseEnv,
261381
userHomeDir: paths.userHomeDir,
262382
stateRoot: paths.stateRoot,
263383
socketPath: paths.socketPath,

0 commit comments

Comments
 (0)