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
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function makeManager(): {

const npmDescriptor = {
id: 'codex',
category: 'agent' as const,
updates: {
kind: 'supported' as const,
releaseSource: { kind: 'npm' as const, package: '@openai/codex' },
Expand Down Expand Up @@ -128,6 +129,39 @@ describe('AgentUpdateService', () => {
vi.unstubAllGlobals();
});

it('ignores core dependency host state events', () => {
vi.mocked(getDependencyDescriptor).mockReturnValue({
id: 'git',
category: 'core',
updates: { kind: 'none' },
} as never);

const service = new AgentUpdateService();
const { manager, emitStatus } = makeManager();
service.attach(manager as unknown as HostDependencyManager, undefined);

emitStatus({
id: 'git' as DependencyId,
state: {
id: 'git' as DependencyId,
category: 'core',
status: 'available',
version: '2.45.0',
path: '/opt/homebrew/bin/git',
checkedAt: 1000,
},
hostDependency: {
hostId: 'local',
dependencyId: 'git' as DependencyId,
used: { kind: 'auto' as const },
installations: [],
},
});

expect(toAgentInstallationStatus).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});

it('getUpdateInfo returns null/false before any fetch', () => {
const service = new AgentUpdateService();

Expand Down Expand Up @@ -210,6 +244,7 @@ describe('AgentUpdateService', () => {
it('enrichHostDependency: unknown+package-manager => updateAvailable=false', async () => {
const pmDescriptor = {
id: 'amp',
category: 'agent' as const,
updates: {
kind: 'supported' as const,
releaseSource: { kind: 'npm' as const, package: '@ampcode/cli' },
Expand Down Expand Up @@ -254,6 +289,7 @@ describe('AgentUpdateService', () => {
it('enrichHostDependency: unknown+cli => updateAvailable=true', async () => {
const cliDescriptor = {
id: 'claude',
category: 'agent' as const,
updates: {
kind: 'supported' as const,
releaseSource: { kind: 'github' as const, repo: 'anthropics/claude-code' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ export class AgentUpdateService {
}

private handleManagerEvent(event: DependencyStatusUpdatedEvent, connectionId?: string): void {
const descriptor = getDependencyDescriptor(event.id);
if (descriptor?.category !== 'agent') return;

const storageKey = `${connectionId ?? 'local'}:${event.id}`;
this.storedEvents.set(storageKey, { raw: event, installedVersion: event.state.version });

Expand All @@ -119,10 +122,7 @@ export class AgentUpdateService {
// Emit the raw event first (no update info yet), then kick off async fetch
this.emitEnrichedEvent(event, null, connectionId);

const descriptor = getDependencyDescriptor(event.id);
if (descriptor) {
void this.fetchLatestAndReemit(event.id as DependencyId, descriptor, connectionId);
}
void this.fetchLatestAndReemit(event.id as DependencyId, descriptor, connectionId);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,33 @@ const mocks = vi.hoisted(() => {
const instances: Array<{
get: ReturnType<typeof vi.fn>;
probeCategory: ReturnType<typeof vi.fn>;
onStatusUpdated: { subscribe: ReturnType<typeof vi.fn> };
onExecutableInvalidated: { subscribe: ReturnType<typeof vi.fn> };
emitStatus(event: unknown): void;
setAgentStates(): void;
}> = [];

class FakeHostDependencyManager {
private readonly states = new Map<string, { id: string; category: string }>();
private statusListener: ((event: unknown) => void) | undefined;
readonly get = vi.fn((id: string) => this.states.get(id));
readonly probeCategory = vi.fn(async (category: string) => {
if (category === 'agent') {
this.states.set('claude', { id: 'claude', category: 'agent' });
this.states.set('codex', { id: 'codex', category: 'agent' });
}
});
readonly onStatusUpdated = {
subscribe: vi.fn((listener: (event: unknown) => void) => {
this.statusListener = listener;
}),
};
readonly onExecutableInvalidated = { subscribe: vi.fn() };

emitStatus(event: unknown): void {
this.statusListener?.(event);
}

setAgentStates(): void {
this.states.set('claude', { id: 'claude', category: 'agent' });
this.states.set('codex', { id: 'codex', category: 'agent' });
Expand All @@ -38,11 +50,15 @@ const mocks = vi.hoisted(() => {
getSelection: vi.fn(),
createLocalInstallCommandRunner: vi.fn(() => vi.fn()),
createSshInstallCommandRunner: vi.fn(() => vi.fn()),
setGitExecutableOverride: vi.fn(),
};
});

vi.mock('@emdash/core/deps/runtime', () => ({
HostDependencyManager: mocks.FakeHostDependencyManager,
resolveActiveInstallation: vi.fn((installations: Array<{ isActive?: boolean }>) =>
installations.find((installation) => installation.isActive)
),
}));

vi.mock('@main/core/conversations/impl/resolve-agent-executable', () => ({
Expand Down Expand Up @@ -77,6 +93,10 @@ vi.mock('@main/core/terminal-shell/resolver', () => ({
resolveLocalAutomationShellWithSystemFallback: vi.fn(async () => ({ shell: '/bin/sh' })),
}));

vi.mock('@main/core/utils/exec', () => ({
setGitExecutableOverride: mocks.setGitExecutableOverride,
}));

vi.mock('@main/lib/logger', () => ({
log: {
warn: vi.fn(),
Expand Down Expand Up @@ -175,6 +195,93 @@ describe('ensureAgentDependenciesProbed', () => {
await expect(getDependencyManager('ssh-1')).resolves.toBe(remoteManager);
});

it('syncs the local git executable from host dependency events', async () => {
await import('./dependency-managers');
const localManager = mocks.instances[0]!;

localManager.emitStatus({
id: 'git',
state: {
id: 'git',
category: 'core',
status: 'available',
path: '/usr/bin/git',
},
hostDependency: {
hostId: 'local',
dependencyId: 'git',
used: { kind: 'auto' as const },
installations: [
{
id: '/opt/homebrew/bin/git',
realpath: '/opt/homebrew/bin/git',
pathEntry: '/opt/homebrew/bin/git',
isActive: true,
status: 'available',
},
],
},
});

expect(mocks.setGitExecutableOverride).toHaveBeenCalledWith('/opt/homebrew/bin/git', undefined);
});

it('honors a missing pinned git selection instead of falling back to PATH', async () => {
await import('./dependency-managers');
const localManager = mocks.instances[0]!;

localManager.emitStatus({
id: 'git',
state: {
id: 'git',
category: 'core',
status: 'available',
path: '/usr/bin/git',
},
hostDependency: {
hostId: 'local',
dependencyId: 'git',
used: { kind: 'pinned' as const, realpath: '/missing/git' },
installations: [],
},
});

expect(mocks.setGitExecutableOverride).toHaveBeenCalledWith('/missing/git', undefined);
});

it('syncs remote git executables per connection', async () => {
const { getDependencyManager } = await import('./dependency-managers');
mocks.connect.mockResolvedValue({});
await getDependencyManager('ssh-1');
const remoteManager = mocks.instances[1]!;

remoteManager.emitStatus({
id: 'git',
state: {
id: 'git',
category: 'core',
status: 'available',
path: '/usr/bin/git',
},
hostDependency: {
hostId: 'ssh-1',
dependencyId: 'git',
used: { kind: 'auto' as const },
installations: [
{
id: '/usr/local/bin/git',
realpath: '/usr/local/bin/git',
pathEntry: '/usr/local/bin/git',
isActive: true,
status: 'available',
},
],
},
});

expect(mocks.setGitExecutableOverride).toHaveBeenCalledWith('/usr/local/bin/git', 'ssh-1');
});

it('deduplicates concurrent remote manager creation', async () => {
const { getDependencyManager } = await import('./dependency-managers');
let resolveConnect: ((proxy: unknown) => void) | undefined;
Expand Down Expand Up @@ -251,6 +358,14 @@ describe('ensureAgentDependenciesProbed', () => {
expect(mocks.connect).toHaveBeenCalledTimes(2);
});

it('clears remote git executable overrides with remote managers', async () => {
const { clearDependencyManager } = await import('./dependency-managers');

clearDependencyManager('ssh-1');

expect(mocks.setGitExecutableOverride).toHaveBeenCalledWith(null, 'ssh-1');
});

it('keeps in-flight probes deduped for a manager after cache clear', async () => {
const { clearDependencyManager, ensureAgentDependenciesProbed, getDependencyManager } =
await import('./dependency-managers');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { Platform } from '@emdash/core/deps';
import {
HostDependencyManager,
resolveActiveInstallation,
type DependencyId,
type DependencyProbeOptions,
type DependencyStatusUpdatedEvent,
type SelectedSource,
} from '@emdash/core/deps/runtime';
import { clearResolvedPathCache } from '@main/core/conversations/impl/resolve-agent-executable';
import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context';
Expand All @@ -11,6 +14,7 @@ import type { IExecutionContext } from '@main/core/execution-context/types';
import { appSettingsService } from '@main/core/settings/settings-service';
import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager';
import { resolveLocalAutomationShellWithSystemFallback } from '@main/core/terminal-shell/resolver';
import { setGitExecutableOverride } from '@main/core/utils/exec';
import { log } from '@main/lib/logger';
import { agentUpdateService } from './agent-update-service';
import { hostDependencyStore } from './host-dependency-store';
Expand All @@ -30,9 +34,34 @@ async function resolveLocalInstallShellProfile() {
});
}

function syncGitExecutable(event: DependencyStatusUpdatedEvent, connectionId?: string): void {
if (event.id !== 'git' || !event.hostDependency) return;

const activeInstallation = resolveActiveInstallation(
event.hostDependency.installations,
event.hostDependency.used
);

const executable = activeInstallation
? (activeInstallation.pathEntry ?? activeInstallation.realpath)
: gitExecutableFromMissingSelection(event.hostDependency.used);

setGitExecutableOverride(executable, connectionId);
}

function gitExecutableFromMissingSelection(selection: SelectedSource): string | null {
if (selection.kind === 'pinned') return selection.realpath;
if (selection.kind === 'path') return selection.path;
if (selection.kind === 'cli') return selection.command;
return null;
}

function wireDesktopBridges(manager: HostDependencyManager, connectionId?: string): void {
// AgentUpdateService owns the enriched event emission (adds latestVersion/updateAvailable)
agentUpdateService.attach(manager, connectionId);
manager.onStatusUpdated.subscribe((event: DependencyStatusUpdatedEvent) =>
syncGitExecutable(event, connectionId)
);
manager.onExecutableInvalidated.subscribe(({ id }: { id: DependencyId }) => {
clearResolvedPathCache(id, connectionId);
});
Expand Down Expand Up @@ -107,6 +136,7 @@ async function createSshDependencyManager(connectionId: string): Promise<HostDep
export function clearDependencyManager(connectionId: string): void {
sshManagers.delete(connectionId);
sshManagerPromises.delete(connectionId);
setGitExecutableOverride(null, connectionId);
}

export async function ensureAgentDependenciesProbed(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ describe('buildDescriptorFromProvider', () => {
});

describe('DEPENDENCIES', () => {
it('contains core dependencies', () => {
expect(DEPENDENCIES).toContainEqual(
expect.objectContaining({ id: 'git', category: 'core', commands: ['git'] })
);
});

it('contains an entry for every registered plugin', () => {
const pluginIds = pluginRegistry.getAll().map((p: CLIAgentPluginProvider) => p.metadata.id);
for (const id of pluginIds) {
Expand Down
14 changes: 9 additions & 5 deletions apps/emdash-desktop/src/main/core/dependencies/registry.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { CLIAgentPluginProvider } from '@emdash/core/agents/plugins';
import type {
DependencyDescriptor,
DependencyStatus,
ProbeResult,
import {
CORE_DEPENDENCIES,
type DependencyDescriptor,
type DependencyStatus,
type ProbeResult,
} from '@emdash/core/deps/runtime';
import { pluginRegistry } from '@emdash/plugins/agents';

Expand Down Expand Up @@ -67,7 +68,10 @@ function buildAgentDependencies(): DependencyDescriptor[] {
return pluginRegistry.getAll().map(buildDescriptorFromProvider);
}

export const DEPENDENCIES: DependencyDescriptor[] = buildAgentDependencies();
export const DEPENDENCIES: DependencyDescriptor[] = [
...CORE_DEPENDENCIES,
...buildAgentDependencies(),
];
export const AGENT_DEPENDENCIES = DEPENDENCIES.filter(
(dependency) => dependency.category === 'agent'
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import {
GIT_EXECUTABLE,
getGitExecutable,
isMissingGitExecutableError,
missingGitExecutableError,
} from '@main/core/utils/exec';
Expand Down Expand Up @@ -34,7 +34,7 @@ export class LocalExecutionContext implements IExecutionContext {
}

private resolveCommand(command: string): string {
return command === 'git' ? GIT_EXECUTABLE : command;
return command === 'git' ? getGitExecutable() : command;
}

exec(command: string, args: string[] = [], opts: ExecOptions = {}): Promise<ExecResult> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,21 @@ describe('buildSshCommand', () => {
const command = buildSshCommand('/workspace/project', 'git', ['fetch', 'origin']);

expect(command).toBe(
"'/bin/sh' -c 'cd '\\''/workspace/project'\\'' && GIT_ASKPASS='\\'''\\'' GIT_TERMINAL_PROMPT='\\''0'\\'' GCM_INTERACTIVE='\\''never'\\'' SSH_ASKPASS='\\'''\\'' git '\\''fetch'\\'' '\\''origin'\\'''"
"'/bin/sh' -c 'cd '\\''/workspace/project'\\'' && GIT_ASKPASS='\\'''\\'' GIT_TERMINAL_PROMPT='\\''0'\\'' GCM_INTERACTIVE='\\''never'\\'' SSH_ASKPASS='\\'''\\'' '\\''git'\\'' '\\''fetch'\\'' '\\''origin'\\'''"
);
});

it('uses the selected remote Git executable when provided', () => {
const command = buildSshCommand(
'/workspace/project',
'git',
['status'],
undefined,
'/opt/homebrew/bin/git'
);

expect(command).toBe(
"'/bin/sh' -c 'cd '\\''/workspace/project'\\'' && GIT_ASKPASS='\\'''\\'' GIT_TERMINAL_PROMPT='\\''0'\\'' GCM_INTERACTIVE='\\''never'\\'' SSH_ASKPASS='\\'''\\'' '\\''/opt/homebrew/bin/git'\\'' '\\''status'\\'''"
);
});
});
Loading
Loading