Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
28 changes: 22 additions & 6 deletions apps/cli/src/lib/history-session-catalog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
type ACPSessionId,
getLocalProjectHistoryProviderKey,
type LocalProjectHistoryProvider,
type CustomAcpLaunchSpec,
type BuiltinRuntimeOverrides,
} from '@lody/shared';
import { LODY_EXTENSION_METHODS } from 'acp-extension-core';

Expand Down Expand Up @@ -126,19 +128,33 @@ type ResolvedHistoryACPProcessLaunch = Omit<ResolvedACPProcessLaunch, 'env'> & {
env: NodeJS.ProcessEnv;
};

export type HistoryLaunchProvider = LocalProjectHistoryProvider & {
customAcp?: CustomAcpLaunchSpec;
runtimeOverrides?: BuiltinRuntimeOverrides;
env?: NodeJS.ProcessEnv;
};

export async function resolveHistoryACPProcessLaunch(args: {
provider: LocalProjectHistoryProvider;
provider: HistoryLaunchProvider;
env?: NodeJS.ProcessEnv;
}): Promise<ResolvedHistoryACPProcessLaunch> {
const launch = await resolveACPProcessLaunchAsync(args.provider);
const launch = await resolveACPProcessLaunchAsync({
cliType: args.provider.cliType,
agentType: args.provider.agentType,
customAcp: args.provider.customAcp,
runtimeOverrides: args.provider.runtimeOverrides,
});
return {
...launch,
env: mergeACPProcessEnv(launch, args.env ?? process.env),
env: mergeACPProcessEnv(launch, {
...(args.env ?? process.env),
...(args.provider.env ?? {}),
}),
};
}

