From cc813fa6e7b38d5491e9aa51b3b01e32e9ae5609 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 14 Oct 2026 17:40:35 +0300 Subject: [PATCH] fix(cli): prevent Windows cmd.exe multiline prompt truncation in watch --execute (#2070) Closes #2070 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cf0811c-a02a-41a7-ab00-904b6900db7b --- .changeset/windows-watch-multiline-prompt.md | 5 ++ .../src/cli/commands/watch/agent-spawn.ts | 83 +++++++++++++++++-- .../commands/watch/capabilities/execute.ts | 1 + .../squad-cli/src/cli/commands/watch/index.ts | 10 ++- test/cli/watch-agent-spawn.test.ts | 63 ++++++++++++++ test/cli/watch-capabilities.test.ts | 19 +++-- 6 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 .changeset/windows-watch-multiline-prompt.md diff --git a/.changeset/windows-watch-multiline-prompt.md b/.changeset/windows-watch-multiline-prompt.md new file mode 100644 index 000000000..a8db2f6f3 --- /dev/null +++ b/.changeset/windows-watch-multiline-prompt.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Spill multiline and oversized prompt arguments to temporary `.md` files on Windows so `cmd.exe` does not truncate `squad watch --execute` prompts at the first newline, and report dispatched issue counts in the round summary (#2070). diff --git a/packages/squad-cli/src/cli/commands/watch/agent-spawn.ts b/packages/squad-cli/src/cli/commands/watch/agent-spawn.ts index 5464a69da..9669b44fa 100644 --- a/packages/squad-cli/src/cli/commands/watch/agent-spawn.ts +++ b/packages/squad-cli/src/cli/commands/watch/agent-spawn.ts @@ -10,8 +10,9 @@ */ import { execFile, execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; +import os from 'node:os'; import path from 'node:path'; import type { WatchContext } from './types.js'; import { withAdditionalMcpConfig } from '../../core/copilot-invocation.js'; @@ -19,6 +20,9 @@ import { withAdditionalMcpConfig } from '../../core/copilot-invocation.js'; /** True when running on Windows — used to gate `shell: true`. */ export const IS_WINDOWS = process.platform === 'win32'; +/** Maximum single-arg length before spilling to a temp prompt file on Windows (cmd.exe limit is 8,191 chars). */ +export const WINDOWS_MAX_INLINE_ARG_LENGTH = 4_000; + export interface CopilotUsageOutput { currentModel: string; lastCallInputTokens: number; @@ -110,13 +114,78 @@ export function escapeForCmd(arg: string): string { return `"${escaped}"`; } +export interface PreparedWindowsArgs { + args: string[]; + tempFiles: string[]; + cleanup: () => void; +} + +/** + * Spill a multiline or oversized prompt argument to a temporary `.md` file + * so `cmd.exe /d /s /c` does not truncate at the first newline or hit the + * 8,191-character command-line limit (#2070). + */ +export function spillPromptArgForCmd( + arg: string, + tempFiles?: string[], +): string { + if ( + typeof arg !== 'string' || + (!/[\r\n]/.test(arg) && arg.length <= WINDOWS_MAX_INLINE_ARG_LENGTH) + ) { + return arg; + } + + try { + const dir = path.join(os.tmpdir(), 'squad-prompts'); + mkdirSync(dir, { recursive: true }); + const file = path.join( + dir, + `prompt-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}.md`, + ); + writeFileSync(file, arg, 'utf8'); + tempFiles?.push(file); + return `Your complete instructions are in the file ${file} - read that whole file first and follow it exactly as if it were this prompt.`; + } catch { + // If temp-file creation fails (e.g. read-only tmpdir), collapse newlines so cmd.exe does not truncate at line 1. + return arg.replace(/\r?\n+/g, ' '); + } +} + +/** + * Prepare and escape an array of args for cmd.exe shell invocation, tracking + * any temporary prompt files so the caller can clean them up when the process exits. + */ +export function prepareArgsForSpawn( + args: string[], + isWindows: boolean = IS_WINDOWS, +): PreparedWindowsArgs { + if (!isWindows) { + return { args, tempFiles: [], cleanup: () => {} }; + } + const tempFiles: string[] = []; + const prepared = args + .map(arg => spillPromptArgForCmd(arg, tempFiles)) + .map(escapeForCmd); + const cleanup = (): void => { + for (const file of tempFiles) { + try { + if (existsSync(file)) unlinkSync(file); + } catch { + // Best-effort cleanup of temp prompt files + } + } + }; + return { args: prepared, tempFiles, cleanup }; +} + /** * Escape an array of args for cmd.exe shell invocation. * Only applies on Windows — returns args unchanged on other platforms. */ -export function escapeArgs(args: string[]): string[] { - if (!IS_WINDOWS) return args; - return args.map(escapeForCmd); +export function escapeArgs(args: string[], isWindows: boolean = IS_WINDOWS): string[] { + if (!isWindows) return args; + return args.map(arg => spillPromptArgForCmd(arg)).map(escapeForCmd); } /** @@ -242,7 +311,7 @@ export function spawnWithTimeout( cwd: string, timeoutMs: number, ): Promise { - const safeArgs = escapeArgs(args); + const { args: safeArgs, cleanup } = prepareArgsForSpawn(args); return new Promise((resolve, reject) => { execFile(cmd, safeArgs, { cwd, @@ -250,6 +319,7 @@ export function spawnWithTimeout( maxBuffer: 50 * 1024 * 1024, shell: IS_WINDOWS, }, (err) => { + cleanup(); if (err) { const execErr = err as Error & { killed?: boolean }; reject(new Error( @@ -281,7 +351,7 @@ export function spawnAgent( pidTracking?: { tracker: NonNullable; label: string }, usageOutputFile?: string, ): Promise { - const safeArgs = escapeArgs(args); + const { args: safeArgs, cleanup } = prepareArgsForSpawn(args); return new Promise((resolve) => { const cp = execFile( cmd, @@ -293,6 +363,7 @@ export function spawnAgent( shell: IS_WINDOWS, }, (err) => { + cleanup(); const usageResult = usageOutputFile ? readCopilotUsageOutput(usageOutputFile) : {}; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts index 95de4dd4a..090e11c42 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts @@ -301,6 +301,7 @@ export class ExecuteCapability implements WatchCapability { : `agent failed: ${result.error}${contextSummary}`, data: { dispatched: eligible.length, + executed: result.success ? eligible.length : 0, success: result.success, ...(contextUtilization ? { contextUtilization } : {}), ...(contextTelemetryError ? { contextTelemetryError } : {}), diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index 54c98e718..015ecda69 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -1044,8 +1044,14 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig // Update executed count from execute capability const execResult = execResults.get('execute'); - if (execResult?.data?.['executed']) { - roundState.executed = execResult.data['executed'] as number; + const executedCount = + typeof execResult?.data?.['executed'] === 'number' + ? (execResult.data['executed'] as number) + : typeof execResult?.data?.['dispatched'] === 'number' + ? (execResult.data['dispatched'] as number) + : 0; + if (executedCount > 0) { + roundState.executed = executedCount; } // Phase 4: housekeeping (monitoring, retro, decision hygiene) diff --git a/test/cli/watch-agent-spawn.test.ts b/test/cli/watch-agent-spawn.test.ts index 11a22883f..35837e071 100644 --- a/test/cli/watch-agent-spawn.test.ts +++ b/test/cli/watch-agent-spawn.test.ts @@ -15,6 +15,7 @@ const { mockFsMkdirSync, mockFsReadFileSync, mockFsUnlinkSync, + mockFsWriteFileSync, } = vi.hoisted(() => ({ mockExecFile: vi.fn((...args: unknown[]) => { const cb = args.find(a => typeof a === 'function') as @@ -27,6 +28,7 @@ const { mockFsMkdirSync: vi.fn(), mockFsReadFileSync: vi.fn((): string => ''), mockFsUnlinkSync: vi.fn(), + mockFsWriteFileSync: vi.fn(), })); vi.mock('node:child_process', () => ({ @@ -39,13 +41,18 @@ vi.mock('node:fs', () => ({ mkdirSync: mockFsMkdirSync, readFileSync: mockFsReadFileSync, unlinkSync: mockFsUnlinkSync, + writeFileSync: mockFsWriteFileSync, })); import { + WINDOWS_MAX_INLINE_ARG_LENGTH, buildAgentCommand, buildCopilotCommand, + escapeArgs, parseCopilotUsageOutput, + prepareArgsForSpawn, spawnAgent, + spillPromptArgForCmd, withCopilotUsageOutput, } from '../../packages/squad-cli/src/cli/commands/watch/agent-spawn.js'; @@ -257,3 +264,59 @@ describe('agent-spawn: Copilot usage capture', () => { expect(missing.usageError).toBe('Copilot usage output was not created'); }); }); + +describe('agent-spawn: Windows multiline and oversized prompt handling (#2070)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFsExistsSync.mockReturnValue(true); + }); + + it('spills multiline prompt arguments to a temp .md file and returns a single-line pointer', () => { + const multilinePrompt = 'You are an AI agent on a software development team.\n\nYOUR CHARTER:\n# Ralph\n\nHere are the current open squad issues:\n- #2070: Fix Windows prompt truncation'; + const tempFiles: string[] = []; + + const rewritten = spillPromptArgForCmd(multilinePrompt, tempFiles); + + expect(tempFiles).toHaveLength(1); + expect(tempFiles[0]).toMatch(/squad-prompts[\\/]prompt-.*\.md$/); + expect(mockFsWriteFileSync).toHaveBeenCalledWith(tempFiles[0], multilinePrompt, 'utf8'); + expect(rewritten).not.toMatch(/[\r\n]/); + expect(rewritten).toContain(tempFiles[0]!); + expect(rewritten).toContain('Your complete instructions are in the file'); + }); + + it('spills single-line prompts exceeding WINDOWS_MAX_INLINE_ARG_LENGTH to a temp file', () => { + const longPrompt = 'x'.repeat(WINDOWS_MAX_INLINE_ARG_LENGTH + 100); + const tempFiles: string[] = []; + + const rewritten = spillPromptArgForCmd(longPrompt, tempFiles); + + expect(tempFiles).toHaveLength(1); + expect(mockFsWriteFileSync).toHaveBeenCalledWith(tempFiles[0], longPrompt, 'utf8'); + expect(rewritten.length).toBeLessThan(500); + }); + + it('prepareArgsForSpawn escapes spilled prompt for cmd.exe on Windows and cleans up temp file', () => { + const multilinePrompt = 'Line 1\nLine 2\nLine 3'; + const prepared = prepareArgsForSpawn(['-p', multilinePrompt, '--yolo'], true); + + expect(prepared.tempFiles).toHaveLength(1); + expect(prepared.args[0]).toBe('-p'); + expect(prepared.args[1]).toMatch(/^"Your complete instructions are in the file .+ - read that whole file first and follow it exactly as if it were this prompt\."$/); + expect(prepared.args[1]).not.toMatch(/[\r\n]/); + expect(prepared.args[2]).toBe('--yolo'); + + prepared.cleanup(); + expect(mockFsUnlinkSync).toHaveBeenCalledWith(prepared.tempFiles[0]); + }); + + it('leaves args untouched when isWindows is false', () => { + const multilinePrompt = 'Line 1\nLine 2'; + const prepared = prepareArgsForSpawn(['-p', multilinePrompt], false); + + expect(prepared.tempFiles).toHaveLength(0); + expect(prepared.args).toEqual(['-p', multilinePrompt]); + expect(escapeArgs(['-p', multilinePrompt], false)).toEqual(['-p', multilinePrompt]); + }); +}); + diff --git a/test/cli/watch-capabilities.test.ts b/test/cli/watch-capabilities.test.ts index d2dece24c..64f3a42d2 100644 --- a/test/cli/watch-capabilities.test.ts +++ b/test/cli/watch-capabilities.test.ts @@ -21,6 +21,7 @@ const { mockFsMkdirSync, mockFsReadFileSync, mockFsUnlinkSync, + mockFsWriteFileSync, mockRmSync, mockListModels, mockDisconnect, @@ -41,6 +42,7 @@ const { mockFsMkdirSync: vi.fn(), mockFsReadFileSync: vi.fn((): string => ''), mockFsUnlinkSync: vi.fn(), + mockFsWriteFileSync: vi.fn(), mockRmSync: vi.fn(), mockListModels: vi.fn().mockResolvedValue([]), mockDisconnect: vi.fn().mockResolvedValue(undefined), @@ -71,6 +73,7 @@ vi.mock('node:fs', () => ({ mkdirSync: mockFsMkdirSync, readFileSync: mockFsReadFileSync, unlinkSync: mockFsUnlinkSync, + writeFileSync: mockFsWriteFileSync, rmSync: mockRmSync, })); @@ -227,18 +230,18 @@ describe('Watch Capabilities', () => { // TEMPLATE_MANIFEST destination ('ralph-instructions.md' under .squad/) // matches the lookup in execute.ts. mockFsExistsSync.mockImplementation((p: unknown) => { - return typeof p === 'string' && p.endsWith('.squad/ralph-instructions.md'); + return typeof p === 'string' && p.replace(/\\/g, '/').endsWith('.squad/ralph-instructions.md'); }); const issues: ExecutableWorkItem[] = [ { number: 1, title: 'Task', labels: [{ name: 'squad' }], assignees: [] }, ]; const prompt = buildAgentPrompt(issues, '/some/repo'); expect(mockFsExistsSync).toHaveBeenCalledWith( - expect.stringContaining('.squad/ralph-instructions.md'), + expect.stringMatching(/\.squad[\\/]ralph-instructions\.md$/), ); // Path must be constructed from teamRoot, not a global path const [calledPath] = mockFsExistsSync.mock.calls[0] as [string]; - expect(calledPath).toContain('/some/repo'); + expect(calledPath.replace(/\\/g, '/')).toContain('/some/repo'); expect(prompt).toContain('Ralph, Go!'); }); @@ -370,6 +373,7 @@ describe('Watch Capabilities', () => { expect(result.success).toBe(true); expect(result.summary).toContain('agent dispatched'); expect(result.data?.dispatched).toBe(1); + expect(result.data?.executed).toBe(1); }); it('reports estimated context utilization from Copilot usage output', async () => { @@ -400,7 +404,9 @@ describe('Watch Capabilities', () => { utilization: 0.5, source: 'estimated', }); - expect(mockFsUnlinkSync).toHaveBeenCalledOnce(); + expect(mockFsUnlinkSync).toHaveBeenCalledWith( + expect.stringMatching(/context-usage-.*\.json$/), + ); expect(mockDisconnect).toHaveBeenCalledOnce(); }); @@ -415,7 +421,10 @@ describe('Watch Capabilities', () => { const args = mockExecFile.mock.calls.at(-1)?.[1] as string[]; expect(args).not.toContain('--usage-output-file'); - expect(mockFsMkdirSync).not.toHaveBeenCalled(); + expect(mockFsMkdirSync).not.toHaveBeenCalledWith( + expect.stringMatching(/[\\/]log$/), + expect.anything(), + ); }); it('still dispatches when usage capture cannot be initialized', async () => {