From e7bfb19203c6139c9fe4a5c538af892edf2c5da3 Mon Sep 17 00:00:00 2001 From: Soham Dasgupta Date: Wed, 14 Jan 2026 19:20:47 +0530 Subject: [PATCH] fix: embed systemPrompt into prompt for CLI-based providers CLI-based providers (OpenCode, etc.) only accept a single prompt via stdin/args and don't support separate system/user message channels like Claude SDK. When systemPrompt is passed to these providers, it was silently dropped, causing: - BacklogPlan JSON parsing failures with OpenCode/GPT-5.2 (missing "output ONLY JSON" formatting instruction) - Loss of critical formatting/schema instructions for structured outputs This fix adds embedSystemPromptIntoPrompt() method to CliProvider base class that: - Prepends systemPrompt to the user prompt before CLI execution - Handles both string and array prompts (vision support) - Handles both string systemPrompt and SystemPromptPreset objects - Uses standard \n\n---\n\n separator (consistent with codebase) - Sets systemPrompt to undefined to prevent double-injection Benefits OpencodeProvider immediately (uses base executeQuery). CursorProvider still uses manual workarounds (overrides executeQuery). Fixes the immediate BacklogPlan + OpenCode bug while maintaining backward compatibility with existing Cursor workarounds. Co-Authored-By: Claude Opus 4.5 --- apps/server/src/providers/cli-provider.ts | 77 +++++++++++++++++++---- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/apps/server/src/providers/cli-provider.ts b/apps/server/src/providers/cli-provider.ts index 7e0599f9..8683f841 100644 --- a/apps/server/src/providers/cli-provider.ts +++ b/apps/server/src/providers/cli-provider.ts @@ -26,22 +26,22 @@ * ``` */ -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { BaseProvider } from './base-provider.js'; -import type { ProviderConfig, ExecuteOptions, ProviderMessage } from './types.js'; import { - spawnJSONLProcess, - type SubprocessOptions, - isWslAvailable, - findCliInWsl, createWslCommand, + findCliInWsl, + isWslAvailable, + spawnJSONLProcess, windowsToWslPath, + type SubprocessOptions, type WslCliResult, } from '@automaker/platform'; import { createLogger, isAbortError } from '@automaker/utils'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { BaseProvider } from './base-provider.js'; +import type { ExecuteOptions, ProviderConfig, ProviderMessage } from './types.js'; /** * Spawn strategy for CLI tools on Windows @@ -522,8 +522,13 @@ export abstract class CliProvider extends BaseProvider { throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`); } - const cliArgs = this.buildCliArgs(options); - const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); + // Many CLI-based providers do not support a separate "system" message. + // If a systemPrompt is provided, embed it into the prompt so downstream models + // still receive critical formatting/schema instructions (e.g., JSON-only outputs). + const effectiveOptions = this.embedSystemPromptIntoPrompt(options); + + const cliArgs = this.buildCliArgs(effectiveOptions); + const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs); try { for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { @@ -555,4 +560,52 @@ export abstract class CliProvider extends BaseProvider { throw error; } } + + /** + * Embed system prompt text into the user prompt for CLI providers. + * + * Most CLI providers we integrate with only accept a single prompt via stdin/args. + * When upstream code supplies `options.systemPrompt`, we prepend it to the prompt + * content and clear `systemPrompt` to avoid any accidental double-injection by + * subclasses. + */ + protected embedSystemPromptIntoPrompt(options: ExecuteOptions): ExecuteOptions { + if (!options.systemPrompt) { + return options; + } + + // Only string system prompts can be reliably embedded for CLI providers. + // Presets are provider-specific (e.g., Claude SDK) and cannot be represented + // universally. If a preset is provided, we only embed its optional `append`. + const systemText = + typeof options.systemPrompt === 'string' + ? options.systemPrompt + : options.systemPrompt.append + ? options.systemPrompt.append + : ''; + + if (!systemText) { + return { ...options, systemPrompt: undefined }; + } + + // Preserve original prompt structure. + if (typeof options.prompt === 'string') { + return { + ...options, + prompt: `${systemText}\n\n---\n\n${options.prompt}`, + systemPrompt: undefined, + }; + } + + if (Array.isArray(options.prompt)) { + return { + ...options, + prompt: [{ type: 'text', text: systemText }, ...options.prompt], + systemPrompt: undefined, + }; + } + + // Should be unreachable due to ExecuteOptions typing, but keep safe. + return { ...options, systemPrompt: undefined }; + } }