Skip to content

Commit b436f19

Browse files
brookscclaude
andcommitted
feat(mcp): task visibility, pilot/co-pilot control, and expanded create_task options
- Coordinator sub-tasks now appear as individual sidebar panels - Pilot/co-pilot control handoff with visual banner - create_task exposes skipPermissions and gitIsolation - Multiple bug fixes and tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f482dcd commit b436f19

8 files changed

Lines changed: 378 additions & 59 deletions

File tree

electron/mcp/client.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,19 @@ export class MCPClient {
1616
'Content-Type': 'application/json',
1717
};
1818

19-
const res = await fetch(url, {
20-
method,
21-
headers,
22-
body: body !== undefined ? JSON.stringify(body) : undefined,
23-
});
19+
let res: Response;
20+
try {
21+
res = await fetch(url, {
22+
method,
23+
headers,
24+
body: body !== undefined ? JSON.stringify(body) : undefined,
25+
});
26+
} catch (err) {
27+
const cause = err instanceof Error ? err.message : String(err);
28+
throw new Error(
29+
`Cannot reach Parallel Code at ${this.baseUrl}. ` + `Is the app running? (${cause})`,
30+
);
31+
}
2432

2533
if (!res.ok) {
2634
const text = await res.text().catch(() => '');
@@ -34,6 +42,8 @@ export class MCPClient {
3442
name: string;
3543
prompt?: string;
3644
projectId?: string;
45+
skipPermissions?: boolean;
46+
gitIsolation?: 'worktree' | 'direct' | 'none';
3747
}): Promise<ApiTaskDetail> {
3848
return this.request<ApiTaskDetail>('POST', '/api/tasks', opts);
3949
}

