Skip to content
Closed
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
21 changes: 18 additions & 3 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ import {
assertOptionalBoolean,
} from './validate.js';
import { validateBranchName as sharedValidateBranchName, validateUUID } from '../mcp/validation.js';
import { warn as logWarn, errMessage } from '../log.js';
import { debug as logDebug, warn as logWarn, errMessage } from '../log.js';
import { getMCPRemoteServerUrl, detectStaleDockerMCPUrl } from '../mcp/config.js';
import { redactServerUrl } from '../remote/server.js';

Expand Down Expand Up @@ -917,16 +917,28 @@ export function registerAllHandlers(win: BrowserWindow): void {
const activeNotifications = new Set<Notification>();
ipcMain.handle(IPC.ShowNotification, (_e, args) => {
try {
if (!Notification.isSupported()) return;
assertString(args.title, 'title');
assertString(args.body, 'body');
assertStringArray(args.taskIds, 'taskIds');
const ctx = { title: args.title, body: args.body, taskIds: args.taskIds };
logDebug('notification', 'show requested', ctx);
if (!Notification.isSupported()) {
logWarn('notification', 'native notifications are not supported', ctx);
return { ok: false, reason: 'unsupported' };
}
const notification = new Notification({
title: args.title,
body: args.body,
});
activeNotifications.add(notification);
const release = () => activeNotifications.delete(notification);
notification.on('show', () => {
logDebug('notification', 'show event', ctx);
});
notification.on('failed', (_event, error) => {
release();
logWarn('notification', 'show failed', { ...ctx, err: error });
});
notification.on('click', () => {
release();
if (!win.isDestroyed()) {
Expand All @@ -937,6 +949,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
});
notification.on('close', release);
notification.show();
logDebug('notification', 'show invoked', ctx);
// On Linux, notifications may not auto-dismiss. Close after 30 seconds
// to prevent accumulation in the notification tray.
if (process.platform === 'linux') {
Expand All @@ -945,8 +958,10 @@ export function registerAllHandlers(win: BrowserWindow): void {
release();
}, 30_000);
}
return { ok: true, reason: 'show_invoked' };
} catch (err) {
console.warn('ShowNotification failed:', err);
logWarn('notification', 'show request failed', { err: errMessage(err) });
return { ok: false, reason: 'error', err: errMessage(err) };
}
});

Expand Down
12 changes: 8 additions & 4 deletions electron/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@ export class MCPClient {
return this.request<ApiTaskDetail>('GET', `/api/tasks/${encodeURIComponent(taskId)}`);
}

async sendPrompt(taskId: string, prompt: string): Promise<void> {
await this.request<unknown>('POST', `/api/tasks/${encodeURIComponent(taskId)}/prompt`, {
prompt,
});
async sendPrompt(taskId: string, prompt: string): Promise<{ queued?: boolean }> {
return this.request<{ queued?: boolean }>(
'POST',
`/api/tasks/${encodeURIComponent(taskId)}/prompt`,
{
prompt,
},
);
}

async waitForIdle(
Expand Down
191 changes: 191 additions & 0 deletions electron/mcp/coordinator-real-agents.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { Coordinator } from './coordinator.js';

const RUN_REAL_AGENT_SMOKE = process.env.RUN_REAL_AGENT_SMOKE === '1';
// Dangerous flags (--dangerously-bypass-approvals-and-sandbox, --dangerously-skip-permissions)
// run agents with no sandbox on the host machine. Require a second opt-in to avoid accidental
// credential exposure on dev machines running with credentials in scope.
const RUN_REAL_AGENT_DANGEROUS = process.env.RUN_REAL_AGENT_DANGEROUS === '1';
const describeRealAgents =
RUN_REAL_AGENT_SMOKE && RUN_REAL_AGENT_DANGEROUS ? describe : describe.skip;

interface RealAgentProfile {
name: 'codex' | 'claude' | 'gemini';
command: string;
args: string[];
}

interface RendererEvent {
channel: string;
payload: unknown;
}

function createMockWindow(events: RendererEvent[]): import('electron').BrowserWindow {
return {
isDestroyed: () => false,
webContents: {
send: (channel: string, payload: unknown) => {
events.push({ channel, payload });
},
},
} as unknown as import('electron').BrowserWindow;
}

function runGit(cwd: string, args: string[]): void {
execFileSync('git', args, { cwd, stdio: 'ignore' });
}

function createRepo(): string {
const repo = mkdtempSync(join(tmpdir(), 'parallel-code-real-agent-repo-'));
runGit(repo, ['init']);
runGit(repo, ['checkout', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'parallel-code-test@example.com']);
runGit(repo, ['config', 'user.name', 'Parallel Code Test']);
writeFileSync(join(repo, 'README.md'), '# real agent smoke\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'initial']);
return repo;
}

function which(command: string): string | null {
try {
return execFileSync('which', [command], { encoding: 'utf8' }).trim() || null;
} catch {
return null;
}
}

function realAgentProfiles(): RealAgentProfile[] {
const profiles: RealAgentProfile[] = [];
const codex = which('codex');
if (codex) {
profiles.push({
name: 'codex',
command: codex,
args: ['--dangerously-bypass-approvals-and-sandbox', '--no-alt-screen'],
});
}

const claude = which('claude');
if (claude) {
profiles.push({
name: 'claude',
command: claude,
args: ['--dangerously-skip-permissions'],
});
}

const gemini = which('gemini');
if (gemini) {
profiles.push({
name: 'gemini',
command: gemini,
args: [
'--skip-trust',
'--prompt-interactive',
'Do not edit files. Wait for the next user instruction.',
],
});
}

return profiles;
}

async function waitForInitialPromptDelivery(
events: RendererEvent[],
taskId: string,
coordinator: Coordinator,
timeoutMs = 90_000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const delivered = events.some((event) => {
if (event.channel !== 'mcp_task_state_sync') return false;
const payload = event.payload as { taskId?: string; initialPrompt?: string | null };
return payload.taskId === taskId && payload.initialPrompt === null;
});
if (delivered) return;
const status = coordinator.getTaskStatus(taskId);
if (status?.status === 'exited' || status?.status === 'error') {
throw new Error(
`Task exited before initial prompt delivery. Last output:\n${coordinator.getTaskOutput(taskId)?.slice(-2048) ?? ''}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for initial prompt delivery to ${taskId}`);
}

describeRealAgents('Coordinator real agent startup smoke', () => {
it.each(realAgentProfiles())(
'delivers an initial prompt to installed $name',
async ({ name, command, args }) => {
const repo = createRepo();
const coordinator = new Coordinator();
const rendererEvents: RendererEvent[] = [];
let taskId: string | undefined;
const token = `PARALLEL_CODE_REAL_AGENT_SMOKE_${name.toUpperCase()}`;

// Isolate spawned CLIs from host dotfiles/credentials.
const tempHome = mkdtempSync(join(tmpdir(), 'parallel-code-test-home-'));
const origHome = process.env.HOME;
process.env.HOME = tempHome;

try {
coordinator.setWindow(createMockWindow(rendererEvents));
coordinator.setDefaultProject('proj-1', repo);
coordinator.registerCoordinator('coord-1', 'proj-1', {
branchName: 'main',
worktreePath: repo,
});
coordinator.setCoordinatorSpawnDefaults('coord-1', command, args);

const task = await coordinator.createTask({
name: `${name} real startup smoke`,
prompt: [
'This is a Parallel Code startup delivery smoke test.',
'Do not edit files, run commands, commit, or call tools.',
`Reply with exactly this token and no extra text: ${token}`,
].join(' '),
coordinatorTaskId: 'coord-1',
});
taskId = task.id;

await waitForInitialPromptDelivery(rendererEvents, task.id, coordinator);

const status = execFileSync('git', ['status', '--short'], {
cwd: task.worktreePath,
encoding: 'utf8',
});
const realChanges = status
.split('\n')
.filter(Boolean)
.filter((line) => {
const filePath = line.slice(3);
return (
!['AGENTS.md', 'GEMINI.md'].includes(filePath) && !filePath.startsWith('.claude/')
);
});
expect(realChanges).toEqual([]);
} finally {
process.env.HOME = origHome;
if (taskId) {
await coordinator.closeTask(taskId).catch(() => undefined);
}
if (existsSync(repo)) {
rmSync(repo, { recursive: true, force: true });
}
rmSync(tempHome, { recursive: true, force: true });
}
},
120_000,
);

it('has at least one installed real agent when enabled', () => {
expect(realAgentProfiles().length).toBeGreaterThan(0);
});
});
Loading
Loading