diff --git a/electron/ipc/agents.ts b/electron/ipc/agents.ts index 36febbba5..2188cbd69 100644 --- a/electron/ipc/agents.ts +++ b/electron/ipc/agents.ts @@ -1,10 +1,11 @@ import { execFile } from 'child_process'; import { promisify } from 'util'; import path from 'path'; +import type { AgentBackend } from '../mcp/agent-backends.js'; const execFileAsync = promisify(execFile); -interface AgentDef { +export interface AgentDef { id: string; name: string; command: string; @@ -99,6 +100,13 @@ export function getSkipPermissionsArgs(command: string): string[] { return agent ? [...agent.skip_permissions_args] : []; } +export function getAgentByBackend(backend: AgentBackend): AgentDef { + const id = backend === 'claude' ? 'claude-code' : backend; + const agent = DEFAULT_AGENTS.find((candidate) => candidate.id === id); + if (!agent) throw new Error(`Missing built-in agent definition for backend: ${backend}`); + return { ...agent, args: [...agent.args] }; +} + export async function listAgents(): Promise { const now = Date.now(); if (cachedAgents && now - cacheTime < AGENT_CACHE_TTL) { diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index 9c71a1bee..fe323d092 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -775,7 +775,10 @@ export async function createWorktree( ensureClaudeSandboxFiles(worktreePath, repoRoot); ensureSandboxExcludes(worktreePath); - ensureSymlinkExcludes(worktreePath, createdSymlinks); + // `.claude` is a real directory, but missing entries are copied from the + // main worktree and required sandbox placeholders are created locally. Ignore + // only untracked contents; tracked `.claude` changes remain visible to Git. + ensureSymlinkExcludes(worktreePath, [...createdSymlinks, '.claude']); return { path: worktreePath, branch: branchName }; } diff --git a/electron/mcp/agent-backends.ts b/electron/mcp/agent-backends.ts new file mode 100644 index 000000000..f1373f1a0 --- /dev/null +++ b/electron/mcp/agent-backends.ts @@ -0,0 +1,14 @@ +export const AGENT_BACKENDS = [ + 'claude', + 'codex', + 'gemini', + 'opencode', + 'copilot', + 'antigravity', +] as const; + +export type AgentBackend = (typeof AGENT_BACKENDS)[number]; + +export function isAgentBackend(value: unknown): value is AgentBackend { + return typeof value === 'string' && AGENT_BACKENDS.some((backend) => backend === value); +} diff --git a/electron/mcp/agent-frame-fixtures.ts b/electron/mcp/agent-frame-fixtures.ts index ad78a1a4c..068c71f82 100644 --- a/electron/mcp/agent-frame-fixtures.ts +++ b/electron/mcp/agent-frame-fixtures.ts @@ -17,6 +17,15 @@ export const READY_AGENT_FRAME_FIXTURES: AgentFrameFixture[] = [ 'opus · /Users/brooksc/git/parallel-code/.worktrees/task-023-10-loading-states · ctx:24k/200k', ].join('\n'), }, + { + name: 'Claude empty prompt with rotating suggestion', + frame: [ + '─'.repeat(80), + '❯ Try "how does work?"', + '─'.repeat(80), + '⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents', + ].join('\r'), + }, { name: 'Claude empty insert mode at fresh prompt', frame: [ @@ -44,6 +53,11 @@ export const READY_AGENT_FRAME_FIXTURES: AgentFrameFixture[] = [ 'gpt-5.5 default · /Users/brooksc/git/parallel-code/.worktrees/task-028-unit-tests', ].join('\n'), }, + { + name: 'Codex prompt with redraw-damaged escape sequences', + frame: + '13;15H s qg se q MCP server q q›Summarize recent commitsgpt-5.5 medium · ~/repo/.worktrees/task/example q', + }, { name: 'Gemini typed-message prompt', frame: [ diff --git a/electron/mcp/client.ts b/electron/mcp/client.ts index fc3ad951b..ae2f06437 100644 --- a/electron/mcp/client.ts +++ b/electron/mcp/client.ts @@ -52,6 +52,7 @@ export class MCPClient { coordinatorTaskId?: string; skipPermissions?: boolean; baseBranch?: string; + backend?: string; }): Promise { return this.request('POST', '/api/tasks', opts); } diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 3b7c34312..8ab027657 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -106,7 +106,7 @@ vi.mock('./prompt-detect.js', () => ({ .slice(-1000) .split(/\r\n?|\n/) .some((line) => - /(?:^|\s)[❯›]\s*$|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test( + /(?:^|\s)[❯›]\s*$|^\s*❯\s*Try\s+["“]|(?:^|\s|q)›[^\r\n›]*?gpt-[\w.-]+\s+\w+\s+·\s+~?\/|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test( line.trim(), ), ); @@ -131,7 +131,7 @@ vi.mock('./prompt-detect.js', () => ({ return tail .split(/\r\n?|\n/) .some((line) => - /(?:^|\s)[❯›]\s*$|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test( + /(?:^|\s)[❯›]\s*$|^\s*❯\s*Try\s+["“]|(?:^|\s|q)›[^\r\n›]*?gpt-[\w.-]+\s+\w+\s+·\s+~?\/|^\s*--\s*INSERT\s*--\s*$|^\s*>\s*(?:Type your message|$)/i.test( line.trim(), ), ); @@ -181,7 +181,7 @@ vi.mock('../log.js', () => ({ // Import after mocks const { Coordinator } = await import('./coordinator.js'); -const { removePreambleBlock } = await import('./preamble.js'); +const { normalizePreambleFileContent, removePreambleBlock } = await import('./preamble.js'); // --- helpers --- function getExitHandler(): (agentId: string, data: unknown) => void { @@ -1987,6 +1987,45 @@ describe('Coordinator sub-agent spawn settings', () => { ); }); + it('uses the requested backend instead of the coordinator command', async () => { + coordinator.setCoordinatorSpawnDefaults('coord-1', 'claude', ['--model', 'opus']); + await coordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-1', + backend: 'codex', + }); + expect(mockSpawnAgent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ command: 'codex', args: expect.not.arrayContaining(['opus']) }), + ); + }); + + it('maps the antigravity backend to the agy command', async () => { + await coordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-1', + backend: 'antigravity', + }); + expect(mockSpawnAgent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ command: 'agy' }), + ); + }); + + it('rejects unsupported backends before creating a worktree', async () => { + await expect( + coordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-1', + backend: 'unknown', + }), + ).rejects.toThrow('Unsupported agent backend'); + expect(mockCreateBackendTask).not.toHaveBeenCalled(); + }); + it('inherits coordinator base args (e.g. --model)', async () => { coordinator.setCoordinatorSpawnDefaults('coord-1', 'claude', ['--model', 'claude-opus-4-7']); await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); @@ -5601,6 +5640,24 @@ describe('Coordinator removePreambleBlock', () => { }); }); +describe('normalizePreambleFileContent', () => { + const BLOCK = '\nrules\n'; + + it('normalizes a committed AGENTS.md preamble on both sides of landing comparisons', () => { + expect(normalizePreambleFileContent('AGENTS.md', BLOCK)).toBe(''); + expect(normalizePreambleFileContent('AGENTS.md', `${BLOCK}\n\n# User change`)).toBe( + '# User change', + ); + }); + + it('normalizes injected Claude settings while preserving user settings', () => { + const content = JSON.stringify({ model: 'opus', systemPrompt: BLOCK }); + expect(normalizePreambleFileContent('.claude/settings.local.json', content)).toBe( + JSON.stringify({ model: 'opus' }, null, 2), + ); + }); +}); + // ─── getTaskDiff — preamble-bearing files (#34) ─────────────────────────────── describe('Coordinator getTaskDiff — preamble-bearing files', () => { diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 5b4132d28..5384156c5 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -30,7 +30,8 @@ import { const execAsync = promisify(execFile); import type { BrowserWindow } from 'electron'; import { createTask as createBackendTask, deleteTask } from '../ipc/tasks.js'; -import { getSkipPermissionsArgs } from '../ipc/agents.js'; +import { getAgentByBackend, getSkipPermissionsArgs } from '../ipc/agents.js'; +import { isAgentBackend } from './agent-backends.js'; import { spawnAgent, writeToAgent, @@ -792,6 +793,7 @@ export class Coordinator { agentArgs?: string[]; skipPermissions?: boolean; baseBranch?: string; + backend?: string; }): Promise { const coordinatorId = opts.coordinatorTaskId !== REST_COORDINATOR_SENTINEL @@ -820,6 +822,16 @@ export class Coordinator { if (baseBranch !== undefined) { validateBranchName(baseBranch, 'baseBranch'); } + const backend = opts.backend; + if (backend && !isAgentBackend(backend)) { + throw new Error(`Unsupported agent backend: ${backend}`); + } + const requestedAgent = + backend && isAgentBackend(backend) ? getAgentByBackend(backend) : undefined; + const selectedAgentCommand = + requestedAgent?.command ?? opts.agentCommand ?? coordinatorState.spawnDefaults.command; + const selectedAgentArgs = + requestedAgent?.args ?? opts.agentArgs ?? coordinatorState.spawnDefaults.args; // Create worktree + branch via existing backend const result = await createBackendTask( @@ -878,7 +890,7 @@ export class Coordinator { // Spawn the agent process if (!this.win) throw new Error('No window set on coordinator'); - const agentCmd = (opts.agentCommand ?? coordinatorState.spawnDefaults.command).toLowerCase(); + const agentCmd = selectedAgentCommand.toLowerCase(); const preamble = `\nThese rules override all skills and hooks:\n- When your work is complete, commit your changes and call the \`land_self\` MCP tool with the verification checks you ran. A successful \`land_self\` call is the finish line — do NOT call \`signal_done\` afterward, use finishing-a-development-branch, or offer merge/PR options.\n- Use \`signal_done\` only if the coordinator explicitly asks for manual review instead of self-landing.\n- Asking questions is fine when requirements are unclear or an action is risky.\n`; // Declared here so the catch block can restore preamble files on failure. let preambleFilePath: string | undefined; @@ -986,8 +998,8 @@ export class Coordinator { task.mcpConfigPath = configPath; } - const agentCommand = opts.agentCommand ?? coordinatorState.spawnDefaults.command; - const agentArgs = opts.agentArgs ?? coordinatorState.spawnDefaults.args; + const agentCommand = selectedAgentCommand; + const agentArgs = selectedAgentArgs; const baseArgs = [ ...agentArgs, ...(coordinatorState.propagateSkipPermissions ? getSkipPermissionsArgs(agentCommand) : []), diff --git a/electron/mcp/mcp-tool-list.test.ts b/electron/mcp/mcp-tool-list.test.ts index c83e8d7be..98e3cdae1 100644 --- a/electron/mcp/mcp-tool-list.test.ts +++ b/electron/mcp/mcp-tool-list.test.ts @@ -71,6 +71,18 @@ describe('selectTools — role-based tool list', () => { expect(properties?.prompt?.type).toBe('string'); }); + it('create_task exposes the supported agent backends', () => { + const createTask = COORDINATOR_TOOLS.find((tool) => tool.name === 'create_task'); + const properties = createTask?.inputSchema.properties as + | Record + | undefined; + expect(properties?.backend).toEqual({ + type: 'string', + enum: ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'antigravity'], + description: expect.stringContaining('Defaults to the coordinator backend'), + }); + }); + it('send_prompt requires a prompt', () => { const sendPrompt = COORDINATOR_TOOLS.find((tool) => tool.name === 'send_prompt'); expect(sendPrompt?.inputSchema.required).toContain('prompt'); diff --git a/electron/mcp/mcp-tool-list.ts b/electron/mcp/mcp-tool-list.ts index d820f4b91..d405c517d 100644 --- a/electron/mcp/mcp-tool-list.ts +++ b/electron/mcp/mcp-tool-list.ts @@ -68,6 +68,12 @@ export const COORDINATOR_TOOLS: ToolDef[] = [ description: 'Git branch to base the worktree on. Defaults to the coordinator task branch. Only set this when deliberately overriding that default.', }, + backend: { + type: 'string', + enum: ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'antigravity'], + description: + 'AI agent backend for this task. Defaults to the coordinator backend when omitted.', + }, }, required: ['name', 'prompt'], }, diff --git a/electron/mcp/preamble.ts b/electron/mcp/preamble.ts index f5a270f79..0460d07fb 100644 --- a/electron/mcp/preamble.ts +++ b/electron/mcp/preamble.ts @@ -36,6 +36,25 @@ export function removePreambleBlock(content: string): string { return `${before}\n\n${after}`; } +export function normalizePreambleFileContent( + filename: string, + content: string, + removePreamble: (value: string) => string = removePreambleBlock, +): string | null { + if (filename !== '.claude/settings.local.json') return removePreamble(content); + try { + const settings = JSON.parse(content) as Record; + if (typeof settings.systemPrompt === 'string') { + const stripped = removePreamble(settings.systemPrompt); + if (stripped.trim()) settings.systemPrompt = stripped; + else delete settings.systemPrompt; + } + return Object.keys(settings).length === 0 ? '' : JSON.stringify(settings, null, 2); + } catch { + return null; + } +} + /** Return the set of filenames (relative to worktreePath) that contain a preamble block. */ export async function detectPreambleFiles(worktreePath: string): Promise> { const result = new Set(); @@ -91,43 +110,28 @@ export async function buildNormalizedPreambleFileDiff( return ''; } - let normalizedContent: string; - if (filename === '.claude/settings.local.json') { - try { - const s = JSON.parse(worktreeContent) as Record; - if (typeof s.systemPrompt === 'string') { - const stripped = removePreamble(s.systemPrompt); - if (stripped.trim()) { - s.systemPrompt = stripped; - } else { - delete s.systemPrompt; - } - } - normalizedContent = Object.keys(s).length === 0 ? '' : JSON.stringify(s, null, 2); - } catch { - return ''; - } - } else { - normalizedContent = removePreamble(worktreeContent); - } + const normalizedContent = normalizePreambleFileContent(filename, worktreeContent, removePreamble); + if (normalizedContent === null) return ''; let baseContent = ''; try { - const { stdout } = await execAsync('git', ['show', `${baseSha}:${filename}`], { + const result = await execAsync('git', ['show', `${baseSha}:${filename}`], { cwd: worktreePath, }); - baseContent = stdout; + const stdout = typeof result === 'string' ? result : result.stdout; + baseContent = typeof stdout === 'string' ? stdout : ''; } catch { baseContent = ''; } - if (normalizedContent === baseContent) return ''; + const normalizedBaseContent = normalizePreambleFileContent(filename, baseContent, removePreamble); + if (normalizedBaseContent === null || normalizedContent === normalizedBaseContent) return ''; const id = randomUUID(); const tmpBase = join(os.tmpdir(), `parallel-code-base-${id}`); const tmpNorm = join(os.tmpdir(), `parallel-code-norm-${id}`); try { - writeFileSync(tmpBase, baseContent); + writeFileSync(tmpBase, normalizedBaseContent); writeFileSync(tmpNorm, normalizedContent); let diffOut = ''; try { diff --git a/electron/mcp/prompt-detect.ts b/electron/mcp/prompt-detect.ts index 58df5841a..6f10dc327 100644 --- a/electron/mcp/prompt-detect.ts +++ b/electron/mcp/prompt-detect.ts @@ -31,8 +31,13 @@ export const PROMPT_PATTERNS: RegExp[] = [ */ export const AGENT_READY_TAIL_PATTERNS: RegExp[] = [ /^\s*❯\s*$/, // Claude Code + /^\s*❯\s*Try\s+["“]/i, // Claude Code empty prompt with rotating suggestion /^\s*--\s*INSERT\s*--(?:$|\s|[^\w].*$)/i, // Claude Code vim-style input mode /^\s*›\s*$/, // Codex CLI + // Codex redraws can lose CSI escape introducers when output chunks split an + // escape sequence. stripAnsi then leaves `q` separators and cursor-position + // fragments, collapsing the input placeholder and footer onto one line. + /(?:^|\s|q)›[^\r\n›]*?gpt-[\w.-]+\s+\w+\s+·\s+~?\//i, /^\s*>\s*(?:Type your message|$)/i, // Gemini CLI ]; diff --git a/electron/mcp/server.test.ts b/electron/mcp/server.test.ts index ddeb81da3..7d5776a62 100644 --- a/electron/mcp/server.test.ts +++ b/electron/mcp/server.test.ts @@ -101,6 +101,35 @@ describe('MCP server tool handling', () => { ); }); + it('passes a valid create_task backend through to the backend', async () => { + const client = makeClient(); + + const result = await handleMCPToolCall( + { client, taskId: '', coordinatorId: 'coord-1' }, + 'create_task', + { name: 'child', prompt: 'do the work', backend: 'codex' }, + ); + + expect(result).not.toHaveProperty('isError'); + expect(client.createTask).toHaveBeenCalledWith(expect.objectContaining({ backend: 'codex' })); + }); + + it('rejects an unsupported create_task backend', async () => { + const client = makeClient(); + + const result = await handleMCPToolCall( + { client, taskId: '', coordinatorId: 'coord-1' }, + 'create_task', + { name: 'child', prompt: 'do the work', backend: 'unknown' }, + ); + + expect(result).toMatchObject({ + isError: true, + content: [{ text: expect.stringContaining('backend must be one of') }], + }); + expect(client.createTask).not.toHaveBeenCalled(); + }); + it('rejects an invalid create_task baseBranch before calling the backend', async () => { const client = makeClient(); diff --git a/electron/mcp/server.ts b/electron/mcp/server.ts index 29f7ac350..a07985ea8 100644 --- a/electron/mcp/server.ts +++ b/electron/mcp/server.ts @@ -9,6 +9,7 @@ import { MCPClient } from './client.js'; import { selectTools } from './mcp-tool-list.js'; import { validateBranchName } from './validation.js'; import type { LandSelfInput } from './types.js'; +import { AGENT_BACKENDS, isAgentBackend } from './agent-backends.js'; export interface MCPToolHandlerContext { client: MCPClient; @@ -47,11 +48,24 @@ export async function handleMCPToolCall( const rawBranch = p.baseBranch; const baseBranch = rawBranch !== undefined ? validateBranchName(rawBranch, 'baseBranch') : undefined; + const backend = p.backend; + if (backend !== undefined && !isAgentBackend(backend)) { + return { + content: [ + { + type: 'text', + text: `Error: backend must be one of: ${AGENT_BACKENDS.join(', ')}`, + }, + ], + isError: true, + }; + } const result = await client.createTask({ name: p.name as string, prompt: p.prompt, coordinatorTaskId: coordinatorId || undefined, baseBranch, + backend, }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } diff --git a/electron/remote/coordinator-scoping.test.ts b/electron/remote/coordinator-scoping.test.ts index 0df31e1aa..81f7282d9 100644 --- a/electron/remote/coordinator-scoping.test.ts +++ b/electron/remote/coordinator-scoping.test.ts @@ -588,6 +588,34 @@ describe('coordinator scoping', () => { expect(mockCoord.createTask).toHaveBeenLastCalledWith(expect.objectContaining({ prompt })); }); + it('passes a supported create_task backend to the coordinator', async () => { + const res = await post( + '/api/tasks', + { name: 'new task', prompt: 'do the work', backend: 'antigravity' }, + COORD_A, + ); + + expect(res.status).toBe(201); + expect(mockCoord.createTask).toHaveBeenLastCalledWith( + expect.objectContaining({ backend: 'antigravity' }), + ); + }); + + it('rejects an unsupported create_task backend before calling the coordinator', async () => { + const beforeCalls = vi.mocked(mockCoord.createTask).mock.calls.length; + const res = await post( + '/api/tasks', + { name: 'new task', prompt: 'do the work', backend: 'unknown' }, + COORD_A, + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + error: expect.stringContaining('backend must be one of'), + }); + expect(vi.mocked(mockCoord.createTask).mock.calls).toHaveLength(beforeCalls); + }); + it('rejects oversized multi-byte create_task prompts before calling the coordinator', async () => { const beforeCalls = vi.mocked(mockCoord.createTask).mock.calls.length; diff --git a/electron/remote/server.ts b/electron/remote/server.ts index 86c093691..0c29e3a3d 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -22,6 +22,7 @@ import { parseClientMessage, type ServerMessage, type RemoteAgent } from './prot import type { Coordinator } from '../mcp/coordinator.js'; import { validateBranchName } from '../mcp/validation.js'; import type { LandSelfInput, SubtaskVerification } from '../mcp/types.js'; +import { AGENT_BACKENDS, isAgentBackend } from '../mcp/agent-backends.js'; // --- MCP log ring buffer --- export interface MCPLogEntry { @@ -470,6 +471,15 @@ export function startRemoteServer(opts: { return jsonReply(400, { error: String(e) }); } } + let backend: string | undefined; + if (body.backend !== undefined) { + if (!isAgentBackend(body.backend)) { + return jsonReply(400, { + error: `backend must be one of: ${AGENT_BACKENDS.join(', ')}`, + }); + } + backend = body.backend; + } // For coordinator-token callers, the authoritative coordinator ID is // the verified X-Coordinator-Id header (callerCoordinatorId). Reject // any body value that tries to create a task under a different coordinator, @@ -494,6 +504,7 @@ export function startRemoteServer(opts: { coordinatorTaskId, projectId: body.projectId as string | undefined, baseBranch, + backend, }); mcpLog('info', `create_task OK id=${result.id}`); jsonReply(201, orch.getTaskStatus(result.id)); diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx index cbf726617..8caf8480f 100644 --- a/src/components/PromptInput.tsx +++ b/src/components/PromptInput.tsx @@ -33,6 +33,7 @@ import { isLandedTaskState } from '../store/landing'; import { processAutoFireTick } from './autofire-tick'; import { resolveAutoSendVerifyOutcome, + shouldAcceptMissingInitialPromptEcho, shouldAckInitialPromptDelivery, shouldHandoffCoordinatorQuestion, shouldRendererAutoSendInitialPrompt, @@ -770,6 +771,14 @@ export function PromptInput(props: PromptInputProps) { aborted: signal.aborted, retryCount: untrack(autoSendRetry), maxRetries: AUTO_SEND_MAX_RETRIES, + // Coordinator prompts include a large injected preamble. Claude Code + // compacts bracketed multi-line pastes instead of echoing the opening + // snippet, so a successful write cannot be verified textually. Do not + // restore that prompt and repopulate the input after it was submitted. + acceptMissingEcho: shouldAcceptMissingInitialPromptEcho({ + coordinatorMode: props.coordinatorMode, + initialPrompt: initialPromptSnapshot, + }), }); if (outcome !== 'deliver') { // The echo was never confirmed — Codex likely received the prompt diff --git a/src/components/prompt-control.test.ts b/src/components/prompt-control.test.ts index 90485683b..cdd1f7a34 100644 --- a/src/components/prompt-control.test.ts +++ b/src/components/prompt-control.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { resolveAutoSendVerifyOutcome, + shouldAcceptMissingInitialPromptEcho, shouldAckInitialPromptDelivery, shouldHandoffCoordinatorQuestion, shouldRendererAutoSendInitialPrompt, @@ -143,6 +144,10 @@ describe('resolveAutoSendVerifyOutcome', () => { expect(resolveAutoSendVerifyOutcome({ ...base, appeared: true })).toBe('deliver'); }); + it('delivers an accepted compacted prompt when its textual echo is missing', () => { + expect(resolveAutoSendVerifyOutcome({ ...base, acceptMissingEcho: true })).toBe('deliver'); + }); + it('retries when the echo is missing and retries remain', () => { expect(resolveAutoSendVerifyOutcome({ ...base, retryCount: 0 })).toBe('retry'); expect(resolveAutoSendVerifyOutcome({ ...base, retryCount: 1 })).toBe('retry'); @@ -163,4 +168,36 @@ describe('resolveAutoSendVerifyOutcome', () => { 'aborted', ); }); + + it('lets abort win over accepted missing echoes', () => { + expect(resolveAutoSendVerifyOutcome({ ...base, aborted: true, acceptMissingEcho: true })).toBe( + 'aborted', + ); + }); +}); + +describe('shouldAcceptMissingInitialPromptEcho', () => { + it('accepts a coordinator prompt whose large paste was compacted by the agent TUI', () => { + expect( + shouldAcceptMissingInitialPromptEcho({ + coordinatorMode: true, + initialPrompt: '[COORDINATOR MODE] coordinate these tasks', + }), + ).toBe(true); + }); + + it('keeps echo verification for ordinary initial prompts', () => { + expect( + shouldAcceptMissingInitialPromptEcho({ + coordinatorMode: false, + initialPrompt: 'implement the feature', + }), + ).toBe(false); + expect( + shouldAcceptMissingInitialPromptEcho({ + coordinatorMode: true, + initialPrompt: 'implement the feature', + }), + ).toBe(false); + }); }); diff --git a/src/components/prompt-control.ts b/src/components/prompt-control.ts index 97f4c56d7..30bdd8f32 100644 --- a/src/components/prompt-control.ts +++ b/src/components/prompt-control.ts @@ -37,6 +37,13 @@ export function shouldRendererAutoSendInitialPrompt(params: { export type AutoSendVerifyOutcome = 'deliver' | 'retry' | 'giveup' | 'aborted'; +export function shouldAcceptMissingInitialPromptEcho(params: { + coordinatorMode: boolean | undefined; + initialPrompt: string | undefined; +}): boolean { + return Boolean(params.coordinatorMode && params.initialPrompt?.startsWith('[COORDINATOR MODE]')); +} + /** * Decides what to do after an auto-sent prompt's echo-verification finishes. * @@ -53,8 +60,9 @@ export function resolveAutoSendVerifyOutcome(params: { aborted: boolean; retryCount: number; maxRetries: number; + acceptMissingEcho?: boolean; }): AutoSendVerifyOutcome { if (params.aborted) return 'aborted'; - if (params.appeared) return 'deliver'; + if (params.appeared || params.acceptMissingEcho) return 'deliver'; return params.retryCount < params.maxRetries ? 'retry' : 'giveup'; } diff --git a/src/store/coordinator-preamble.ts b/src/store/coordinator-preamble.ts index 27a0f40ee..bd5670363 100644 --- a/src/store/coordinator-preamble.ts +++ b/src/store/coordinator-preamble.ts @@ -6,7 +6,7 @@ export const COORDINATOR_PREAMBLE = `[COORDINATOR MODE] You are a coordinating agent inside Parallel Code. \ You have MCP tools to coordinate work across isolated git worktree tasks: -- create_task — Create a new task (own worktree + AI agent). Prompt is auto-delivered when the agent is ready. +- create_task — Create a new task (own worktree + AI agent). Optionally select its backend. Prompt is auto-delivered when the agent is ready. - list_tasks — List all coordinated tasks with status - get_task_status — Detailed status of a task - send_prompt — Send follow-up instructions to a task's agent