electron/mcp/orchestrator.test.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { BrowserWindow } from 'electron';
3+
4+
// --- hoisted mocks (must be before any imports that use them) ---
5+
const {
6+
mockCreateBackendTask,
7+
mockDeleteTask,
8+
mockWriteToAgent,
9+
mockKillAgent,
10+
mockOnPtyEvent,
11+
spawnListeners,
12+
exitListeners,
13+
} = vi.hoisted(() => {
14+
// Track registered listeners so tests can fire them
15+
const spawnListeners: Array<(agentId: string) => void> = [];
16+
const exitListeners: Array<(agentId: string, data?: unknown) => void> = [];
17+
18+
const mockCreateBackendTask = vi.fn().mockResolvedValue({
19+
id: 'task-abc',
20+
branch_name: 'task/test-abc',
21+
worktree_path: '/workspace/.worktrees/test',
22+
});
23+
const mockDeleteTask = vi.fn().mockResolvedValue(undefined);
24+
const mockWriteToAgent = vi.fn();
25+
const mockKillAgent = vi.fn();
26+
27+
const mockOnPtyEvent = vi.fn((event: string, listener: (id: string, data?: unknown) => void) => {
28+
if (event === 'spawn') spawnListeners.push(listener as (id: string) => void);
29+
if (event === 'exit') exitListeners.push(listener);
30+
return () => {};
31+
});
32+
33+
return {
34+
mockCreateBackendTask,
35+
mockDeleteTask,
36+
mockWriteToAgent,
37+
mockKillAgent,
38+
mockOnPtyEvent,
39+
spawnListeners,
40+
exitListeners,
41+
};
42+
});
43+
44+
vi.mock('../ipc/tasks.js', () => ({
45+
createTask: mockCreateBackendTask,
46+
deleteTask: mockDeleteTask,
47+
}));
48+
49+
vi.mock('../ipc/pty.js', () => ({
50+
writeToAgent: mockWriteToAgent,
51+
killAgent: mockKillAgent,
52+
subscribeToAgent: vi.fn().mockReturnValue(true),
53+
unsubscribeFromAgent: vi.fn(),
54+
getAgentScrollback: vi.fn().mockReturnValue(null),
55+
onPtyEvent: mockOnPtyEvent,
56+
}));
57+
58+
vi.mock('../ipc/git.js', () => ({
59+
getChangedFiles: vi.fn().mockResolvedValue([]),
60+
getAllFileDiffs: vi.fn().mockResolvedValue(''),
61+
mergeTask: vi.fn().mockResolvedValue({
62+
main_branch: 'main',
63+
lines_added: 0,
64+
lines_removed: 0,
65+
}),
66+
}));
67+
68+
import { Orchestrator } from './orchestrator.js';
69+
70+
function mockWindow(): BrowserWindow {
71+
return {
72+
isDestroyed: vi.fn(() => false),
73+
webContents: { send: vi.fn() },
74+
} as unknown as BrowserWindow;
75+
}
76+
77+
async function makeOrchestratorWithTask(): Promise<{ orch: Orchestrator; taskId: string }> {
78+
const orch = new Orchestrator();
79+
orch.setWindow(mockWindow());
80+
orch.setDefaultProject('proj-1', '/workspace', 'coord-1');
81+
82+
const task = await orch.createTask({ name: 'test-task', coordinatorTaskId: 'coord-1' });
83+
const taskId = task.id;
84+
85+
// Simulate the renderer spawning the agent (triggers onPtyEvent 'spawn')
86+
for (const listener of spawnListeners) {
87+
listener(task.agentId);
88+
}
89+
90+
return { orch, taskId };
91+
}
92+
93+
beforeEach(() => {
94+
vi.clearAllMocks();
95+
spawnListeners.length = 0;
96+
exitListeners.length = 0;
97+
});
98+
99+
afterEach(() => {
100+
vi.restoreAllMocks();
101+
});
102+
103+
describe('Orchestrator.listTasks', () => {
104+
it('returns an empty list when no tasks exist', () => {
105+
const orch = new Orchestrator();
106+
expect(orch.listTasks()).toEqual([]);
107+
});
108+
109+
it('includes a task after createTask', async () => {
110+
const { orch, taskId } = await makeOrchestratorWithTask();
111+
const list = orch.listTasks();
112+
expect(list).toHaveLength(1);
113+
expect(list[0].id).toBe(taskId);
114+
expect(list[0].name).toBe('test-task');
115+
});
116+
});
117+
118+
describe('Orchestrator control handoff — sendPrompt', () => {
119+
it('sends the prompt normally when no control override is set', async () => {
120+
const { orch, taskId } = await makeOrchestratorWithTask();
121+
await orch.sendPrompt(taskId, 'do something');
122+
expect(mockWriteToAgent).toHaveBeenCalledWith(expect.any(String), 'do something');
123+
});
124+
125+
it('throws when task is under human control', async () => {
126+
const { orch, taskId } = await makeOrchestratorWithTask();
127+
orch.setTaskControl(taskId, 'human');
128+
await expect(orch.sendPrompt(taskId, 'do something')).rejects.toThrow(/human control/);
129+
});
130+
131+
it('resumes sending after control is returned to orchestrator', async () => {
132+
const { orch, taskId } = await makeOrchestratorWithTask();
133+
orch.setTaskControl(taskId, 'human');
134+
orch.setTaskControl(taskId, 'orchestrator');
135+
await orch.sendPrompt(taskId, 'back in action');
136+
expect(mockWriteToAgent).toHaveBeenCalledWith(expect.any(String), 'back in action');
137+
});
138+
139+
it('throws for unknown task id', async () => {
140+
const orch = new Orchestrator();
141+
await expect(orch.sendPrompt('nonexistent', 'hello')).rejects.toThrow(/not found/);
142+
});
143+
});
144+
145+
describe('Orchestrator control handoff — waitForIdle', () => {
146+
it('resolves immediately when task is already idle', async () => {
147+
const { orch, taskId } = await makeOrchestratorWithTask();
148+
// Manually set status to idle (simulating output detection)
149+
const task = orch.getTask(taskId);
150+
if (task) task.status = 'idle';
151+
await expect(orch.waitForIdle(taskId, 100)).resolves.toBeUndefined();
152+
});
153+
154+
it('resolves immediately when human has control (orchestrator paused)', async () => {
155+
const { orch, taskId } = await makeOrchestratorWithTask();
156+
orch.setTaskControl(taskId, 'human');
157+
await expect(orch.waitForIdle(taskId, 100)).resolves.toBeUndefined();
158+
});
159+
160+
it('resolves pending waiters when control is returned to orchestrator', async () => {
161+
const { orch, taskId } = await makeOrchestratorWithTask();
162+
// Task is running — waitForIdle should block
163+
const task = orch.getTask(taskId);
164+
if (task) task.status = 'running';
165+
166+
const waited = orch.waitForIdle(taskId, 10_000);
167+
// Taking human control resolves it immediately
168+
orch.setTaskControl(taskId, 'human');
169+
await expect(waited).resolves.toBeUndefined();
170+
});
171+
172+
it('times out if the agent never becomes idle', async () => {
173+
const { orch, taskId } = await makeOrchestratorWithTask();
174+
const task = orch.getTask(taskId);
175+
if (task) task.status = 'running';
176+
await expect(orch.waitForIdle(taskId, 10)).rejects.toThrow(/[Tt]imed out/);
177+
});
178+
179+
it('rejects for unknown task id', async () => {
180+
const orch = new Orchestrator();
181+
await expect(orch.waitForIdle('nonexistent', 100)).rejects.toThrow(/not found/);
182+
});
183+
});
184+
185+
describe('Orchestrator task status after PTY events', () => {
186+
it('marks task exited when PTY exit fires for its agent', async () => {
187+
const { orch, taskId } = await makeOrchestratorWithTask();
188+
const task = orch.getTask(taskId);
189+
expect(task).toBeTruthy();
190+
191+
const agentId = task?.agentId ?? '';
192+
for (const listener of exitListeners) {
193+
listener(agentId, { exitCode: 0 });
194+
}
195+
196+
expect(orch.getTask(taskId)?.status).toBe('exited');
197+
expect(orch.getTask(taskId)?.exitCode).toBe(0);
198+
});
199+
200+
it('resolves pending waitForIdle when task exits', async () => {
201+
const { orch, taskId } = await makeOrchestratorWithTask();
202+
const task = orch.getTask(taskId);
203+
if (task) task.status = 'running';
204+
205+
const waited = orch.waitForIdle(taskId, 10_000);
206+
const agentId = task?.agentId ?? '';
207+
for (const listener of exitListeners) {
208+
listener(agentId, { exitCode: 1 });
209+
}
210+
await expect(waited).resolves.toBeUndefined();
211+
});
212+
});

0 commit comments

Comments
 (0)