async function createHistoryAcpConnection(args: {
provider: LocalProjectHistoryProvider;
provider: HistoryLaunchProvider;
workdir: string;
logger: Logger;
}): Promise<HistoryAcpConnection> {
Expand Down Expand Up @@ -327,7 +343,7 @@ export function dedupeHistorySessionsById(sessions: SessionInfo[]): SessionInfo[
}

export async function listHistorySessionsForLocalProject(args: {
provider: LocalProjectHistoryProvider;
provider: HistoryLaunchProvider;
rootPath: string;
logger: Logger;
requiredSessionIds?: readonly string[];
Expand Down Expand Up @@ -376,7 +392,7 @@ export async function listHistorySessionsForLocalProject(args: {
}

export async function loadHistorySessionReplay(args: {
provider: LocalProjectHistoryProvider;
provider: HistoryLaunchProvider;
rootPath: string;
acpSessionId: ACPSessionId;
logger: Logger;
Expand Down
52 changes: 42 additions & 10 deletions apps/cli/src/lib/local-project-history-sync-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ import {

import type { LoroDocumentManager, SessionDocument } from '@/lib/loro/doc';
import { readMachineLocalProjects, upsertMachineLocalProject } from '@/lib/local-project-meta';
import { listMergedAgentConfigs } from '@/lib/agent-config-machine-flock';
import {
listHistorySessionsForLocalProject,
type HistoryLaunchProvider,
loadHistorySessionReplay,
MAX_LOCAL_PROJECT_HISTORY_CATALOG_SESSIONS,
} from './history-session-catalog-client';
Expand Down Expand Up @@ -546,6 +548,7 @@ function buildExternalHistoryMeta(args: {
export class LocalProjectHistorySyncService {
private readonly provider: LocalProjectHistoryProvider;
private readonly providerKey: string;
private launchProvider: HistoryLaunchProvider;

constructor(
private readonly manager: LoroDocumentManager,
Expand All @@ -559,6 +562,29 @@ export class LocalProjectHistorySyncService {
) {
this.provider = provider;
this.providerKey = getLocalProjectHistoryProviderKey(provider);
this.launchProvider = provider;
}

private async resolveLaunchProvider(): Promise<HistoryLaunchProvider> {
if (this.provider.cliType !== 'custom') {
return this.provider;
}
const configs = await listMergedAgentConfigs(this.manager.repo, this.context.workspaceId, [
this.context.machineId,
]);
const config = configs.find(
(candidate) =>
candidate.machineId === this.context.machineId &&
candidate.cliType === this.provider.cliType &&
candidate.agentType === this.provider.agentType
);
const resolved = {
...this.provider,
customAcp: config?.customAcp,
env: config?.env,
};
this.launchProvider = resolved;
return resolved;
}

async syncLocalProject(args: {
Expand All @@ -585,7 +611,8 @@ export class LocalProjectHistorySyncService {
localProjectId: LocalProjectId;
rootPath: string;
}): Promise<LocalProjectHistoryCatalogResult> {
const snapshot = await this.listCatalogSnapshot(args);
this.launchProvider = await this.resolveLaunchProvider();
const snapshot = await this.listCatalogSnapshot(args, this.launchProvider);
return await this.writeCatalogResult({
localProjectId: args.localProjectId,
sessions: snapshot.sessions,
Expand Down Expand Up @@ -644,6 +671,7 @@ export class LocalProjectHistorySyncService {
const summary = emptySummary();
const selectedIds = [...new Set(args.acpSessionIds)];
summary.listed = selectedIds.length;
this.launchProvider = await this.resolveLaunchProvider();
const snapshot = await this.listCatalogSnapshot({
...args,
requiredSessionIds: selectedIds,
Expand Down Expand Up @@ -672,7 +700,7 @@ export class LocalProjectHistorySyncService {
snapshot.existingByImportKey.get(importKey);
if (!existing) {
const replayNotifications = await loadHistorySessionReplay({
provider: this.provider,
provider: this.launchProvider,
rootPath: args.rootPath,
acpSessionId,
logger: this.logger,
Expand Down Expand Up @@ -730,6 +758,7 @@ export class LocalProjectHistorySyncService {
sessionId: SessionId;
acpSessionId: string;
}): Promise<LocalProjectHistoryConflictResolveResult> {
this.launchProvider = await this.resolveLaunchProvider();
const snapshot = await this.listCatalogSnapshot({
...args,
requiredSessionIds: [args.acpSessionId],
Expand Down Expand Up @@ -812,7 +841,7 @@ export class LocalProjectHistorySyncService {

const acpSessionId = args.acpSessionId as unknown as ACPSessionId;
const replayNotifications = await loadHistorySessionReplay({
provider: this.provider,
provider: this.launchProvider,
rootPath: args.rootPath,
acpSessionId,
logger: this.logger,
Expand Down Expand Up @@ -906,13 +935,16 @@ export class LocalProjectHistorySyncService {
});
}

private async listCatalogSnapshot(args: {
localProjectId: LocalProjectId;
rootPath: string;
requiredSessionIds?: readonly string[];
}): Promise<HistoryCatalogSnapshot> {
private async listCatalogSnapshot(
args: {
localProjectId: LocalProjectId;
rootPath: string;
requiredSessionIds?: readonly string[];
},
provider: HistoryLaunchProvider = this.launchProvider
): Promise<HistoryCatalogSnapshot> {
const catalog = await listHistorySessionsForLocalProject({
provider: this.provider,
provider,
rootPath: args.rootPath,
logger: this.logger,
requiredSessionIds: args.requiredSessionIds,
Expand Down Expand Up @@ -1124,7 +1156,7 @@ export class LocalProjectHistorySyncService {
}

const replayNotifications = await loadHistorySessionReplay({
provider: this.provider,
provider: this.launchProvider,
rootPath: args.rootPath,
acpSessionId: args.acpSessionId,
logger: this.logger,
Expand Down
41 changes: 41 additions & 0 deletions apps/cli/tests/history-session-catalog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,47 @@ describe('resolveHistoryACPProcessLaunch', () => {
expect(historyLaunch.env.PATH).toBe('/usr/bin');
});

it('resolves a custom ACP launch from the provider launch spec', async () => {
const historyLaunch = await resolveHistoryACPProcessLaunch({
provider: {
cliType: 'custom',
agentType: 'my-agent',
customAcp: {
command: process.execPath,
args: ['custom-acp-server.mjs', '--stdio'],
},
},
env: { PATH: '/usr/bin' },
});

expect(historyLaunch.command).toBe(process.execPath);
expect(historyLaunch.args).toEqual(['custom-acp-server.mjs', '--stdio']);
expect(historyLaunch.env.PATH).toBe('/usr/bin');
});

it('merges the authoritative custom ACP environment before spawning history', async () => {
const historyLaunch = await resolveHistoryACPProcessLaunch({
provider: {
cliType: 'custom',
agentType: 'my-agent',
customAcp: { command: process.execPath },
env: {
ACP_API_KEY: 'config-secret',
ACP_BASE_URL: 'https://config.example.test',
PATH: '/config/bin',
},
},
env: {
ACP_API_KEY: 'caller-secret',
PATH: '/caller/bin',
},
});

expect(historyLaunch.env.ACP_API_KEY).toBe('config-secret');
expect(historyLaunch.env.ACP_BASE_URL).toBe('https://config.example.test');
expect(historyLaunch.env.PATH).toBe('/config/bin');
});

it('uses the same registry Interactive Claude npx launch as normal sessions', async () => {
const provider = { cliType: 'registry', agentType: 'claude-p' } as const;
const sessionLaunch = resolveACPProcessLaunch(provider);
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/node/local-project-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function isStringArray(value: unknown): value is string[] {
function isLocalProjectHistoryProvider(value: unknown): boolean {
return (
isObjectRecord(value) &&
(value.cliType === 'builtin' || value.cliType === 'registry') &&
(value.cliType === 'builtin' || value.cliType === 'registry' || value.cliType === 'custom') &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow custom providers through the desktop IPC guard

In the OSS Electron flow, the renderer routes local-project history operations through LocalProjectsIpc.syncHistory/importHistory/resolveHistoryConflict, but apps/electron/src/main/ipc/services/local-projects-ipc.ts:59-67 still accepts only builtin and registry. Consequently, selecting a newly exposed custom provider returns Invalid history provider before the request reaches this updated validator or the CLI, so custom ACP history synchronization remains unusable from the desktop. Update the Electron guard alongside this accepted provider set.

Useful? React with 👍 / 👎.

typeof value.agentType === 'string' &&
value.agentType.trim().length > 0
);
Expand Down
26 changes: 26 additions & 0 deletions packages/shared/tests/local-project-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {

const codexProvider = { cliType: 'builtin', agentType: 'codex' } as const;
const claudeProvider = { cliType: 'builtin', agentType: 'claude' } as const;
const customProvider = {
cliType: 'custom',
agentType: 'custom-agent',
} as const;

describe('local project control request schema', () => {
it('parses add request', () => {
Expand Down Expand Up @@ -198,6 +202,28 @@ describe('local project control request schema', () => {
});
});

it('parses custom provider history sync requests', () => {
const parsed = safeParseLocalProjectControlRequest(
JSON.stringify({
type: 'local-project/sync-history',
machineId: 'machine-1',
workspaceId: 'workspace-1',
localProjectId: 'project-1',
provider: customProvider,
})
);

expect(parsed.success).toBe(true);
if (!parsed.success) {
return;
}
if (parsed.data.type !== 'local-project/sync-history') {
throw new Error(`Unexpected request type: ${parsed.data.type}`);
}

expect(parsed.data.provider).toEqual(customProvider);
});

it('parses Codex provider history import request with selected sessions', () => {
const parsed = safeParseLocalProjectControlRequest(
JSON.stringify({
Expand Down