mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: persist chat history in Remote SSH sessions
- Add remote environment detection utility to identify SSH, WSL, Dev Containers, etc. - Modify storage paths to include remote-specific subdirectories - Add configuration option "persistChatInRemote" to control the feature - Ensure chat history is saved per remote workspace and restored on reconnection - Add comprehensive tests for remote storage functionality Fixes #8028
This commit is contained in:
parent
3c2fd1d3d3
commit
e319f8b025
6 changed files with 381 additions and 21 deletions
|
|
@ -384,6 +384,11 @@
|
|||
"default": "",
|
||||
"description": "%settings.customStoragePath.description%"
|
||||
},
|
||||
"roo-cline.persistChatInRemote": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%settings.persistChatInRemote.description%"
|
||||
},
|
||||
"roo-cline.enableCodeActions": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"settings.vsCodeLmModelSelector.vendor.description": "The vendor of the language model (e.g. copilot)",
|
||||
"settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)",
|
||||
"settings.customStoragePath.description": "Custom storage path. Leave empty to use the default location. Supports absolute paths (e.g. 'D:\\RooCodeStorage')",
|
||||
"settings.persistChatInRemote.description": "Persist chat history in Remote SSH sessions. When enabled, chat history will be saved per remote workspace and restored on reconnection.",
|
||||
"settings.enableCodeActions.description": "Enable Roo Code quick fixes",
|
||||
"settings.autoImportSettingsPath.description": "Path to a RooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import.",
|
||||
"settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)",
|
||||
|
|
|
|||
234
src/utils/__tests__/remoteEnvironment.spec.ts
Normal file
234
src/utils/__tests__/remoteEnvironment.spec.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import {
|
||||
isRemoteEnvironment,
|
||||
getRemoteType,
|
||||
getRemoteWorkspaceId,
|
||||
getEnvironmentStoragePath,
|
||||
} from "../remoteEnvironment"
|
||||
|
||||
// Mock vscode module
|
||||
vi.mock("vscode", () => ({
|
||||
env: {
|
||||
remoteName: undefined,
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
describe("remoteEnvironment", () => {
|
||||
let mockVscode: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockVscode = vi.mocked(vscode)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Reset mocks after each test
|
||||
mockVscode.env.remoteName = undefined
|
||||
mockVscode.workspace.workspaceFolders = undefined
|
||||
})
|
||||
|
||||
describe("isRemoteEnvironment", () => {
|
||||
it("should return false when not in remote environment", () => {
|
||||
mockVscode.env.remoteName = undefined
|
||||
expect(isRemoteEnvironment()).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when in SSH remote environment", () => {
|
||||
mockVscode.env.remoteName = "ssh-remote"
|
||||
expect(isRemoteEnvironment()).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when in WSL environment", () => {
|
||||
mockVscode.env.remoteName = "wsl"
|
||||
expect(isRemoteEnvironment()).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when in dev container environment", () => {
|
||||
mockVscode.env.remoteName = "dev-container"
|
||||
expect(isRemoteEnvironment()).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when in codespaces environment", () => {
|
||||
mockVscode.env.remoteName = "codespaces"
|
||||
expect(isRemoteEnvironment()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getRemoteType", () => {
|
||||
it("should return undefined when not in remote environment", () => {
|
||||
mockVscode.env.remoteName = undefined
|
||||
expect(getRemoteType()).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return 'ssh-remote' for SSH remote", () => {
|
||||
mockVscode.env.remoteName = "ssh-remote"
|
||||
expect(getRemoteType()).toBe("ssh-remote")
|
||||
})
|
||||
|
||||
it("should return 'wsl' for WSL", () => {
|
||||
mockVscode.env.remoteName = "wsl"
|
||||
expect(getRemoteType()).toBe("wsl")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getRemoteWorkspaceId", () => {
|
||||
it("should return undefined when not in remote environment", () => {
|
||||
mockVscode.env.remoteName = undefined
|
||||
expect(getRemoteWorkspaceId()).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return remote name when no workspace folders", () => {
|
||||
mockVscode.env.remoteName = "ssh-remote"
|
||||
mockVscode.workspace.workspaceFolders = undefined
|
||||
expect(getRemoteWorkspaceId()).toBe("ssh-remote")
|
||||
})
|
||||
|
||||
it("should return remote name when workspace folders is empty", () => {
|
||||
mockVscode.env.remoteName = "ssh-remote"
|
||||
mockVscode.workspace.workspaceFolders = []
|
||||
expect(getRemoteWorkspaceId()).toBe("ssh-remote")
|
||||
})
|
||||
|
||||
it("should include workspace folder name and hash in ID", () => {
|
||||
mockVscode.env.remoteName = "ssh-remote"
|
||||
mockVscode.workspace.workspaceFolders = [
|
||||
{
|
||||
name: "my-project",
|
||||
uri: {
|
||||
toString: () => "file:///home/user/projects/my-project",
|
||||
},
|
||||
},
|
||||
]
|
||||
const id = getRemoteWorkspaceId()
|
||||
expect(id).toMatch(/^ssh-remote-my-project-[a-z0-9]+$/)
|
||||
})
|
||||
|
||||
it("should create consistent hash for same workspace", () => {
|
||||
mockVscode.env.remoteName = "ssh-remote"
|
||||
const workspaceUri = "file:///home/user/projects/my-project"
|
||||
mockVscode.workspace.workspaceFolders = [
|
||||
{
|
||||
name: "my-project",
|
||||
uri: {
|
||||
toString: () => workspaceUri,
|
||||
},
|
||||
},
|
||||
]
|
||||
const id1 = getRemoteWorkspaceId()
|
||||
const id2 = getRemoteWorkspaceId()
|
||||
expect(id1).toBe(id2)
|
||||
})
|
||||
|
||||
it("should create different hashes for different workspaces", () => {
|
||||
mockVscode.env.remoteName = "ssh-remote"
|
||||
|
||||
// First workspace
|
||||
mockVscode.workspace.workspaceFolders = [
|
||||
{
|
||||
name: "project1",
|
||||
uri: {
|
||||
toString: () => "file:///home/user/projects/project1",
|
||||
},
|
||||
},
|
||||
]
|
||||
const id1 = getRemoteWorkspaceId()
|
||||
|
||||
// Second workspace
|
||||
mockVscode.workspace.workspaceFolders = [
|
||||
{
|
||||
name: "project2",
|
||||
uri: {
|
||||
toString: () => "file:///home/user/projects/project2",
|
||||
},
|
||||
},
|
||||
]
|
||||
const id2 = getRemoteWorkspaceId()
|
||||
|
||||
expect(id1).not.toBe(id2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEnvironmentStoragePath", () => {
|
||||
const basePath = "/home/user/.vscode/extensions/storage"
|
||||
|
||||
it("should return base path unchanged when not in remote environment", () => {
|
||||
mockVscode.env.remoteName = undefined
|
||||
expect(getEnvironmentStoragePath(basePath)).toBe(basePath)
|
||||
})
|
||||
|
||||
it("should add remote subdirectory for SSH remote", () => {
|
||||
mockVscode.env.remoteName = "ssh-remote"
|
||||
mockVscode.workspace.workspaceFolders = [
|
||||
{
|
||||
name: "my-project",
|
||||
uri: {
|
||||
toString: () => "file:///home/user/projects/my-project",
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = getEnvironmentStoragePath(basePath)
|
||||
expect(result).toMatch(
|
||||
/^\/home\/user\/.vscode\/extensions\/storage\/remote\/ssh-remote-my-project-[a-z0-9]+$/,
|
||||
)
|
||||
})
|
||||
|
||||
it("should add remote subdirectory for WSL", () => {
|
||||
mockVscode.env.remoteName = "wsl"
|
||||
mockVscode.workspace.workspaceFolders = [
|
||||
{
|
||||
name: "linux-project",
|
||||
uri: {
|
||||
toString: () => "file:///home/user/linux-project",
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = getEnvironmentStoragePath(basePath)
|
||||
expect(result).toMatch(/^\/home\/user\/.vscode\/extensions\/storage\/remote\/wsl-linux-project-[a-z0-9]+$/)
|
||||
})
|
||||
|
||||
it("should add remote subdirectory for dev containers", () => {
|
||||
mockVscode.env.remoteName = "dev-container"
|
||||
mockVscode.workspace.workspaceFolders = [
|
||||
{
|
||||
name: "container-app",
|
||||
uri: {
|
||||
toString: () => "file:///workspace/container-app",
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = getEnvironmentStoragePath(basePath)
|
||||
expect(result).toMatch(
|
||||
/^\/home\/user\/.vscode\/extensions\/storage\/remote\/dev-container-container-app-[a-z0-9]+$/,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle Windows paths correctly", () => {
|
||||
const windowsBasePath = "C:\\Users\\user\\AppData\\Roaming\\Code\\User\\globalStorage\\roo-cline"
|
||||
mockVscode.env.remoteName = "ssh-remote"
|
||||
mockVscode.workspace.workspaceFolders = [
|
||||
{
|
||||
name: "remote-project",
|
||||
uri: {
|
||||
toString: () => "file:///home/remote/project",
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = getEnvironmentStoragePath(windowsBasePath)
|
||||
// The path.join will use forward slashes even on Windows in Node.js context
|
||||
expect(result).toMatch(
|
||||
/^C:\\Users\\user\\AppData\\Roaming\\Code\\User\\globalStorage\\roo-cline\/remote\/ssh-remote-remote-project-[a-z0-9]+$/,
|
||||
)
|
||||
})
|
||||
|
||||
it("should fallback to base path when remote ID cannot be determined", () => {
|
||||
mockVscode.env.remoteName = "unknown-remote"
|
||||
mockVscode.workspace.workspaceFolders = undefined
|
||||
// In this case, getRemoteWorkspaceId returns just "unknown-remote"
|
||||
const result = getEnvironmentStoragePath(basePath)
|
||||
expect(result).toBe(`${basePath}/remote/unknown-remote`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,12 @@ vi.mock("fs/promises", async () => {
|
|||
return (mod as any).default ?? mod
|
||||
})
|
||||
|
||||
// Mock the remoteEnvironment module
|
||||
vi.mock("../remoteEnvironment", () => ({
|
||||
isRemoteEnvironment: vi.fn(() => false),
|
||||
getEnvironmentStoragePath: vi.fn((path: string) => path),
|
||||
}))
|
||||
|
||||
describe("getStorageBasePath - customStoragePath", () => {
|
||||
const defaultPath = "/test/global-storage"
|
||||
|
||||
|
|
@ -63,9 +69,13 @@ describe("getStorageBasePath - customStoragePath", () => {
|
|||
const firstArg = showErrorSpy.mock.calls[0][0]
|
||||
expect(typeof firstArg).toBe("string")
|
||||
})
|
||||
it("returns the default path when customStoragePath is an empty string and does not touch fs", async () => {
|
||||
it("returns the default path when customStoragePath is an empty string", async () => {
|
||||
vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({
|
||||
get: vi.fn().mockReturnValue(""),
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "customStoragePath") return ""
|
||||
if (key === "persistChatInRemote") return true
|
||||
return undefined
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const fsPromises = await import("fs/promises")
|
||||
|
|
@ -74,7 +84,9 @@ describe("getStorageBasePath - customStoragePath", () => {
|
|||
const result = await getStorageBasePath(defaultPath)
|
||||
|
||||
expect(result).toBe(defaultPath)
|
||||
expect((fsPromises as any).mkdir).not.toHaveBeenCalled()
|
||||
// Now it will create the directory for the final path
|
||||
expect((fsPromises as any).mkdir).toHaveBeenCalledWith(defaultPath, { recursive: true })
|
||||
// Access check is not called since we're using the default path
|
||||
expect((fsPromises as any).access).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
82
src/utils/remoteEnvironment.ts
Normal file
82
src/utils/remoteEnvironment.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import * as vscode from "vscode"
|
||||
|
||||
/**
|
||||
* Detects if the extension is running in a remote environment
|
||||
* (Remote SSH, WSL, Dev Containers, Codespaces, etc.)
|
||||
*/
|
||||
export function isRemoteEnvironment(): boolean {
|
||||
// Check if we're in a remote extension host
|
||||
// vscode.env.remoteName will be defined in remote contexts
|
||||
// It will be 'ssh-remote' for SSH, 'wsl' for WSL, 'dev-container' for containers, etc.
|
||||
return typeof vscode.env.remoteName !== "undefined"
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the type of remote environment if in one
|
||||
* @returns The remote name (e.g., 'ssh-remote', 'wsl', 'dev-container') or undefined if local
|
||||
*/
|
||||
export function getRemoteType(): string | undefined {
|
||||
return vscode.env.remoteName
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a unique identifier for the remote workspace
|
||||
* This combines the remote type with workspace information to create
|
||||
* a stable identifier for the remote session
|
||||
*/
|
||||
export function getRemoteWorkspaceId(): string | undefined {
|
||||
if (!isRemoteEnvironment()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const remoteName = vscode.env.remoteName || "remote"
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders
|
||||
|
||||
if (workspaceFolders && workspaceFolders.length > 0) {
|
||||
// Use the first workspace folder's name and URI as part of the identifier
|
||||
const firstFolder = workspaceFolders[0]
|
||||
const folderName = firstFolder.name
|
||||
// Create a stable hash from the URI to avoid issues with special characters
|
||||
const uriHash = hashString(firstFolder.uri.toString())
|
||||
return `${remoteName}-${folderName}-${uriHash}`
|
||||
}
|
||||
|
||||
// Fallback to just remote name if no workspace
|
||||
return remoteName
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple string hash function for creating stable identifiers
|
||||
*/
|
||||
function hashString(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate storage base path for the current environment
|
||||
* In remote environments, this will include a remote-specific subdirectory
|
||||
* to keep remote and local chat histories separate
|
||||
*/
|
||||
export function getEnvironmentStoragePath(basePath: string): string {
|
||||
if (!isRemoteEnvironment()) {
|
||||
// Local environment - use the base path as-is
|
||||
return basePath
|
||||
}
|
||||
|
||||
// Remote environment - add a remote-specific subdirectory
|
||||
const remoteId = getRemoteWorkspaceId()
|
||||
if (remoteId) {
|
||||
// Use path.join for proper path construction
|
||||
const path = require("path")
|
||||
return path.join(basePath, "remote", remoteId)
|
||||
}
|
||||
|
||||
// Fallback to base path if we can't determine remote ID
|
||||
return basePath
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { constants as fsConstants } from "fs"
|
|||
|
||||
import { Package } from "../shared/package"
|
||||
import { t } from "../i18n"
|
||||
import { getEnvironmentStoragePath, isRemoteEnvironment } from "./remoteEnvironment"
|
||||
|
||||
/**
|
||||
* Gets the base storage path for conversations
|
||||
|
|
@ -12,39 +13,64 @@ import { t } from "../i18n"
|
|||
* Otherwise uses the default VSCode extension global storage path
|
||||
*/
|
||||
export async function getStorageBasePath(defaultPath: string): Promise<string> {
|
||||
// Get user-configured custom storage path
|
||||
// Get user-configured custom storage path and remote persistence setting
|
||||
let customStoragePath = ""
|
||||
let persistChatInRemote = true
|
||||
|
||||
try {
|
||||
// This is the line causing the error in tests
|
||||
const config = vscode.workspace.getConfiguration(Package.name)
|
||||
customStoragePath = config.get<string>("customStoragePath", "")
|
||||
persistChatInRemote = config.get<boolean>("persistChatInRemote", true)
|
||||
} catch (error) {
|
||||
console.warn("Could not access VSCode configuration - using default path")
|
||||
return defaultPath
|
||||
// Apply remote environment path adjustment even for default path if enabled
|
||||
return persistChatInRemote ? getEnvironmentStoragePath(defaultPath) : defaultPath
|
||||
}
|
||||
|
||||
// If no custom path is set, use default path
|
||||
// Determine the base path (custom or default)
|
||||
let basePath: string
|
||||
|
||||
if (!customStoragePath) {
|
||||
return defaultPath
|
||||
}
|
||||
basePath = defaultPath
|
||||
} else {
|
||||
try {
|
||||
// Ensure custom path exists
|
||||
await fs.mkdir(customStoragePath, { recursive: true })
|
||||
|
||||
try {
|
||||
// Ensure custom path exists
|
||||
await fs.mkdir(customStoragePath, { recursive: true })
|
||||
// Check directory write permission without creating temp files
|
||||
await fs.access(customStoragePath, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK)
|
||||
|
||||
// Check directory write permission without creating temp files
|
||||
await fs.access(customStoragePath, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK)
|
||||
|
||||
return customStoragePath
|
||||
} catch (error) {
|
||||
// If path is unusable, report error and fall back to default path
|
||||
console.error(`Custom storage path is unusable: ${error instanceof Error ? error.message : String(error)}`)
|
||||
if (vscode.window) {
|
||||
vscode.window.showErrorMessage(t("common:errors.custom_storage_path_unusable", { path: customStoragePath }))
|
||||
basePath = customStoragePath
|
||||
} catch (error) {
|
||||
// If path is unusable, report error and fall back to default path
|
||||
console.error(`Custom storage path is unusable: ${error instanceof Error ? error.message : String(error)}`)
|
||||
if (vscode.window) {
|
||||
vscode.window.showErrorMessage(
|
||||
t("common:errors.custom_storage_path_unusable", { path: customStoragePath }),
|
||||
)
|
||||
}
|
||||
basePath = defaultPath
|
||||
}
|
||||
return defaultPath
|
||||
}
|
||||
|
||||
// Apply remote environment path adjustment if enabled
|
||||
// This will add a remote-specific subdirectory if in a remote environment
|
||||
const finalPath = persistChatInRemote ? getEnvironmentStoragePath(basePath) : basePath
|
||||
|
||||
// Ensure the final path exists
|
||||
try {
|
||||
await fs.mkdir(finalPath, { recursive: true })
|
||||
} catch (error) {
|
||||
console.warn(`Could not create storage path: ${error}`)
|
||||
}
|
||||
|
||||
// Log the storage path being used for debugging
|
||||
if (isRemoteEnvironment() && persistChatInRemote) {
|
||||
console.log(`Using remote-specific storage path: ${finalPath}`)
|
||||
}
|
||||
|
||||
return finalPath
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue