From 1911933c8de11b78ed5c30c04c7ddee23e7b66a7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 5 Dec 2025 14:57:39 +0100 Subject: [PATCH] feat: implement Plan Mode with external file change detection Add Plan Mode - a deliberate workflow where agents propose plans before implementation. ## Core Features **Plan Mode Workflow** - Toggle between Plan and Exec modes with Cmd+Shift+M / Ctrl+Shift+M - In Plan mode, file edits are restricted to ~/.mux/plans/.md - Agent writes plan via file_edit tools, then calls propose_plan to present for review - Users can edit plans externally (in their preferred editor) before approval - Start CLI sessions in plan mode with `mux run --mode plan` **External File Change Detection** - Timestamp-based polling detects when users edit plan files externally - Computes unified diff of changes and injects as synthetic user message - Agent automatically sees external modifications without manual notification - Preserves prompt cache by injecting as user message, not system message mutation **ProposePlan Tool UI** - Renders proposed plans with markdown formatting - Edit button opens plan in native terminal editor ($EDITOR) - Fresh content refetches on window focus for external edit workflow - "Start Here" button replaces chat context with plan (for long sessions) - Show Text/Markdown toggle for raw vs rendered views ## Infrastructure **File Edit Tool Refactoring** - Extracted common execution pipeline (executeFileEditOperation) - Shared validation utilities (isPlanFileAccess, validatePathInCwd) - Custom diff utility (computeDiff) replaces external dependency - Runtime-aware path resolution for SSH compatibility **Test Coverage** - Unit tests for change detection algorithm and diff computation - Integration tests for file change notifications via IPC - Plan mode enforcement tests for file_edit_* tools - Tests for both local and SSH runtime scenarios ## Documentation - New docs/plan-mode.mdx with comprehensive usage guide - Updated docs/cli.mdx with --mode flag documentation - Navigation updated in docs.json fix: use local filesystem for plan files in SSH workspaces Plan files are local artifacts that should always be stored on the local machine, not on remote SSH hosts. Previously, plan mode would use the workspace runtime for file operations, causing plan files to be written to/read from the remote host when using SSH workspaces. Add localRuntime option to tool config that bypasses SSH for plan file I/O. The AI service now creates a LocalRuntime instance when in plan mode, and file edit tools select the appropriate runtime based on whether the operation targets a plan file. --- docs/cli.mdx | 22 + docs/docs.json | 1 + docs/plan-mode.mdx | 124 ++++++ src/browser/components/AIView.tsx | 16 + src/browser/components/ChatInput/index.tsx | 31 ++ .../ChatInput/useCreationWorkspace.test.tsx | 4 +- .../components/Messages/MessageRenderer.tsx | 31 +- .../components/Messages/ToolMessage.tsx | 4 + .../components/tools/ProposePlanToolCall.tsx | 402 ++++++++++++++---- src/browser/hooks/useSendMessageOptions.ts | 7 +- src/browser/hooks/useTerminalSession.ts | 71 ++-- src/browser/stores/WorkspaceStore.ts | 34 ++ src/browser/utils/chatCommands.test.ts | 168 +++++++- src/browser/utils/chatCommands.ts | 111 +++++ .../messages/StreamingMessageAggregator.ts | 36 +- src/browser/utils/messages/messageUtils.ts | 3 +- .../messages/modelMessageTransform.test.ts | 96 +++++ .../utils/messages/modelMessageTransform.ts | 40 ++ src/browser/utils/messages/sendOptions.ts | 6 +- .../utils/slashCommands/parser.test.ts | 18 + src/browser/utils/slashCommands/registry.ts | 21 + src/browser/utils/slashCommands/types.ts | 2 + src/cli/run.ts | 5 +- src/common/constants/paths.ts | 11 + src/common/orpc/schemas/api.ts | 63 +++ src/common/orpc/schemas/telemetry.ts | 1 + src/common/orpc/schemas/terminal.ts | 2 + src/common/telemetry/payload.ts | 1 + src/common/types/ipc.ts | 0 src/common/types/message.ts | 12 + src/common/types/mode.ts | 5 +- src/common/types/tools.ts | 31 +- src/common/utils/planStorage.ts | 31 ++ src/common/utils/tools/toolDefinitions.ts | 21 +- src/common/utils/tools/tools.ts | 12 +- src/common/utils/ui/modeUtils.ts | 39 +- src/constants/slashCommands.ts | 2 + src/node/orpc/router.ts | 151 +++++++ .../agentSession.changeDetection.test.ts | 299 +++++++++++++ src/node/services/agentSession.ts | 79 +++- src/node/services/aiService.ts | 71 +++- src/node/services/terminalService.ts | 134 +++--- src/node/services/tools/fileCommon.ts | 17 + .../services/tools/file_edit_insert.test.ts | 93 ++++ src/node/services/tools/file_edit_insert.ts | 53 ++- .../tools/file_edit_operation.test.ts | 171 +++++++- .../services/tools/file_edit_operation.ts | 57 ++- src/node/services/tools/file_read.ts | 47 +- src/node/services/tools/propose_plan.ts | 68 ++- src/node/utils/commandDiscovery.ts | 74 ++++ src/node/utils/diff.ts | 235 ++++++++++ tests/ipc/fileChangeNotification.test.ts | 284 +++++++++++++ tests/ipc/planCommands.test.ts | 277 ++++++++++++ 53 files changed, 3314 insertions(+), 280 deletions(-) create mode 100644 docs/plan-mode.mdx delete mode 100644 src/common/types/ipc.ts create mode 100644 src/common/utils/planStorage.ts create mode 100644 src/node/services/agentSession.changeDetection.test.ts create mode 100644 src/node/utils/commandDiscovery.ts create mode 100644 src/node/utils/diff.ts create mode 100644 tests/ipc/fileChangeNotification.test.ts create mode 100644 tests/ipc/planCommands.test.ts diff --git a/docs/cli.mdx b/docs/cli.mdx index 139bf339c0..1b774601f8 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -102,3 +102,25 @@ Print the version and git commit: mux --version # v0.8.4 (abc123) ``` + +## Debug Environment Variables + +These environment variables help diagnose issues with LLM requests and responses. + +| Variable | Purpose | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MUX_DEBUG_LLM_REQUEST` | Set to `1` to log the complete LLM request (system prompt, messages, tools, provider options) as formatted JSON to the debug logs. Useful for diagnosing prompt issues. | + +Example usage: + +```bash +MUX_DEBUG_LLM_REQUEST=1 mux run "Hello world" +``` + +The output includes: + +- `systemMessage`: The full system prompt sent to the model +- `messages`: All conversation messages in the request +- `tools`: Tool definitions with descriptions and input schemas +- `providerOptions`: Provider-specific options (thinking level, etc.) +- `mode`, `thinkingLevel`, `maxOutputTokens`, `toolPolicy` diff --git a/docs/docs.json b/docs/docs.json index 96be48abd8..155381e307 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -46,6 +46,7 @@ "init-hooks" ] }, + "plan-mode", "vscode-extension", "models", { diff --git a/docs/plan-mode.mdx b/docs/plan-mode.mdx new file mode 100644 index 0000000000..35ee7f2e47 --- /dev/null +++ b/docs/plan-mode.mdx @@ -0,0 +1,124 @@ +--- +title: Plan Mode +description: Review and collaborate on plans before execution +--- + +Plan mode lets you review and refine the agent's approach before any code changes happen. Instead of diving straight into implementation, the agent writes a plan to a file, proposes it for your review, and waits for approval. + +## How It Works + +1. **Toggle to Plan Mode**: Press `Cmd+Shift+M` (Mac) or `Ctrl+Shift+M` (Windows/Linux), or use the mode switcher in the UI. + +2. **Agent Writes Plan**: In plan mode, all file edit tools (`file_edit_*`) are restricted to only modify the plan file. The agent can still read any file in the workspace to gather context. + +3. **Propose for Review**: When ready, the agent calls `propose_plan` to present the plan in the chat UI with rendered markdown. + +4. **Edit Externally**: Click the **Edit** button on the latest plan to open it in your preferred editor (nvim, VS Code, etc.). Your changes are automatically detected. + +5. **Iterate or Execute**: Provide feedback in chat, or switch to Exec mode (`Cmd+Shift+M`) to implement the plan. + +## External Edit Detection + +When you edit the plan file externally and send a message, mux automatically detects the changes and informs the agent with a diff. This uses a timestamp-based polling approach: + +1. **State Tracking**: When `propose_plan` runs, it records the plan file's content and modification time. +2. **Change Detection**: Before each LLM query, mux checks if the file's mtime has changed. +3. **Diff Injection**: If modified, mux computes a diff and injects it into the context so the agent sees exactly what changed. + +This means you can make edits in your preferred editor, return to mux, send a message, and the agent will incorporate your changes. + +## Plan File Location + +Plans are stored in a dedicated directory: + +``` +~/.mux/plans/.md +``` + +The file is created when the agent first writes a plan and persists across sessions. + +## UI Features + +The `propose_plan` tool call in chat includes: + +- **Rendered Markdown**: View the plan with proper formatting. +- **Edit Button**: Opens the plan file in your external editor (latest plan only). +- **Copy Button**: Copy plan content to clipboard. +- **Show Text/Markdown Toggle**: Switch between rendered and raw views. +- **Start Here**: Replace chat history with this plan as context (useful for long sessions). + +## Workflow Example + +``` +User: "Add user authentication to the app" + │ + ▼ +┌─────────────────────────────────────┐ +│ Agent reads codebase, writes plan │ +│ to ~/.mux/plans/.md │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Agent calls propose_plan │ +│ (plan displayed in chat) │ +└─────────────────────────────────────┘ + │ + ├─────────────────────────────────┐ + │ │ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────────┐ +│ User provides │ │ User clicks "Edit" │ +│ feedback in chat │ │ → edits in nvim/vscode │ +└─────────────────────┘ └─────────────────────────┘ + │ │ + │ │ (external edits) + │ ▼ + │ ┌─────────────────────────┐ + │ │ User sends message │ + │ │ → mux detects changes │ + │ │ → diff injected │ + │ └─────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ Agent revises plan based on feedback │ +│ (cycles back to propose_plan) │ +└─────────────────────────────────────────────────────────┘ + │ + │ (when satisfied) + ▼ +┌─────────────────────────────────────┐ +│ User switches to Exec mode │ +│ (Cmd+Shift+M) │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Agent implements the plan │ +└─────────────────────────────────────┘ +``` + +## Customizing Plan Mode Behavior + +Use [scoped instructions](/instruction-files) to customize how the agent behaves in plan mode: + +```markdown +## Mode: Plan + +When planning: + +- Focus on goals and trade-offs +- Propose alternatives with pros/cons +- Attach LoC estimates to each approach +``` + +## CLI Usage + +Plan mode is also available via the CLI: + +```bash +mux run --mode plan "Design a caching strategy for the API" +``` + +See [CLI documentation](/cli) for more options. diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index f8e3a051ff..7316d8ff4d 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -445,6 +445,17 @@ const AIViewInner: React.FC = ({ )?.historyId : undefined; + // Find the ID of the latest propose_plan tool call for external edit detection + // Only the latest plan should fetch fresh content from disk + let latestProposePlanId: string | null = null; + for (let i = mergedMessages.length - 1; i >= 0; i--) { + const msg = mergedMessages[i]; + if (msg.type === "tool" && msg.toolName === "propose_plan") { + latestProposePlanId = msg.id; + break; + } + } + if (loading) { return (
= ({ workspaceId={workspaceId} isCompacting={isCompacting} onReviewNote={handleReviewNote} + isLatestProposePlan={ + msg.type === "tool" && + msg.toolName === "propose_plan" && + msg.id === latestProposePlanId + } />
{isAtCutoff && ( diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 88a04edd53..85cf91c935 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -31,6 +31,8 @@ import { import { handleNewCommand, handleCompactCommand, + handlePlanShowCommand, + handlePlanOpenCommand, forkWorkspace, prepareCompactionMessage, executeCompaction, @@ -933,6 +935,35 @@ export const ChatInput: React.FC = (props) => { return; } + // Handle /plan command + if (parsed.type === "plan-show" || parsed.type === "plan-open") { + if (!api) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "Not connected to server", + }); + return; + } + const context: CommandHandlerContext = { + api: api, + workspaceId: props.workspaceId, + sendMessageOptions, + setInput, + setImageAttachments, + setIsSending, + setToast, + }; + + const handler = + parsed.type === "plan-show" ? handlePlanShowCommand : handlePlanOpenCommand; + const result = await handler(context); + if (!result.clearInput) { + setInput(messageText); // Restore input on error + } + return; + } + // Handle all other commands - show display toast const commandToast = createCommandToast(parsed); if (commandToast) { diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index c1d0eb4e7a..8d9d2848ef 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -527,8 +527,8 @@ function createDraftSettingsHarness( }> ) { const state = { - runtimeMode: initial?.runtimeMode ?? ("local" as RuntimeMode), - defaultRuntimeMode: initial?.defaultRuntimeMode ?? ("worktree" as RuntimeMode), + runtimeMode: initial?.runtimeMode ?? "local", + defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree", sshHost: initial?.sshHost ?? "", trunkBranch: initial?.trunkBranch ?? "main", runtimeString: initial?.runtimeString, diff --git a/src/browser/components/Messages/MessageRenderer.tsx b/src/browser/components/Messages/MessageRenderer.tsx index fca1ad1726..c13adc8e18 100644 --- a/src/browser/components/Messages/MessageRenderer.tsx +++ b/src/browser/components/Messages/MessageRenderer.tsx @@ -8,6 +8,8 @@ import { ReasoningMessage } from "./ReasoningMessage"; import { StreamErrorMessage } from "./StreamErrorMessage"; import { HistoryHiddenMessage } from "./HistoryHiddenMessage"; import { InitMessage } from "./InitMessage"; +import { ProposePlanToolCall } from "../tools/ProposePlanToolCall"; +import { removeEphemeralMessage } from "@/browser/stores/WorkspaceStore"; interface MessageRendererProps { message: DisplayedMessage; @@ -18,11 +20,21 @@ interface MessageRendererProps { isCompacting?: boolean; /** Handler for adding review notes from inline diffs */ onReviewNote?: (data: ReviewNoteData) => void; + /** Whether this message is the latest propose_plan tool call (for external edit detection) */ + isLatestProposePlan?: boolean; } // Memoized to prevent unnecessary re-renders when parent (AIView) updates export const MessageRenderer = React.memo( - ({ message, className, onEditUserMessage, workspaceId, isCompacting, onReviewNote }) => { + ({ + message, + className, + onEditUserMessage, + workspaceId, + isCompacting, + onReviewNote, + isLatestProposePlan, + }) => { // Route based on message type switch (message.type) { case "user": @@ -50,6 +62,7 @@ export const MessageRenderer = React.memo( className={className} workspaceId={workspaceId} onReviewNote={onReviewNote} + isLatestProposePlan={isLatestProposePlan} /> ); case "reasoning": @@ -60,6 +73,22 @@ export const MessageRenderer = React.memo( return ; case "workspace-init": return ; + case "plan-display": + return ( + { + if (workspaceId) { + removeEphemeralMessage(workspaceId, message.historyId); + } + }} + className={className} + /> + ); default: console.error("don't know how to render message", message); return null; diff --git a/src/browser/components/Messages/ToolMessage.tsx b/src/browser/components/Messages/ToolMessage.tsx index 1de41ffb36..319bb6e93f 100644 --- a/src/browser/components/Messages/ToolMessage.tsx +++ b/src/browser/components/Messages/ToolMessage.tsx @@ -43,6 +43,8 @@ interface ToolMessageProps { workspaceId?: string; /** Handler for adding review notes from inline diffs */ onReviewNote?: (data: ReviewNoteData) => void; + /** Whether this is the latest propose_plan in the conversation */ + isLatestProposePlan?: boolean; } // Type guards using Zod schemas for single source of truth @@ -116,6 +118,7 @@ export const ToolMessage: React.FC = ({ className, workspaceId, onReviewNote, + isLatestProposePlan, }) => { // Route to specialized components based on tool name if (isBashTool(message.toolName, message.args)) { @@ -193,6 +196,7 @@ export const ToolMessage: React.FC = ({ result={message.result as ProposePlanToolResult | undefined} status={message.status} workspaceId={workspaceId} + isLatest={isLatestProposePlan} /> ); diff --git a/src/browser/components/tools/ProposePlanToolCall.tsx b/src/browser/components/tools/ProposePlanToolCall.tsx index b5b4edcbf7..41562dad42 100644 --- a/src/browser/components/tools/ProposePlanToolCall.tsx +++ b/src/browser/components/tools/ProposePlanToolCall.tsx @@ -1,5 +1,10 @@ -import React, { useState } from "react"; -import type { ProposePlanToolArgs, ProposePlanToolResult } from "@/common/types/tools"; +import React, { useState, useEffect } from "react"; +import type { + ProposePlanToolResult, + ProposePlanToolError, + LegacyProposePlanToolArgs, + LegacyProposePlanToolResult, +} from "@/common/types/tools"; import { ToolContainer, ToolHeader, @@ -15,29 +20,187 @@ import { useStartHere } from "@/browser/hooks/useStartHere"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import { cn } from "@/common/lib/utils"; +import { useAPI } from "@/browser/contexts/API"; + +/** + * Check if the result is from the new file-based propose_plan tool + */ +function isNewProposePlanResult(result: unknown): result is ProposePlanToolResult { + return ( + result !== null && + typeof result === "object" && + "success" in result && + result.success === true && + "planContent" in result && + "planPath" in result + ); +} + +/** + * Check if the result is an error from propose_plan tool + */ +function isProposePlanError(result: unknown): result is ProposePlanToolError { + return ( + result !== null && + typeof result === "object" && + "success" in result && + result.success === false && + "error" in result + ); +} + +/** + * Check if the result is from the legacy propose_plan tool + */ +function isLegacyProposePlanResult(result: unknown): result is LegacyProposePlanToolResult { + return ( + result !== null && + typeof result === "object" && + "success" in result && + result.success === true && + "title" in result && + "plan" in result + ); +} + +/** + * Check if args are from the legacy propose_plan tool + */ +function isLegacyProposePlanArgs(args: unknown): args is LegacyProposePlanToolArgs { + return args !== null && typeof args === "object" && "title" in args && "plan" in args; +} interface ProposePlanToolCallProps { - args: ProposePlanToolArgs; - result?: ProposePlanToolResult; + args: Record; + result?: unknown; status?: ToolStatus; workspaceId?: string; + /** Whether this is the latest propose_plan in the conversation */ + isLatest?: boolean; + /** When true, renders as ephemeral preview (no tool wrapper, shows close button) */ + isEphemeralPreview?: boolean; + /** Callback when user closes ephemeral preview */ + onClose?: () => void; + /** Direct content for ephemeral preview (bypasses args/result extraction) */ + content?: string; + /** Direct path for ephemeral preview */ + path?: string; + /** Optional className for the outer wrapper */ + className?: string; } -export const ProposePlanToolCall: React.FC = ({ - args, - result: _result, - status = "pending", - workspaceId, -}) => { +export const ProposePlanToolCall: React.FC = (props) => { + const { + args, + result, + status = "pending", + workspaceId, + isLatest, + isEphemeralPreview, + onClose, + content: directContent, + path: directPath, + className, + } = props; const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default const [showRaw, setShowRaw] = useState(false); + const { api } = useAPI(); + + // Fresh content from disk for the latest plan (external edit detection) + // Skip for ephemeral previews which already have fresh content + const [freshContent, setFreshContent] = useState(null); + const [freshPath, setFreshPath] = useState(null); + + // Check if an editor is available (hides Edit button if not) + const [canEdit, setCanEdit] = useState(false); + + useEffect(() => { + if (!api) return; + void api.general.canOpenInEditor().then((result) => { + setCanEdit(result.method !== "none"); + }); + }, [api]); + + // Fetch fresh plan content for the latest plan + // Re-fetches on mount and when window regains focus (after user edits in external editor) + useEffect(() => { + if (isEphemeralPreview || !isLatest || !workspaceId || !api) return; + + const fetchPlan = async () => { + try { + const res = await api.workspace.getPlanContent({ workspaceId }); + if (res.success) { + setFreshContent(res.data.content); + setFreshPath(res.data.path); + } + } catch { + // Fetch failed, use cached content from result + } + }; + + // Fetch immediately on mount + void fetchPlan(); + + // Re-fetch when window regains focus (user returns from external editor) + const handleFocus = () => { + void fetchPlan(); + }; + window.addEventListener("focus", handleFocus); + + return () => { + window.removeEventListener("focus", handleFocus); + }; + }, [api, workspaceId, isLatest, isEphemeralPreview]); + + // Determine plan content and title based on result type (prefer result over args) + // For ephemeral previews, use direct content/path props + // For the latest plan, prefer fresh content from disk (external edit support) + let planContent: string; + let planTitle: string; + let planPath: string | undefined; + let errorMessage: string | undefined; + + if (isEphemeralPreview && directContent !== undefined) { + // Ephemeral preview mode: use direct props + planContent = directContent; + planPath = directPath; + const titleMatch = /^#\s+(.+)$/m.exec(directContent); + planTitle = titleMatch ? titleMatch[1] : "Plan"; + } else if (isLatest && freshContent !== null) { + planContent = freshContent; + planPath = freshPath ?? undefined; + // Extract title from first markdown heading or use filename + const titleMatch = /^#\s+(.+)$/m.exec(freshContent); + planTitle = titleMatch ? titleMatch[1] : (planPath?.split("/").pop() ?? "Plan"); + } else if (isNewProposePlanResult(result)) { + planContent = result.planContent; + planPath = result.planPath; + // Extract title from first markdown heading or use filename + const titleMatch = /^#\s+(.+)$/m.exec(result.planContent); + planTitle = titleMatch ? titleMatch[1] : (planPath.split("/").pop() ?? "Plan"); + } else if (isLegacyProposePlanResult(result)) { + planContent = result.plan; + planTitle = result.title; + } else if (isProposePlanError(result)) { + // Error from backend (e.g., plan file missing or empty) + planContent = ""; + planTitle = "Plan Error"; + errorMessage = result.error; + } else if (isLegacyProposePlanArgs(args)) { + // Fallback to args for backwards compatibility + planContent = args.plan; + planTitle = args.title; + } else { + // No valid plan data available + planContent = ""; + planTitle = "Plan"; + } // Format: Title as H1 + plan content for "Start Here" functionality - const startHereContent = `# ${args.title}\n\n${args.plan}`; + const startHereContent = `# ${planTitle}\n\n${planContent}`; const { openModal, buttonLabel, - buttonEmoji, disabled: startHereDisabled, modal, } = useStartHere( @@ -49,105 +212,164 @@ export const ProposePlanToolCall: React.FC = ({ // Copy to clipboard with feedback const { copied, copyToClipboard } = useCopyToClipboard(); - const [isHovered, setIsHovered] = useState(false); + const handleOpenInEditor = async () => { + if (!planPath || !api) { + return; + } + try { + const result = await api.general.openInEditor({ + filePath: planPath, + workspaceId, + }); + if (!result.success) { + console.error("Failed to open plan in editor:", result.error); + return; + } + // If opened in embedded terminal (server mode), open terminal window + if ( + result.data.openedInEmbeddedTerminal && + result.data.workspaceId && + result.data.sessionId + ) { + const isBrowser = !window.api; + if (isBrowser) { + const url = `/terminal.html?workspaceId=${encodeURIComponent(result.data.workspaceId)}&sessionId=${encodeURIComponent(result.data.sessionId)}`; + window.open( + url, + `terminal-editor-${result.data.sessionId}`, + "width=1000,height=600,popup=yes" + ); + } + } + } catch (err) { + console.error("openInEditor threw error:", err); + } + }; const controlButtonClasses = "px-2 py-1 text-[10px] font-mono rounded-sm cursor-pointer transition-all duration-150 active:translate-y-px"; const statusDisplay = getStatusDisplay(status); - return ( - - - - propose_plan - {statusDisplay} - - - {expanded && ( - -
-
-
-
📋
-
- {args.title} -
-
-
- {workspaceId && ( - - - - - - Replace all chat history with this plan - - - )} + // Shared plan UI content (used in both tool call and ephemeral preview modes) + const planUI = ( +
+
+
+
📋
+
{planTitle}
+ {isEphemeralPreview && ( +
preview only
+ )} +
+
+ {/* Edit button: show for ephemeral preview OR latest tool call, only if editor available */} + {(isEphemeralPreview ?? isLatest) && planPath && api && canEdit && ( + + + + Open plan in external editor + + )} + {/* Start Here button: only for tool calls, not ephemeral previews */} + {!isEphemeralPreview && workspaceId && ( + + -
-
- - {showRaw ? ( -
-                {args.plan}
-              
- ) : ( -
- -
+ + + Replace all chat history with this plan + + + )} + + + {/* Close button: only for ephemeral previews */} + {isEphemeralPreview && onClose && ( + + )} +
+
- {status === "completed" && ( -
- Respond with revisions or switch to Exec mode ( - - {formatKeybind(KEYBINDS.TOGGLE_MODE)} - - ) and ask to implement. -
- )} -
- + {errorMessage ? ( +
{errorMessage}
+ ) : showRaw ? ( +
+          {planContent}
+        
+ ) : ( +
+ +
+ )} + + {/* Completion guidance: only for completed tool calls without errors, not ephemeral previews */} + {!isEphemeralPreview && status === "completed" && !errorMessage && ( +
+ Respond with revisions or switch to Exec mode ( + {formatKeybind(KEYBINDS.TOGGLE_MODE)}) + and ask to implement. +
)} +
+ ); + + // Ephemeral preview mode: simple wrapper without tool container + if (isEphemeralPreview) { + return
{planUI}
; + } + + // Tool call mode: full tool container with header + return ( + + + + propose_plan + {statusDisplay} + + + {expanded && {planUI}} {modal} diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index 2c8da3799f..f7270ca2a8 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -3,7 +3,7 @@ import { useMode } from "@/browser/contexts/ModeContext"; import { usePersistedState } from "./usePersistedState"; import { getDefaultModel } from "./useModelLRU"; import { migrateGatewayModel, useGateway, isProviderSupported } from "./useGatewayModels"; -import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; +import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; import { getModelKey } from "@/common/constants/storage"; import type { SendMessageOptions } from "@/common/orpc/types"; import type { UIMode } from "@/common/types/mode"; @@ -35,6 +35,8 @@ function applyGatewayTransform(modelId: string, gateway: GatewayState): string { /** * Construct SendMessageOptions from raw values * Shared logic for both hook and non-hook versions + * + * Note: Plan mode instructions are handled by the backend (has access to plan file path) */ function constructSendMessageOptions( mode: UIMode, @@ -44,8 +46,6 @@ function constructSendMessageOptions( fallbackModel: string, gateway: GatewayState ): SendMessageOptions { - const additionalSystemInstructions = mode === "plan" ? PLAN_MODE_INSTRUCTION : undefined; - // Ensure model is always a valid string (defensive against corrupted localStorage) const rawModel = typeof preferredModel === "string" && preferredModel ? preferredModel : fallbackModel; @@ -64,7 +64,6 @@ function constructSendMessageOptions( model, mode: mode === "exec" || mode === "plan" ? mode : "exec", // Only pass exec/plan to backend toolPolicy: modeToToolPolicy(mode), - additionalSystemInstructions, providerOptions, }; } diff --git a/src/browser/hooks/useTerminalSession.ts b/src/browser/hooks/useTerminalSession.ts index 65cc4949eb..8a228cdbfd 100644 --- a/src/browser/hooks/useTerminalSession.ts +++ b/src/browser/hooks/useTerminalSession.ts @@ -1,14 +1,19 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useAPI } from "@/browser/contexts/API"; import type { TerminalSession } from "@/common/types/terminal"; /** - * Hook to manage terminal IPC session lifecycle + * Hook to manage terminal IPC session lifecycle. + * + * Supports two modes: + * 1. Create new session: when existingSessionId is undefined, creates a new PTY session + * 2. Reattach to existing session: when existingSessionId is provided (e.g., from openInEditor), + * subscribes to that session without creating a new one */ export function useTerminalSession( workspaceId: string, - _existingSessionId: string | undefined, // Reserved for future use (session reload support) + existingSessionId: string | undefined, enabled: boolean, terminalSize?: { cols: number; rows: number } | null, onOutput?: (data: string) => void, @@ -20,6 +25,10 @@ export function useTerminalSession( const [error, setError] = useState(null); const [shouldInit, setShouldInit] = useState(false); + // Track whether we created the session (vs reattaching to existing) + // Used to determine if we should close the session on cleanup + const createdSessionRef = useRef(false); + // Watch for terminalSize to become available useEffect(() => { if (enabled && terminalSize && !shouldInit) { @@ -28,31 +37,40 @@ export function useTerminalSession( }, [enabled, terminalSize, shouldInit]); // Create terminal session and subscribe to IPC events - // Only depends on workspaceId and shouldInit, NOT terminalSize + // Only depends on workspaceId, existingSessionId and shouldInit, NOT terminalSize useEffect(() => { if (!shouldInit || !terminalSize || !api) { return; } let mounted = true; - let createdSessionId: string | null = null; // Track session ID in closure + let targetSessionId: string | null = null; const cleanupFns: Array<() => void> = []; const initSession = async () => { try { - // Create terminal session with current terminal size - const session: TerminalSession = await api.terminal.create({ - workspaceId, - cols: terminalSize.cols, - rows: terminalSize.rows, - }); - - if (!mounted) { - return; + if (existingSessionId) { + // Reattach to existing session (e.g., from openInEditor) + // The session was already created by the backend with initialCommand + targetSessionId = existingSessionId; + createdSessionRef.current = false; + } else { + // Create new terminal session with current terminal size + const session: TerminalSession = await api.terminal.create({ + workspaceId, + cols: terminalSize.cols, + rows: terminalSize.rows, + }); + + if (!mounted) { + return; + } + + targetSessionId = session.sessionId; + createdSessionRef.current = true; } - createdSessionId = session.sessionId; // Store in closure - setSessionId(session.sessionId); + setSessionId(targetSessionId); const abortController = new AbortController(); const { signal } = abortController; @@ -62,7 +80,7 @@ export function useTerminalSession( (async () => { try { const iterator = await api.terminal.onOutput( - { sessionId: session.sessionId }, + { sessionId: targetSessionId }, { signal } ); for await (const data of iterator) { @@ -79,10 +97,7 @@ export function useTerminalSession( // Subscribe to exit events via ORPC async iterator (async () => { try { - const iterator = await api.terminal.onExit( - { sessionId: session.sessionId }, - { signal } - ); + const iterator = await api.terminal.onExit({ sessionId: targetSessionId }, { signal }); for await (const code of iterator) { if (!mounted) break; setConnected(false); @@ -100,9 +115,9 @@ export function useTerminalSession( setConnected(true); setError(null); } catch (err) { - console.error("[Terminal] Failed to create terminal session:", err); + console.error("[Terminal] Failed to initialize terminal session:", err); if (mounted) { - setError(err instanceof Error ? err.message : "Failed to create terminal"); + setError(err instanceof Error ? err.message : "Failed to initialize terminal"); } } }; @@ -115,17 +130,17 @@ export function useTerminalSession( // Unsubscribe from IPC events cleanupFns.forEach((fn) => fn()); - // Close terminal session using the closure variable - // This ensures we close the session created by this specific effect run - if (createdSessionId) { - void api?.terminal.close({ sessionId: createdSessionId }); + // Only close the session if WE created it (not for reattached sessions) + // Reattached sessions (e.g., from openInEditor) should persist when the window closes + if (targetSessionId && createdSessionRef.current) { + void api?.terminal.close({ sessionId: targetSessionId }); } // Reset init flag so a new session can be created if workspace changes setShouldInit(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, shouldInit]); // DO NOT include terminalSize - changes should not recreate session + }, [workspaceId, existingSessionId, shouldInit, api]); // DO NOT include terminalSize - changes should not recreate session // Send input to terminal const sendInput = useCallback( diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index b5e67b3e5c..4b7e4bd735 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -530,6 +530,14 @@ export class WorkspaceStore { return this.aggregators.get(workspaceId); } + /** + * Bump state for a workspace to trigger React re-renders. + * Used by addEphemeralMessage for frontend-only messages. + */ + bumpState(workspaceId: string): void { + this.states.bump(workspaceId); + } + /** * Get current TODO list for a workspace. * Returns empty array if workspace doesn't exist or has no TODOs. @@ -1127,6 +1135,32 @@ export function useWorkspaceAggregator( return store.getAggregator(workspaceId); } +/** + * Add an ephemeral message to a workspace and trigger a re-render. + * Used for displaying frontend-only messages like /plan output. + */ +export function addEphemeralMessage(workspaceId: string, message: MuxMessage): void { + const store = getStoreInstance(); + const aggregator = store.getAggregator(workspaceId); + if (aggregator) { + aggregator.addMessage(message); + store.bumpState(workspaceId); + } +} + +/** + * Remove an ephemeral message from a workspace and trigger a re-render. + * Used for dismissing frontend-only messages like /plan output. + */ +export function removeEphemeralMessage(workspaceId: string, messageId: string): void { + const store = getStoreInstance(); + const aggregator = store.getAggregator(workspaceId); + if (aggregator) { + aggregator.removeMessage(messageId); + store.bumpState(workspaceId); + } +} + /** * Hook for usage metadata (instant, no tokenization). * Updates immediately when usage metadata arrives from API responses. diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index 9e4fd2c8fa..49b68629cc 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, test, beforeEach } from "bun:test"; +import { describe, expect, test, beforeEach, mock } from "bun:test"; import type { SendMessageOptions } from "@/common/orpc/types"; -import { parseRuntimeString, prepareCompactionMessage } from "./chatCommands"; +import { + parseRuntimeString, + prepareCompactionMessage, + handlePlanShowCommand, + handlePlanOpenCommand, +} from "./chatCommands"; +import type { CommandHandlerContext } from "./chatCommands"; // Simple mock for localStorage to satisfy resolveCompactionModel beforeEach(() => { @@ -179,3 +185,161 @@ describe("prepareCompactionMessage", () => { expect(metadata.parsed.continueMessage?.imageParts).toHaveLength(1); }); }); + +describe("handlePlanShowCommand", () => { + const createMockContext = ( + getPlanContentResult: + | { success: true; data: { content: string; path: string } } + | { success: false; error: string } + ): CommandHandlerContext => { + const setInput = mock(() => undefined); + const setToast = mock(() => undefined); + + return { + workspaceId: "test-workspace-id", + setInput, + setToast, + api: { + workspace: { + getPlanContent: mock(() => Promise.resolve(getPlanContentResult)), + }, + general: {}, + } as unknown as CommandHandlerContext["api"], + // Required fields for CommandHandlerContext + sendMessageOptions: { + model: "anthropic:claude-3-5-sonnet", + thinkingLevel: "off", + toolPolicy: [], + mode: "exec", + }, + setImageAttachments: mock(() => undefined), + setIsSending: mock(() => undefined), + }; + }; + + test("shows error toast when no plan exists", async () => { + const context = createMockContext({ success: false, error: "No plan found" }); + + const result = await handlePlanShowCommand(context); + + expect(result.clearInput).toBe(true); + expect(result.toastShown).toBe(true); + expect(context.setToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + message: "No plan found for this workspace", + }) + ); + }); + + test("clears input when plan is found", async () => { + const context = createMockContext({ + success: true, + data: { content: "# My Plan\n\nStep 1", path: "/path/to/plan.md" }, + }); + + const result = await handlePlanShowCommand(context); + + expect(result.clearInput).toBe(true); + expect(result.toastShown).toBe(false); + expect(context.setInput).toHaveBeenCalledWith(""); + expect(context.api.workspace.getPlanContent).toHaveBeenCalledWith({ + workspaceId: "test-workspace-id", + }); + }); +}); + +describe("handlePlanOpenCommand", () => { + const createMockContext = ( + getPlanContentResult: + | { success: true; data: { content: string; path: string } } + | { success: false; error: string }, + openInEditorResult?: + | { success: true; data: { openedInEmbeddedTerminal: boolean } } + | { success: false; error: string } + ): CommandHandlerContext => { + const setInput = mock(() => undefined); + const setToast = mock(() => undefined); + + return { + workspaceId: "test-workspace-id", + setInput, + setToast, + api: { + workspace: { + getPlanContent: mock(() => Promise.resolve(getPlanContentResult)), + }, + general: { + openInEditor: mock(() => + Promise.resolve( + openInEditorResult ?? { success: true, data: { openedInEmbeddedTerminal: false } } + ) + ), + }, + } as unknown as CommandHandlerContext["api"], + // Required fields for CommandHandlerContext + sendMessageOptions: { + model: "anthropic:claude-3-5-sonnet", + thinkingLevel: "off", + toolPolicy: [], + mode: "exec", + }, + setImageAttachments: mock(() => undefined), + setIsSending: mock(() => undefined), + }; + }; + + test("shows error toast when no plan exists", async () => { + const context = createMockContext({ success: false, error: "No plan found" }); + + const result = await handlePlanOpenCommand(context); + + expect(result.clearInput).toBe(true); + expect(result.toastShown).toBe(true); + expect(context.setToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + message: "No plan found for this workspace", + }) + ); + // Should not attempt to open editor + expect(context.api.general.openInEditor).not.toHaveBeenCalled(); + }); + + test("opens plan in editor when plan exists", async () => { + const context = createMockContext( + { success: true, data: { content: "# My Plan", path: "/path/to/plan.md" } }, + { success: true, data: { openedInEmbeddedTerminal: false } } + ); + + const result = await handlePlanOpenCommand(context); + + expect(result.clearInput).toBe(true); + expect(context.setInput).toHaveBeenCalledWith(""); + expect(context.api.workspace.getPlanContent).toHaveBeenCalledWith({ + workspaceId: "test-workspace-id", + }); + expect(context.api.general.openInEditor).toHaveBeenCalledWith({ + filePath: "/path/to/plan.md", + workspaceId: "test-workspace-id", + }); + }); + + test("shows error toast when editor fails to open", async () => { + const context = createMockContext( + { success: true, data: { content: "# My Plan", path: "/path/to/plan.md" } }, + { success: false, error: "No editor configured" } + ); + + const result = await handlePlanOpenCommand(context); + + expect(result.clearInput).toBe(true); + expect(result.toastShown).toBe(true); + expect(context.setToast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + message: "No editor configured", + }) + ); + }); +}); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 089c3bb6df..e550db5214 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -38,6 +38,7 @@ import { import { createCommandToast } from "@/browser/components/ChatInputToasts"; import { trackCommandUsed, trackProviderConfigured } from "@/common/telemetry"; +import { addEphemeralMessage } from "@/browser/stores/WorkspaceStore"; export interface ForkOptions { client: RouterClient; @@ -271,6 +272,18 @@ export async function processSlashCommand( ...context, workspaceId: context.workspaceId, } as CommandHandlerContext); + case "plan-show": + if (!context.workspaceId) throw new Error("Workspace ID required"); + return handlePlanShowCommand({ + ...context, + workspaceId: context.workspaceId, + } as CommandHandlerContext); + case "plan-open": + if (!context.workspaceId) throw new Error("Workspace ID required"); + return handlePlanOpenCommand({ + ...context, + workspaceId: context.workspaceId, + } as CommandHandlerContext); } } @@ -898,6 +911,104 @@ export async function handleCompactCommand( } } +// ============================================================================ +// Plan Command Handlers +// ============================================================================ + +export async function handlePlanShowCommand( + context: CommandHandlerContext +): Promise { + const { api, workspaceId, setInput, setToast } = context; + + setInput(""); + + const result = await api.workspace.getPlanContent({ workspaceId }); + if (!result.success) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "No plan found for this workspace", + }); + return { clearInput: true, toastShown: true }; + } + + // Create ephemeral plan-display message (not persisted to history) + // Uses addEphemeralMessage to properly trigger React re-render via store bump + // Use a very high historySequence so it appears at the end of the chat + const planMessage = { + id: `plan-display-${Date.now()}`, + role: "assistant" as const, + parts: [{ type: "text" as const, text: result.data.content }], + metadata: { + historySequence: Number.MAX_SAFE_INTEGER, // Appear at end of chat + muxMetadata: { type: "plan-display" as const, path: result.data.path }, + }, + }; + addEphemeralMessage(workspaceId, planMessage); + + trackCommandUsed("plan"); + return { clearInput: true, toastShown: false }; +} + +export async function handlePlanOpenCommand( + context: CommandHandlerContext +): Promise { + const { api, workspaceId, setInput, setToast } = context; + + setInput(""); + + // First get the plan path + const planResult = await api.workspace.getPlanContent({ workspaceId }); + if (!planResult.success) { + setToast({ + id: Date.now().toString(), + type: "error", + message: "No plan found for this workspace", + }); + return { clearInput: true, toastShown: true }; + } + + // Open in editor + const openResult = await api.general.openInEditor({ + filePath: planResult.data.path, + workspaceId, + }); + + if (!openResult.success) { + setToast({ + id: Date.now().toString(), + type: "error", + message: openResult.error ?? "Failed to open editor", + }); + return { clearInput: true, toastShown: true }; + } + + // If opened in embedded terminal (server mode), open terminal window + if ( + openResult.data.openedInEmbeddedTerminal && + openResult.data.workspaceId && + openResult.data.sessionId + ) { + const isBrowser = !window.api; + if (isBrowser) { + const url = `/terminal.html?workspaceId=${encodeURIComponent(openResult.data.workspaceId)}&sessionId=${encodeURIComponent(openResult.data.sessionId)}`; + window.open( + url, + `terminal-editor-${openResult.data.sessionId}`, + "width=1000,height=600,popup=yes" + ); + } + } + + trackCommandUsed("plan"); + setToast({ + id: Date.now().toString(), + type: "success", + message: "Opened plan in editor", + }); + return { clearInput: true, toastShown: true }; +} + // ============================================================================ // Utilities // ============================================================================ diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 68768fb55f..8ab6e9ce47 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -264,6 +264,16 @@ export class StreamingMessageAggregator { this.invalidateCache(); } + /** + * Remove a message from the aggregator. + * Used for dismissing ephemeral messages like /plan output. + */ + removeMessage(messageId: string): void { + if (this.messages.delete(messageId)) { + this.invalidateCache(); + } + } + /** * Load historical messages in batch, preserving their historySequence numbers. * This is more efficient than calling addMessage() repeatedly. @@ -828,12 +838,36 @@ export class StreamingMessageAggregator { getDisplayedMessages(): DisplayedMessage[] { if (!this.cachedDisplayedMessages) { const displayedMessages: DisplayedMessage[] = []; + const allMessages = this.getAllMessages(); + + for (const message of allMessages) { + // Skip synthetic messages - they're for model context only, not UI display + if (message.metadata?.synthetic) { + continue; + } - for (const message of this.getAllMessages()) { const baseTimestamp = message.metadata?.timestamp; // Get historySequence from backend (required field) const historySequence = message.metadata?.historySequence ?? 0; + // Check for plan-display messages (ephemeral /plan output) + const muxMeta = message.metadata?.muxMetadata; + if (muxMeta?.type === "plan-display") { + const content = message.parts + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + displayedMessages.push({ + type: "plan-display", + id: message.id, + historyId: message.id, + content, + path: muxMeta.path, + historySequence, + }); + continue; + } + if (message.role === "user") { // User messages: combine all text parts into single block, extract images const content = message.parts diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index 32d40640fe..3bd9cb2caf 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -12,7 +12,8 @@ export function shouldShowInterruptedBarrier(msg: DisplayedMessage): boolean { msg.type === "user" || msg.type === "stream-error" || msg.type === "history-hidden" || - msg.type === "workspace-init" + msg.type === "workspace-init" || + msg.type === "plan-display" ) return false; diff --git a/src/browser/utils/messages/modelMessageTransform.test.ts b/src/browser/utils/messages/modelMessageTransform.test.ts index 1a5339a026..e2a57c9e82 100644 --- a/src/browser/utils/messages/modelMessageTransform.test.ts +++ b/src/browser/utils/messages/modelMessageTransform.test.ts @@ -6,6 +6,7 @@ import { addInterruptedSentinel, injectModeTransition, filterEmptyAssistantMessages, + injectFileChangeNotifications, } from "./modelMessageTransform"; import type { MuxMessage } from "@/common/types/message"; @@ -1027,3 +1028,98 @@ describe("filterEmptyAssistantMessages", () => { expect(result2[1].metadata?.partial).toBe(true); }); }); + +describe("injectFileChangeNotifications", () => { + it("should return messages unchanged when no file attachments provided", () => { + const messages: MuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: 1000 }, + }, + ]; + + const result = injectFileChangeNotifications(messages, undefined); + expect(result).toEqual(messages); + + const result2 = injectFileChangeNotifications(messages, []); + expect(result2).toEqual(messages); + }); + + it("should append synthetic user message with file change notification", () => { + const messages: MuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Fix this code" }], + metadata: { timestamp: 1000 }, + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "I'll fix it" }], + metadata: { timestamp: 2000 }, + }, + ]; + + const changedFiles = [ + { + type: "edited_text_file" as const, + filename: "src/app.ts", + snippet: "@@ -10,3 +10,3 @@\n-const x = 1\n+const x = 2", + }, + ]; + + const result = injectFileChangeNotifications(messages, changedFiles); + + expect(result.length).toBe(3); + expect(result[0]).toEqual(messages[0]); + expect(result[1]).toEqual(messages[1]); + + const syntheticMsg = result[2]; + expect(syntheticMsg.role).toBe("user"); + expect(syntheticMsg.metadata?.synthetic).toBe(true); + expect(syntheticMsg.id).toMatch(/^file-change-/); + expect(syntheticMsg.parts[0]).toMatchObject({ + type: "text", + }); + const text = (syntheticMsg.parts[0] as { type: "text"; text: string }).text; + expect(text).toContain(""); + expect(text).toContain("src/app.ts was modified"); + expect(text).toContain("@@ -10,3 +10,3 @@"); + }); + + it("should handle multiple file changes", () => { + const messages: MuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: 1000 }, + }, + ]; + + const changedFiles = [ + { + type: "edited_text_file" as const, + filename: "src/foo.ts", + snippet: "diff1", + }, + { + type: "edited_text_file" as const, + filename: "src/bar.ts", + snippet: "diff2", + }, + ]; + + const result = injectFileChangeNotifications(messages, changedFiles); + + expect(result.length).toBe(2); + const text = (result[1].parts[0] as { type: "text"; text: string }).text; + expect(text).toContain("src/foo.ts was modified"); + expect(text).toContain("src/bar.ts was modified"); + expect(text).toContain("diff1"); + expect(text).toContain("diff2"); + }); +}); diff --git a/src/browser/utils/messages/modelMessageTransform.ts b/src/browser/utils/messages/modelMessageTransform.ts index c0ae3bd1d1..ce549be3aa 100644 --- a/src/browser/utils/messages/modelMessageTransform.ts +++ b/src/browser/utils/messages/modelMessageTransform.ts @@ -5,6 +5,7 @@ import type { ModelMessage, AssistantModelMessage, ToolModelMessage } from "ai"; import type { MuxMessage } from "@/common/types/message"; +import type { EditedFileAttachment } from "@/node/services/agentSession"; /** * Filter out assistant messages that are empty or only contain reasoning parts. @@ -198,6 +199,45 @@ export function injectModeTransition( return result; } +/** + * Inject file change notifications as a synthetic user message. + * When files are modified externally (by user or linter), append a notification at the end + * so the model is aware of changes without busting the system message cache. + * + * @param messages The conversation history + * @param changedFileAttachments Files that were modified externally + * @returns Messages with file change notification appended if any files changed + */ +export function injectFileChangeNotifications( + messages: MuxMessage[], + changedFileAttachments?: EditedFileAttachment[] +): MuxMessage[] { + if (!changedFileAttachments || changedFileAttachments.length === 0) { + return messages; + } + + const notice = changedFileAttachments + .map( + (att) => + `Note: ${att.filename} was modified, either by the user or by a linter.\n` + + `This change was intentional, so make sure to take it into account as you proceed ` + + `(i.e., don't revert it unless the user asks you to). Here are the relevant changes:\n${att.snippet}` + ) + .join("\n\n"); + + const syntheticMessage: MuxMessage = { + id: `file-change-${Date.now()}`, + role: "user", + parts: [{ type: "text", text: `\n${notice}\n` }], + metadata: { + timestamp: Date.now(), + synthetic: true, + }, + }; + + return [...messages, syntheticMessage]; +} + /** * Filter out assistant messages that only contain reasoning parts (no text or tool parts). * Anthropic API rejects messages that have reasoning but no actual content. diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 646643efaf..cd22d38a0a 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -1,5 +1,5 @@ import { getModelKey, getThinkingLevelKey, getModeKey } from "@/common/constants/storage"; -import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; +import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { getDefaultModel } from "@/browser/hooks/useModelLRU"; import { toGatewayModel, migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; @@ -57,8 +57,7 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio // Get provider options const providerOptions = getProviderOptions(); - // Plan mode system instructions - const additionalSystemInstructions = mode === "plan" ? PLAN_MODE_INSTRUCTION : undefined; + // Plan mode instructions are now handled by the backend (has access to plan file path) // Enforce thinking policy (gpt-5-pro → high only) const effectiveThinkingLevel = enforceThinkingPolicy(model, thinkingLevel); @@ -68,7 +67,6 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio mode: mode === "exec" || mode === "plan" ? mode : "exec", // Only pass exec/plan to backend thinkingLevel: effectiveThinkingLevel, toolPolicy: modeToToolPolicy(mode), - additionalSystemInstructions, providerOptions, }; } diff --git a/src/browser/utils/slashCommands/parser.test.ts b/src/browser/utils/slashCommands/parser.test.ts index 099c98cbec..571a8ac9f0 100644 --- a/src/browser/utils/slashCommands/parser.test.ts +++ b/src/browser/utils/slashCommands/parser.test.ts @@ -208,3 +208,21 @@ it("should preserve start message with runtime flag when no workspace name", () startMessage: "Deploy to staging", }); }); + +describe("plan commands", () => { + it("should parse /plan as plan-show", () => { + expectParse("/plan", { type: "plan-show" }); + }); + + it("should parse /plan open as plan-open", () => { + expectParse("/plan open", { type: "plan-open" }); + }); + + it("should return unknown-command for invalid /plan subcommand", () => { + expectParse("/plan invalid", { + type: "unknown-command", + command: "plan", + subcommand: "invalid", + }); + }); +}); diff --git a/src/browser/utils/slashCommands/registry.ts b/src/browser/utils/slashCommands/registry.ts index d87d3dbf0d..0eaad5d14a 100644 --- a/src/browser/utils/slashCommands/registry.ts +++ b/src/browser/utils/slashCommands/registry.ts @@ -456,6 +456,26 @@ const vimCommandDefinition: SlashCommandDefinition = { }, }; +const planOpenCommandDefinition: SlashCommandDefinition = { + key: "open", + description: "Open plan in external editor", + appendSpace: false, + handler: (): ParsedCommand => ({ type: "plan-open" }), +}; + +const planCommandDefinition: SlashCommandDefinition = { + key: "plan", + description: "Show or edit the current plan", + appendSpace: false, + handler: ({ cleanRemainingTokens }): ParsedCommand => { + if (cleanRemainingTokens.length > 0) { + return { type: "unknown-command", command: "plan", subcommand: cleanRemainingTokens[0] }; + } + return { type: "plan-show" }; + }, + children: [planOpenCommandDefinition], +}; + const forkCommandDefinition: SlashCommandDefinition = { key: "fork", description: @@ -640,6 +660,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ compactCommandDefinition, modelCommandDefinition, providersCommandDefinition, + planCommandDefinition, forkCommandDefinition, newCommandDefinition, diff --git a/src/browser/utils/slashCommands/types.ts b/src/browser/utils/slashCommands/types.ts index bbf3a17be8..cf32f9a45e 100644 --- a/src/browser/utils/slashCommands/types.ts +++ b/src/browser/utils/slashCommands/types.ts @@ -33,6 +33,8 @@ export type ParsedCommand = | { type: "mcp-edit"; name: string; command: string } | { type: "mcp-remove"; name: string } | { type: "mcp-open" } + | { type: "plan-show" } + | { type: "plan-open" } | { type: "unknown-command"; command: string; subcommand?: string } | null; diff --git a/src/cli/run.ts b/src/cli/run.ts index b53bba92fe..9b903a857d 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -35,7 +35,7 @@ import { } from "@/common/orpc/types"; import { defaultModel } from "@/common/utils/ai/models"; import { ensureProvidersConfig } from "@/common/utils/providers/ensureProvidersConfig"; -import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils"; +import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { RuntimeConfig } from "@/common/types/runtime"; import { parseRuntimeModeAndHost, RUNTIME_MODE } from "@/common/types/runtime"; @@ -338,8 +338,9 @@ async function main(): Promise { const buildSendOptions = (cliMode: CLIMode): SendMessageOptions => ({ model, thinkingLevel, + mode: cliMode, toolPolicy: modeToToolPolicy(cliMode), - additionalSystemInstructions: cliMode === "plan" ? PLAN_MODE_INSTRUCTION : undefined, + // Plan mode instructions are handled by the backend (has access to plan file path) }); const liveEvents: WorkspaceChatMessage[] = []; diff --git a/src/common/constants/paths.ts b/src/common/constants/paths.ts index f4ca660b57..389d8c6a60 100644 --- a/src/common/constants/paths.ts +++ b/src/common/constants/paths.ts @@ -77,6 +77,17 @@ export function getMuxSessionsDir(rootDir?: string): string { return join(root, "sessions"); } +/** + * Get the directory where plan files are stored. + * Example: ~/.mux/plans/workspace-id.md + * + * @param rootDir - Optional root directory (defaults to getMuxHome()) + */ +export function getMuxPlansDir(rootDir?: string): string { + const root = rootDir ?? getMuxHome(); + return join(root, "plans"); +} + /** * Get the main configuration file path. * diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 1163c9a80d..184c517b28 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -294,6 +294,20 @@ export const workspace = { ), }, }, + /** + * Get the current plan file content for a workspace. + * Used by UI to refresh plan display when file is edited externally. + */ + getPlanContent: { + input: z.object({ workspaceId: z.string() }), + output: ResultSchema( + z.object({ + content: z.string(), + path: z.string(), + }), + z.string() + ), + }, }; export type WorkspaceSendMessageOutput = z.infer; @@ -418,6 +432,55 @@ export const general = { }), output: eventIterator(z.object({ tick: z.number(), timestamp: z.number() })), }, + /** + * Open a file in the user's preferred external editor. + * Uses $VISUAL -> $EDITOR -> 'code' fallback chain. + * + * In desktop mode: Opens native terminal or spawns GUI editor directly. + * In server mode: Creates embedded terminal session with the editor command. + * + * When openedInEmbeddedTerminal is true, the frontend should open/focus + * the terminal panel for the specified workspace to show the editor. + */ + openInEditor: { + input: z.object({ + filePath: z.string(), + /** Required for server mode to create embedded terminal */ + workspaceId: z.string().optional(), + }), + output: ResultSchema( + z.object({ + /** True if opened in embedded terminal (server mode with $EDITOR) */ + openedInEmbeddedTerminal: z.boolean(), + /** Workspace ID if embedded terminal was used */ + workspaceId: z.string().optional(), + /** Terminal session ID if embedded terminal was used */ + sessionId: z.string().optional(), + }), + z.string() + ), + }, + /** + * Check if an external editor is available for opening files. + * Used to conditionally show/hide Edit buttons in the UI. + * + * Discovery priority: + * 1. $VISUAL - User's explicit GUI editor preference + * 2. $EDITOR - User's explicit editor preference + * 3. GUI fallbacks: cursor, code, zed, subl (discovered via `which`) + * 4. Terminal fallbacks: nvim, vim, vi, nano, emacs (discovered via `which`) + */ + canOpenInEditor: { + input: z.void(), + output: z.object({ + /** How the editor was discovered */ + method: z.enum(["visual", "editor", "gui-fallback", "terminal-fallback", "none"]), + /** The actual editor command that will be used (undefined when method="none") */ + editor: z.string().optional(), + /** True if the editor requires a terminal window (terminal-fallback only) */ + requiresTerminal: z.boolean().optional(), + }), + }, }; // Menu events (main→renderer notifications) diff --git a/src/common/orpc/schemas/telemetry.ts b/src/common/orpc/schemas/telemetry.ts index 1a1f06eacf..2b46196d2c 100644 --- a/src/common/orpc/schemas/telemetry.ts +++ b/src/common/orpc/schemas/telemetry.ts @@ -40,6 +40,7 @@ const TelemetryCommandTypeSchema = z.enum([ "vim", "model", "mode", + "plan", "providers", ]); diff --git a/src/common/orpc/schemas/terminal.ts b/src/common/orpc/schemas/terminal.ts index e6ca2fbd3f..62cd0e84d4 100644 --- a/src/common/orpc/schemas/terminal.ts +++ b/src/common/orpc/schemas/terminal.ts @@ -11,6 +11,8 @@ export const TerminalCreateParamsSchema = z.object({ workspaceId: z.string(), cols: z.number(), rows: z.number(), + /** Optional command to run immediately after terminal creation */ + initialCommand: z.string().optional(), }); export const TerminalResizeParamsSchema = z.object({ diff --git a/src/common/telemetry/payload.ts b/src/common/telemetry/payload.ts index 9fed2ea75b..a266983c5e 100644 --- a/src/common/telemetry/payload.ts +++ b/src/common/telemetry/payload.ts @@ -144,6 +144,7 @@ export type TelemetryCommandType = | "vim" | "model" | "mode" + | "plan" | "providers"; /** diff --git a/src/common/types/ipc.ts b/src/common/types/ipc.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/common/types/message.ts b/src/common/types/message.ts index fc402fc5dd..78617aba5b 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -83,6 +83,10 @@ export type MuxFrontendMetadata = MuxFrontendMetadataBase & rawCommand: string; // The original /compact command as typed by user (for display) parsed: CompactionRequestData; } + | { + type: "plan-display"; // Ephemeral plan display from /plan command + path: string; + } | { type: "normal"; // Regular messages } @@ -237,6 +241,14 @@ export type DisplayedMessage = lines: string[]; // Accumulated output lines (stderr prefixed with "ERROR:") exitCode: number | null; // Final exit code (null while running) timestamp: number; + } + | { + type: "plan-display"; // Ephemeral plan display from /plan command + id: string; // Display ID for UI/React keys + historyId: string; // Original MuxMessage ID (same as id for ephemeral messages) + content: string; // Plan markdown content + path: string; // Path to the plan file + historySequence: number; // Global ordering across all messages }; export interface QueuedMessage { diff --git a/src/common/types/mode.ts b/src/common/types/mode.ts index 7880b8b79a..85fb8d7980 100644 --- a/src/common/types/mode.ts +++ b/src/common/types/mode.ts @@ -1,5 +1,8 @@ +import { z } from "zod"; + /** * UI Mode types */ -export type UIMode = "plan" | "exec"; +export const UIModeSchema = z.enum(["plan", "exec"]); +export type UIMode = z.infer; diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts index 5f805193e2..06c44792fe 100644 --- a/src/common/types/tools.ts +++ b/src/common/types/tools.ts @@ -3,6 +3,9 @@ * These types are used by both the tool implementations and UI components */ +import type { z } from "zod"; +import type { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; + // Bash Tool Types export interface BashToolArgs { script: string; @@ -166,12 +169,36 @@ export type FileEditToolArgs = | FileEditInsertToolArgs; // Propose Plan Tool Types -export interface ProposePlanToolArgs { +// Args derived from schema +export type ProposePlanToolArgs = z.infer; + +// Result type for new file-based propose_plan tool +export interface ProposePlanToolResult { + success: true; + planPath: string; + planContent: string; + message: string; +} + +// Error result when plan file not found +export interface ProposePlanToolError { + success: false; + error: string; +} + +/** + * @deprecated Legacy args type for backwards compatibility with old propose_plan tool calls. + * Old sessions may have tool calls with title + plan args stored in chat history. + */ +export interface LegacyProposePlanToolArgs { title: string; plan: string; } -export interface ProposePlanToolResult { +/** + * @deprecated Legacy result type for backwards compatibility. + */ +export interface LegacyProposePlanToolResult { success: true; title: string; plan: string; diff --git a/src/common/utils/planStorage.ts b/src/common/utils/planStorage.ts new file mode 100644 index 0000000000..446f1f2d6f --- /dev/null +++ b/src/common/utils/planStorage.ts @@ -0,0 +1,31 @@ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { getMuxPlansDir } from "@/common/constants/paths"; + +/** + * Get the plan file path for a workspace. + * Plan files are stored in a dedicated directory: ~/.mux/plans/{workspaceId}.md + */ +export function getPlanFilePath(workspaceId: string): string { + return join(getMuxPlansDir(), `${workspaceId}.md`); +} + +/** + * Read the plan file content for a workspace. + * Returns null if the file doesn't exist or can't be read. + */ +export function readPlanFile(workspaceId: string): string | null { + const planPath = getPlanFilePath(workspaceId); + try { + return readFileSync(planPath, "utf-8"); + } catch { + return null; + } +} + +/** + * Check if a plan file exists for a workspace. + */ +export function planFileExists(workspaceId: string): boolean { + return existsSync(getPlanFilePath(workspaceId)); +} diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index f53ad142f0..f37cead657 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -161,23 +161,10 @@ export const TOOL_DEFINITIONS = { }, propose_plan: { description: - "Propose a plan before taking action. The plan should be complete but minimal - cover what needs to be decided or understood, nothing more. Use this tool to get approval before proceeding with implementation.", - schema: z.object({ - title: z - .string() - .describe("A short, descriptive title for the plan (e.g., 'Add User Authentication')"), - plan: z - .string() - .describe( - "Implementation plan in markdown (start at h2 level). " + - "Scale the detail to match the task complexity: for straightforward changes, briefly state what and why; " + - "for complex changes, explain approach, key decisions, risks/tradeoffs; " + - "for uncertain changes, clarify options and what needs user input. " + - "When presenting options, always provide your recommendation for the overall best option for the user. " + - "For highly complex concepts, use mermaid diagrams where they'd clarify better than text. " + - "Cover what's necessary to understand and approve the approach. Omit obvious details or ceremony." - ), - }), + "Signal that your plan is complete and ready for user approval. " + + "This tool reads the plan from the plan file you wrote. " + + "You must write your plan to the plan file before calling this tool.", + schema: z.object({}), }, todo_write: { description: diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index d688ebbd2d..0bdf428cf4 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -15,6 +15,8 @@ import { log } from "@/node/services/log"; import type { Runtime } from "@/node/runtime/Runtime"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import type { UIMode } from "@/common/types/mode"; +import type { FileState } from "@/node/services/agentSession"; /** * Configuration for tools that need runtime context @@ -24,6 +26,8 @@ export interface ToolConfiguration { cwd: string; /** Runtime environment for executing commands and file operations */ runtime: Runtime; + /** Local runtime for plan file operations (bypasses SSH for plan files which are always local) */ + localRuntime?: Runtime; /** Environment secrets to inject (optional) */ secrets?: Record; /** MUX_ environment variables (MUX_PROJECT_PATH, MUX_RUNTIME) - set from init hook env */ @@ -36,8 +40,14 @@ export interface ToolConfiguration { overflow_policy?: "truncate" | "tmpfile"; /** Background process manager for bash tool (optional, AI-only) */ backgroundProcessManager?: BackgroundProcessManager; - /** Workspace ID for tracking background processes (optional for token estimation) */ + /** Current UI mode (plan or exec) - used for plan file path enforcement */ + mode?: UIMode; + /** Plan file path - only this file can be edited in plan mode */ + planFilePath?: string; + /** Workspace ID for tracking background processes and plan storage */ workspaceId?: string; + /** Callback to record file state for external edit detection (plan files) */ + recordFileState?: (filePath: string, state: FileState) => void; } /** diff --git a/src/common/utils/ui/modeUtils.ts b/src/common/utils/ui/modeUtils.ts index 1e40f3eac7..451ddfc587 100644 --- a/src/common/utils/ui/modeUtils.ts +++ b/src/common/utils/ui/modeUtils.ts @@ -2,30 +2,51 @@ import type { UIMode } from "@/common/types/mode"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; /** - * System instruction for Plan Mode - * Extracted as constant to avoid duplication across sendMessageOptions builders + * Generate the system instruction for Plan Mode with file path context. + * The plan file path tells the agent where to write their plan. + */ +export function getPlanModeInstruction(planFilePath: string, planExists: boolean): string { + const fileStatus = planExists + ? `A plan file already exists at ${planFilePath}. You can read it and make incremental edits using the file_edit_* tools.` + : `No plan file exists yet. You should create your plan at ${planFilePath} using the file_edit_* tools.`; + + return `You are in Plan Mode. ${fileStatus} + +You should build your plan incrementally by writing to or editing this file. +NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions. + +When you have finished writing your plan and are ready for user approval, call the propose_plan tool. +Do not make other edits in plan mode. You may have tools like bash but only use them for read-only operations. + +If the user suggests that you should make edits to other files, ask them to switch to Exec mode first! +`; +} + +/** + * Legacy constant for backwards compatibility. + * @deprecated Use getPlanModeInstruction(planFilePath, planExists) instead */ export const PLAN_MODE_INSTRUCTION = `You are in Plan Mode. You may use tools to research and understand the task, but you MUST call the propose_plan tool with your findings before completing your response. Do not provide a text response without calling propose_plan. Do not make edits in plan mode. You may have tools like bash but only use them for read-only operations. This rule on edits applies beyond files. Do not create side effects of any kind in plan mode. -If the user suggests that you should make edits, ask them to changes modes first! +If the user suggests that you should make edits, ask them to changes modes first! `; /** - * Get the tool policy for a given UI mode + * Get the tool policy for a given UI mode. + * In plan mode, file_edit_* tools remain enabled (agent needs them to write plan file), + * but strict path enforcement in file_edit_operation.ts restricts edits to only the plan file. */ export function modeToToolPolicy(mode: UIMode): ToolPolicy { if (mode === "plan") { return [ - { regex_match: "file_edit_.*", action: "disable" }, { regex_match: "propose_plan", action: "enable" }, + // file_edit_* stays enabled - agent needs it to write plan file + // Path restriction is enforced in file_edit_operation.ts ]; } // exec mode - return [ - { regex_match: "propose_plan", action: "disable" }, - { regex_match: "file_edit_.*", action: "enable" }, - ]; + return [{ regex_match: "propose_plan", action: "disable" }]; } diff --git a/src/constants/slashCommands.ts b/src/constants/slashCommands.ts index 5e6a294315..3e81dadd7e 100644 --- a/src/constants/slashCommands.ts +++ b/src/constants/slashCommands.ts @@ -12,4 +12,6 @@ export const WORKSPACE_ONLY_COMMANDS: ReadonlySet = new Set([ "compact", "fork", "new", + "plan-show", + "plan-open", ]); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index b7ee647e9a..5947759882 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1,3 +1,4 @@ +import { spawn } from "child_process"; import { os } from "@orpc/server"; import * as schemas from "@/common/orpc/schemas"; import type { ORPCContext } from "./context"; @@ -13,6 +14,8 @@ import type { } from "@/common/orpc/types"; import { createAuthMiddleware } from "./authMiddleware"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; +import { getPlanFilePath, readPlanFile } from "@/common/utils/planStorage"; +import { findAvailableCommand, GUI_EDITORS, TERMINAL_EDITORS } from "@/node/utils/commandDiscovery"; export const router = (authToken?: string) => { const t = os.$context().use(createAuthMiddleware(authToken)); @@ -122,6 +125,143 @@ export const router = (authToken?: string) => { } } }), + openInEditor: t + .input(schemas.general.openInEditor.input) + .output(schemas.general.openInEditor.output) + .handler(async ({ context, input }) => { + try { + const visual = process.env.VISUAL; + const editor = process.env.EDITOR; + const isDesktop = context.terminalService.isDesktopMode(); + + if (visual) { + // $VISUAL is set - GUI editor, spawn directly (works in both modes) + const child = spawn(visual, [input.filePath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + return { success: true as const, data: { openedInEmbeddedTerminal: false } }; + } + + if (editor) { + // $EDITOR is set - terminal editor + if (isDesktop) { + // Desktop mode: open native terminal + await context.terminalService.openNativeWithCommand( + `${editor} "${input.filePath}"` + ); + return { success: true as const, data: { openedInEmbeddedTerminal: false } }; + } else { + // Server mode: create embedded terminal with the command + if (!input.workspaceId) { + return { + success: false as const, + error: "workspaceId required for opening editor in server mode", + }; + } + const session = await context.terminalService.create({ + workspaceId: input.workspaceId, + cols: 120, + rows: 30, + initialCommand: `${editor} "${input.filePath}"`, + }); + return { + success: true as const, + data: { + openedInEmbeddedTerminal: true, + workspaceId: input.workspaceId, + sessionId: session.sessionId, + }, + }; + } + } + + // Fallback: discover available GUI editors + const guiEditor = await findAvailableCommand([...GUI_EDITORS]); + if (guiEditor) { + const child = spawn(guiEditor, [input.filePath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + return { success: true as const, data: { openedInEmbeddedTerminal: false } }; + } + + // Fallback: discover available terminal editors + const terminalEditor = await findAvailableCommand([...TERMINAL_EDITORS]); + if (terminalEditor) { + if (isDesktop) { + // Desktop mode: open native terminal with editor + await context.terminalService.openNativeWithCommand( + `${terminalEditor} "${input.filePath}"` + ); + return { success: true as const, data: { openedInEmbeddedTerminal: false } }; + } else { + // Server mode: create embedded terminal with editor + if (!input.workspaceId) { + return { + success: false as const, + error: "workspaceId required for opening editor in server mode", + }; + } + const session = await context.terminalService.create({ + workspaceId: input.workspaceId, + cols: 120, + rows: 30, + initialCommand: `${terminalEditor} "${input.filePath}"`, + }); + return { + success: true as const, + data: { + openedInEmbeddedTerminal: true, + workspaceId: input.workspaceId, + sessionId: session.sessionId, + }, + }; + } + } + + return { success: false as const, error: "No editor available" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false as const, error: `Failed to open editor: ${message}` }; + } + }), + canOpenInEditor: t + .input(schemas.general.canOpenInEditor.input) + .output(schemas.general.canOpenInEditor.output) + .handler(async () => { + // Check $VISUAL first + const visual = process.env.VISUAL; + if (visual) { + return { method: "visual" as const, editor: visual }; + } + + // Check $EDITOR + const editor = process.env.EDITOR; + if (editor) { + return { method: "editor" as const, editor }; + } + + // Discover GUI editors + const guiEditor = await findAvailableCommand([...GUI_EDITORS]); + if (guiEditor) { + return { method: "gui-fallback" as const, editor: guiEditor }; + } + + // Discover terminal editors + const terminalEditor = await findAvailableCommand([...TERMINAL_EDITORS]); + if (terminalEditor) { + return { + method: "terminal-fallback" as const, + editor: terminalEditor, + requiresTerminal: true, + }; + } + + return { method: "none" as const }; + }), }, projects: { list: t @@ -534,6 +674,17 @@ export const router = (authToken?: string) => { } }), }, + getPlanContent: t + .input(schemas.workspace.getPlanContent.input) + .output(schemas.workspace.getPlanContent.output) + .handler(({ input }) => { + const planPath = getPlanFilePath(input.workspaceId); + const content = readPlanFile(input.workspaceId); + if (content === null) { + return { success: false as const, error: `Plan file not found at ${planPath}` }; + } + return { success: true as const, data: { content, path: planPath } }; + }), }, window: { setTitle: t diff --git a/src/node/services/agentSession.changeDetection.test.ts b/src/node/services/agentSession.changeDetection.test.ts new file mode 100644 index 0000000000..a7161497e8 --- /dev/null +++ b/src/node/services/agentSession.changeDetection.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, writeFile, rm, utimes, stat, readFile } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import type { FileState, EditedFileAttachment } from "./agentSession"; +import { computeDiff } from "@/node/utils/diff"; + +/** + * Tests for external file change detection in AgentSession. + * + * Pattern: timestamp-based polling with diff injection + * 1. Track file state (content + mtime) when reading/writing files + * 2. Poll before each LLM query to detect external modifications + * 3. Compute diff and inject as context attachment if changed + * + * These tests verify the core detection algorithm by testing + * the isolated logic without requiring full AgentSession integration. + */ + +/** + * Extracted core logic from AgentSession.getChangedFileAttachments + * for isolated unit testing. This mirrors the actual implementation. + */ +async function getChangedFileAttachments( + readFileState: Map, + readFileFn: (path: string) => Promise<{ content: string; mtime: number }> +): Promise { + const checks = Array.from(readFileState.entries()).map( + async ([filePath, state]): Promise => { + try { + const { content: currentContent, mtime: currentMtime } = await readFileFn(filePath); + if (currentMtime <= state.timestamp) return null; // No change + + const diff = computeDiff(state.content, currentContent); + if (!diff) return null; // Content identical despite mtime change + + // Update stored state + readFileState.set(filePath, { content: currentContent, timestamp: currentMtime }); + + return { + type: "edited_text_file", + filename: filePath, + snippet: diff, + }; + } catch { + // File deleted or inaccessible, skip + return null; + } + } + ); + + const results = await Promise.all(checks); + return results.filter((r): r is EditedFileAttachment => r !== null); +} + +describe("AgentSession change detection", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "mux-change-detection-test-")); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + // Helper to get file info using Node.js fs API + async function getFileInfo(path: string): Promise<{ content: string; mtime: number }> { + const fileStat = await stat(path); + const content = await readFile(path, "utf-8"); + return { + content, + mtime: fileStat.mtimeMs, + }; + } + + describe("recordFileState and getChangedFileAttachments", () => { + it("should detect no changes when file unchanged", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const content = "# Plan\n\n## Step 1\n\nDo something"; + + await writeFile(testFile, content); + const { mtime } = await getFileInfo(testFile); + + // Record initial state + readFileState.set(testFile, { content, timestamp: mtime }); + + // Check for changes - should be empty since file is unchanged + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + expect(attachments).toHaveLength(0); + }); + + it("should detect changes when file content modified externally", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const originalContent = "# Plan\n\n## Step 1\n\nDo something"; + + await writeFile(testFile, originalContent); + const { mtime: originalMtime } = await getFileInfo(testFile); + + // Record initial state + readFileState.set(testFile, { content: originalContent, timestamp: originalMtime }); + + // Simulate external edit (wait briefly to ensure mtime changes) + await new Promise((resolve) => setTimeout(resolve, 10)); + const modifiedContent = "# Plan\n\n## Step 1\n\nDo something better\n\n## Step 2\n\nNew step"; + await writeFile(testFile, modifiedContent); + + // Update mtime to be in the future to simulate external edit + const newMtime = Date.now() + 1000; + await utimes(testFile, newMtime / 1000, newMtime / 1000); + + // Check for changes - should detect the modification + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + + expect(attachments).toHaveLength(1); + expect(attachments[0].type).toBe("edited_text_file"); + expect(attachments[0].filename).toBe(testFile); + expect(attachments[0].snippet).toContain("Do something better"); + expect(attachments[0].snippet).toContain("Step 2"); + expect(attachments[0].snippet).toContain("New step"); + }); + + it("should update stored state after detecting change", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const originalContent = "# Original"; + + await writeFile(testFile, originalContent); + const { mtime: originalMtime } = await getFileInfo(testFile); + + readFileState.set(testFile, { content: originalContent, timestamp: originalMtime }); + + // Modify file + await new Promise((resolve) => setTimeout(resolve, 10)); + const modifiedContent = "# Modified"; + await writeFile(testFile, modifiedContent); + const newMtime = Date.now() + 1000; + await utimes(testFile, newMtime / 1000, newMtime / 1000); + + // First detection + const firstCheck = await getChangedFileAttachments(readFileState, getFileInfo); + expect(firstCheck).toHaveLength(1); + + // Second check without further changes - should be empty + // because state was updated after first detection + const secondCheck = await getChangedFileAttachments(readFileState, getFileInfo); + expect(secondCheck).toHaveLength(0); + }); + + it("should return empty when file deleted", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const content = "# Plan"; + + await writeFile(testFile, content); + const { mtime } = await getFileInfo(testFile); + + readFileState.set(testFile, { content, timestamp: mtime }); + + // Delete the file + await rm(testFile); + + // Should gracefully handle deleted file + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + expect(attachments).toHaveLength(0); + }); + + it("should detect changes across multiple tracked files", async () => { + const readFileState = new Map(); + const file1 = join(tmpDir, "plan.md"); + const file2 = join(tmpDir, "notes.md"); + const file3 = join(tmpDir, "unchanged.md"); + + await writeFile(file1, "Original 1"); + await writeFile(file2, "Original 2"); + await writeFile(file3, "Original 3"); + + const { mtime: mtime1 } = await getFileInfo(file1); + const { mtime: mtime2 } = await getFileInfo(file2); + const { mtime: mtime3 } = await getFileInfo(file3); + + readFileState.set(file1, { content: "Original 1", timestamp: mtime1 }); + readFileState.set(file2, { content: "Original 2", timestamp: mtime2 }); + readFileState.set(file3, { content: "Original 3", timestamp: mtime3 }); + + // Modify only files 1 and 2 + await new Promise((resolve) => setTimeout(resolve, 10)); + await writeFile(file1, "Modified 1"); + await writeFile(file2, "Modified 2"); + const newMtime = Date.now() + 1000; + await utimes(file1, newMtime / 1000, newMtime / 1000); + await utimes(file2, newMtime / 1000, newMtime / 1000); + + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + + expect(attachments).toHaveLength(2); + const filenames = attachments.map((a) => a.filename); + expect(filenames).toContain(file1); + expect(filenames).toContain(file2); + expect(filenames).not.toContain(file3); + }); + + it("should ignore mtime change when content identical (touch scenario)", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const content = "# Plan unchanged"; + + await writeFile(testFile, content); + const { mtime: originalMtime } = await getFileInfo(testFile); + + readFileState.set(testFile, { content, timestamp: originalMtime }); + + // Update only mtime (like 'touch' command) without changing content + const newMtime = Date.now() + 1000; + await utimes(testFile, newMtime / 1000, newMtime / 1000); + + // Should not report change since content is identical + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + expect(attachments).toHaveLength(0); + }); + + it("should produce valid unified diff format", async () => { + const readFileState = new Map(); + const testFile = join(tmpDir, "plan.md"); + const originalContent = "line 1\nline 2\nline 3\nline 4\nline 5"; + + await writeFile(testFile, originalContent); + const { mtime: originalMtime } = await getFileInfo(testFile); + + readFileState.set(testFile, { content: originalContent, timestamp: originalMtime }); + + // Modify middle line + await new Promise((resolve) => setTimeout(resolve, 10)); + const modifiedContent = "line 1\nline 2\nmodified line 3\nline 4\nline 5"; + await writeFile(testFile, modifiedContent); + const newMtime = Date.now() + 1000; + await utimes(testFile, newMtime / 1000, newMtime / 1000); + + const attachments = await getChangedFileAttachments(readFileState, getFileInfo); + + expect(attachments).toHaveLength(1); + const diff = attachments[0].snippet; + + // Verify unified diff format + expect(diff).toContain("@@"); + expect(diff).toContain("-line 3"); + expect(diff).toContain("+modified line 3"); + }); + }); + + describe("computeDiff utility", () => { + it("should return null for identical content", () => { + const content = "# Plan\n\nContent here"; + expect(computeDiff(content, content)).toBeNull(); + }); + + it("should return diff for modified content", () => { + const old = "line 1\nline 2\nline 3"; + const modified = "line 1\nmodified line 2\nline 3"; + + const diff = computeDiff(old, modified); + expect(diff).not.toBeNull(); + expect(diff).toContain("-line 2"); + expect(diff).toContain("+modified line 2"); + }); + + it("should handle added lines", () => { + const old = "line 1\nline 2"; + const modified = "line 1\nline 2\nline 3"; + + const diff = computeDiff(old, modified); + expect(diff).not.toBeNull(); + expect(diff).toContain("+line 3"); + }); + + it("should handle removed lines", () => { + const old = "line 1\nline 2\nline 3"; + const modified = "line 1\nline 3"; + + const diff = computeDiff(old, modified); + expect(diff).not.toBeNull(); + expect(diff).toContain("-line 2"); + }); + + it("should handle empty to non-empty", () => { + const diff = computeDiff("", "new content"); + expect(diff).not.toBeNull(); + expect(diff).toContain("+new content"); + }); + + it("should handle non-empty to empty", () => { + const diff = computeDiff("old content", ""); + expect(diff).not.toBeNull(); + expect(diff).toContain("-old content"); + }); + }); +}); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 5d8ab4387f..ca80b30969 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1,6 +1,7 @@ import assert from "@/common/utils/assert"; import { EventEmitter } from "events"; import * as path from "path"; +import { stat, readFile } from "fs/promises"; import { PlatformPaths } from "@/common/utils/paths"; import { createMuxMessage } from "@/common/types/message"; import type { Config } from "@/node/config"; @@ -29,6 +30,25 @@ import { MessageQueue } from "./messageQueue"; import type { StreamEndEvent } from "@/common/types/stream"; import { CompactionHandler } from "./compactionHandler"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; +import { computeDiff } from "@/node/utils/diff"; + +/** + * Tracked file state for detecting external edits. + * Uses timestamp-based polling with diff injection. + */ +export interface FileState { + content: string; + timestamp: number; // mtime in ms +} + +/** + * Attachment for files that were edited externally between messages. + */ +export interface EditedFileAttachment { + type: "edited_text_file"; + filename: string; + snippet: string; // diff of changes +} // Type guard for compaction request metadata interface CompactionRequestMetadata { @@ -83,6 +103,12 @@ export class AgentSession { private readonly messageQueue = new MessageQueue(); private readonly compactionHandler: CompactionHandler; + /** + * Tracked file state for detecting external edits. + * Key: absolute file path, Value: last known content and mtime. + */ + private readonly readFileState = new Map(); + constructor(options: AgentSessionOptions) { assert(options, "AgentSession requires options"); const { @@ -492,12 +518,18 @@ export class AgentSession { return Err(createUnknownSendMessageError(historyResult.error)); } + // Check for external file edits (timestamp-based polling) + const changedFileAttachments = await this.getChangedFileAttachments(); + // Enforce thinking policy for the specified model (single source of truth) // This ensures model-specific requirements are met regardless of where the request originates const effectiveThinkingLevel = options?.thinkingLevel ? enforceThinkingPolicy(modelString, options.thinkingLevel) : undefined; + // Bind recordFileState to this session for the propose_plan tool + const recordFileState = this.recordFileState.bind(this); + return this.aiService.streamMessage( historyResult.data, this.workspaceId, @@ -508,7 +540,9 @@ export class AgentSession { options?.additionalSystemInstructions, options?.maxOutputTokens, options?.providerOptions, - options?.mode + options?.mode, + recordFileState, + changedFileAttachments.length > 0 ? changedFileAttachments : undefined ); } @@ -678,6 +712,49 @@ export class AgentSession { } } + /** + * Record file state for change detection. + * Called by tools (e.g., propose_plan) after reading/writing files. + */ + recordFileState(filePath: string, state: FileState): void { + this.readFileState.set(filePath, state); + } + + /** + * Check tracked files for external modifications. + * Returns attachments for files that changed since last recorded state. + * Uses timestamp-based polling with diff injection. + */ + async getChangedFileAttachments(): Promise { + const checks = Array.from(this.readFileState.entries()).map( + async ([filePath, state]): Promise => { + try { + const currentMtime = (await stat(filePath)).mtimeMs; + if (currentMtime <= state.timestamp) return null; // No change + + const currentContent = await readFile(filePath, "utf-8"); + const diff = computeDiff(state.content, currentContent); + if (!diff) return null; // Content identical despite mtime change + + // Update stored state + this.readFileState.set(filePath, { content: currentContent, timestamp: currentMtime }); + + return { + type: "edited_text_file", + filename: filePath, + snippet: diff, + }; + } catch { + // File deleted or inaccessible, skip + return null; + } + } + ); + + const results = await Promise.all(checks); + return results.filter((r): r is EditedFileAttachment => r !== null); + } + private assertNotDisposed(operation: string): void { assert(!this.disposed, `AgentSession.${operation} called after dispose`); } diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 297d5e8847..e4353f67c6 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -23,10 +23,12 @@ import type { InitStateManager } from "./initStateManager"; import type { SendMessageError } from "@/common/types/errors"; import { getToolsForModel } from "@/common/utils/tools/tools"; import { createRuntime } from "@/node/runtime/runtimeFactory"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import { getMuxEnv, getRuntimeType } from "@/node/runtime/initHook"; import { secretsToRecord } from "@/common/types/secrets"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import type { FileState, EditedFileAttachment } from "@/node/services/agentSession"; import { log } from "./log"; import { transformModelMessages, @@ -34,6 +36,7 @@ import { addInterruptedSentinel, filterEmptyAssistantMessages, injectModeTransition, + injectFileChangeNotifications, } from "@/browser/utils/messages/modelMessageTransform"; import { applyCacheControl } from "@/common/utils/ai/cacheStrategy"; import type { HistoryService } from "./historyService"; @@ -52,6 +55,9 @@ import type { import { applyToolPolicy, type ToolPolicy } from "@/common/utils/tools/toolPolicy"; import { MockScenarioPlayer } from "./mock/mockScenarioPlayer"; import { EnvHttpProxyAgent, type Dispatcher } from "undici"; +import { getPlanFilePath, planFileExists } from "@/common/utils/planStorage"; +import { getPlanModeInstruction } from "@/common/utils/ui/modeUtils"; +import type { UIMode } from "@/common/types/mode"; // Export a standalone version of getToolsForModel for use in backend @@ -833,6 +839,8 @@ export class AIService extends EventEmitter { * @param maxOutputTokens Optional maximum tokens for model output * @param muxProviderOptions Optional provider-specific options * @param mode Optional mode name - affects system message via Mode: sections in AGENTS.md + * @param recordFileState Optional callback to record file state for external edit detection + * @param changedFileAttachments Optional attachments for files that were edited externally * @returns Promise that resolves when streaming completes or fails */ async streamMessage( @@ -845,7 +853,9 @@ export class AIService extends EventEmitter { additionalSystemInstructions?: string, maxOutputTokens?: number, muxProviderOptions?: MuxProviderOptions, - mode?: string + mode?: string, + recordFileState?: (filePath: string, state: FileState) => void, + changedFileAttachments?: EditedFileAttachment[] ): Promise> { try { if (this.mockModeEnabled && this.mockScenarioPlayer) { @@ -937,9 +947,15 @@ export class AIService extends EventEmitter { toolNamesForSentinel ); + // Inject file change notifications as user messages (preserves system message cache) + const messagesWithFileChanges = injectFileChangeNotifications( + messagesWithModeContext, + changedFileAttachments + ); + // Apply centralized tool-output redaction BEFORE converting to provider ModelMessages // This keeps the persisted/UI history intact while trimming heavy fields for the request - const redactedForProvider = applyToolOutputRedaction(messagesWithModeContext); + const redactedForProvider = applyToolOutputRedaction(messagesWithFileChanges); log.debug_obj(`${workspaceId}/2a_redacted_messages.json`, redactedForProvider); // Sanitize tool inputs to ensure they are valid objects (not strings or arrays) @@ -1007,13 +1023,25 @@ export class AIService extends EventEmitter { ? await this.mcpServerManager.listServers(metadata.projectPath) : undefined; + // Construct plan mode instruction if in plan mode + // This is done backend-side because we have access to the plan file path + let effectiveAdditionalInstructions = additionalSystemInstructions; + if (mode === "plan") { + const planFilePath = getPlanFilePath(workspaceId); + const planExists = planFileExists(workspaceId); + const planModeInstruction = getPlanModeInstruction(planFilePath, planExists); + effectiveAdditionalInstructions = additionalSystemInstructions + ? `${planModeInstruction}\n\n${additionalSystemInstructions}` + : planModeInstruction; + } + // Build system message from workspace metadata const systemMessage = await buildSystemMessage( metadata, runtime, workspacePath, mode, - additionalSystemInstructions, + effectiveAdditionalInstructions, modelString, mcpServers ); @@ -1057,6 +1085,10 @@ export class AIService extends EventEmitter { { cwd: workspacePath, runtime, + // Plan files are always local - create a local runtime for plan file I/O + // even when the workspace uses SSH runtime + localRuntime: + mode === "plan" ? new LocalRuntime(workspacePath, runtimeTempDir) : undefined, secrets: secretsToRecord(projectSecrets), muxEnv: getMuxEnv( metadata.projectPath, @@ -1065,7 +1097,12 @@ export class AIService extends EventEmitter { ), runtimeTempDir, backgroundProcessManager: this.backgroundProcessManager, + // Plan mode configuration for path enforcement + mode: mode as UIMode | undefined, + planFilePath: mode === "plan" ? getPlanFilePath(workspaceId) : undefined, workspaceId, + // External edit detection callback + recordFileState, }, workspaceId, this.initStateManager, @@ -1231,6 +1268,34 @@ export class AIService extends EventEmitter { effectiveMuxProviderOptions ); + // Debug dump: Log the complete LLM request when MUX_DEBUG_LLM_REQUEST is set + // This helps diagnose issues with system prompts, messages, tools, etc. + if (process.env.MUX_DEBUG_LLM_REQUEST === "1") { + const llmRequest = { + workspaceId, + model: modelString, + systemMessage, + messages: finalMessages, + tools: Object.fromEntries( + Object.entries(tools).map(([name, tool]) => [ + name, + { + description: tool.description, + inputSchema: tool.inputSchema, + }, + ]) + ), + providerOptions, + thinkingLevel, + maxOutputTokens, + mode, + toolPolicy, + }; + log.info( + `[MUX_DEBUG_LLM_REQUEST] Full LLM request:\n${JSON.stringify(llmRequest, null, 2)}` + ); + } + // Delegate to StreamManager with model instance, system message, tools, historySequence, and initial metadata const streamResult = await this.streamManager.startStream( workspaceId, diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index e043fa1c40..7a5563d018 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -1,6 +1,5 @@ import { EventEmitter } from "events"; -import { spawn, spawnSync } from "child_process"; -import * as fs from "fs/promises"; +import { spawn } from "child_process"; import type { Config } from "@/node/config"; import type { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; @@ -13,16 +12,18 @@ import { createRuntime } from "@/node/runtime/runtimeFactory"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; import { log } from "@/node/services/log"; +import { isCommandAvailable, findAvailableCommand } from "@/node/utils/commandDiscovery"; /** * Configuration for opening a native terminal */ type NativeTerminalConfig = - | { type: "local"; workspacePath: string } + | { type: "local"; workspacePath: string; command?: string } | { type: "ssh"; sshConfig: Extract; remotePath: string; + command?: string; }; export class TerminalService { @@ -48,6 +49,13 @@ export class TerminalService { this.terminalWindowManager = manager; } + /** + * Check if we're running in desktop mode (Electron) vs server mode (browser). + */ + isDesktopMode(): boolean { + return !!this.terminalWindowManager; + } + async create(params: TerminalCreateParams): Promise { try { // 1. Resolve workspace @@ -122,6 +130,11 @@ export class TerminalService { this.emitOutput(session.sessionId, data); } + // Send initial command if provided + if (params.initialCommand) { + this.sendInput(session.sessionId, `${params.initialCommand}\n`); + } + return session; } catch (err) { log.error("Error creating terminal session:", err); @@ -236,6 +249,20 @@ export class TerminalService { } } + /** + * Open a native terminal and run a command. + * Used for opening $EDITOR in a terminal when editing files. + * @param command The command to run + * @param workspacePath Optional directory to run the command in (defaults to cwd) + */ + async openNativeWithCommand(command: string, workspacePath?: string): Promise { + await this.openNativeTerminal({ + type: "local", + workspacePath: workspacePath ?? process.cwd(), + command, + }); + } + /** * Open a native terminal (local or SSH) with platform-specific handling. * This spawns the user's native terminal emulator, not a web-based terminal. @@ -282,9 +309,11 @@ export class TerminalService { logPrefix: string ): Promise { const isSSH = config.type === "ssh"; + const command = config.command; + const workspacePath = config.type === "local" ? config.workspacePath : config.remotePath; // macOS - try Ghostty first, fallback to Terminal.app - const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); + const terminal = await findAvailableCommand(["ghostty", "terminal"]); if (terminal === "ghostty") { const cmd = "open"; let args: string[]; @@ -293,10 +322,16 @@ export class TerminalService { // Build the full SSH command as a single string const sshCommand = ["ssh", ...sshArgs].join(" "); args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`]; + } else if (command) { + // Ghostty: Run command in workspace directory + // Wrap in sh -c to handle cd and command properly + const escapedPath = workspacePath.replace(/'/g, "'\\''"); + const escapedCmd = command.replace(/'/g, "'\\''"); + const fullCommand = `sh -c 'cd "${escapedPath}" && ${escapedCmd}'`; + args = ["-n", "-a", "Ghostty", "--args", `--command=${fullCommand}`]; } else { // Ghostty: Pass workspacePath to 'open -a Ghostty' to avoid regressions - if (config.type !== "local") throw new Error("Expected local config"); - args = ["-a", "Ghostty", config.workspacePath]; + args = ["-a", "Ghostty", workspacePath]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); const child = spawn(cmd, args, { @@ -306,7 +341,7 @@ export class TerminalService { child.unref(); } else { // Terminal.app - const cmd = isSSH ? "osascript" : "open"; + const cmd = isSSH || command ? "osascript" : "open"; let args: string[]; if (isSSH && sshArgs) { // Terminal.app: Use osascript with proper AppleScript structure @@ -324,10 +359,15 @@ export class TerminalService { const escapedCommand = sshCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const script = `tell application "Terminal"\nactivate\ndo script "${escapedCommand}"\nend tell`; args = ["-e", script]; + } else if (command) { + // Terminal.app: Run command in workspace directory via AppleScript + const fullCommand = `cd "${workspacePath}" && ${command}`; + const escapedCommand = fullCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const script = `tell application "Terminal"\nactivate\ndo script "${escapedCommand}"\nend tell`; + args = ["-e", script]; } else { // Terminal.app opens in the directory when passed as argument - if (config.type !== "local") throw new Error("Expected local config"); - args = ["-a", "Terminal", config.workspacePath]; + args = ["-a", "Terminal", workspacePath]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); const child = spawn(cmd, args, { @@ -344,6 +384,8 @@ export class TerminalService { logPrefix: string ): void { const isSSH = config.type === "ssh"; + const command = config.command; + const workspacePath = config.type === "local" ? config.workspacePath : config.remotePath; // Windows const cmd = "cmd"; @@ -351,9 +393,12 @@ export class TerminalService { if (isSSH && sshArgs) { // Windows - use cmd to start ssh args = ["/c", "start", "cmd", "/K", "ssh", ...sshArgs]; + } else if (command) { + // Windows - cd to directory and run command + args = ["/c", "start", "cmd", "/K", `cd /D "${workspacePath}" && ${command}`]; } else { - if (config.type !== "local") throw new Error("Expected local config"); - args = ["/c", "start", "cmd", "/K", "cd", "/D", config.workspacePath]; + // Windows - just cd to directory + args = ["/c", "start", "cmd", "/K", "cd", "/D", workspacePath]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); const child = spawn(cmd, args, { @@ -370,6 +415,8 @@ export class TerminalService { logPrefix: string ): Promise { const isSSH = config.type === "ssh"; + const command = config.command; + const workspacePath = config.type === "local" ? config.workspacePath : config.remotePath; // Linux - try terminal emulators in order of preference let terminals: Array<{ cmd: string; args: string[]; cwd?: string }>; @@ -387,9 +434,22 @@ export class TerminalService { { cmd: "xfce4-terminal", args: ["-e", `ssh ${sshArgs.join(" ")}`] }, { cmd: "xterm", args: ["-e", "ssh", ...sshArgs] }, ]; + } else if (command) { + // Run command in workspace directory + const fullCommand = `cd "${workspacePath}" && ${command}`; + terminals = [ + { cmd: "x-terminal-emulator", args: ["-e", "sh", "-c", fullCommand] }, + { cmd: "ghostty", args: ["-e", "sh", "-c", fullCommand] }, + { cmd: "alacritty", args: ["-e", "sh", "-c", fullCommand] }, + { cmd: "kitty", args: ["sh", "-c", fullCommand] }, + { cmd: "wezterm", args: ["start", "--", "sh", "-c", fullCommand] }, + { cmd: "gnome-terminal", args: ["--", "sh", "-c", fullCommand] }, + { cmd: "konsole", args: ["-e", "sh", "-c", fullCommand] }, + { cmd: "xfce4-terminal", args: ["-e", `sh -c '${fullCommand.replace(/'/g, "'\\''")}'`] }, + { cmd: "xterm", args: ["-e", "sh", "-c", fullCommand] }, + ]; } else { - if (config.type !== "local") throw new Error("Expected local config"); - const workspacePath = config.workspacePath; + // Just open terminal in directory terminals = [ { cmd: "x-terminal-emulator", args: [], cwd: workspacePath }, { cmd: "ghostty", args: ["--working-directory=" + workspacePath] }, @@ -422,52 +482,6 @@ export class TerminalService { } } - /** - * Check if a command is available in the system PATH or known locations - */ - private async isCommandAvailable(command: string): Promise { - // Special handling for ghostty on macOS - check common installation paths - if (command === "ghostty" && process.platform === "darwin") { - const ghosttyPaths = [ - "/opt/homebrew/bin/ghostty", - "/Applications/Ghostty.app/Contents/MacOS/ghostty", - "/usr/local/bin/ghostty", - ]; - - for (const ghosttyPath of ghosttyPaths) { - try { - const stats = await fs.stat(ghosttyPath); - // Check if it's a file and any executable bit is set (owner, group, or other) - if (stats.isFile() && (stats.mode & 0o111) !== 0) { - return true; - } - } catch { - // Try next path - } - } - // If none of the known paths work, fall through to which check - } - - try { - const result = spawnSync("which", [command], { encoding: "utf8" }); - return result.status === 0; - } catch { - return false; - } - } - - /** - * Find the first available command from a list of commands - */ - private async findAvailableCommand(commands: string[]): Promise { - for (const cmd of commands) { - if (await this.isCommandAvailable(cmd)) { - return cmd; - } - } - return null; - } - /** * Find the first available terminal emulator from a list */ @@ -475,7 +489,7 @@ export class TerminalService { terminals: Array<{ cmd: string; args: string[]; cwd?: string }> ): Promise<{ cmd: string; args: string[]; cwd?: string } | null> { for (const terminal of terminals) { - if (await this.isCommandAvailable(terminal.cmd)) { + if (await isCommandAvailable(terminal.cmd)) { return terminal; } } diff --git a/src/node/services/tools/fileCommon.ts b/src/node/services/tools/fileCommon.ts index 10179b1be9..d407a5e258 100644 --- a/src/node/services/tools/fileCommon.ts +++ b/src/node/services/tools/fileCommon.ts @@ -2,6 +2,7 @@ import * as path from "path"; import { createPatch } from "diff"; import type { FileStat, Runtime } from "@/node/runtime/Runtime"; import { SSHRuntime } from "@/node/runtime/SSHRuntime"; +import type { ToolConfiguration } from "@/common/utils/tools/tools"; /** * Maximum file size for file operations (1MB) @@ -28,6 +29,22 @@ export function generateDiff(filePath: string, oldContent: string, newContent: s return createPatch(filePath, oldContent, newContent, "", "", { context: 3 }); } +/** + * Check if a file path is the plan file in plan mode. + * Used to allow access to the plan file even though it's outside the workspace cwd. + * + * @param resolvedPath - The resolved absolute path of the file + * @param config - Tool configuration containing mode and planFilePath + * @returns true if this is the plan file in plan mode + */ +export function isPlanFileAccess(resolvedPath: string, config: ToolConfiguration): boolean { + if (config.mode !== "plan" || !config.planFilePath) { + return false; + } + const resolvedPlanPath = config.runtime.normalizePath(config.planFilePath, config.cwd); + return resolvedPath === resolvedPlanPath; +} + /** * Validates that a file size is within the allowed limit. * Returns an error object if the file is too large, null if valid. diff --git a/src/node/services/tools/file_edit_insert.test.ts b/src/node/services/tools/file_edit_insert.test.ts index aeb64911ff..1f3fccaf18 100644 --- a/src/node/services/tools/file_edit_insert.test.ts +++ b/src/node/services/tools/file_edit_insert.test.ts @@ -123,3 +123,96 @@ describe("file_edit_insert tool", () => { } }); }); + +describe("file_edit_insert plan mode enforcement", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), "plan-mode-insert-")); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it("blocks creating non-plan files when in plan mode", async () => { + const planFilePath = path.join(testDir, "sessions", "workspace", "plan.md"); + const otherFilePath = path.join(testDir, "workspace", "main.ts"); + const workspaceCwd = path.join(testDir, "workspace"); + + // Create workspace directory + await fs.mkdir(workspaceCwd, { recursive: true }); + + const tool = createFileEditInsertTool({ + ...getTestDeps(), + cwd: workspaceCwd, + runtime: createRuntime({ type: "local", srcBaseDir: workspaceCwd }), + runtimeTempDir: testDir, + mode: "plan", + planFilePath: planFilePath, + }); + + const args: FileEditInsertToolArgs = { + file_path: otherFilePath, + content: "console.log('test');", + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as FileEditInsertToolResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("In plan mode, only the plan file can be edited"); + } + }); + + it("allows creating plan file when in plan mode", async () => { + const planFilePath = path.join(testDir, "plan.md"); + const workspaceCwd = path.join(testDir, "workspace"); + + // Create workspace directory + await fs.mkdir(workspaceCwd, { recursive: true }); + + const tool = createFileEditInsertTool({ + ...getTestDeps(), + cwd: workspaceCwd, + runtime: createRuntime({ type: "local", srcBaseDir: workspaceCwd }), + runtimeTempDir: testDir, + mode: "plan", + planFilePath: planFilePath, + }); + + const args: FileEditInsertToolArgs = { + file_path: planFilePath, + content: "# My Plan\n\n- Step 1\n- Step 2\n", + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as FileEditInsertToolResult; + + expect(result.success).toBe(true); + expect(await fs.readFile(planFilePath, "utf-8")).toBe("# My Plan\n\n- Step 1\n- Step 2\n"); + }); + + it("allows editing any file in exec mode", async () => { + const testFilePath = path.join(testDir, "main.ts"); + await fs.writeFile(testFilePath, "const x = 1;"); + + const tool = createFileEditInsertTool({ + ...getTestDeps(), + cwd: testDir, + runtime: createRuntime({ type: "local", srcBaseDir: testDir }), + runtimeTempDir: testDir, + mode: "exec", + }); + + const args: FileEditInsertToolArgs = { + file_path: testFilePath, + content: "// header\n", + after: "const x = 1;", + }; + + const result = (await tool.execute!(args, mockToolCallOptions)) as FileEditInsertToolResult; + + expect(result.success).toBe(true); + expect(await fs.readFile(testFilePath, "utf-8")).toBe("// header\nconst x = 1;"); + }); +}); diff --git a/src/node/services/tools/file_edit_insert.ts b/src/node/services/tools/file_edit_insert.ts index 267b6d00cf..8dea2f84d0 100644 --- a/src/node/services/tools/file_edit_insert.ts +++ b/src/node/services/tools/file_edit_insert.ts @@ -3,7 +3,12 @@ import type { FileEditInsertToolArgs, FileEditInsertToolResult } from "@/common/ import { EDIT_FAILED_NOTE_PREFIX, NOTE_READ_FILE_RETRY } from "@/common/types/tools"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; -import { generateDiff, validateAndCorrectPath, validatePathInCwd } from "./fileCommon"; +import { + generateDiff, + validateAndCorrectPath, + validatePathInCwd, + isPlanFileAccess, +} from "./fileCommon"; import { executeFileEditOperation } from "./file_edit_operation"; import { fileExists } from "@/node/utils/runtime/fileExists"; import { writeFileString } from "@/node/utils/runtime/helpers"; @@ -59,20 +64,46 @@ export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration) ); file_path = correctedPath; - const pathValidation = validatePathInCwd(file_path, config.cwd, config.runtime); - if (pathValidation) { - return { - success: false, - error: pathValidation.error, - }; + const resolvedPath = config.runtime.normalizePath(file_path, config.cwd); + + // Determine if this is a plan file access - plan files always use local filesystem + const isPlanFile = isPlanFileAccess(resolvedPath, config); + + // Select runtime: plan files use localRuntime (always local), others use workspace runtime + const effectiveRuntime = + isPlanFile && config.localRuntime ? config.localRuntime : config.runtime; + + // For plan files, resolve path using local runtime since plan files are always local + const effectiveResolvedPath = + isPlanFile && config.localRuntime + ? config.localRuntime.normalizePath(file_path, config.cwd) + : resolvedPath; + + // Plan mode restriction: only allow editing/creating the plan file + if (config.mode === "plan" && config.planFilePath) { + if (!isPlanFile) { + return { + success: false, + error: `In plan mode, only the plan file can be edited. Attempted to edit: ${file_path}`, + }; + } + // Skip cwd validation for plan file - it's intentionally outside workspace + } else { + // Standard cwd validation for non-plan-mode edits + const pathValidation = validatePathInCwd(file_path, config.cwd, config.runtime); + if (pathValidation) { + return { + success: false, + error: pathValidation.error, + }; + } } - const resolvedPath = config.runtime.normalizePath(file_path, config.cwd); - const exists = await fileExists(config.runtime, resolvedPath, abortSignal); + const exists = await fileExists(effectiveRuntime, effectiveResolvedPath, abortSignal); if (!exists) { try { - await writeFileString(config.runtime, resolvedPath, content, abortSignal); + await writeFileString(effectiveRuntime, effectiveResolvedPath, content, abortSignal); } catch (err) { if (err instanceof RuntimeError) { return { @@ -83,7 +114,7 @@ export const createFileEditInsertTool: ToolFactory = (config: ToolConfiguration) throw err; } - const diff = generateDiff(resolvedPath, "", content); + const diff = generateDiff(effectiveResolvedPath, "", content); return { success: true, diff, diff --git a/src/node/services/tools/file_edit_operation.test.ts b/src/node/services/tools/file_edit_operation.test.ts index de13e93a21..fe480f2e75 100644 --- a/src/node/services/tools/file_edit_operation.test.ts +++ b/src/node/services/tools/file_edit_operation.test.ts @@ -1,8 +1,11 @@ import { describe, test, expect, jest } from "@jest/globals"; +import * as fs from "fs/promises"; +import * as path from "path"; import { executeFileEditOperation } from "./file_edit_operation"; import type { Runtime } from "@/node/runtime/Runtime"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; -import { createTestToolConfig, getTestDeps } from "./testHelpers"; +import { createTestToolConfig, getTestDeps, TestTempDir } from "./testHelpers"; const TEST_CWD = "/tmp"; @@ -85,3 +88,169 @@ describe("executeFileEditOperation", () => { } }); }); + +describe("executeFileEditOperation plan mode enforcement", () => { + test("should block editing non-plan files when in plan mode", async () => { + // This test verifies that when in plan mode with a planFilePath set, + // attempting to edit any other file is blocked BEFORE trying to read/write + const OTHER_FILE_PATH = "/home/user/project/src/main.ts"; + const PLAN_FILE_PATH = "/home/user/.mux/sessions/workspace-123/plan.md"; + const TEST_CWD = "/home/user/project"; + + const readFileMock = jest.fn(); + const mockRuntime = { + stat: jest + .fn<() => Promise<{ size: number; modifiedTime: Date; isDirectory: boolean }>>() + .mockResolvedValue({ + size: 100, + modifiedTime: new Date(), + isDirectory: false, + }), + readFile: readFileMock, + writeFile: jest.fn(), + normalizePath: jest.fn<(targetPath: string, _basePath: string) => string>( + (targetPath: string, _basePath: string) => { + // For absolute paths, return as-is + if (targetPath.startsWith("/")) return targetPath; + // For relative paths, join with base + return `${_basePath}/${targetPath}`; + } + ), + } as unknown as Runtime; + + const result = await executeFileEditOperation({ + config: { + cwd: TEST_CWD, + runtime: mockRuntime, + runtimeTempDir: "/tmp", + mode: "plan", + planFilePath: PLAN_FILE_PATH, + }, + filePath: OTHER_FILE_PATH, + operation: () => ({ success: true, newContent: "console.log('test')", metadata: {} }), + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("In plan mode, only the plan file can be edited"); + expect(result.error).toContain(OTHER_FILE_PATH); + } + + // Verify readFile was never called - we should fail before reaching file IO + expect(readFileMock).not.toHaveBeenCalled(); + }); + + test("should allow editing the plan file when in plan mode (integration)", async () => { + using tempDir = new TestTempDir("plan-mode-test"); + + // Create the plan file in the temp directory + const planPath = path.join(tempDir.path, "plan.md"); + await fs.writeFile(planPath, "# Original Plan\n"); + + // CWD is separate from plan file location (simulates real setup) + const workspaceCwd = path.join(tempDir.path, "workspace"); + await fs.mkdir(workspaceCwd); + + const result = await executeFileEditOperation({ + config: { + cwd: workspaceCwd, + runtime: new LocalRuntime(workspaceCwd, tempDir.path), + runtimeTempDir: tempDir.path, + mode: "plan", + planFilePath: planPath, + }, + filePath: planPath, + operation: () => ({ success: true, newContent: "# Updated Plan\n", metadata: {} }), + }); + + expect(result.success).toBe(true); + expect(await fs.readFile(planPath, "utf-8")).toBe("# Updated Plan\n"); + }); + + test("should allow editing any file when in exec mode (integration)", async () => { + using tempDir = new TestTempDir("exec-mode-test"); + + const testFile = path.join(tempDir.path, "main.ts"); + await fs.writeFile(testFile, "const x = 1;\n"); + + const result = await executeFileEditOperation({ + config: { + cwd: tempDir.path, + runtime: new LocalRuntime(tempDir.path, tempDir.path), + runtimeTempDir: tempDir.path, + mode: "exec", + // No planFilePath in exec mode + }, + filePath: testFile, + operation: () => ({ success: true, newContent: "const x = 2;\n", metadata: {} }), + }); + + expect(result.success).toBe(true); + expect(await fs.readFile(testFile, "utf-8")).toBe("const x = 2;\n"); + }); + + test("should allow editing any file when mode is not set (integration)", async () => { + using tempDir = new TestTempDir("no-mode-test"); + + const testFile = path.join(tempDir.path, "main.ts"); + await fs.writeFile(testFile, "const x = 1;\n"); + + const result = await executeFileEditOperation({ + config: { + cwd: tempDir.path, + runtime: new LocalRuntime(tempDir.path, tempDir.path), + runtimeTempDir: tempDir.path, + // mode is undefined + }, + filePath: testFile, + operation: () => ({ success: true, newContent: "const x = 2;\n", metadata: {} }), + }); + + expect(result.success).toBe(true); + expect(await fs.readFile(testFile, "utf-8")).toBe("const x = 2;\n"); + }); + + test("should handle relative path to plan file in plan mode", async () => { + // When user provides a relative path that resolves to the plan file, + // it should still be allowed + const normalizePathCalls: string[] = []; + + const mockRuntime = { + stat: jest.fn(), + readFile: jest.fn(), + writeFile: jest.fn(), + normalizePath: jest.fn<(targetPath: string, basePath: string) => string>( + (targetPath: string, basePath: string) => { + normalizePathCalls.push(targetPath); + // Simulate: "../.mux/sessions/ws/plan.md" resolves to "/home/user/.mux/sessions/ws/plan.md" + if (targetPath === "../.mux/sessions/ws/plan.md") { + return "/home/user/.mux/sessions/ws/plan.md"; + } + if (targetPath === "/home/user/.mux/sessions/ws/plan.md") { + return "/home/user/.mux/sessions/ws/plan.md"; + } + if (targetPath.startsWith("/")) return targetPath; + return `${basePath}/${targetPath}`; + } + ), + } as unknown as Runtime; + + await executeFileEditOperation({ + config: { + cwd: "/home/user/project", + runtime: mockRuntime, + runtimeTempDir: "/tmp", + mode: "plan", + planFilePath: "/home/user/.mux/sessions/ws/plan.md", + }, + filePath: "../.mux/sessions/ws/plan.md", // Relative path to plan file + operation: () => ({ success: true, newContent: "# Plan", metadata: {} }), + }); + + // This will fail at file read (because mock doesn't provide real stream), + // but the key is that it passes the plan mode check and tries to read the file + // So we check that normalizePath was called for both paths + expect(normalizePathCalls).toContain("../.mux/sessions/ws/plan.md"); + expect(normalizePathCalls).toContain("/home/user/.mux/sessions/ws/plan.md"); + }); +}); diff --git a/src/node/services/tools/file_edit_operation.ts b/src/node/services/tools/file_edit_operation.ts index ccf5bf20d9..7cd6113b1f 100644 --- a/src/node/services/tools/file_edit_operation.ts +++ b/src/node/services/tools/file_edit_operation.ts @@ -5,6 +5,7 @@ import { validateFileSize, validatePathInCwd, validateAndCorrectPath, + isPlanFileAccess, } from "./fileCommon"; import { RuntimeError } from "@/node/runtime/Runtime"; import { readFileString, writeFileString } from "@/node/utils/runtime/helpers"; @@ -51,22 +52,47 @@ export async function executeFileEditOperation({ ); filePath = validatedPath; - const pathValidation = validatePathInCwd(filePath, config.cwd, config.runtime); - if (pathValidation) { - return { - success: false, - error: pathValidation.error, - }; - } - // Use runtime's normalizePath method to resolve paths correctly for both local and SSH runtimes // This ensures path resolution uses runtime-specific semantics instead of Node.js path module const resolvedPath = config.runtime.normalizePath(filePath, config.cwd); + // Determine if this is a plan file access - plan files always use local filesystem + const isPlanFile = isPlanFileAccess(resolvedPath, config); + + // Select runtime: plan files use localRuntime (always local), others use workspace runtime + const effectiveRuntime = + isPlanFile && config.localRuntime ? config.localRuntime : config.runtime; + + // For plan files, resolve path using local runtime since plan files are always local + const effectiveResolvedPath = + isPlanFile && config.localRuntime + ? config.localRuntime.normalizePath(filePath, config.cwd) + : resolvedPath; + + // Plan mode restriction: only allow editing the plan file + if (config.mode === "plan" && config.planFilePath) { + if (!isPlanFile) { + return { + success: false, + error: `In plan mode, only the plan file can be edited. Attempted to edit: ${filePath}`, + }; + } + // Skip cwd validation for plan file - it's intentionally outside workspace + } else { + // Standard cwd validation for non-plan-mode edits + const pathValidation = validatePathInCwd(filePath, config.cwd, config.runtime); + if (pathValidation) { + return { + success: false, + error: pathValidation.error, + }; + } + } + // Check if file exists and get stats using runtime let fileStat; try { - fileStat = await config.runtime.stat(resolvedPath, abortSignal); + fileStat = await effectiveRuntime.stat(effectiveResolvedPath, abortSignal); } catch (err) { if (err instanceof RuntimeError) { return { @@ -80,7 +106,7 @@ export async function executeFileEditOperation({ if (fileStat.isDirectory) { return { success: false, - error: `Path is a directory, not a file: ${resolvedPath}`, + error: `Path is a directory, not a file: ${effectiveResolvedPath}`, }; } @@ -95,7 +121,7 @@ export async function executeFileEditOperation({ // Read file content using runtime helper let originalContent: string; try { - originalContent = await readFileString(config.runtime, resolvedPath, abortSignal); + originalContent = await readFileString(effectiveRuntime, effectiveResolvedPath, abortSignal); } catch (err) { if (err instanceof RuntimeError) { return { @@ -117,7 +143,12 @@ export async function executeFileEditOperation({ // Write file using runtime helper try { - await writeFileString(config.runtime, resolvedPath, operationResult.newContent, abortSignal); + await writeFileString( + effectiveRuntime, + effectiveResolvedPath, + operationResult.newContent, + abortSignal + ); } catch (err) { if (err instanceof RuntimeError) { return { @@ -128,7 +159,7 @@ export async function executeFileEditOperation({ throw err; } - const diff = generateDiff(resolvedPath, originalContent, operationResult.newContent); + const diff = generateDiff(effectiveResolvedPath, originalContent, operationResult.newContent); return { success: true, diff --git a/src/node/services/tools/file_read.ts b/src/node/services/tools/file_read.ts index 1075ed92be..bee17f7b61 100644 --- a/src/node/services/tools/file_read.ts +++ b/src/node/services/tools/file_read.ts @@ -2,7 +2,12 @@ import { tool } from "ai"; import type { FileReadToolResult } from "@/common/types/tools"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; -import { validatePathInCwd, validateFileSize, validateAndCorrectPath } from "./fileCommon"; +import { + validatePathInCwd, + validateFileSize, + validateAndCorrectPath, + isPlanFileAccess, +} from "./fileCommon"; import { RuntimeError } from "@/node/runtime/Runtime"; import { readFileString } from "@/node/utils/runtime/helpers"; @@ -30,22 +35,38 @@ export const createFileReadTool: ToolFactory = (config: ToolConfiguration) => { ); filePath = validatedPath; - // Validate that the path is within the working directory - const pathValidation = validatePathInCwd(filePath, config.cwd, config.runtime); - if (pathValidation) { - return { - success: false, - error: pathValidation.error, - }; - } - // Use runtime's normalizePath method to resolve paths correctly for both local and SSH runtimes const resolvedPath = config.runtime.normalizePath(filePath, config.cwd); + // Determine if this is a plan file access - plan files always use local filesystem + const isPlanFile = isPlanFileAccess(resolvedPath, config); + + // Select runtime: plan files use localRuntime (always local), others use workspace runtime + const effectiveRuntime = + isPlanFile && config.localRuntime ? config.localRuntime : config.runtime; + + // For plan files, resolve path using local runtime since plan files are always local + const effectiveResolvedPath = + isPlanFile && config.localRuntime + ? config.localRuntime.normalizePath(filePath, config.cwd) + : resolvedPath; + + // Validate that the path is within the working directory + // Exception: allow reading the plan file in plan mode (it's outside workspace cwd) + if (!isPlanFile) { + const pathValidation = validatePathInCwd(filePath, config.cwd, config.runtime); + if (pathValidation) { + return { + success: false, + error: pathValidation.error, + }; + } + } + // Check if file exists using runtime let fileStat; try { - fileStat = await config.runtime.stat(resolvedPath); + fileStat = await effectiveRuntime.stat(effectiveResolvedPath); } catch (err) { if (err instanceof RuntimeError) { return { @@ -59,7 +80,7 @@ export const createFileReadTool: ToolFactory = (config: ToolConfiguration) => { if (fileStat.isDirectory) { return { success: false, - error: `Path is a directory, not a file: ${resolvedPath}`, + error: `Path is a directory, not a file: ${effectiveResolvedPath}`, }; } @@ -75,7 +96,7 @@ export const createFileReadTool: ToolFactory = (config: ToolConfiguration) => { // Read full file content using runtime helper let fullContent: string; try { - fullContent = await readFileString(config.runtime, resolvedPath); + fullContent = await readFileString(effectiveRuntime, effectiveResolvedPath); } catch (err) { if (err instanceof RuntimeError) { return { diff --git a/src/node/services/tools/propose_plan.ts b/src/node/services/tools/propose_plan.ts index 7ea89bd94f..bca9d66d31 100644 --- a/src/node/services/tools/propose_plan.ts +++ b/src/node/services/tools/propose_plan.ts @@ -1,25 +1,67 @@ +import { stat } from "fs/promises"; import { tool } from "ai"; +import { z } from "zod"; import type { ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import { getPlanFilePath, readPlanFile } from "@/common/utils/planStorage"; + +// Schema for propose_plan - empty object (no input parameters) +// Defined locally to avoid type inference issues with `as const` in TOOL_DEFINITIONS +const proposePlanSchema = z.object({}); /** - * Propose plan tool factory for AI assistant - * Creates a tool that allows the AI to propose a plan for approval before execution - * @param config Required configuration (not used for this tool, but required by interface) + * Propose plan tool factory for AI assistant. + * The tool reads the plan from the plan file the agent wrote to. + * If the plan file doesn't exist, it returns an error instructing + * the agent to write the plan first. */ -export const createProposePlanTool: ToolFactory = () => { +export const createProposePlanTool: ToolFactory = (config) => { return tool({ description: TOOL_DEFINITIONS.propose_plan.description, - inputSchema: TOOL_DEFINITIONS.propose_plan.schema, - execute: ({ title, plan }) => { - // Tool execution is a no-op on the backend - // The plan is displayed in the frontend and user decides whether to approve - return Promise.resolve({ - success: true, - title, - plan, + inputSchema: proposePlanSchema, + execute: async () => { + const workspaceId = config.workspaceId; + + if (!workspaceId) { + return { + success: false as const, + error: "No workspace ID available. Cannot determine plan file location.", + }; + } + + const planPath = getPlanFilePath(workspaceId); + const planContent = readPlanFile(workspaceId); + + if (planContent === null) { + return { + success: false as const, + error: `No plan file found at ${planPath}. Please write your plan to this file before calling propose_plan.`, + }; + } + + if (planContent === "") { + return { + success: false as const, + error: `Plan file at ${planPath} is empty. Please write your plan content before calling propose_plan.`, + }; + } + + // Record file state for external edit detection + if (config.recordFileState) { + try { + const mtime = (await stat(planPath)).mtimeMs; + config.recordFileState(planPath, { content: planContent, timestamp: mtime }); + } catch { + // File stat failed, skip recording (shouldn't happen since we just read it) + } + } + + return { + success: true as const, + planPath, + planContent, message: "Plan proposed. Waiting for user approval.", - }); + }; }, }); }; diff --git a/src/node/utils/commandDiscovery.ts b/src/node/utils/commandDiscovery.ts new file mode 100644 index 0000000000..4d1ff3788c --- /dev/null +++ b/src/node/utils/commandDiscovery.ts @@ -0,0 +1,74 @@ +/** + * Utilities for discovering available commands in the system. + * Used by terminalService and openInEditor to find executables. + */ + +import * as fs from "fs/promises"; +import { spawnSync } from "child_process"; + +/** Known installation paths for GUI editors on macOS */ +const MACOS_APP_PATHS: Record = { + cursor: ["/Applications/Cursor.app/Contents/Resources/app/bin/cursor", "/usr/local/bin/cursor"], + code: [ + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + "/usr/local/bin/code", + ], + zed: ["/Applications/Zed.app/Contents/MacOS/cli", "/usr/local/bin/zed"], + subl: ["/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl", "/usr/local/bin/subl"], + ghostty: [ + "/opt/homebrew/bin/ghostty", + "/Applications/Ghostty.app/Contents/MacOS/ghostty", + "/usr/local/bin/ghostty", + ], +}; + +/** + * Check if a command is available in the system PATH or known locations. + * First checks macOS-specific paths for known apps, then falls back to `which`. + */ +export async function isCommandAvailable(command: string): Promise { + // Check known paths for macOS apps + if (process.platform === "darwin" && command in MACOS_APP_PATHS) { + for (const appPath of MACOS_APP_PATHS[command]) { + try { + const stats = await fs.stat(appPath); + // Check if it's a file and any executable bit is set + if (stats.isFile() && (stats.mode & 0o111) !== 0) { + return true; + } + } catch { + // Try next path + } + } + } + + // Fall back to which + try { + const result = spawnSync("which", [command], { encoding: "utf8" }); + return result.status === 0; + } catch { + return false; + } +} + +/** + * Find the first available command from a list of candidates. + * Returns the command name (not full path) of the first available one. + */ +export async function findAvailableCommand(commands: string[]): Promise { + for (const cmd of commands) { + if (await isCommandAvailable(cmd)) { + return cmd; + } + } + return null; +} + +/** GUI editors that spawn detached (no terminal needed) */ +export const GUI_EDITORS = ["cursor", "code", "zed", "subl"] as const; + +/** Terminal editors that require a terminal session */ +export const TERMINAL_EDITORS = ["nvim", "vim", "vi", "nano", "emacs"] as const; + +/** All known GUI terminal emulators */ +export const TERMINAL_EMULATORS = ["ghostty", "kitty", "alacritty", "wezterm", "iterm2"] as const; diff --git a/src/node/utils/diff.ts b/src/node/utils/diff.ts new file mode 100644 index 0000000000..22c4f29dfc --- /dev/null +++ b/src/node/utils/diff.ts @@ -0,0 +1,235 @@ +/** + * Simple line-based diff utility for detecting external file edits. + * Uses timestamp-based polling with diff injection. + */ + +/** + * Compute a unified diff between old and new content. + * Returns null if contents are identical. + * + * The diff format shows: + * - Lines prefixed with '-' were removed + * - Lines prefixed with '+' were added + * - Context lines (unchanged) are shown around changes + */ +export function computeDiff(oldContent: string, newContent: string): string | null { + if (oldContent === newContent) return null; + + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + + // Simple line-by-line comparison with context + const changes: string[] = []; + const contextSize = 3; + + // Find all changed line ranges using longest common subsequence approach + const lcs = computeLCS(oldLines, newLines); + const hunks = buildHunks(oldLines, newLines, lcs, contextSize); + + if (hunks.length === 0) { + // Edge case: whitespace-only differences that don't show up in line comparison + return null; + } + + for (const hunk of hunks) { + changes.push( + `@@ -${hunk.oldStart + 1},${hunk.oldCount} +${hunk.newStart + 1},${hunk.newCount} @@` + ); + changes.push(...hunk.lines); + } + + return changes.join("\n"); +} + +interface Hunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: string[]; +} + +type LCSMatch = [number, number]; + +/** + * Compute longest common subsequence indices. + * Returns array of [oldIndex, newIndex] pairs for matching lines. + */ +function computeLCS(oldLines: string[], newLines: string[]): LCSMatch[] { + const m = oldLines.length; + const n = newLines.length; + + // Build LCS table + const dp: number[][] = Array.from({ length: m + 1 }, () => + Array.from({ length: n + 1 }, () => 0) + ); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // Backtrack to find matching pairs + const result: LCSMatch[] = []; + let i = m; + let j = n; + + while (i > 0 && j > 0) { + if (oldLines[i - 1] === newLines[j - 1]) { + result.unshift([i - 1, j - 1]); + i--; + j--; + } else if (dp[i - 1][j] > dp[i][j - 1]) { + i--; + } else { + j--; + } + } + + return result; +} + +/** + * Build unified diff hunks from LCS matches. + */ +function buildHunks( + oldLines: string[], + newLines: string[], + lcs: LCSMatch[], + contextSize: number +): Hunk[] { + const hunks: Hunk[] = []; + let currentHunk: Hunk | null = null; + + let oldIdx = 0; + let newIdx = 0; + let lcsIdx = 0; + + while (oldIdx < oldLines.length || newIdx < newLines.length) { + const match = lcsIdx < lcs.length ? lcs[lcsIdx] : null; + + if (match?.[0] === oldIdx && match[1] === newIdx) { + // Matching line + if (currentHunk) { + currentHunk.lines.push(` ${oldLines[oldIdx]}`); + currentHunk.oldCount++; + currentHunk.newCount++; + } + oldIdx++; + newIdx++; + lcsIdx++; + } else { + // Non-matching: we have deletions or additions + const startHunk = !currentHunk; + if (startHunk) { + // Start new hunk with context + currentHunk = { + oldStart: Math.max(0, oldIdx - contextSize), + oldCount: 0, + newStart: Math.max(0, newIdx - contextSize), + newCount: 0, + lines: [], + }; + + // Add leading context + const contextStart = Math.max(0, oldIdx - contextSize); + for (let c = contextStart; c < oldIdx; c++) { + currentHunk.lines.push(` ${oldLines[c]}`); + currentHunk.oldCount++; + currentHunk.newCount++; + } + } + + // Add deletions (old lines not in new) + while (oldIdx < oldLines.length && (!match || oldIdx < match[0])) { + currentHunk!.lines.push(`-${oldLines[oldIdx]}`); + currentHunk!.oldCount++; + oldIdx++; + } + + // Add additions (new lines not in old) + while (newIdx < newLines.length && (!match || newIdx < match[1])) { + currentHunk!.lines.push(`+${newLines[newIdx]}`); + currentHunk!.newCount++; + newIdx++; + } + } + + // Check if we should close the hunk (enough context after changes) + if (currentHunk && match?.[0] === oldIdx && match[1] === newIdx) { + // Look ahead to see if there are more changes within context range + const nextNonMatch = findNextNonMatch(oldIdx, newIdx, lcs, lcsIdx, oldLines, newLines); + + if (nextNonMatch === null || nextNonMatch > contextSize * 2) { + // No more changes nearby, add trailing context and close hunk + let contextAdded = 0; + while ( + contextAdded < contextSize && + oldIdx < oldLines.length && + newIdx < newLines.length && + lcsIdx < lcs.length && + lcs[lcsIdx][0] === oldIdx && + lcs[lcsIdx][1] === newIdx + ) { + currentHunk.lines.push(` ${oldLines[oldIdx]}`); + currentHunk.oldCount++; + currentHunk.newCount++; + oldIdx++; + newIdx++; + lcsIdx++; + contextAdded++; + } + + hunks.push(currentHunk); + currentHunk = null; + } + } + } + + // Close any remaining hunk + if (currentHunk) { + hunks.push(currentHunk); + } + + return hunks; +} + +/** + * Find distance to next non-matching pair. + */ +function findNextNonMatch( + oldIdx: number, + newIdx: number, + lcs: LCSMatch[], + lcsIdx: number, + oldLines: string[], + newLines: string[] +): number | null { + let distance = 0; + let oi = oldIdx; + let ni = newIdx; + let li = lcsIdx; + + while (oi < oldLines.length && ni < newLines.length && li < lcs.length) { + if (lcs[li][0] === oi && lcs[li][1] === ni) { + oi++; + ni++; + li++; + distance++; + } else { + return distance; + } + } + + // Reached end of one or both arrays + if (oi < oldLines.length || ni < newLines.length) { + return distance; + } + + return null; +} diff --git a/tests/ipc/fileChangeNotification.test.ts b/tests/ipc/fileChangeNotification.test.ts new file mode 100644 index 0000000000..9774511983 --- /dev/null +++ b/tests/ipc/fileChangeNotification.test.ts @@ -0,0 +1,284 @@ +/** + * Integration test for file change notification injection. + * + * Tests that when a tracked file (like a plan file) is modified externally, + * the change is detected and injected as a synthetic user message with + * tags into the message stream sent to the LLM. + * + * This tests the cache-preserving approach where file changes are communicated + * via user messages rather than system message modifications. + */ +import { writeFile, mkdir, stat, utimes, readFile, readdir } from "fs/promises"; +import { join } from "path"; +import { + createTestEnvironment, + cleanupTestEnvironment, + setupProviders, + preloadTestModules, + type TestEnvironment, + shouldRunIntegrationTests, +} from "./setup"; +import { + createTempGitRepo, + cleanupTempGitRepo, + generateBranchName, + createStreamCollector, + HAIKU_MODEL, + STREAM_TIMEOUT_LOCAL_MS, +} from "./helpers"; +import { detectDefaultTrunkBranch } from "../../src/node/git"; +import { getApiKey, validateApiKeys } from "../testUtils"; +import { getPlanFilePath } from "../../src/common/utils/planStorage"; +import { log } from "../../src/node/services/log"; +import { getMuxHome } from "../../src/common/constants/paths"; + +// Skip tests if integration tests are disabled or API keys are missing +const runTests = shouldRunIntegrationTests(); + +// Validate API keys are available +if (runTests) { + validateApiKeys(["ANTHROPIC_API_KEY"]); +} + +const describeIntegration = runTests ? describe : describe.skip; + +describeIntegration("File Change Notification Integration", () => { + let env: TestEnvironment; + let repoPath: string; + let originalLogLevel: ReturnType; + + beforeAll(async () => { + await preloadTestModules(); + env = await createTestEnvironment(); + repoPath = await createTempGitRepo(); + + // Setup Anthropic provider + const apiKey = getApiKey("ANTHROPIC_API_KEY"); + await setupProviders(env, { + anthropic: { apiKey }, + }); + }, 30000); + + afterAll(async () => { + if (repoPath) { + await cleanupTempGitRepo(repoPath); + } + if (env) { + await cleanupTestEnvironment(env); + } + }); + + beforeEach(() => { + // Enable debug mode to ensure debug_obj files are written + originalLogLevel = log.getLevel(); + log.setLevel("debug"); + }); + + afterEach(() => { + // Restore original log level + log.setLevel(originalLogLevel); + }); + + it("should inject file change notification when tracked plan file is modified externally", async () => { + // 1. Create a workspace + const branchName = generateBranchName("file-change-test"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // 2. Get the AgentSession and plan file path + const session = env.services.workspaceService.getOrCreateSession(workspaceId); + const planPath = getPlanFilePath(workspaceId); + + // 3. Create the plan directory and file + const planDir = join(planPath, ".."); + await mkdir(planDir, { recursive: true }); + + const originalContent = "# Plan\n\n## Step 1\n\nOriginal plan content"; + await writeFile(planPath, originalContent); + + // 4. Record the file state (simulates what propose_plan does) + const { mtimeMs: originalMtime } = await stat(planPath); + session.recordFileState(planPath, { + content: originalContent, + timestamp: originalMtime, + }); + + // 5. Modify the file externally (simulate user edit) + await new Promise((resolve) => setTimeout(resolve, 50)); // Ensure mtime changes + const modifiedContent = + "# Plan\n\n## Step 1\n\nModified plan content\n\n## Step 2\n\nNew step added"; + await writeFile(planPath, modifiedContent); + + // Update mtime to be clearly in the future + const newMtime = Date.now() + 1000; + await utimes(planPath, newMtime / 1000, newMtime / 1000); + + // 6. Set up stream collector and send a message + const collector = createStreamCollector(env.orpc, workspaceId); + collector.start(); + await collector.waitForSubscription(); + + // Send a simple message to trigger LLM call + const sendResult = await env.orpc.workspace.sendMessage({ + workspaceId, + message: "Continue with the plan", + options: { + model: HAIKU_MODEL, + mode: "exec", + thinkingLevel: "off", + }, + }); + + expect(sendResult.success).toBe(true); + + // Wait for stream to complete + await collector.waitForEvent("stream-end", STREAM_TIMEOUT_LOCAL_MS); + collector.stop(); + + // 7. Check the debug log file for the injected message + // The messages with file changes are logged to ~/.mux/debug_obj/${workspaceId}/2a_redacted_messages.json + const debugObjDir = join(getMuxHome(), "debug_obj", workspaceId); + const debugFiles = await readdir(debugObjDir).catch(() => [] as string[]); + + // Find the redacted messages file + const redactedFile = debugFiles.find((f) => f.includes("2a_redacted_messages")); + expect(redactedFile).toBeDefined(); + + if (redactedFile) { + const redactedPath = join(debugObjDir, redactedFile); + const content = await readFile(redactedPath, "utf-8"); + const messages = JSON.parse(content) as Array<{ + role: string; + parts?: Array<{ type: string; text?: string }>; + metadata?: { synthetic?: boolean }; + }>; + + // Find the synthetic file change message + const fileChangeMessage = messages.find( + (m) => + m.role === "user" && + m.metadata?.synthetic === true && + m.parts?.some((p) => p.type === "text" && p.text?.includes("")) + ); + + expect(fileChangeMessage).toBeDefined(); + + if (fileChangeMessage) { + const textPart = fileChangeMessage.parts?.find((p) => p.type === "text"); + expect(textPart?.text).toContain(""); + expect(textPart?.text).toContain(""); + expect(textPart?.text).toContain("was modified"); + // Should contain the diff showing the changes + expect(textPart?.text).toContain("Modified plan content"); + expect(textPart?.text).toContain("Step 2"); + expect(textPart?.text).toContain("New step added"); + } + } + } finally { + // Cleanup workspace + await env.orpc.workspace.remove({ workspaceId }); + } + }, 60000); + + it("should not inject notification when tracked file is unchanged", async () => { + // 1. Create a workspace + const branchName = generateBranchName("file-unchanged-test"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // 2. Get the AgentSession and plan file path + const session = env.services.workspaceService.getOrCreateSession(workspaceId); + const planPath = getPlanFilePath(workspaceId); + + // 3. Create the plan directory and file + const planDir = join(planPath, ".."); + await mkdir(planDir, { recursive: true }); + + const originalContent = "# Plan\n\nUnchanged content"; + await writeFile(planPath, originalContent); + + // 4. Record the file state + const { mtimeMs: originalMtime } = await stat(planPath); + session.recordFileState(planPath, { + content: originalContent, + timestamp: originalMtime, + }); + + // 5. DO NOT modify the file - leave it unchanged + + // 6. Set up stream collector and send a message + const collector = createStreamCollector(env.orpc, workspaceId); + collector.start(); + await collector.waitForSubscription(); + + // Send a simple message to trigger LLM call + const sendResult = await env.orpc.workspace.sendMessage({ + workspaceId, + message: "Hello", + options: { + model: HAIKU_MODEL, + mode: "exec", + thinkingLevel: "off", + }, + }); + + expect(sendResult.success).toBe(true); + + // Wait for stream to complete + await collector.waitForEvent("stream-end", STREAM_TIMEOUT_LOCAL_MS); + collector.stop(); + + // 7. Check the debug log file - should NOT have file change notification + const debugObjDir = join(getMuxHome(), "debug_obj", workspaceId); + const debugFiles = await readdir(debugObjDir).catch(() => [] as string[]); + + const redactedFile = debugFiles.find((f) => f.includes("2a_redacted_messages")); + expect(redactedFile).toBeDefined(); + + if (redactedFile) { + const redactedPath = join(debugObjDir, redactedFile); + const content = await readFile(redactedPath, "utf-8"); + const messages = JSON.parse(content) as Array<{ + role: string; + parts?: Array<{ type: string; text?: string }>; + metadata?: { synthetic?: boolean }; + }>; + + // Should NOT find a file change message + const fileChangeMessage = messages.find( + (m) => + m.role === "user" && + m.metadata?.synthetic === true && + m.parts?.some((p) => p.type === "text" && p.text?.includes("")) + ); + + expect(fileChangeMessage).toBeUndefined(); + } + } finally { + // Cleanup workspace + await env.orpc.workspace.remove({ workspaceId }); + } + }, 60000); +}); diff --git a/tests/ipc/planCommands.test.ts b/tests/ipc/planCommands.test.ts new file mode 100644 index 0000000000..e620a33033 --- /dev/null +++ b/tests/ipc/planCommands.test.ts @@ -0,0 +1,277 @@ +/** + * Integration tests for plan commands (/plan, /plan open) + * + * Tests: + * - getPlanContent API returns plan file content + * - openInEditor API attempts to open file with configured editor + * - Plan file CRUD operations + */ + +import * as fs from "fs/promises"; +import * as path from "path"; +import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; +import type { TestEnvironment } from "./setup"; +import { createTempGitRepo, cleanupTempGitRepo, generateBranchName } from "./helpers"; +import { detectDefaultTrunkBranch } from "../../src/node/git"; +import { getPlanFilePath } from "../../src/common/utils/planStorage"; + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Plan Commands Integration", () => { + let env: TestEnvironment; + let repoPath: string; + + beforeAll(async () => { + env = await createTestEnvironment(); + repoPath = await createTempGitRepo(); + }, 30000); + + afterAll(async () => { + if (repoPath) { + await cleanupTempGitRepo(repoPath); + } + if (env) { + await cleanupTestEnvironment(env); + } + }); + + describe("getPlanContent", () => { + it("should return error when no plan file exists", async () => { + const branchName = generateBranchName("plan-no-file"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + const result = await env.orpc.workspace.getPlanContent({ workspaceId }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("not found"); + } + } finally { + await env.orpc.workspace.remove({ workspaceId }); + } + }, 30000); + + it("should return plan content when plan file exists", async () => { + const branchName = generateBranchName("plan-with-file"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // Create a plan file + const planPath = getPlanFilePath(workspaceId); + const planDir = path.dirname(planPath); + await fs.mkdir(planDir, { recursive: true }); + + const planContent = "# Test Plan\n\n## Step 1\n\nDo something\n\n## Step 2\n\nDo more"; + await fs.writeFile(planPath, planContent); + + const result = await env.orpc.workspace.getPlanContent({ workspaceId }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toBe(planContent); + expect(result.data.path).toBe(planPath); + } + } finally { + await env.orpc.workspace.remove({ workspaceId }); + } + }, 30000); + + it("should handle empty plan file", async () => { + const branchName = generateBranchName("plan-empty"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // Create an empty plan file + const planPath = getPlanFilePath(workspaceId); + const planDir = path.dirname(planPath); + await fs.mkdir(planDir, { recursive: true }); + await fs.writeFile(planPath, ""); + + const result = await env.orpc.workspace.getPlanContent({ workspaceId }); + + // Empty file should still be returned (not an error) + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toBe(""); + } + } finally { + await env.orpc.workspace.remove({ workspaceId }); + } + }, 30000); + }); + + describe("canOpenInEditor", () => { + it("should return method based on available editors", async () => { + // Save original env vars + const originalVisual = process.env.VISUAL; + const originalEditor = process.env.EDITOR; + + try { + // Clear VISUAL and EDITOR to test discovery + delete process.env.VISUAL; + delete process.env.EDITOR; + + const result = await env.orpc.general.canOpenInEditor(); + + // In CI without editors, method depends on what's discoverable + expect(result).toHaveProperty("method"); + expect(["visual", "editor", "gui-fallback", "terminal-fallback", "none"]).toContain( + result.method + ); + + // If an editor was found, editor field should be set + if (result.method !== "none") { + expect(result.editor).toBeDefined(); + } + } finally { + // Restore env vars + if (originalVisual !== undefined) { + process.env.VISUAL = originalVisual; + } + if (originalEditor !== undefined) { + process.env.EDITOR = originalEditor; + } + } + }, 30000); + + it("should return method=editor when EDITOR is set", async () => { + // Save original env vars + const originalVisual = process.env.VISUAL; + const originalEditor = process.env.EDITOR; + + try { + // Set EDITOR, clear VISUAL (VISUAL takes priority) + delete process.env.VISUAL; + process.env.EDITOR = "vim"; + + const result = await env.orpc.general.canOpenInEditor(); + + expect(result.method).toBe("editor"); + expect(result.editor).toBe("vim"); + } finally { + // Restore env vars + if (originalVisual !== undefined) { + process.env.VISUAL = originalVisual; + } else { + delete process.env.VISUAL; + } + if (originalEditor !== undefined) { + process.env.EDITOR = originalEditor; + } else { + delete process.env.EDITOR; + } + } + }, 30000); + + it("should return method=visual when VISUAL is set", async () => { + // Save original env vars + const originalVisual = process.env.VISUAL; + const originalEditor = process.env.EDITOR; + + try { + // Set VISUAL (takes priority over EDITOR) + process.env.VISUAL = "code"; + process.env.EDITOR = "vim"; + + const result = await env.orpc.general.canOpenInEditor(); + + expect(result.method).toBe("visual"); + expect(result.editor).toBe("code"); + } finally { + // Restore env vars + if (originalVisual !== undefined) { + process.env.VISUAL = originalVisual; + } else { + delete process.env.VISUAL; + } + if (originalEditor !== undefined) { + process.env.EDITOR = originalEditor; + } else { + delete process.env.EDITOR; + } + } + }, 30000); + }); + + describe("openInEditor", () => { + it("should return result without throwing", async () => { + const branchName = generateBranchName("plan-open-test"); + const trunkBranch = await detectDefaultTrunkBranch(repoPath); + + const createResult = await env.orpc.workspace.create({ + projectPath: repoPath, + branchName, + trunkBranch, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) throw new Error("Failed to create workspace"); + + const workspaceId = createResult.metadata.id; + + try { + // Create a plan file + const planPath = getPlanFilePath(workspaceId); + const planDir = path.dirname(planPath); + await fs.mkdir(planDir, { recursive: true }); + await fs.writeFile(planPath, "# Test Plan"); + + // Check if any editor is available first + const canEdit = await env.orpc.general.canOpenInEditor(); + + const result = await env.orpc.general.openInEditor({ + filePath: planPath, + workspaceId, + }); + + // Should return a result (success or failure) without throwing + expect(result).toBeDefined(); + + // If no editor available, should return error + if (canEdit.method === "none") { + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe("No editor available"); + } + } + } finally { + await env.orpc.workspace.remove({ workspaceId }); + } + }, 30000); + }); +});