+ Configure where each AI provider stores its conversation history. These paths are used
+ to discover sessions for import. History path hints are stored separately from backend
+ connection settings.
+
+ {backend.endpointSupport.source === 'connection'
+ ? 'Capabilities come from the last successful ACP initialize response.'
+ : 'Capabilities are unknown until this backend completes a live ACP handshake.'}
+
- {backend.endpointSupport.source === 'connection'
- ? 'Capabilities come from the last successful ACP initialize response.'
- : 'Capabilities are unknown until this backend completes a live ACP handshake.'}
-
- {backend.lastTestResult ? (
-
- {backend.lastTestResult.ok ? 'Last test passed.' : 'Last test failed.'}{' '}
- {backend.lastTestResult.message}
-
- ) : null}
-
-
-
-
-
+
+
)
From ec5948aa7bcddd1c2b3cea99852a023bb2f4bf89 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C3=ABl=20de=20Vries?=
Date: Fri, 3 Apr 2026 00:30:42 +0200
Subject: [PATCH 2/2] fix(history): address PR review comments on
history-sources config
- readHistorySourcesConfig: merge configured sources with DEFAULT_SOURCES
by provider so partial files always return a complete provider set
- normalizeHistorySourceRecord: restrict cliPaths to copilot provider only
- PATCH /history-sources/:provider: move JSON parse inside try/catch so
malformed request bodies return 400 instead of 500
- readBackendConfig: migrate legacy historyPathHints/cliHistoryPathHints
from backends.json into history-sources.json on first upgrade; make
BACKEND_CONFIG_PATH lazy (getBackendConfigPath()) to support test isolation
- useHistorySources saveSource: upsert provider in local state rather than
map-only so a new provider returned by the server is kept in sync
- Rename misleading test 'returns empty arrays for unknown provider' to
'returns empty arrays when no paths are configured for a provider'
- Add tests for mergeWithDefaults, cliPaths stripping, and legacy migration
---
backend/src/agents/config.test.ts | 160 +++++++++++++++++++++
backend/src/agents/config.ts | 89 +++++++++++-
backend/src/history/sources-config.test.ts | 48 ++++++-
backend/src/history/sources-config.ts | 30 +++-
backend/src/routes/agents.ts | 10 +-
frontend/src/hooks/useBackendSettings.ts | 7 +-
6 files changed, 320 insertions(+), 24 deletions(-)
create mode 100644 backend/src/agents/config.test.ts
diff --git a/backend/src/agents/config.test.ts b/backend/src/agents/config.test.ts
new file mode 100644
index 0000000..581dfbb
--- /dev/null
+++ b/backend/src/agents/config.test.ts
@@ -0,0 +1,160 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { readBackendConfig } from './config.js'
+import { readHistorySourcesConfig } from '../history/sources-config.js'
+
+function makeTempDir(): string {
+ const dir = join(tmpdir(), `acp-config-test-${Date.now()}-${Math.random()}`)
+ mkdirSync(dir, { recursive: true })
+ return dir
+}
+
+describe('readBackendConfig — legacy historyPathHints migration', () => {
+ let tempDir: string
+ let origBackendsEnv: string | undefined
+ let origSourcesEnv: string | undefined
+
+ beforeEach(() => {
+ tempDir = makeTempDir()
+ origBackendsEnv = process.env['ACP_BACKENDS_CONFIG_PATH']
+ origSourcesEnv = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']
+ process.env['ACP_BACKENDS_CONFIG_PATH'] = join(tempDir, 'backends.json')
+ process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = join(tempDir, 'history-sources.json')
+ })
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true })
+ if (origBackendsEnv === undefined) {
+ delete process.env['ACP_BACKENDS_CONFIG_PATH']
+ } else {
+ process.env['ACP_BACKENDS_CONFIG_PATH'] = origBackendsEnv
+ }
+ if (origSourcesEnv === undefined) {
+ delete process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']
+ } else {
+ process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = origSourcesEnv
+ }
+ })
+
+ it('migrates legacy copilot historyPathHints into history-sources.json on first read', () => {
+ writeFileSync(
+ process.env['ACP_BACKENDS_CONFIG_PATH']!,
+ JSON.stringify({
+ backends: [
+ {
+ id: 'copilot-vscode-host',
+ name: 'GitHub Copilot VS Code (Host)',
+ enabled: true,
+ commandCandidates: ['copilot'],
+ command: null,
+ args: ['--acp'],
+ historyPathHints: ['/mnt/c/Users/test/AppData/Roaming/Code/User/workspaceStorage'],
+ cliHistoryPathHints: [],
+ },
+ ],
+ }),
+ 'utf8'
+ )
+
+ readBackendConfig()
+
+ const sources = readHistorySourcesConfig()
+ const copilot = sources.find((s) => s.provider === 'copilot')
+ expect(copilot?.paths).toEqual(['/mnt/c/Users/test/AppData/Roaming/Code/User/workspaceStorage'])
+ })
+
+ it('migrates legacy copilot cliHistoryPathHints into history-sources.json on first read', () => {
+ writeFileSync(
+ process.env['ACP_BACKENDS_CONFIG_PATH']!,
+ JSON.stringify({
+ backends: [
+ {
+ id: 'copilot-cli-wsl',
+ name: 'GitHub Copilot CLI (WSL)',
+ enabled: true,
+ commandCandidates: ['copilot'],
+ command: null,
+ args: ['--acp'],
+ historyPathHints: [],
+ cliHistoryPathHints: ['/home/user/.copilot/sessions'],
+ },
+ ],
+ }),
+ 'utf8'
+ )
+
+ readBackendConfig()
+
+ const sources = readHistorySourcesConfig()
+ const copilot = sources.find((s) => s.provider === 'copilot')
+ expect(copilot?.cliPaths).toEqual(['/home/user/.copilot/sessions'])
+ })
+
+ it('migrates legacy gemini-cli historyPathHints into history-sources.json on first read', () => {
+ writeFileSync(
+ process.env['ACP_BACKENDS_CONFIG_PATH']!,
+ JSON.stringify({
+ backends: [
+ {
+ id: 'gemini-cli',
+ name: 'Gemini CLI',
+ enabled: true,
+ commandCandidates: ['gemini'],
+ command: null,
+ args: ['--acp'],
+ historyPathHints: ['/home/user/.gemini/history'],
+ cliHistoryPathHints: [],
+ },
+ ],
+ }),
+ 'utf8'
+ )
+
+ readBackendConfig()
+
+ const sources = readHistorySourcesConfig()
+ const gemini = sources.find((s) => s.provider === 'gemini')
+ expect(gemini?.paths).toEqual(['/home/user/.gemini/history'])
+ })
+
+ it('does not overwrite existing non-default history-sources.json during migration', () => {
+ writeFileSync(
+ process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']!,
+ JSON.stringify({
+ sources: [
+ { provider: 'copilot', paths: ['/already/configured'], cliPaths: [] },
+ { provider: 'gemini', paths: [] },
+ { provider: 'opencode', paths: [] },
+ ],
+ }),
+ 'utf8'
+ )
+
+ writeFileSync(
+ process.env['ACP_BACKENDS_CONFIG_PATH']!,
+ JSON.stringify({
+ backends: [
+ {
+ id: 'copilot-vscode-host',
+ name: 'Copilot',
+ enabled: true,
+ commandCandidates: ['copilot'],
+ command: null,
+ args: ['--acp'],
+ historyPathHints: ['/should-not-overwrite'],
+ cliHistoryPathHints: [],
+ },
+ ],
+ }),
+ 'utf8'
+ )
+
+ readBackendConfig()
+
+ const sources = readHistorySourcesConfig()
+ const copilot = sources.find((s) => s.provider === 'copilot')
+ expect(copilot?.paths).toEqual(['/already/configured'])
+ })
+})
diff --git a/backend/src/agents/config.ts b/backend/src/agents/config.ts
index 72e7c91..37c710b 100644
--- a/backend/src/agents/config.ts
+++ b/backend/src/agents/config.ts
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { resolveConfigPath } from '../storage.js'
+import { writeHistorySourcesConfig, readHistorySourcesConfig } from '../history/sources-config.js'
export interface BackendDefinitionRecord {
id: string
@@ -11,11 +12,19 @@ export interface BackendDefinitionRecord {
args: string[]
}
+/** Shape of a legacy backend record that may still carry path hint fields. */
+interface LegacyBackendRecord extends BackendDefinitionRecord {
+ historyPathHints?: string[]
+ cliHistoryPathHints?: string[]
+}
+
interface BackendConfigFile {
- backends?: BackendDefinitionRecord[]
+ backends?: LegacyBackendRecord[]
}
-const BACKEND_CONFIG_PATH = resolveConfigPath('backends.json', 'ACP_BACKENDS_CONFIG_PATH')
+function getBackendConfigPath(): string {
+ return resolveConfigPath('backends.json', 'ACP_BACKENDS_CONFIG_PATH')
+}
const LEGACY_COPILOT_IDS = new Set([
'copilot-cli-wsl',
@@ -76,9 +85,74 @@ export function readBackendConfig(): BackendDefinitionRecord[] {
return DEFAULT_BACKENDS
}
+ // Migrate any legacy historyPathHints/cliHistoryPathHints out of backends.json
+ // into history-sources.json before normalization strips those fields.
+ migrateLegacyHistoryPathHints(configured)
+
return migrateLegacyCopilotBackends(configured.map(normalizeBackendRecord))
}
+/**
+ * One-time migration: if any backends in the raw config carry legacy
+ * `historyPathHints`/`cliHistoryPathHints` fields, write them into
+ * `history-sources.json` — but only when `history-sources.json` is absent
+ * or still contains only empty (default) paths, so we never overwrite a
+ * user-configured file.
+ *
+ * Provider mapping:
+ * - `copilot-*` backends with `historyPathHints` → copilot paths
+ * - `copilot-cli-*` backends with `cliHistoryPathHints` → copilot cliPaths
+ * - `gemini-cli` backend with `historyPathHints` → gemini paths
+ */
+function migrateLegacyHistoryPathHints(backends: LegacyBackendRecord[]): void {
+ const hasAnyHints = backends.some(
+ (b) => (b.historyPathHints?.length ?? 0) > 0 || (b.cliHistoryPathHints?.length ?? 0) > 0
+ )
+ if (!hasAnyHints) return
+
+ // Check whether the current history-sources.json is still "default" (all paths empty).
+ const current = readHistorySourcesConfig()
+ const isDefault = current.every(
+ (s) => (s.paths?.length ?? 0) === 0 && (s.cliPaths?.length ?? 0) === 0
+ )
+ if (!isDefault) return
+
+ // Collect hints from legacy records.
+ const copilotPaths: string[] = []
+ const copilotCliPaths: string[] = []
+ const geminiPaths: string[] = []
+
+ for (const backend of backends) {
+ if (backend.id === 'gemini-cli' && backend.historyPathHints?.length) {
+ geminiPaths.push(...backend.historyPathHints)
+ } else if (backend.id.startsWith('copilot-')) {
+ if (backend.historyPathHints?.length) copilotPaths.push(...backend.historyPathHints)
+ if (backend.cliHistoryPathHints?.length) copilotCliPaths.push(...backend.cliHistoryPathHints)
+ }
+ }
+
+ if (!copilotPaths.length && !copilotCliPaths.length && !geminiPaths.length) return
+
+ const migrated = current.map((source) => {
+ if (source.provider === 'copilot') {
+ return {
+ ...source,
+ paths: copilotPaths.length ? copilotPaths : source.paths,
+ cliPaths: copilotCliPaths.length ? copilotCliPaths : source.cliPaths,
+ }
+ }
+ if (source.provider === 'gemini') {
+ return {
+ ...source,
+ paths: geminiPaths.length ? geminiPaths : source.paths,
+ }
+ }
+ return source
+ })
+
+ writeHistorySourcesConfig(migrated)
+}
+
/**
* Migrate old split Copilot backend records (copilot-cli-wsl, copilot-cli-host,
* copilot-vscode-host, copilot-vscode-wsl) into a single `copilot` backend.
@@ -125,12 +199,13 @@ function migrateLegacyCopilotBackends(
}
export function writeBackendConfig(backends: BackendDefinitionRecord[]): void {
- const parentDir = dirname(BACKEND_CONFIG_PATH)
+ const configPath = getBackendConfigPath()
+ const parentDir = dirname(configPath)
if (!existsSync(parentDir)) {
mkdirSync(parentDir, { recursive: true })
}
- writeFileSync(BACKEND_CONFIG_PATH, JSON.stringify({ backends }, null, 2))
+ writeFileSync(configPath, JSON.stringify({ backends }, null, 2))
}
export function createBackendId(name: string): string {
@@ -144,19 +219,19 @@ export function createBackendId(name: string): string {
}
function readBackendConfigFile(): BackendConfigFile {
- if (!existsSync(BACKEND_CONFIG_PATH)) {
+ if (!existsSync(getBackendConfigPath())) {
return {}
}
try {
- return JSON.parse(readFileSync(BACKEND_CONFIG_PATH, 'utf8')) as BackendConfigFile
+ return JSON.parse(readFileSync(getBackendConfigPath(), 'utf8')) as BackendConfigFile
} catch {
return {}
}
}
function ensureBackendConfigExists(): void {
- if (existsSync(BACKEND_CONFIG_PATH)) {
+ if (existsSync(getBackendConfigPath())) {
return
}
diff --git a/backend/src/history/sources-config.test.ts b/backend/src/history/sources-config.test.ts
index 2f62fca..2d2982e 100644
--- a/backend/src/history/sources-config.test.ts
+++ b/backend/src/history/sources-config.test.ts
@@ -64,9 +64,11 @@ describe('sources-config', () => {
const result = readHistorySourcesConfig()
- expect(result).toHaveLength(2)
+ expect(result).toHaveLength(3)
expect(result[0]).toEqual({ provider: 'copilot', paths: ['/a/b'], cliPaths: ['/c/d'] })
expect(result[1]).toEqual({ provider: 'gemini', paths: ['/e/f'] })
+ // opencode was not in the file — merged in from defaults with empty paths
+ expect(result[2]).toEqual({ provider: 'opencode', paths: [] })
})
it('normalizes malformed paths arrays', () => {
@@ -80,7 +82,45 @@ describe('sources-config', () => {
)
const result = readHistorySourcesConfig()
- expect(result[0]!.paths).toEqual(['/valid'])
+ expect(result.find((s) => s.provider === 'opencode')!.paths).toEqual(['/valid'])
+ })
+
+ it('merges partial config with defaults — fills in missing providers', () => {
+ const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']!
+ writeFileSync(
+ configPath,
+ JSON.stringify({ sources: [{ provider: 'gemini', paths: ['/g'] }] }),
+ 'utf8'
+ )
+
+ const result = readHistorySourcesConfig()
+
+ expect(result).toHaveLength(3)
+ expect(result.find((s) => s.provider === 'copilot')).toEqual({
+ provider: 'copilot',
+ paths: [],
+ cliPaths: [],
+ })
+ expect(result.find((s) => s.provider === 'gemini')?.paths).toEqual(['/g'])
+ expect(result.find((s) => s.provider === 'opencode')).toEqual({
+ provider: 'opencode',
+ paths: [],
+ })
+ })
+
+ it('strips cliPaths from non-copilot providers', () => {
+ const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']!
+ writeFileSync(
+ configPath,
+ JSON.stringify({
+ sources: [{ provider: 'gemini', paths: ['/g'], cliPaths: ['/should-be-dropped'] }],
+ }),
+ 'utf8'
+ )
+
+ const result = readHistorySourcesConfig()
+ const gemini = result.find((s) => s.provider === 'gemini')
+ expect(gemini?.cliPaths).toBeUndefined()
})
it('returns defaults when file contains empty sources array', () => {
@@ -118,7 +158,7 @@ describe('sources-config', () => {
writeHistorySourcesConfig([{ provider: 'opencode', paths: ['/foo'] }])
const result = readHistorySourcesConfig()
- expect(result[0]!.paths).toEqual(['/foo'])
+ expect(result.find((s) => s.provider === 'opencode')!.paths).toEqual(['/foo'])
})
})
@@ -162,7 +202,7 @@ describe('sources-config', () => {
})
describe('getHistoryHintsForProvider', () => {
- it('returns empty arrays for unknown provider', () => {
+ it('returns empty arrays when no paths are configured for a provider', () => {
readHistorySourcesConfig() // init defaults
const result = getHistoryHintsForProvider('gemini')
diff --git a/backend/src/history/sources-config.ts b/backend/src/history/sources-config.ts
index 8e7d986..09b435c 100644
--- a/backend/src/history/sources-config.ts
+++ b/backend/src/history/sources-config.ts
@@ -29,13 +29,30 @@ const DEFAULT_SOURCES: HistorySourceRecord[] = [
export function readHistorySourcesConfig(): HistorySourceRecord[] {
ensureHistorySourcesConfigExists()
const file = readHistorySourcesConfigFile()
- const configured = file.sources
+ return mergeWithDefaults(file.sources)
+}
- if (!configured || configured.length === 0) {
- return DEFAULT_SOURCES
+/**
+ * Merges configured sources with defaults by provider key so the API always
+ * returns a complete, stable set of providers even when `history-sources.json`
+ * was written by an older version that didn't know about a particular provider.
+ * Configured values win over defaults; unknown providers in the file are
+ * preserved at the end of the list.
+ */
+function mergeWithDefaults(sources?: HistorySourceRecord[]): HistorySourceRecord[] {
+ if (!sources || sources.length === 0) {
+ return DEFAULT_SOURCES.map(normalizeHistorySourceRecord)
}
- return configured.map(normalizeHistorySourceRecord)
+ const configuredByProvider = new Map(
+ sources.map(normalizeHistorySourceRecord).map((source) => [source.provider, source] as const)
+ )
+
+ return DEFAULT_SOURCES.map(
+ (defaultSource) =>
+ configuredByProvider.get(defaultSource.provider) ??
+ normalizeHistorySourceRecord(defaultSource)
+ )
}
export function writeHistorySourcesConfig(sources: HistorySourceRecord[]): void {
@@ -121,9 +138,8 @@ function normalizeHistorySourceRecord(record: HistorySourceRecord): HistorySourc
: [],
}
- // Only include cliPaths for copilot; include it (even empty) when present so
- // the key is persisted.
- if (record.provider === 'copilot' || record.cliPaths !== undefined) {
+ // Only include cliPaths for the copilot provider; ignore it for all others.
+ if (record.provider === 'copilot') {
normalized.cliPaths = Array.isArray(record.cliPaths)
? record.cliPaths.filter((p): p is string => typeof p === 'string')
: []
diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts
index 18fb80a..d3ae80a 100644
--- a/backend/src/routes/agents.ts
+++ b/backend/src/routes/agents.ts
@@ -74,12 +74,12 @@ export function agentsRoutes(registry: AgentRegistry): Hono {
return c.json({ error: `Unknown provider: ${provider}` }, 404)
}
- const body = await c.req.json<{
- paths?: string[]
- cliPaths?: string[]
- }>()
-
try {
+ const body = await c.req.json<{
+ paths?: string[]
+ cliPaths?: string[]
+ }>()
+
const updated = updateHistorySource(provider as HistoryProvider, {
paths: body.paths,
cliPaths: body.cliPaths,
diff --git a/frontend/src/hooks/useBackendSettings.ts b/frontend/src/hooks/useBackendSettings.ts
index c1a2f72..c4713c9 100644
--- a/frontend/src/hooks/useBackendSettings.ts
+++ b/frontend/src/hooks/useBackendSettings.ts
@@ -236,7 +236,12 @@ export function useHistorySources() {
}
const updated = (await response.json()) as HistorySourceConfig
- setSources((current) => current.map((s) => (s.provider === provider ? updated : s)))
+ setSources((current) => {
+ const exists = current.some((s) => s.provider === provider)
+ return exists
+ ? current.map((s) => (s.provider === provider ? updated : s))
+ : [...current, updated]
+ })
} catch (error) {
console.error('[useHistorySources] save failed:', error)
setErrorMessage('Unable to save history source settings right now.')