mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f83420464d | |||
| 8beadfbcd7 | |||
| 3105826a84 |
@@ -236,11 +236,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
|
||||
// Update default terminal profile
|
||||
if (request.defaultTerminalProfile !== undefined) {
|
||||
const previousProfile = controller.stateManager.getGlobalSettingsKey("defaultTerminalProfile")
|
||||
controller.stateManager.setGlobalState("defaultTerminalProfile", request.defaultTerminalProfile)
|
||||
// Update the live terminal manager so new terminals use the new profile.
|
||||
// Existing terminals are left open — they're keyed by effective shell
|
||||
// and reused when compatible, or skipped when not.
|
||||
controller.terminalManager?.setDefaultTerminalProfile(request.defaultTerminalProfile)
|
||||
// Rebuild the session so the run_commands tool description names the new shell.
|
||||
controller.handleTerminalProfileChanged(previousProfile, request.defaultTerminalProfile)
|
||||
}
|
||||
|
||||
if (request.backgroundEditEnabled !== undefined) {
|
||||
|
||||
@@ -189,11 +189,14 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
|
||||
|
||||
// Update default terminal profile
|
||||
if (defaultTerminalProfile !== undefined && defaultTerminalProfile !== "") {
|
||||
const previousProfile = controller.stateManager.getGlobalSettingsKey("defaultTerminalProfile")
|
||||
controller.stateManager.setGlobalState("defaultTerminalProfile", defaultTerminalProfile)
|
||||
// Update the live terminal manager so new terminals use the new profile.
|
||||
// Existing terminals are left open — they're keyed by effective shell
|
||||
// and reused when compatible, or skipped when not.
|
||||
controller.terminalManager?.setDefaultTerminalProfile(defaultTerminalProfile)
|
||||
// Rebuild the session so the run_commands tool description names the new shell.
|
||||
controller.handleTerminalProfileChanged(previousProfile, defaultTerminalProfile)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isClineManagedProvider } from "@/shared/utils/cline"
|
||||
import { arePathsEqual, getDesktopDir } from "@/utils/path"
|
||||
import { getShellForProfile } from "@/utils/shell"
|
||||
import { ClineAccountService } from "./account-service"
|
||||
import { AuthService, LogoutReason } from "./auth-service"
|
||||
import { buildStartSessionInput, createHistoryItemFromSession } from "./cline-session-factory"
|
||||
@@ -359,9 +360,11 @@ export class Controller {
|
||||
onSendStart: () => {
|
||||
this.beginProviderFailureTelemetryTurn()
|
||||
},
|
||||
// this.mode is assigned later in this constructor; the closure only
|
||||
// runs at send time, long after construction completes.
|
||||
// this.mode and this.terminalExecutionMode are assigned later in this
|
||||
// constructor; the closures only run at send time, long after
|
||||
// construction completes.
|
||||
consumeModeSwitchNotice: (sessionId) => this.mode.consumeModeSwitchNotice(sessionId),
|
||||
consumeShellChangeNotice: (sessionId) => this.terminalExecutionMode.consumeShellChangeNotice(sessionId),
|
||||
onSendComplete: async () => {
|
||||
// Normal flows close their diff sessions inline; anything left here is orphaned.
|
||||
void this.diffEdits.discardAllPreviews("turn complete")
|
||||
@@ -482,6 +485,7 @@ export class Controller {
|
||||
buildStartSessionInput,
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
rebuilds: this.sessionRebuilds,
|
||||
resolveShellForProfile: getShellForProfile,
|
||||
})
|
||||
this.providerChanges = new SdkProviderChangeCoordinator({
|
||||
stateManager: this.stateManager,
|
||||
@@ -648,6 +652,10 @@ export class Controller {
|
||||
this.terminalExecutionMode.handleTerminalExecutionModeChanged(previous, next)
|
||||
}
|
||||
|
||||
handleTerminalProfileChanged(previous: string | undefined, next: string): void {
|
||||
this.terminalExecutionMode.handleTerminalProfileChanged(previous, next)
|
||||
}
|
||||
|
||||
private handleSessionBecameIdle(): void {
|
||||
if (this.mode?.hasPendingModeChange()) {
|
||||
// The mode rebuild reads the latest provider and tool configuration, so
|
||||
|
||||
@@ -611,6 +611,61 @@ describe("SdkSessionLifecycle", () => {
|
||||
|
||||
expect(send).toHaveBeenCalledWith(expect.objectContaining({ prompt: "hello" }))
|
||||
})
|
||||
|
||||
it("stamps a pending shell-change notice onto the outbound prompt", async () => {
|
||||
const send = vi.fn().mockResolvedValue(undefined)
|
||||
const sdkHost = makeSdkHost({ send })
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
let pending: { from: string; to: string } | null = { from: "powershell", to: "cmd.exe" }
|
||||
const consumeShellChangeNotice = vi.fn(() => {
|
||||
const notice = pending
|
||||
pending = null
|
||||
return notice
|
||||
})
|
||||
const lifecycle = makeLifecycle({ consumeShellChangeNotice })
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
await lifecycle.startNewSession({} as any)
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
lifecycle.fireAndForgetSend(sdkHost as any, "session-123", "list the files")
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1))
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "<environment_notice>The user changed the terminal shell from PowerShell to cmd.exe before sending this message. Commands now run through cmd.exe; write all subsequent commands in cmd.exe syntax.</environment_notice>\nlist the files",
|
||||
}),
|
||||
)
|
||||
|
||||
// Consumed by the first send; the next message is clean.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
lifecycle.fireAndForgetSend(sdkHost as any, "session-123", "now build")
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2))
|
||||
expect(send).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: "now build" }))
|
||||
})
|
||||
|
||||
it("stamps pending mode and shell notices together, mode first", async () => {
|
||||
const send = vi.fn().mockResolvedValue(undefined)
|
||||
const sdkHost = makeSdkHost({ send })
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle({
|
||||
consumeModeSwitchNotice: vi.fn(() => ({ from: "plan" as const, to: "act" as const })),
|
||||
consumeShellChangeNotice: vi.fn(() => ({ from: "powershell", to: "cmd.exe" })),
|
||||
})
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
await lifecycle.startNewSession({} as any)
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
lifecycle.fireAndForgetSend(sdkHost as any, "session-123", "do it")
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalled())
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt:
|
||||
"<mode_notice>The user switched from plan mode to act mode before sending this message.</mode_notice>\n" +
|
||||
"<environment_notice>The user changed the terminal shell from PowerShell to cmd.exe before sending this message. Commands now run through cmd.exe; write all subsequent commands in cmd.exe syntax.</environment_notice>\n" +
|
||||
"do it",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function makeLifecycle(overrides: Partial<ConstructorParameters<typeof SdkSessionLifecycle>[0]> = {}) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
RestoreResult,
|
||||
StartSessionResult,
|
||||
} from "@cline/core"
|
||||
import { formatModeSwitchNotice, type ModeSwitchNotice } from "@cline/shared"
|
||||
import { formatModeSwitchNotice, formatShellChangeNotice, type ModeSwitchNotice, type ShellChangeNotice } from "@cline/shared"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import { McpHub } from "@/services/mcp/McpHub"
|
||||
@@ -49,6 +49,13 @@ export interface SdkSessionLifecycleOptions {
|
||||
* message. Consumed exactly once; null when no switch is pending.
|
||||
*/
|
||||
consumeModeSwitchNotice?: (sessionId: string) => ModeSwitchNotice | null
|
||||
/**
|
||||
* Returns (and clears) a pending terminal-shell change recorded by
|
||||
* SdkTerminalExecutionModeCoordinator for this session, stamped as an
|
||||
* <environment_notice> the same way. Independent of the mode notice; when
|
||||
* both are pending, both are stamped onto the same message.
|
||||
*/
|
||||
consumeShellChangeNotice?: (sessionId: string) => ShellChangeNotice | null
|
||||
onDidBecomeIdle?: () => void
|
||||
}
|
||||
|
||||
@@ -364,14 +371,22 @@ export class SdkSessionLifecycle {
|
||||
Logger.debug(`[SdkController] Ignoring ${label} of superseded send for session: ${sessionId}`)
|
||||
return true
|
||||
}
|
||||
// Mark a preceding user-initiated mode switch on this message so the model
|
||||
// sees exactly when the rules changed, instead of only inferring it from
|
||||
// the user_input mode attribute flipping (mirrors the CLI's
|
||||
// run-interactive stamping). The notice survives prepareTurnInput's
|
||||
// normalizeUserInput sanitize and is hidden from display surfaces by
|
||||
// stripModeNotices.
|
||||
const notice = this.options.consumeModeSwitchNotice?.(sessionId)
|
||||
const noticedPrompt = notice ? `${formatModeSwitchNotice(notice.from, notice.to)}\n${prompt}` : prompt
|
||||
// Mark preceding user-initiated setting changes on this message so the
|
||||
// model sees exactly when the rules changed: a plan/act switch (mirrors
|
||||
// the CLI's run-interactive stamping) and/or a terminal-shell change.
|
||||
// Each notice is tracked and consumed independently, so either, both, or
|
||||
// neither may be present. The notices survive prepareTurnInput's
|
||||
// normalizeUserInput sanitize and are hidden from display surfaces by
|
||||
// stripRuntimeNotices.
|
||||
const modeNotice = this.options.consumeModeSwitchNotice?.(sessionId)
|
||||
const shellNotice = this.options.consumeShellChangeNotice?.(sessionId)
|
||||
const noticedPrompt = [
|
||||
modeNotice ? formatModeSwitchNotice(modeNotice.from, modeNotice.to) : undefined,
|
||||
shellNotice ? formatShellChangeNotice(shellNotice.from, shellNotice.to) : undefined,
|
||||
prompt,
|
||||
]
|
||||
.filter((part): part is string => part !== undefined)
|
||||
.join("\n")
|
||||
this.options.onSendStart?.(sessionId)
|
||||
sdkHost
|
||||
.send({
|
||||
|
||||
@@ -13,6 +13,13 @@ vi.mock("@/shared/services/Logger", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
/** Profile-to-shell mapping used by the injected resolveShellForProfile. */
|
||||
const SHELL_BY_PROFILE: Record<string, string> = {
|
||||
default: "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
powershell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
cmd: "C:\\Windows\\System32\\cmd.exe",
|
||||
}
|
||||
|
||||
describe("SdkTerminalExecutionModeCoordinator", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -35,6 +42,86 @@ describe("SdkTerminalExecutionModeCoordinator", () => {
|
||||
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does nothing when the terminal profile did not change", () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
|
||||
coordinator.handleTerminalProfileChanged("powershell", "powershell")
|
||||
|
||||
expect(options.rebuilds.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("treats undefined and 'default' as the same profile", () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
|
||||
coordinator.handleTerminalProfileChanged(undefined, "default")
|
||||
|
||||
expect(options.rebuilds.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("requests a rebuild when the terminal profile changes", async () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
|
||||
coordinator.handleTerminalProfileChanged("default", "cmd")
|
||||
|
||||
expect(options.rebuilds.request).toHaveBeenCalledWith("terminalExecutionMode", expect.any(Function))
|
||||
})
|
||||
|
||||
it("records a shell notice for the active session when the profile changes shell", () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator } = makeCoordinator({ activeSession })
|
||||
|
||||
coordinator.handleTerminalProfileChanged("default", "cmd")
|
||||
|
||||
expect(coordinator.consumeShellChangeNotice("some-other-task")).toBeNull()
|
||||
expect(coordinator.consumeShellChangeNotice("old-session")).toEqual({
|
||||
from: SHELL_BY_PROFILE.default,
|
||||
to: SHELL_BY_PROFILE.cmd,
|
||||
})
|
||||
expect(coordinator.consumeShellChangeNotice("old-session")).toBeNull()
|
||||
})
|
||||
|
||||
it("records no shell notice when the new profile resolves to the same shell", () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
|
||||
coordinator.handleTerminalProfileChanged("default", "powershell")
|
||||
|
||||
expect(coordinator.consumeShellChangeNotice("old-session")).toBeNull()
|
||||
// The tool description still names the (unchanged) shell correctly; the
|
||||
// rebuild is cheap and keeps profile bookkeeping in one place.
|
||||
expect(options.rebuilds.request).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("cancels the shell notice when the user switches back before sending", () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator } = makeCoordinator({ activeSession })
|
||||
|
||||
coordinator.handleTerminalProfileChanged("default", "cmd")
|
||||
coordinator.handleTerminalProfileChanged("cmd", "default")
|
||||
|
||||
expect(coordinator.consumeShellChangeNotice("old-session")).toBeNull()
|
||||
})
|
||||
|
||||
it("records no shell notice without an active session", () => {
|
||||
const { coordinator } = makeCoordinator()
|
||||
|
||||
coordinator.handleTerminalProfileChanged("default", "cmd")
|
||||
|
||||
expect(coordinator.consumeShellChangeNotice("old-session")).toBeNull()
|
||||
})
|
||||
|
||||
it("records no shell notice for execution mode changes", () => {
|
||||
const activeSession = makeActiveSession()
|
||||
const { coordinator } = makeCoordinator({ activeSession })
|
||||
|
||||
coordinator.handleTerminalExecutionModeChanged("backgroundExec", "vscodeTerminal")
|
||||
|
||||
expect(coordinator.consumeShellChangeNotice("old-session")).toBeNull()
|
||||
})
|
||||
|
||||
it("schedules restart while the active session is running", () => {
|
||||
const activeSession = makeActiveSession({ isRunning: true })
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
@@ -144,6 +231,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
loadInitialMessages: vi.fn().mockResolvedValue([{ role: "user", content: "hello" }]),
|
||||
buildStartSessionInput: vi.fn(() => ({ prompt: "start" })),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
resolveShellForProfile: vi.fn((profileId: string) => SHELL_BY_PROFILE[profileId] ?? SHELL_BY_PROFILE.default),
|
||||
rebuilds: {
|
||||
request: vi.fn((_reason: string, rebuild: () => Promise<void>) => {
|
||||
if (!initialRebuildScheduled && !activeSession?.isRunning) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createShellChangeNoticeTracker, type ShellChangeNotice, type ShellChangeNoticeTracker } from "@cline/shared"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
@@ -24,17 +25,90 @@ export interface SdkTerminalExecutionModeCoordinatorOptions {
|
||||
buildStartSessionInput: (config: SessionConfig, input: { cwd: string; mode: Mode }) => StartInput
|
||||
postStateToWebview: () => Promise<void>
|
||||
rebuilds: Pick<SdkSessionRebuildScheduler, "request">
|
||||
/**
|
||||
* Maps a terminal profile ID to the shell it runs (getShellForProfile).
|
||||
* Injected so shell-notice decisions are testable without VS Code config.
|
||||
*/
|
||||
resolveShellForProfile: (profileId: string) => string
|
||||
}
|
||||
|
||||
export class SdkTerminalExecutionModeCoordinator {
|
||||
/**
|
||||
* Pending shell change, stamped as an <environment_notice> onto the next
|
||||
* outbound message by SdkSessionLifecycle.fireAndForgetSend. Tracked over
|
||||
* resolved shells rather than profile IDs so profile changes that keep the
|
||||
* same shell (e.g. "default" -> "powershell" where default is PowerShell)
|
||||
* record nothing, and a round trip back to the shell the model last used
|
||||
* cancels out. Session-scoped like SdkModeCoordinator's mode notice: the
|
||||
* setting is global, so the notice stays pending for the recorded session
|
||||
* even if the user visits another task before sending.
|
||||
*/
|
||||
private shellChangeNoticeTracker: ShellChangeNoticeTracker = createShellChangeNoticeTracker()
|
||||
private shellChangeNoticeSessionId: string | null = null
|
||||
|
||||
constructor(private readonly options: SdkTerminalExecutionModeCoordinatorOptions) {}
|
||||
|
||||
handleTerminalExecutionModeChanged(previous: VscodeTerminalExecutionMode, next: VscodeTerminalExecutionMode): void {
|
||||
if (previous === next) {
|
||||
return
|
||||
}
|
||||
// No shell notice: both modes resolve the shell from the same profile
|
||||
// setting, so the shell does not change with the execution mode.
|
||||
this.requestRebuild(`Terminal execution mode changed: ${previous} -> ${next}`)
|
||||
}
|
||||
|
||||
Logger.log(`[SdkController] Terminal execution mode changed: ${previous} -> ${next}`)
|
||||
/**
|
||||
* The terminal profile selects the shell, and the run_commands tool
|
||||
* description names that shell, so a profile change requires the same
|
||||
* session rebuild as an execution mode change — plus a conversation
|
||||
* notice, since the transcript's earlier commands still model the old
|
||||
* shell's syntax.
|
||||
*/
|
||||
handleTerminalProfileChanged(previous: string | undefined, next: string): void {
|
||||
if ((previous || "default") === (next || "default")) {
|
||||
return
|
||||
}
|
||||
this.recordShellChangeNotice(previous || "default", next || "default")
|
||||
this.requestRebuild(`Terminal profile changed: ${previous ?? "default"} -> ${next}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns (and clears) the pending shell-change notice when the outbound
|
||||
* message targets the session the change was recorded for; otherwise
|
||||
* leaves it pending.
|
||||
*/
|
||||
consumeShellChangeNotice(sessionId: string): ShellChangeNotice | null {
|
||||
if (this.shellChangeNoticeSessionId !== sessionId) {
|
||||
return null
|
||||
}
|
||||
const notice = this.shellChangeNoticeTracker.consume()
|
||||
if (notice) {
|
||||
this.shellChangeNoticeSessionId = null
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
private recordShellChangeNotice(previousProfileId: string, nextProfileId: string): void {
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
if (!activeSession) {
|
||||
// No transcript to correct: a future session starts with the right
|
||||
// tool description and no momentum in the old shell.
|
||||
return
|
||||
}
|
||||
if (this.shellChangeNoticeSessionId !== activeSession.sessionId) {
|
||||
// A stale notice for another session is superseded rather than merged:
|
||||
// round-trip cancellation only makes sense within one transcript.
|
||||
this.shellChangeNoticeTracker = createShellChangeNoticeTracker()
|
||||
}
|
||||
this.shellChangeNoticeSessionId = activeSession.sessionId
|
||||
this.shellChangeNoticeTracker.record(
|
||||
this.options.resolveShellForProfile(previousProfileId),
|
||||
this.options.resolveShellForProfile(nextProfileId),
|
||||
)
|
||||
}
|
||||
|
||||
private requestRebuild(reason: string): void {
|
||||
Logger.log(`[SdkController] ${reason}`)
|
||||
|
||||
const activeSession = this.options.sessions.getActiveSession()
|
||||
if (!activeSession) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeUserInput, stripModeNotices } from "@cline/shared"
|
||||
import { normalizeUserInput, stripRuntimeNotices } from "@cline/shared"
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "./sdk-mode-coordinator"
|
||||
|
||||
export type SdkUserMessage = {
|
||||
@@ -46,7 +46,7 @@ export function isSyntheticUserPrompt(text: string): boolean {
|
||||
// <mode_notice> element to the canned continuation, so strip those too or
|
||||
// the synthetic prompt would start counting as a visible user message and
|
||||
// shift every later edit/regenerate ordinal by one.
|
||||
const normalized = stripModeNotices(normalizeUserInput(text))
|
||||
const normalized = stripRuntimeNotices(normalizeUserInput(text))
|
||||
return normalized.startsWith("[TASK RESUMPTION]") || normalized === ACT_MODE_CONTINUATION_PROMPT
|
||||
}
|
||||
|
||||
|
||||
@@ -289,18 +289,33 @@ export async function executeForeground(
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the shell the user's terminal profile setting selects right now.
|
||||
* Both execution modes run this shell: foreground terminals are created from
|
||||
* the same profile (VscodeTerminalManager.setDefaultTerminalProfile), and the
|
||||
* background executor spawns it directly.
|
||||
*/
|
||||
function resolveConfiguredShell(): string {
|
||||
// The setting is typed string, but guard empty values the same way the
|
||||
// settings handlers do (they skip persisting "" but older stores may hold one).
|
||||
const profileId = StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") || "default"
|
||||
return getShellForProfile(profileId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the custom `run_commands` tool for the VSCode extension.
|
||||
*
|
||||
* This tool suppresses and replaces the SDK's built-in `run_commands` tool.
|
||||
* The terminal execution mode is captured when the session's tool set is built.
|
||||
* Switching modes rebuilds the active SDK session so the tool timeout and
|
||||
* execution mode stay aligned.
|
||||
* The terminal execution mode and shell are captured when the session's tool
|
||||
* set is built. Switching modes or terminal profiles rebuilds the active SDK
|
||||
* session so the tool timeout, execution mode, and the shell named in the
|
||||
* tool description stay aligned with what actually runs.
|
||||
*/
|
||||
export function createVscodeRunCommandsTool(options: VscodeRunCommandsToolOptions): AgentTool {
|
||||
return createShellTool(createVscodeShellExecutor(options), {
|
||||
cwd: options.cwd,
|
||||
bashTimeoutMs: options.bashTimeoutMs,
|
||||
shell: resolveConfiguredShell(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -319,10 +334,11 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
|
||||
Logger.log(`[VscodeRunCommands] Executing command in ${executionMode} mode`)
|
||||
|
||||
if (executionMode === "backgroundExec") {
|
||||
// Background path — use SDK's createShellExecutor
|
||||
// Resolve shell from the user's terminal profile setting
|
||||
const profileId = (StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") as string) || "default"
|
||||
const shell = getShellForProfile(profileId)
|
||||
// Background path — use SDK's createShellExecutor.
|
||||
// Re-resolve the shell per invocation: a profile change rebuilds the
|
||||
// session, but that rebuild is deferred while a task is running, so
|
||||
// commands issued in the meantime must still use the new profile.
|
||||
const shell = resolveConfiguredShell()
|
||||
|
||||
// Recreate the executor if the shell has changed
|
||||
if (!bgExecutor || bgExecutorShell !== shell) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
|
||||
import { expect } from "chai"
|
||||
import * as actualFs from "fs"
|
||||
import * as actualOs from "os"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
@@ -15,6 +16,16 @@ const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
|
||||
mock.module("os", osMock)
|
||||
mock.module("node:os", osMock)
|
||||
|
||||
// getShell() probes the filesystem for PowerShell 7 when no Windows terminal
|
||||
// profile is configured. Route existsSync through a mutable delegate so tests
|
||||
// control which PowerShell installs "exist" regardless of the host machine.
|
||||
let existsSyncImpl: typeof actualFs.existsSync = actualFs.existsSync
|
||||
const existsSyncDelegate = ((path: unknown) => existsSyncImpl(path as string)) as typeof actualFs.existsSync
|
||||
const fsMockNamespace = { ...actualFs, existsSync: existsSyncDelegate }
|
||||
const fsMock = () => ({ ...fsMockNamespace, default: fsMockNamespace })
|
||||
mock.module("fs", fsMock)
|
||||
mock.module("node:fs", fsMock)
|
||||
|
||||
import { getShell } from "@utils/shell"
|
||||
|
||||
describe("Shell Detection Tests", () => {
|
||||
@@ -22,6 +33,7 @@ describe("Shell Detection Tests", () => {
|
||||
let originalEnv: NodeJS.ProcessEnv
|
||||
let originalGetConfig: typeof vscode.workspace.getConfiguration
|
||||
let originalUserInfo: typeof actualOs.userInfo
|
||||
let originalExistsSync: typeof actualFs.existsSync
|
||||
|
||||
// Helper to mock VS Code configuration
|
||||
function mockVsCodeConfig(platformKey: string, defaultProfileName: string | null, profiles: Record<string, any>) {
|
||||
@@ -45,6 +57,7 @@ describe("Shell Detection Tests", () => {
|
||||
originalEnv = { ...process.env }
|
||||
originalGetConfig = vscode.workspace.getConfiguration
|
||||
originalUserInfo = userInfoImpl
|
||||
originalExistsSync = existsSyncImpl
|
||||
|
||||
// Clear environment variables for a clean test
|
||||
delete process.env.SHELL
|
||||
@@ -52,6 +65,9 @@ describe("Shell Detection Tests", () => {
|
||||
|
||||
// Default userInfo() mock
|
||||
userInfoImpl = (() => ({ shell: null })) as any
|
||||
// Default: PowerShell 7 is not installed, so the Windows default
|
||||
// resolves to legacy Windows PowerShell.
|
||||
existsSyncImpl = (() => false) as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -60,6 +76,7 @@ describe("Shell Detection Tests", () => {
|
||||
process.env = originalEnv
|
||||
vscode.workspace.getConfiguration = originalGetConfig
|
||||
userInfoImpl = originalUserInfo
|
||||
existsSyncImpl = originalExistsSync
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -117,18 +134,36 @@ describe("Shell Detection Tests", () => {
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
|
||||
})
|
||||
|
||||
it("respects userInfo() if no VS Code config is available", () => {
|
||||
it("defaults to PowerShell 7 when no profile is configured and pwsh is installed", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "C:\\Custom\\PowerShell.exe" }) as any
|
||||
process.env.ProgramW6432 = "C:\\Program Files"
|
||||
existsSyncImpl = (() => true) as any
|
||||
|
||||
expect(getShell()).to.equal("C:\\Custom\\PowerShell.exe")
|
||||
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
|
||||
})
|
||||
|
||||
it("respects an odd COMSPEC if no userInfo shell is available", () => {
|
||||
it("defaults to Store-installed pwsh when that is the only pwsh present", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"
|
||||
const storePwsh = "C:\\Users\\Test\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe"
|
||||
existsSyncImpl = ((path: string) => path === storePwsh) as any
|
||||
|
||||
expect(getShell()).to.equal(storePwsh)
|
||||
})
|
||||
|
||||
it("defaults to legacy Windows PowerShell when no profile is configured and pwsh is absent", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
existsSyncImpl = (() => false) as any
|
||||
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
})
|
||||
|
||||
it("ignores userInfo() and COMSPEC — VS Code's default terminal ignores them too", () => {
|
||||
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
|
||||
userInfoImpl = () => ({ shell: "C:\\Custom\\OtherShell.exe" }) as any
|
||||
process.env.COMSPEC = "D:\\CustomCmd\\cmd.exe"
|
||||
|
||||
expect(getShell()).to.equal("D:\\CustomCmd\\cmd.exe")
|
||||
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as childProcess from "child_process"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { WINDOWS_POWERSHELL_7_PATH, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
|
||||
import { getWindowsPwshInstallPaths, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
|
||||
|
||||
const POWERSHELL_PROBE_TIMEOUT_MS = 1200
|
||||
|
||||
@@ -16,14 +16,7 @@ export function getFallbackWindowsPowerShellPath(): string {
|
||||
}
|
||||
|
||||
export function getWindowsPowerShellCandidates(): string[] {
|
||||
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
|
||||
|
||||
const envAbsoluteCandidates = [
|
||||
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
|
||||
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
|
||||
WINDOWS_POWERSHELL_7_PATH,
|
||||
WINDOWS_POWERSHELL_LEGACY_PATH,
|
||||
]
|
||||
const envAbsoluteCandidates = [...getWindowsPwshInstallPaths(), WINDOWS_POWERSHELL_LEGACY_PATH]
|
||||
|
||||
const commandNameFallbacks = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"]
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync } from "fs"
|
||||
import { userInfo } from "os"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
@@ -171,11 +172,6 @@ function getShellFromUserInfo(): string | null {
|
||||
function getShellFromEnv(): string | null {
|
||||
const { env } = process
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, COMSPEC typically holds cmd.exe
|
||||
return env.COMSPEC || "C:\\Windows\\System32\\cmd.exe"
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
// On macOS/Linux, SHELL is commonly the environment variable
|
||||
return env.SHELL || "/bin/zsh"
|
||||
@@ -304,6 +300,35 @@ export function getShellForProfile(profileId: string): string {
|
||||
// 5) Publicly Exposed Shell Getter
|
||||
// -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Absolute paths where a modern PowerShell (pwsh) may be installed, most
|
||||
* preferred first: MSI/ZIP installs under Program Files (either architecture),
|
||||
* then the Microsoft Store install under LOCALAPPDATA. This is the single
|
||||
* candidate list shared with the async prober in utils/powershell.ts.
|
||||
*/
|
||||
export function getWindowsPwshInstallPaths(): string[] {
|
||||
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
|
||||
const localAppData = process.env.LOCALAPPDATA
|
||||
return [
|
||||
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
|
||||
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
|
||||
SHELL_PATHS.POWERSHELL_7,
|
||||
...(localAppData ? [`${localAppData}\\Microsoft\\WindowsApps\\pwsh.exe`] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell VS Code launches on Windows when the user has not configured a
|
||||
* default terminal profile: its built-in default is PowerShell (pwsh when
|
||||
* installed, Windows PowerShell otherwise) — never cmd.exe. Mirroring that
|
||||
* here keeps the "default" profile meaning the same shell whether commands
|
||||
* run in a visible VS Code terminal or a background child process.
|
||||
*/
|
||||
function getWindowsDefaultShell(): string {
|
||||
const pwsh = getWindowsPwshInstallPaths().find((candidate) => existsSync(candidate))
|
||||
return pwsh ?? SHELL_PATHS.POWERSHELL_LEGACY
|
||||
}
|
||||
|
||||
export function getShell(): string {
|
||||
// 1. Check VS Code config first.
|
||||
if (process.platform === "win32") {
|
||||
@@ -312,7 +337,12 @@ export function getShell(): string {
|
||||
if (windowsShell) {
|
||||
return windowsShell
|
||||
}
|
||||
} else if (process.platform === "darwin") {
|
||||
// No profile configured — match the shell VS Code's default terminal
|
||||
// would launch. userInfo()/COMSPEC are not consulted: VS Code's own
|
||||
// terminal ignores them too, and they would resolve to cmd.exe.
|
||||
return getWindowsDefaultShell()
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
// macOS from VS Code
|
||||
const macShell = getMacShellFromVSCode()
|
||||
if (macShell) {
|
||||
@@ -338,12 +368,6 @@ export function getShell(): string {
|
||||
return envShell
|
||||
}
|
||||
|
||||
// 4. Finally, fall back to a default
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, if we got here, we have no config, no COMSPEC, and one very messed up operating system.
|
||||
// Use CMD as a last resort
|
||||
return SHELL_PATHS.CMD
|
||||
}
|
||||
// On macOS/Linux, fallback to a POSIX shell - This is the behavior of our old shell detection method.
|
||||
// 4. Fall back to a POSIX shell - This is the behavior of our old shell detection method.
|
||||
return SHELL_PATHS.FALLBACK
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getToolContextTelemetry,
|
||||
} from "../../services/telemetry/tool-context";
|
||||
import {
|
||||
buildRunCommandsDescription,
|
||||
createDefaultTools,
|
||||
createReadFilesTool,
|
||||
createSearchTool,
|
||||
@@ -480,6 +481,54 @@ describe("default apply_patch tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("run_commands tool description", () => {
|
||||
it("names PowerShell with ';' sequencing for PowerShell shells", () => {
|
||||
const description = buildRunCommandsDescription("powershell", true);
|
||||
expect(description).toContain("Commands run through PowerShell");
|
||||
expect(description).toContain("use ';' to sequence commands");
|
||||
expect(description).toContain("in Windows environment");
|
||||
});
|
||||
|
||||
it("names cmd.exe with '&&' sequencing for cmd shells", () => {
|
||||
const description = buildRunCommandsDescription("cmd", true);
|
||||
expect(description).toContain("Commands run through cmd.exe");
|
||||
expect(description).toContain("use '&&' to sequence commands");
|
||||
expect(description).not.toContain("PowerShell");
|
||||
});
|
||||
|
||||
it("describes WSL bash with the /mnt working-directory mapping", () => {
|
||||
const description = buildRunCommandsDescription("wsl", true);
|
||||
expect(description).toContain("bash in WSL");
|
||||
expect(description).toContain("/mnt/<drive>");
|
||||
expect(description).not.toContain("PowerShell");
|
||||
});
|
||||
|
||||
it("notes the Windows host for POSIX shells on Windows only", () => {
|
||||
const onWindows = buildRunCommandsDescription("posix", true);
|
||||
expect(onWindows).toContain("POSIX (bash-compatible) shell on Windows");
|
||||
expect(onWindows).not.toContain("PowerShell");
|
||||
|
||||
const onUnix = buildRunCommandsDescription("posix", false);
|
||||
expect(onUnix).not.toContain("Windows");
|
||||
expect(onUnix).toContain("grep/head/tail");
|
||||
});
|
||||
|
||||
it("derives the createShellTool description from config.shell", () => {
|
||||
const posixTool = createShellTool(async () => "ok", {
|
||||
shell: "/bin/bash",
|
||||
});
|
||||
expect(posixTool.description).toContain(
|
||||
"Run non-interactive shell commands",
|
||||
);
|
||||
expect(posixTool.description).not.toContain("PowerShell");
|
||||
|
||||
const cmdTool = createShellTool(async () => "ok", {
|
||||
shell: "C:\\Windows\\System32\\cmd.exe",
|
||||
});
|
||||
expect(cmdTool.description).toContain("Commands run through cmd.exe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default run_commands tool", () => {
|
||||
function createTelemetryStub(): ITelemetryService {
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
type AgentTool,
|
||||
type AgentToolContext,
|
||||
createTool,
|
||||
getDefaultShell,
|
||||
getShellKind,
|
||||
type ShellKind,
|
||||
validateWithZod,
|
||||
zodToJsonSchema,
|
||||
} from "@cline/shared";
|
||||
@@ -395,15 +398,55 @@ const RUN_COMMANDS_SHARED_INSTRUCTIONS =
|
||||
"Use for listing files, checking git status, running builds, executing tests, etc. " +
|
||||
"Commands must be non-interactive. Commands that require follow-up input like pagers should be skipped or used with supported flags/env (e.g. git --no-pager, --non-interactive) to bypass the interaction steps. ";
|
||||
|
||||
/**
|
||||
* Build the run_commands tool description for the shell that will actually
|
||||
* execute the commands. The shell kind decides the syntax guidance (quoting,
|
||||
* sequencing, heredocs), and isWindows adds environment context for POSIX
|
||||
* shells running on a Windows host (e.g. Git Bash).
|
||||
*/
|
||||
export function buildRunCommandsDescription(
|
||||
shellKind: ShellKind,
|
||||
isWindows: boolean,
|
||||
): string {
|
||||
if (shellKind === "powershell" || shellKind === "cmd") {
|
||||
const shellName = shellKind === "powershell" ? "PowerShell" : "cmd.exe";
|
||||
const sequencingOperator = shellKind === "powershell" ? "';'" : "'&&'";
|
||||
return (
|
||||
"Run non-interactive shell commands from the root of the workspace in Windows environment. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); filter output when you need specific sections. ` +
|
||||
`Commands run through ${shellName}; quote paths and arguments for ${shellName} and use ${sequencingOperator} to sequence commands. ` +
|
||||
"Include multiple commands in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response."
|
||||
);
|
||||
}
|
||||
|
||||
const environmentNote =
|
||||
shellKind === "wsl"
|
||||
? "Commands run through bash in WSL (wsl.exe); the Windows working directory is mounted under /mnt/<drive>. "
|
||||
: isWindows
|
||||
? "Commands run through a POSIX (bash-compatible) shell on Windows. "
|
||||
: "";
|
||||
return (
|
||||
"Run non-interactive shell commands from the root of the workspace. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
environmentNote +
|
||||
"Commands should be properly shell-escaped and targeted to avoid error or timeout. Include multiple commands in the same call when they are independent complete shell commands and safe to run concurrently; multiline scripts and heredocs must be a single command string. When independent reads, searches, or edits are also needed, call those tools in the same response. " +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); pipe through grep/head/tail when you need specific sections of large output. ` +
|
||||
"For long-running commands, run them in background and redirect output to a tmp file that you can read from later."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the run_commands shell tool for the current platform.
|
||||
*
|
||||
* This preserves the SDK's platform-specific prompting/schema choices while
|
||||
* exposing a single generic shell-tool factory for host integrations.
|
||||
* exposing a single generic shell-tool factory for host integrations. Pass
|
||||
* config.shell (matching the executor's shell) so the syntax guidance in the
|
||||
* tool description matches the shell that actually runs the commands.
|
||||
*/
|
||||
export function createShellTool(
|
||||
executor: ShellExecutor,
|
||||
config: Pick<DefaultToolsConfig, "cwd" | "bashTimeoutMs"> = {},
|
||||
config: Pick<DefaultToolsConfig, "cwd" | "bashTimeoutMs" | "shell"> = {},
|
||||
): AgentTool<unknown, ToolOperationResult[]> {
|
||||
const timeoutMs = config.bashTimeoutMs ?? 30000;
|
||||
const timeoutSource =
|
||||
@@ -412,19 +455,11 @@ export function createShellTool(
|
||||
: "configured_setting";
|
||||
const cwd = config.cwd ?? process.cwd();
|
||||
const isWindows = process.platform === "win32";
|
||||
const shell = config.shell ?? getDefaultShell(process.platform);
|
||||
|
||||
return createTool<unknown, ToolOperationResult[]>({
|
||||
name: "run_commands",
|
||||
description: isWindows
|
||||
? "Run non-interactive shell commands from the root of the workspace in Windows environment. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); filter output when you need specific sections. ` +
|
||||
"Commands run through PowerShell; quote paths and arguments for PowerShell and use ';' to sequence commands. Include multiple commands in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response."
|
||||
: "Run non-interactive shell commands from the root of the workspace. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
"Commands should be properly shell-escaped and targeted to avoid error or timeout. Include multiple commands in the same call when they are independent complete shell commands and safe to run concurrently; multiline scripts and heredocs must be a single command string. When independent reads, searches, or edits are also needed, call those tools in the same response. " +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); pipe through grep/head/tail when you need specific sections of large output. ` +
|
||||
"For long-running commands, run them in background and redirect output to a tmp file that you can read from later.",
|
||||
description: buildRunCommandsDescription(getShellKind(shell), isWindows),
|
||||
inputSchema: zodToJsonSchema(RunCommandsInputSchema),
|
||||
timeoutMs: timeoutMs * 2,
|
||||
retryable: false,
|
||||
|
||||
@@ -297,6 +297,14 @@ export interface DefaultToolsConfig {
|
||||
*/
|
||||
cwd?: string;
|
||||
|
||||
/**
|
||||
* Shell executable (name or full path) the run_commands executor will use.
|
||||
* The tool description tells the model which shell syntax to write, so this
|
||||
* must match the shell configured on the executor.
|
||||
* @default getDefaultShell(process.platform) — "/bin/bash" on Unix, "powershell" on Windows
|
||||
*/
|
||||
shell?: string;
|
||||
|
||||
/**
|
||||
* Timeout for file read operations in milliseconds
|
||||
* @default 10000
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type * as LlmsProviders from "@cline/llms";
|
||||
import type { AgentConfig, AgentEvent, AgentResult } from "@cline/shared";
|
||||
import { normalizeUserInput, stripModeNotices } from "@cline/shared";
|
||||
import { normalizeUserInput, stripRuntimeNotices } from "@cline/shared";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
parseSubSessionId,
|
||||
@@ -235,7 +235,9 @@ export function deriveTitleFromPrompt(
|
||||
// stripped here rather than inside normalizeUserInput -- that function also
|
||||
// sanitizes model-bound prompts (prepareTurnInput), where the notice must
|
||||
// survive to reach the model.
|
||||
const normalized = stripModeNotices(normalizeUserInput(prompt ?? "")).trim();
|
||||
const normalized = stripRuntimeNotices(
|
||||
normalizeUserInput(prompt ?? ""),
|
||||
).trim();
|
||||
if (!normalized) return undefined;
|
||||
return normalizeTitle(normalized.split("\n")[0]?.trim());
|
||||
}
|
||||
|
||||
@@ -220,7 +220,13 @@ export {
|
||||
} from "./parse/json";
|
||||
export { decodeJwtPayload } from "./parse/jwt";
|
||||
export { type OmitUndefinedValues, omitUndefinedValues } from "./parse/object";
|
||||
export { getDefaultShell, getShellArgs } from "./parse/shell";
|
||||
export {
|
||||
getDefaultShell,
|
||||
getShellArgs,
|
||||
getShellDisplayName,
|
||||
getShellKind,
|
||||
type ShellKind,
|
||||
} from "./parse/shell";
|
||||
export {
|
||||
maskSecret,
|
||||
sanitizeFileName,
|
||||
@@ -239,17 +245,23 @@ export {
|
||||
export type {
|
||||
ModeSwitchNotice,
|
||||
ModeSwitchNoticeTracker,
|
||||
ShellChangeNotice,
|
||||
ShellChangeNoticeTracker,
|
||||
SwitchNotice,
|
||||
} from "./prompt/format";
|
||||
export {
|
||||
createModeSwitchNoticeTracker,
|
||||
createShellChangeNoticeTracker,
|
||||
createSwitchNoticeTracker,
|
||||
formatDisplayUserInput,
|
||||
formatFileContentBlock,
|
||||
formatModeSwitchNotice,
|
||||
formatShellChangeNotice,
|
||||
formatUserCommandBlock,
|
||||
formatUserInputBlock,
|
||||
normalizeUserInput,
|
||||
parseUserCommandEnvelope,
|
||||
stripModeNotices,
|
||||
stripRuntimeNotices,
|
||||
xmlTagsRemoval,
|
||||
} from "./prompt/format";
|
||||
export { isClineProvider } from "./providers/utils";
|
||||
|
||||
@@ -234,7 +234,13 @@ export {
|
||||
} from "./parse/json";
|
||||
export { decodeJwtPayload } from "./parse/jwt";
|
||||
export { type OmitUndefinedValues, omitUndefinedValues } from "./parse/object";
|
||||
export { getDefaultShell, getShellArgs } from "./parse/shell";
|
||||
export {
|
||||
getDefaultShell,
|
||||
getShellArgs,
|
||||
getShellDisplayName,
|
||||
getShellKind,
|
||||
type ShellKind,
|
||||
} from "./parse/shell";
|
||||
export {
|
||||
maskSecret,
|
||||
sanitizeFileName,
|
||||
@@ -254,18 +260,24 @@ export {
|
||||
export type {
|
||||
ModeSwitchNotice,
|
||||
ModeSwitchNoticeTracker,
|
||||
ShellChangeNotice,
|
||||
ShellChangeNoticeTracker,
|
||||
SwitchNotice,
|
||||
} from "./prompt/format";
|
||||
export {
|
||||
createModeSwitchNoticeTracker,
|
||||
createShellChangeNoticeTracker,
|
||||
createSwitchNoticeTracker,
|
||||
formatDisplayUserInput,
|
||||
formatFileContentBlock,
|
||||
formatModeSwitchNotice,
|
||||
formatShellChangeNotice,
|
||||
formatUserCommandBlock,
|
||||
formatUserInputBlock,
|
||||
normalizeUserInput,
|
||||
parseUserCommandEnvelope,
|
||||
parseUserInputMode,
|
||||
stripModeNotices,
|
||||
stripRuntimeNotices,
|
||||
xmlTagsRemoval,
|
||||
} from "./prompt/format";
|
||||
export { isClineProvider } from "./providers/utils";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDefaultShell, getShellArgs } from "./shell";
|
||||
import {
|
||||
getDefaultShell,
|
||||
getShellArgs,
|
||||
getShellDisplayName,
|
||||
getShellKind,
|
||||
} from "./shell";
|
||||
|
||||
describe("shell helpers", () => {
|
||||
it("selects PowerShell on Windows and bash elsewhere", () => {
|
||||
@@ -56,4 +61,40 @@ describe("shell helpers", () => {
|
||||
"echo hi",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names shells for prompting by family or POSIX base name", () => {
|
||||
expect(
|
||||
getShellDisplayName("C:\\Program Files\\PowerShell\\7\\pwsh.exe"),
|
||||
).toBe("PowerShell");
|
||||
expect(getShellDisplayName("C:\\Windows\\System32\\cmd.exe")).toBe(
|
||||
"cmd.exe",
|
||||
);
|
||||
expect(getShellDisplayName("C:\\Windows\\System32\\wsl.exe")).toBe(
|
||||
"bash (WSL)",
|
||||
);
|
||||
expect(getShellDisplayName("/bin/zsh")).toBe("zsh");
|
||||
expect(
|
||||
getShellDisplayName("C:\\Program Files\\Git\\bin\\bash.exe"),
|
||||
).toBe("bash");
|
||||
});
|
||||
|
||||
it("classifies shells into kinds consistent with their spawn args", () => {
|
||||
expect(getShellKind("powershell")).toBe("powershell");
|
||||
expect(getShellKind("C:\\Program Files\\PowerShell\\7\\pwsh.exe")).toBe(
|
||||
"powershell",
|
||||
);
|
||||
expect(
|
||||
getShellKind(
|
||||
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
),
|
||||
).toBe("powershell");
|
||||
expect(getShellKind("cmd.exe")).toBe("cmd");
|
||||
expect(getShellKind("C:\\Windows\\System32\\cmd.exe")).toBe("cmd");
|
||||
expect(getShellKind("C:\\Windows\\System32\\wsl.exe")).toBe("wsl");
|
||||
expect(getShellKind("/bin/bash")).toBe("posix");
|
||||
expect(getShellKind("/bin/zsh")).toBe("posix");
|
||||
expect(getShellKind("C:\\Program Files\\Git\\bin\\bash.exe")).toBe(
|
||||
"posix",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,21 @@ export function getDefaultShell(platform: string): string {
|
||||
return platform === "win32" ? "powershell" : "/bin/bash";
|
||||
}
|
||||
|
||||
export function getShellArgs(shell: string, command: string): string[] {
|
||||
/**
|
||||
* Shell families that differ in invocation flags and command syntax.
|
||||
* "wsl" is the wsl.exe launcher (which runs bash in the default distro);
|
||||
* "posix" covers bash/zsh/sh and other `-c`-style shells.
|
||||
*/
|
||||
export type ShellKind = "powershell" | "cmd" | "wsl" | "posix";
|
||||
|
||||
/**
|
||||
* Classify a shell executable (name or full path) into its family.
|
||||
*
|
||||
* This is the single classification used both for building spawn arguments
|
||||
* (getShellArgs) and for shell-specific prompting, so the syntax the model is
|
||||
* told to use always matches the syntax the executor actually accepts.
|
||||
*/
|
||||
export function getShellKind(shell: string): ShellKind {
|
||||
const shellName = normalizeShellName(shell);
|
||||
|
||||
if (
|
||||
@@ -21,20 +35,54 @@ export function getShellArgs(shell: string, command: string): string[] {
|
||||
shellName === "pwsh" ||
|
||||
shellName === "pwsh.exe"
|
||||
) {
|
||||
return ["-NoProfile", "-NonInteractive", "-Command", command];
|
||||
return "powershell";
|
||||
}
|
||||
|
||||
if (shellName === "cmd" || shellName === "cmd.exe") {
|
||||
return ["/d", "/s", "/c", command];
|
||||
return "cmd";
|
||||
}
|
||||
|
||||
// wsl.exe is the Windows launcher for the default WSL distro, not a shell
|
||||
// itself. Run the command through the guest's bash so operators like `|`
|
||||
// and `;` are handled by bash rather than treated as wsl.exe arguments.
|
||||
// wsl.exe translates the Windows cwd to its /mnt mount automatically.
|
||||
if (shellName === "wsl" || shellName === "wsl.exe") {
|
||||
return ["bash", "-c", command];
|
||||
return "wsl";
|
||||
}
|
||||
|
||||
return ["-c", command];
|
||||
return "posix";
|
||||
}
|
||||
|
||||
/**
|
||||
* Human/model-facing name for a shell executable, used when prompting about
|
||||
* shell changes. PowerShell variants and cmd get their family name (their
|
||||
* syntax is what matters, not the install path); POSIX shells keep their
|
||||
* base name (bash, zsh, fish, ...) since syntax differs between them.
|
||||
*/
|
||||
export function getShellDisplayName(shell: string): string {
|
||||
switch (getShellKind(shell)) {
|
||||
case "powershell":
|
||||
return "PowerShell";
|
||||
case "cmd":
|
||||
return "cmd.exe";
|
||||
case "wsl":
|
||||
return "bash (WSL)";
|
||||
case "posix": {
|
||||
const baseName = normalizeShellName(shell);
|
||||
return baseName.endsWith(".exe") ? baseName.slice(0, -4) : baseName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getShellArgs(shell: string, command: string): string[] {
|
||||
switch (getShellKind(shell)) {
|
||||
case "powershell":
|
||||
return ["-NoProfile", "-NonInteractive", "-Command", command];
|
||||
case "cmd":
|
||||
return ["/d", "/s", "/c", command];
|
||||
// wsl.exe is the Windows launcher for the default WSL distro, not a shell
|
||||
// itself. Run the command through the guest's bash so operators like `|`
|
||||
// and `;` are handled by bash rather than treated as wsl.exe arguments.
|
||||
// wsl.exe translates the Windows cwd to its /mnt mount automatically.
|
||||
case "wsl":
|
||||
return ["bash", "-c", command];
|
||||
case "posix":
|
||||
return ["-c", command];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createModeSwitchNoticeTracker,
|
||||
createShellChangeNoticeTracker,
|
||||
formatDisplayUserInput,
|
||||
formatModeSwitchNotice,
|
||||
formatShellChangeNotice,
|
||||
formatUserCommandBlock,
|
||||
formatUserInputBlock,
|
||||
normalizeUserInput,
|
||||
parseUserCommandEnvelope,
|
||||
parseUserInputMode,
|
||||
stripModeNotices,
|
||||
stripRuntimeNotices,
|
||||
} from "./format";
|
||||
|
||||
describe("prompt format helpers", () => {
|
||||
@@ -88,13 +90,45 @@ describe("prompt format helpers", () => {
|
||||
expect(normalizeUserInput(prompt)).toBe(prompt);
|
||||
});
|
||||
|
||||
it("removes every mode notice and leaves unclosed ones intact", () => {
|
||||
it("formats a shell change notice using shell display names", () => {
|
||||
expect(
|
||||
stripModeNotices(
|
||||
"<mode_notice>a</mode_notice>hello<mode_notice>b</mode_notice> there",
|
||||
formatShellChangeNotice(
|
||||
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
"C:\\Windows\\System32\\cmd.exe",
|
||||
),
|
||||
).toBe(
|
||||
"<environment_notice>The user changed the terminal shell from PowerShell to cmd.exe before sending this message. Commands now run through cmd.exe; write all subsequent commands in cmd.exe syntax.</environment_notice>",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides shell change notices from displayed user input", () => {
|
||||
const wrapped = formatUserInputBlock(
|
||||
`${formatShellChangeNotice("/bin/bash", "/bin/zsh")}\nnow run the build`,
|
||||
"act",
|
||||
);
|
||||
expect(formatDisplayUserInput(wrapped)).toBe("now run the build");
|
||||
});
|
||||
|
||||
it("keeps shell change notices when normalizing outbound prompts", () => {
|
||||
const prompt = `${formatShellChangeNotice("powershell", "cmd.exe")}\ndo it`;
|
||||
expect(normalizeUserInput(prompt)).toBe(prompt);
|
||||
});
|
||||
|
||||
it("hides stacked mode and shell notices on one message from display", () => {
|
||||
const wrapped = formatUserInputBlock(
|
||||
`${formatModeSwitchNotice("plan", "act")}\n${formatShellChangeNotice("powershell", "cmd.exe")}\ngo`,
|
||||
"act",
|
||||
);
|
||||
expect(formatDisplayUserInput(wrapped)).toBe("go");
|
||||
});
|
||||
|
||||
it("removes every runtime notice and leaves unclosed ones intact", () => {
|
||||
expect(
|
||||
stripRuntimeNotices(
|
||||
"<mode_notice>a</mode_notice>hello<environment_notice>b</environment_notice> there",
|
||||
),
|
||||
).toBe("hello there");
|
||||
expect(stripModeNotices("<mode_notice>dangling")).toBe(
|
||||
expect(stripRuntimeNotices("<mode_notice>dangling")).toBe(
|
||||
"<mode_notice>dangling",
|
||||
);
|
||||
});
|
||||
@@ -104,7 +138,7 @@ describe("prompt format helpers", () => {
|
||||
// opening tags must not trigger quadratic rescanning.
|
||||
const hostile = "<mode_notice>".repeat(50_000);
|
||||
const started = performance.now();
|
||||
const result = stripModeNotices(hostile);
|
||||
const result = stripRuntimeNotices(hostile);
|
||||
expect(performance.now() - started).toBeLessThan(1_000);
|
||||
expect(result).toBe(hostile);
|
||||
});
|
||||
@@ -149,3 +183,19 @@ describe("createModeSwitchNoticeTracker", () => {
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createShellChangeNoticeTracker", () => {
|
||||
it("records a change and cancels a round trip back to the original shell", () => {
|
||||
const tracker = createShellChangeNoticeTracker();
|
||||
|
||||
tracker.record("powershell", "cmd.exe");
|
||||
expect(tracker.consume()).toEqual({
|
||||
from: "powershell",
|
||||
to: "cmd.exe",
|
||||
});
|
||||
|
||||
tracker.record("powershell", "cmd.exe");
|
||||
tracker.record("cmd.exe", "powershell");
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getShellDisplayName } from "../parse/shell";
|
||||
|
||||
export function formatFileContentBlock(path: string, content: string): string {
|
||||
return `<file_content path="${path}">\n${content}\n</file_content>`;
|
||||
}
|
||||
@@ -36,7 +38,7 @@ export function parseUserInputMode(
|
||||
* plan and act modes. Prepended to the first user message sent after the
|
||||
* switch. It survives normalizeUserInput (so the outbound sanitize in
|
||||
* prepareTurnInput delivers it to the model) and is hidden from transcript
|
||||
* display by stripModeNotices at display boundaries.
|
||||
* display by stripRuntimeNotices at display boundaries.
|
||||
*/
|
||||
export function formatModeSwitchNotice(
|
||||
from: "act" | "plan",
|
||||
@@ -45,23 +47,41 @@ export function formatModeSwitchNotice(
|
||||
return `<mode_notice>The user switched from ${from} mode to ${to} mode before sending this message.</mode_notice>`;
|
||||
}
|
||||
|
||||
export type ModeSwitchNotice = {
|
||||
from: "act" | "plan";
|
||||
to: "act" | "plan";
|
||||
/**
|
||||
* Marks the point where the user changed the terminal shell that run_commands
|
||||
* uses, so the model stops writing commands in the previous shell's syntax.
|
||||
* Transcript momentum dominates the (also updated) tool description here:
|
||||
* after pages of PowerShell commands the model keeps writing PowerShell
|
||||
* unless the change is called out in the conversation itself. Same delivery
|
||||
* and display rules as formatModeSwitchNotice.
|
||||
*/
|
||||
export function formatShellChangeNotice(
|
||||
fromShell: string,
|
||||
toShell: string,
|
||||
): string {
|
||||
const from = getShellDisplayName(fromShell);
|
||||
const to = getShellDisplayName(toShell);
|
||||
return `<environment_notice>The user changed the terminal shell from ${from} to ${to} before sending this message. Commands now run through ${to}; write all subsequent commands in ${to} syntax.</environment_notice>`;
|
||||
}
|
||||
|
||||
export type SwitchNotice<T extends string> = {
|
||||
from: T;
|
||||
to: T;
|
||||
};
|
||||
|
||||
export type ModeSwitchNotice = SwitchNotice<"act" | "plan">;
|
||||
export type ShellChangeNotice = SwitchNotice<string>;
|
||||
|
||||
/**
|
||||
* Tracks a user-initiated mode switch so the next user message can carry a
|
||||
* <mode_notice> marking it. Only UI toggles should be recorded: the
|
||||
* model-initiated switch_to_act_mode path already announces itself via the
|
||||
* continuation prompt. A round trip (plan -> act -> plan before sending
|
||||
* anything) cancels out, since the mode the model last saw never effectively
|
||||
* changed.
|
||||
* Tracks a user-initiated setting switch so the next user message can carry a
|
||||
* notice marking it. A round trip (a -> b -> a before sending anything)
|
||||
* cancels out, since the value the model last saw never effectively changed,
|
||||
* and chained switches keep the original starting value.
|
||||
*/
|
||||
export function createModeSwitchNoticeTracker() {
|
||||
let pending: ModeSwitchNotice | null = null;
|
||||
export function createSwitchNoticeTracker<T extends string>() {
|
||||
let pending: SwitchNotice<T> | null = null;
|
||||
return {
|
||||
record(from: "act" | "plan", to: "act" | "plan"): void {
|
||||
record(from: T, to: T): void {
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
@@ -71,7 +91,7 @@ export function createModeSwitchNoticeTracker() {
|
||||
}
|
||||
pending = { from, to };
|
||||
},
|
||||
consume(): ModeSwitchNotice | null {
|
||||
consume(): SwitchNotice<T> | null {
|
||||
const notice = pending;
|
||||
pending = null;
|
||||
return notice;
|
||||
@@ -79,10 +99,28 @@ export function createModeSwitchNoticeTracker() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode-switch tracker for <mode_notice>. Only UI toggles should be recorded:
|
||||
* the model-initiated switch_to_act_mode path already announces itself via
|
||||
* the continuation prompt.
|
||||
*/
|
||||
export function createModeSwitchNoticeTracker() {
|
||||
return createSwitchNoticeTracker<"act" | "plan">();
|
||||
}
|
||||
|
||||
export type ModeSwitchNoticeTracker = ReturnType<
|
||||
typeof createModeSwitchNoticeTracker
|
||||
>;
|
||||
|
||||
/** Shell-change tracker for <environment_notice>, over resolved shell paths. */
|
||||
export function createShellChangeNoticeTracker() {
|
||||
return createSwitchNoticeTracker<string>();
|
||||
}
|
||||
|
||||
export type ShellChangeNoticeTracker = ReturnType<
|
||||
typeof createShellChangeNoticeTracker
|
||||
>;
|
||||
|
||||
export type UserCommandEnvelope = {
|
||||
slash: string;
|
||||
content: string;
|
||||
@@ -146,15 +184,18 @@ export function normalizeUserInput(input?: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes runtime-generated <mode_notice> elements (content included): they
|
||||
* are not user-typed text and must not render as such. Deliberately NOT part
|
||||
* of normalizeUserInput -- that function also sanitizes outbound prompts
|
||||
* before the host wraps them (prepareTurnInput), and stripping there deletes
|
||||
* the notice before the model ever sees it.
|
||||
* Removes runtime-generated notice elements (content included): they are not
|
||||
* user-typed text and must not render as such. Deliberately NOT part of
|
||||
* normalizeUserInput -- that function also sanitizes outbound prompts before
|
||||
* the host wraps them (prepareTurnInput), and stripping there deletes the
|
||||
* notices before the model ever sees them.
|
||||
*/
|
||||
export function stripModeNotices(input?: string): string {
|
||||
export function stripRuntimeNotices(input?: string): string {
|
||||
if (!input?.trim()) return "";
|
||||
return removeTagElements(input, "mode_notice").trim();
|
||||
return removeTagElements(
|
||||
removeTagElements(input, "mode_notice"),
|
||||
"environment_notice",
|
||||
).trim();
|
||||
}
|
||||
|
||||
// indexOf-based rather than a regex: a lazy dot-all pattern re-scans to the
|
||||
@@ -177,7 +218,7 @@ function removeTagElements(input: string, tag: string): string {
|
||||
}
|
||||
|
||||
export function formatDisplayUserInput(input?: string): string {
|
||||
const normalized = stripModeNotices(normalizeUserInput(input));
|
||||
const normalized = stripRuntimeNotices(normalizeUserInput(input));
|
||||
const envelope = parseUserCommandEnvelope(input);
|
||||
if (!envelope) {
|
||||
return normalized;
|
||||
|
||||
Reference in New Issue
Block a user