Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,12 +423,12 @@ async function readGitOriginAsync(projectRoot: string): Promise<string | null> {
return remote || null;
}

async function runGitHeadlessAsync(
function runGitHeadlessAsync(
projectRoot: string,
args: string[],
timeoutMs: number,
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return await runCommandAsync("git", args, {
return runCommandAsync("git", args, {
cwd: projectRoot,
timeoutMs,
});
Expand Down Expand Up @@ -1123,7 +1123,7 @@ export function createHeadlessGitHubService(
};
},
async detectRepo() {
return await detectGitHubRepoAsync(projectRoot);
return detectGitHubRepoAsync(projectRoot);
},
async getAppInstallationStatus(args = {}) {
const owner = args.owner?.trim();
Expand Down
16 changes: 15 additions & 1 deletion apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from "node:fs";
import { createHash } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEventBuffer } from "./eventBuffer";
import {
createMultiProjectRpcRequestHandler,
Expand All @@ -15,6 +15,20 @@ import { ProjectScopeRegistry } from "./services/projects/projectScope";
import type { SyncRoleSnapshot } from "../../desktop/src/shared/types";
import { RUNTIME_COMPAT_LEVEL } from "../../desktop/src/shared/adeRuntimeProtocol";

const originalAdeHome = process.env.ADE_HOME;
let isolatedAdeHome = "";

beforeEach(() => {
isolatedAdeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-multi-project-home-"));
process.env.ADE_HOME = isolatedAdeHome;
});

afterEach(() => {
if (originalAdeHome === undefined) delete process.env.ADE_HOME;
else process.env.ADE_HOME = originalAdeHome;
fs.rmSync(isolatedAdeHome, { recursive: true, force: true });
});

function createRegistry() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-multi-project-rpc-"));
const rawProjectRoot = path.join(root, "project");
Expand Down
6 changes: 6 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,12 @@ export function createMultiProjectRpcRequestHandler(
};
const ownsPersonalChatScope = options.personalChatScope == null;
const personalChatScope = options.personalChatScope ?? new PersonalChatScope();
if (ownsPersonalChatScope && personalChatScope instanceof PersonalChatScope) {
void personalChatScope.warmExisting().catch(() => {
Comment thread
arul28 marked this conversation as resolved.
Outdated
// The first explicit personal-chat call retries runtime creation and
// returns the actionable error to the caller.
});
}
const handlers = new Map<ProjectId, Promise<HandlerEntry>>();
const eventSubscriptions = new Map<string, RuntimeEventSubscription>();
const disposeProjectRuntimeCaches = (projectId: ProjectId): void => {
Expand Down
8 changes: 4 additions & 4 deletions apps/ade-cli/src/services/credentials/credentialStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ function readOrCreateMacKeychainMaterial(): Buffer | null {

async function readMacKeychainMaterialAsync(): Promise<Buffer | null> {
if (process.platform !== "darwin") return null;
return await new Promise((resolve) => {
return new Promise((resolve) => {
execFile(
"security",
[
Expand Down Expand Up @@ -815,12 +815,12 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore {
if (!key.equals(machineKey)) {
try {
const values = deserializeStore(raw, key, { emptyOnDecryptFailure: false });
this.lastReadState = credentialsExist ? "available" : "missing";
this.lastReadState = "available";
return values;
} catch {
try {
const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false });
this.lastReadState = credentialsExist ? "available" : "missing";
this.lastReadState = "available";
return values;
} catch (error) {
this.lastReadState = "unreadable";
Expand All @@ -830,7 +830,7 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore {
}
try {
const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false });
this.lastReadState = credentialsExist ? "available" : "missing";
this.lastReadState = "available";
return values;
} catch {
this.lastReadState = "unreadable";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ describe("PersonalChatScope", () => {
paused: boolean;
}) => ({ sessionId, paused, nextWakeAt: null })),
updateSession: vi.fn(async () => summary),
ensureSessionSurface: vi.fn(),
archiveSession: vi.fn(async () => undefined),
unarchiveSession: vi.fn(async () => undefined),
deleteSession: vi.fn(async () => undefined),
Expand Down Expand Up @@ -124,6 +125,23 @@ describe("PersonalChatScope", () => {
await scope.dispose();
});

it("repairs legacy surface metadata for transcript and activity subscriptions", async () => {
const { summary, service, runtime, createRuntime } = fixture("work");
service.getSessionSummary.mockResolvedValue({
...summary,
status: "active",
} as never);
const durablePath = path.join(runtime.projectRoot, ".ade", "transcripts", "chat", "chat-1.jsonl");
fs.mkdirSync(path.dirname(durablePath), { recursive: true });
fs.writeFileSync(durablePath, "history\n");
const scope = new PersonalChatScope({ createRuntime });

await expect(scope.transcriptPath("chat-1")).resolves.toBe(durablePath);
await expect(scope.isTurnActive("chat-1")).resolves.toBe(true);
expect(service.ensureSessionSurface).toHaveBeenCalledWith("chat-1", "personal");
await scope.dispose();
});

it("creates a hidden personal session and dispatches an optional kickoff", async () => {
const { service, createRuntime } = fixture();
const scope = new PersonalChatScope({ createRuntime });
Expand Down Expand Up @@ -159,7 +177,7 @@ describe("PersonalChatScope", () => {
expect(runtimeArgs.publishPushEvents).toBe(false);
});

it("filters the hidden scope list to personal sessions", async () => {
it("repairs legacy surface metadata while listing the hidden scope", async () => {
const personal = fixture("personal");
const scope = new PersonalChatScope({ createRuntime: personal.createRuntime });
await expect(scope.call("list", { includeArchived: false })).resolves.toMatchObject({
Expand All @@ -169,22 +187,49 @@ describe("PersonalChatScope", () => {

const work = fixture("work");
const workScope = new PersonalChatScope({ createRuntime: work.createRuntime });
await expect(workScope.call("list", {})).resolves.toMatchObject({ result: [] });
await expect(workScope.call("list", {})).resolves.toMatchObject({
result: [{ sessionId: "chat-1", surface: "personal" }],
});
expect(work.service.ensureSessionSurface).toHaveBeenCalledWith("chat-1", "personal");
expect(personal.service.listSessions).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ includeArchived: false }),
);
});

it("rejects session-scoped calls when the session is not personal", async () => {
it("repairs legacy surface metadata before a session-scoped call", async () => {
const { createRuntime, service } = fixture("work");
const scope = new PersonalChatScope({ createRuntime });
await expect(scope.call("send", { sessionId: "chat-1", text: "nope" }))
.rejects.toThrow("Personal chat session 'chat-1' was not found");
await expect(scope.call("send", { sessionId: "chat-1", text: "continue" }))
.resolves.toMatchObject({ action: "send" });
expect(service.getSessionSummary).toHaveBeenCalledWith("chat-1");
expect(service.ensureSessionSurface).toHaveBeenCalledWith("chat-1", "personal");
expect(service.sendMessage).toHaveBeenCalledWith({ sessionId: "chat-1", text: "continue" });
});

it("rejects session-scoped calls when the session row is missing", async () => {
const { createRuntime, service } = fixture();
service.getSessionSummary.mockResolvedValueOnce(null as never);
const scope = new PersonalChatScope({ createRuntime });
await expect(scope.call("send", { sessionId: "missing", text: "nope" }))
.rejects.toThrow("Personal chat session 'missing' was not found");
expect(service.sendMessage).not.toHaveBeenCalled();
});

it("prewarms only when personal-chat state already exists", async () => {
const { createRuntime } = fixture();
const scope = new PersonalChatScope({ createRuntime });
await scope.warmExisting();
expect(createRuntime).not.toHaveBeenCalled();

const dbPath = path.join(adeHome, "personal-chats", "state", ".ade", "ade.db");
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.writeFileSync(dbPath, "");

await scope.warmExisting();
expect(createRuntime).toHaveBeenCalledTimes(1);
});

it("cancels scheduled work only for an owned personal session", async () => {
const { createRuntime, service } = fixture();
const scope = new PersonalChatScope({ createRuntime });
Expand Down
61 changes: 52 additions & 9 deletions apps/ade-cli/src/services/personalChats/personalChatScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ export class PersonalChatScope {
return summarizeRuntimeActivity(runtime);
}

/**
* Existing personal-chat users should not pay the hidden runtime's cold boot
* after opening the Chats pane. Fresh installs remain lazy.
*/
async warmExisting(): Promise<void> {
const layout = resolveMachineAdeLayout();
const stateRoot = layout.personalChatsStateRoot ?? path.join(layout.adeDir, "personal-chats", "state");
if (!fs.existsSync(path.join(stateRoot, ".ade", "ade.db"))) return;
await this.getRuntime();
}

async call(
actionValue: unknown,
argsValue: unknown,
Expand All @@ -115,13 +126,28 @@ export class PersonalChatScope {

let result: unknown;
switch (action) {
case "list":
result = (await service.listSessions(undefined, {
case "list": {
const sessions = await service.listSessions(undefined, {
includeIdentity: false,
includeAutomation: true,
includeArchived: args.includeArchived === true,
})).filter((session) => session.surface === "personal");
});
// This runtime is a private machine-owned scope: every chat row inside
// it is personal. Older rows may have lost their surface while being
// reconstructed for a follow-up, so repair and return them instead of
// filtering intact transcripts out of the UI.
result = sessions
.filter((session) => session.surface !== "automation")
.map((session) => {
if (session.surface !== "personal") {
service.ensureSessionSurface(session.sessionId, "personal");
}
return session.surface === "personal"
? session
: { ...session, surface: "personal" as const };
});
break;
}
case "create": {
const provider = requiredString(args.provider, "provider") as AgentChatCreateArgs["provider"];
const model = requiredString(args.model, "model");
Expand Down Expand Up @@ -373,8 +399,8 @@ export class PersonalChatScope {
async transcriptPath(sessionIdValue: unknown): Promise<string | null> {
const sessionId = requiredString(sessionIdValue, "sessionId");
const runtime = await this.getRuntime();
const summary = await runtime.agentChatService?.getSessionSummary(sessionId);
if (!summary || summary.surface !== "personal") return null;
const service = runtime.agentChatService;
if (!service || !(await this.resolvePersonalSession(service, sessionId))) return null;
// The session transcript is byte-capped. Remote clients must tail the
// dedicated durable chat transcript or long conversations stop updating.
const durablePath = path.join(resolveAdeLayout(runtime.projectRoot).chatTranscriptsDir, `${sessionId}.jsonl`);
Expand All @@ -387,8 +413,10 @@ export class PersonalChatScope {
async isTurnActive(sessionIdValue: unknown): Promise<boolean> {
const sessionId = requiredString(sessionIdValue, "sessionId");
const runtime = await this.getRuntime();
const summary = await runtime.agentChatService?.getSessionSummary(sessionId);
return summary?.surface === "personal" && summary.status === "active";
const service = runtime.agentChatService;
if (!service) return false;
const summary = await this.resolvePersonalSession(service, sessionId);
return summary?.status === "active";
}

async dispose(): Promise<void> {
Expand Down Expand Up @@ -440,13 +468,28 @@ export class PersonalChatScope {
service: NonNullable<AdeRuntime["agentChatService"]>,
sessionId: string,
): Promise<AgentChatSessionSummary> {
const summary = await service.getSessionSummary(sessionId);
if (!summary || summary.surface !== "personal") {
const summary = await this.resolvePersonalSession(service, sessionId);
if (!summary) {
throw new Error(`Personal chat session '${sessionId}' was not found.`);
}
return summary;
}

private async resolvePersonalSession(
service: NonNullable<AdeRuntime["agentChatService"]>,
sessionId: string,
): Promise<AgentChatSessionSummary | null> {
const summary = await service.getSessionSummary(sessionId);
if (!summary || summary.surface === "automation") {
return null;
}
if (summary.surface !== "personal") {
service.ensureSessionSurface(sessionId, "personal");
return { ...summary, surface: "personal" };
}
return summary;
}

private requirePersonalTerminal(ptyId: string, sessionId?: string): string {
const ownedSessionId = this.personalTerminalSessions.get(ptyId);
if (!ownedSessionId || (sessionId && ownedSessionId !== sessionId)) {
Expand Down
11 changes: 5 additions & 6 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1565,26 +1565,25 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null {
};
}
if (typeof base.getChatEventHistory === "function") {
service.getChatEventHistory = async (args?: unknown) => {
service.getChatEventHistory = (args?: unknown) => {
const { sessionId, options } = readChatHistoryActionArgs(args, "chat.getChatEventHistory");
const maxEvents = readOptionalIntegerActionField(options.maxEvents, "maxEvents");
const maxBytes = readOptionalIntegerActionField(options.maxBytes, "maxBytes");
const historyOptions = {
return agentChatService.getChatEventHistory(sessionId, {
...(maxEvents !== undefined ? { maxEvents } : {}),
...(maxBytes !== undefined ? { maxBytes } : {}),
};
return await agentChatService.getChatEventHistory(sessionId, historyOptions);
});
};
}
if (typeof base.getChatEventHistoryPage === "function") {
service.getChatEventHistoryPage = async (args?: unknown) => {
service.getChatEventHistoryPage = (args?: unknown) => {
const { sessionId, options } = readChatHistoryActionArgs(args, "chat.getChatEventHistoryPage");
const beforeOffset = readOptionalIntegerActionField(options.beforeOffset, "beforeOffset");
if (beforeOffset === undefined) {
throw new Error("Expected 'beforeOffset' to be a finite number.");
}
const maxBytes = readOptionalIntegerActionField(options.maxBytes, "maxBytes");
return await agentChatService.getChatEventHistoryPage(sessionId, {
return agentChatService.getChatEventHistoryPage(sessionId, {
beforeOffset,
...(maxBytes !== undefined ? { maxBytes } : {}),
});
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4020,6 +4020,26 @@ describe("createAgentChatService", () => {
expect(persisted.provider).toBe("opencode");
});

it("preserves the personal surface when reconstructing a persisted session", async () => {
const { service } = createService();
const session = await service.createSession({
laneId: "lane-1",
provider: "opencode",
model: "",
modelId: "opencode/anthropic/claude-sonnet-5",
surface: "personal",
});

await service.dispose({ sessionId: session.id });
await service.updateSession({ sessionId: session.id, title: "Reopened personal chat" });

await expect(service.getSessionSummary(session.id)).resolves.toMatchObject({
sessionId: session.id,
surface: "personal",
});
expect(readPersistedChatState(session.id).surface).toBe("personal");
});

it("writes a chat transcript init record", async () => {
const { service } = createService();
const session = await service.createSession({
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15418,6 +15418,7 @@ export function createAgentChatService(args: {
...(persisted?.cursorPromotedTurnId ? { cursorPromotedTurnId: persisted.cursorPromotedTurnId } : {}),
...(persisted?.permissionMode ? { permissionMode: persisted.permissionMode } : {}),
...(persisted?.identityKey ? { identityKey: persisted.identityKey } : {}),
...(persisted?.surface ? { surface: persisted.surface } : {}),
capabilityMode: persisted?.capabilityMode ?? inferCapabilityMode(provider),
completion: persisted?.completion ?? null,
codexGoal: persisted?.codexGoal ?? null,
Expand Down Expand Up @@ -35665,6 +35666,23 @@ export function createAgentChatService(args: {
return await summarizeSessionRow(row);
};

/**
* Repairs the owning surface for a session loaded from legacy metadata.
* This is intentionally not part of the public chat action contract; the
* machine-owned personal-chat scope uses it to migrate sessions that predate
* persisted `surface` metadata without exposing a cross-surface mutation.
*/
const ensureSessionSurface = (
sessionId: string,
surface: AgentChatSurface,
): void => {
const managed = ensureManagedSession(sessionId);
if (managed.session.surface !== surface) {
managed.session.surface = surface;
persistChatState(managed);
}
};

const toScheduledWorkItem = (schedule: ChatScheduledWorkRecord): AgentChatScheduledWorkItem => ({
id: schedule.id,
sessionId: schedule.sessionId,
Expand Down Expand Up @@ -40616,6 +40634,7 @@ export function createAgentChatService(args: {
resumeSession,
listSessions,
getSessionSummary,
ensureSessionSurface,
hasActiveWorkloads,
hasRetainableSessions,
countActiveForLane,
Expand Down
Loading