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 d779966..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
@@ -9,20 +10,21 @@ export interface BackendDefinitionRecord {
commandCandidates: string[]
command: string | null
args: string[]
- /** VS Code workspace storage root paths to search for history. */
+}
+
+/** Shape of a legacy backend record that may still carry path hint fields. */
+interface LegacyBackendRecord extends BackendDefinitionRecord {
historyPathHints?: string[]
- /**
- * CLI session-state directory paths (WSL or Host) to search for history.
- * Only meaningful for the `copilot` backend.
- */
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',
@@ -39,8 +41,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [
commandCandidates: ['copilot'],
command: null,
args: ['--acp'],
- historyPathHints: [],
- cliHistoryPathHints: [],
},
{
id: 'claude-code',
@@ -49,7 +49,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [
commandCandidates: ['claude', 'claude-code'],
command: null,
args: ['--acp'],
- historyPathHints: [],
},
{
id: 'gemini-cli',
@@ -58,7 +57,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [
commandCandidates: ['gemini'],
command: null,
args: ['--acp'],
- historyPathHints: [],
},
{
id: 'codex',
@@ -67,7 +65,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [
commandCandidates: ['codex'],
command: null,
args: ['--acp'],
- historyPathHints: [],
},
{
id: 'opencode',
@@ -76,7 +73,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [
commandCandidates: ['opencode'],
command: null,
args: ['acp'],
- historyPathHints: [],
},
]
@@ -89,16 +85,80 @@ 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.
* Any user-customized command/args from the CLI backends are preserved.
- * All historyPathHints are merged into `historyPathHints` (VS Code roots).
- * Paths that look like CLI session-state directories (absolute paths containing
- * '.copilot' but not 'workspaceStorage') are also placed in `cliHistoryPathHints`.
+ * History path hints previously stored on these records are no longer part of
+ * `BackendDefinitionRecord` — they live in `history-sources.json` now.
* Non-Copilot backends are unchanged.
* Does not delete unknown custom user backends.
*/
@@ -118,21 +178,12 @@ function migrateLegacyCopilotBackends(
return otherBackends
}
- // Merge legacy backends into a single copilot record
+ // Merge legacy backends into a single copilot record.
// Prefer a backend with an explicit command (i.e., the CLI backend the user configured)
// over a history-only record that only has commandCandidates but no resolved command.
const cliBackend =
legacyBackends.find((b) => b.command !== null) ??
legacyBackends.find((b) => b.commandCandidates.length > 0)
- const allHints = Array.from(
- new Set(legacyBackends.flatMap((b) => b.historyPathHints ?? []).filter(Boolean))
- )
- // CLI-specific roots: absolute paths that look like CLI session-state dirs (contain '.copilot').
- // Paths containing 'workspaceStorage' are VS Code workspace storage paths, not CLI dirs,
- // so they must stay in historyPathHints only.
- const cliHints = allHints.filter(
- (p) => p.startsWith('/') && p.includes('.copilot') && !p.includes('workspaceStorage')
- )
const enabled = legacyBackends.some((b) => b.enabled)
const merged: BackendDefinitionRecord = {
@@ -142,20 +193,19 @@ function migrateLegacyCopilotBackends(
commandCandidates: cliBackend?.commandCandidates ?? ['copilot'],
command: cliBackend?.command ?? null,
args: cliBackend?.args ?? ['--acp'],
- historyPathHints: allHints,
- cliHistoryPathHints: cliHints,
}
return [merged, ...otherBackends]
}
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 {
@@ -169,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
}
@@ -192,7 +242,7 @@ function normalizeBackendRecord(record: BackendDefinitionRecord): BackendDefinit
return {
id: record.id,
name: record.name,
- enabled: record.enabled ?? true,
+ enabled: (record as { enabled?: boolean }).enabled ?? true,
commandCandidates: Array.isArray(record.commandCandidates)
? record.commandCandidates.filter((value): value is string => typeof value === 'string')
: [],
@@ -201,11 +251,5 @@ function normalizeBackendRecord(record: BackendDefinitionRecord): BackendDefinit
args: Array.isArray(record.args)
? record.args.filter((value): value is string => typeof value === 'string')
: [],
- historyPathHints: Array.isArray(record.historyPathHints)
- ? record.historyPathHints.filter((value): value is string => typeof value === 'string')
- : [],
- cliHistoryPathHints: Array.isArray(record.cliHistoryPathHints)
- ? record.cliHistoryPathHints.filter((value): value is string => typeof value === 'string')
- : [],
}
}
diff --git a/backend/src/agents/registry.test.ts b/backend/src/agents/registry.test.ts
index 5d2db84..218c0ee 100644
--- a/backend/src/agents/registry.test.ts
+++ b/backend/src/agents/registry.test.ts
@@ -11,6 +11,13 @@ const mergeSessionsMock = vi.fn((live, history) => [...history, ...live])
const readBackendConfigMock = vi.fn()
const detectAvailableCommandMock = vi.fn(() => ({ command: null }))
const getHistorySourceDescriptorsMock = vi.fn<(...args: unknown[]) => unknown[]>(() => [])
+const getHistoryHintsForProviderMock = vi.fn(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ (_provider: string) => ({
+ historyPathHints: [] as string[],
+ cliHistoryPathHints: [] as string[],
+ })
+)
vi.mock('../projects/service.js', () => ({
listProjects: listProjectsMock,
@@ -24,6 +31,10 @@ vi.mock('../history/index.js', () => ({
HISTORY_AGENT_IDS: new Set(['gemini-cli', 'copilot', 'opencode']),
}))
+vi.mock('../history/sources-config.js', () => ({
+ getHistoryHintsForProvider: getHistoryHintsForProviderMock,
+}))
+
vi.mock('./config.js', () => ({
createBackendId: vi.fn((value: string) => value),
readBackendConfig: readBackendConfigMock,
@@ -61,8 +72,6 @@ describe('AgentRegistry.listSessions', () => {
commandCandidates: ['copilot'],
command: 'copilot',
args: ['--acp'],
- historyPathHints: ['/tmp/copilot-vscode'],
- cliHistoryPathHints: [],
},
{
id: 'gemini-cli',
@@ -71,10 +80,16 @@ describe('AgentRegistry.listSessions', () => {
commandCandidates: ['gemini'],
command: null,
args: ['--acp'],
- historyPathHints: [],
},
])
+ getHistoryHintsForProviderMock.mockImplementation((provider: string) => {
+ if (provider === 'copilot')
+ return { historyPathHints: ['/tmp/copilot-vscode'], cliHistoryPathHints: [] }
+ if (provider === 'gemini') return { historyPathHints: [], cliHistoryPathHints: [] }
+ return { historyPathHints: [], cliHistoryPathHints: [] }
+ })
+
listProjectsMock.mockReturnValue([
{
id: 'repo-1',
@@ -267,10 +282,13 @@ describe('AgentRegistry.listBackends', () => {
commandCandidates: ['opencode'],
command: 'opencode',
args: ['--acp'],
- historyPathHints: [],
},
])
listProjectsMock.mockReturnValue([])
+ getHistoryHintsForProviderMock.mockReturnValue({
+ historyPathHints: [],
+ cliHistoryPathHints: [],
+ })
})
it('reports OpenCode history compatibility support', async () => {
@@ -311,10 +329,14 @@ describe('AgentRegistry.listBackends', () => {
commandCandidates: ['copilot'],
command: null,
args: ['--acp'],
- historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'],
},
])
+ getHistoryHintsForProviderMock.mockReturnValue({
+ historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'],
+ cliHistoryPathHints: [],
+ })
+
getHistorySourceDescriptorsMock.mockReturnValue([
{
id: 'src-vscode',
@@ -335,7 +357,6 @@ describe('AgentRegistry.listBackends', () => {
expect(registry.listBackends()).toEqual([
expect.objectContaining({
id: 'copilot',
- historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'],
historySupport: {
source: 'derived',
supported: ['text', 'markdown', 'reasoning', 'tool_calls', 'truncation'],
diff --git a/backend/src/agents/registry.ts b/backend/src/agents/registry.ts
index 9f50ce6..103c9cd 100644
--- a/backend/src/agents/registry.ts
+++ b/backend/src/agents/registry.ts
@@ -27,6 +27,8 @@ import {
getHistorySession,
HISTORY_AGENT_IDS,
} from '../history/index.js'
+import { getHistoryHintsForProvider } from '../history/sources-config.js'
+import type { HistoryProvider } from '../history/sources-config.js'
import { FakeSessionAdapter } from '../testing/fakeAdapter.js'
interface RegisteredAgent {
@@ -36,8 +38,6 @@ interface RegisteredAgent {
detectedCommand: string | null
args: string[]
defaultArgs: string[]
- historyPathHints: string[]
- cliHistoryPathHints: string[]
enabled: boolean
usesCustomCommand: boolean
adapter?: SessionAdapter
@@ -63,6 +63,17 @@ const UNKNOWN_ENDPOINT_SUPPORT = {
],
}
+/** Agent IDs that map to a `HistoryProvider` for path-hint lookups. */
+const HISTORY_PROVIDER_IDS = new Set(['copilot', 'gemini-cli', 'opencode'])
+
+/** Map from backend agent ID to the HistoryProvider key used in sources-config. */
+function toHistoryProvider(agentId: string): HistoryProvider | null {
+ if (agentId === 'copilot') return 'copilot'
+ if (agentId === 'gemini-cli') return 'gemini'
+ if (agentId === 'opencode') return 'opencode'
+ return null
+}
+
export class AgentRegistry {
private agents: RegisteredAgent[]
@@ -99,8 +110,6 @@ export class AgentRegistry {
detectedCommand: agent.detectedCommand,
args: agent.args,
defaultArgs: agent.args,
- historyPathHints: agent.historyPathHints,
- cliHistoryPathHints: agent.cliHistoryPathHints,
enabled: agent.enabled,
usesCustomCommand: agent.usesCustomCommand,
endpointSupport,
@@ -136,8 +145,6 @@ export class AgentRegistry {
command?: string | null
args?: string[]
name?: string
- historyPathHints?: string[]
- cliHistoryPathHints?: string[]
}
): BackendSummary {
const config = readBackendConfig()
@@ -154,12 +161,6 @@ export class AgentRegistry {
enabled: input.enabled ?? current.enabled,
command: normalizeCommand(input.command ?? current.command),
args: Array.isArray(input.args) ? input.args.filter(Boolean) : current.args,
- historyPathHints: Array.isArray(input.historyPathHints)
- ? input.historyPathHints.filter(Boolean)
- : current.historyPathHints,
- cliHistoryPathHints: Array.isArray(input.cliHistoryPathHints)
- ? input.cliHistoryPathHints.filter(Boolean)
- : current.cliHistoryPathHints,
}
writeBackendConfig(config)
@@ -179,11 +180,15 @@ export class AgentRegistry {
const knownProjects = listProjects().map(toSessionProjectContext)
const historySessions = listHistorySessions(
knownProjects,
- this.agents.map((agent) => ({
- id: agent.id,
- historyPathHints: agent.historyPathHints,
- cliHistoryPathHints: agent.cliHistoryPathHints,
- }))
+ this.agents.map((agent) => {
+ const provider = toHistoryProvider(agent.id)
+ const hints = provider ? getHistoryHintsForProvider(provider) : null
+ return {
+ id: agent.id,
+ historyPathHints: hints?.historyPathHints ?? [],
+ cliHistoryPathHints: hints?.cliHistoryPathHints ?? [],
+ }
+ })
)
return mergeSessions(liveSessions, historySessions)
@@ -199,11 +204,15 @@ export class AgentRegistry {
return getHistorySession(
sessionId,
knownProjects,
- this.agents.map((agent) => ({
- id: agent.id,
- historyPathHints: agent.historyPathHints,
- cliHistoryPathHints: agent.cliHistoryPathHints,
- })),
+ this.agents.map((agent) => {
+ const provider = toHistoryProvider(agent.id)
+ const hints = provider ? getHistoryHintsForProvider(provider) : null
+ return {
+ id: agent.id,
+ historyPathHints: hints?.historyPathHints ?? [],
+ cliHistoryPathHints: hints?.cliHistoryPathHints ?? [],
+ }
+ }),
agentId
)
}
@@ -291,8 +300,6 @@ export class AgentRegistry {
detectedCommand,
args: backend.args,
defaultArgs: backend.args,
- historyPathHints: backend.historyPathHints ?? [],
- cliHistoryPathHints: backend.cliHistoryPathHints ?? [],
enabled: backend.enabled,
usesCustomCommand,
adapter,
@@ -344,11 +351,12 @@ export class AgentRegistry {
}
function getHistorySupport(agentId: string): HistorySupport {
- const backend = readBackendConfig().find((entry) => entry.id === agentId)
+ const provider = toHistoryProvider(agentId)
+ const hints = provider ? getHistoryHintsForProvider(provider) : null
const discoveredSources = getHistorySourceDescriptors(
agentId,
- backend?.historyPathHints ?? [],
- backend?.cliHistoryPathHints ?? []
+ hints?.historyPathHints ?? [],
+ hints?.cliHistoryPathHints ?? []
)
const discoverySummary = summarizeDiscoveredSources(discoveredSources)
@@ -461,3 +469,6 @@ function uniqueBackendId(backends: BackendDefinitionRecord[], baseId: string): s
export function createAgentRegistry(): AgentRegistry {
return new AgentRegistry()
}
+
+// Export for use from routes
+export { HISTORY_PROVIDER_IDS, toHistoryProvider }
diff --git a/backend/src/agents/types.ts b/backend/src/agents/types.ts
index f3ec9d0..2428a4b 100644
--- a/backend/src/agents/types.ts
+++ b/backend/src/agents/types.ts
@@ -107,9 +107,6 @@ export interface BackendSummary extends AgentSummary {
enabled: boolean
args: string[]
defaultArgs: string[]
- historyPathHints: string[]
- /** CLI session-state directory hints. Only used by the `copilot` backend. */
- cliHistoryPathHints: string[]
detectedCommand: string | null
usesCustomCommand: boolean
endpointSupport: BackendEndpointSupport
@@ -117,6 +114,15 @@ export interface BackendSummary extends AgentSummary {
lastTestResult: BackendTestResult | null
}
+/** Shape returned by GET /api/history-sources and accepted by PATCH /api/history-sources/:provider. */
+export interface HistorySourceConfig {
+ provider: 'gemini' | 'copilot' | 'opencode'
+ /** VS Code workspace storage roots (or generic search roots for non-Copilot providers). */
+ paths: string[]
+ /** CLI session-state directory paths. Only meaningful for the `copilot` provider. */
+ cliPaths?: string[]
+}
+
export interface BackendTestResult {
ok: boolean
message: string
diff --git a/backend/src/history/sources-config.test.ts b/backend/src/history/sources-config.test.ts
new file mode 100644
index 0000000..2d2982e
--- /dev/null
+++ b/backend/src/history/sources-config.test.ts
@@ -0,0 +1,226 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import {
+ getHistoryHintsForProvider,
+ readHistorySourcesConfig,
+ updateHistorySource,
+ writeHistorySourcesConfig,
+} from './sources-config.js'
+
+function makeTempDir(): string {
+ const dir = join(tmpdir(), `acp-sources-config-test-${Date.now()}-${Math.random()}`)
+ mkdirSync(dir, { recursive: true })
+ return dir
+}
+
+describe('sources-config', () => {
+ let tempDir: string
+ let origEnv: string | undefined
+
+ beforeEach(() => {
+ tempDir = makeTempDir()
+ origEnv = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']
+ process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = join(tempDir, 'history-sources.json')
+ })
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true })
+ if (origEnv === undefined) {
+ delete process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']
+ } else {
+ process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = origEnv
+ }
+ })
+
+ describe('readHistorySourcesConfig', () => {
+ it('returns defaults when no config file exists', () => {
+ const result = readHistorySourcesConfig()
+
+ expect(result).toHaveLength(3)
+ expect(result.map((r) => r.provider)).toEqual(['copilot', 'gemini', 'opencode'])
+ })
+
+ it('writes the defaults when the config file is missing', () => {
+ readHistorySourcesConfig()
+
+ const path = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']!
+ expect(existsSync(path)).toBe(true)
+ })
+
+ it('reads configured sources from the file', () => {
+ const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']!
+ writeFileSync(
+ configPath,
+ JSON.stringify({
+ sources: [
+ { provider: 'copilot', paths: ['/a/b'], cliPaths: ['/c/d'] },
+ { provider: 'gemini', paths: ['/e/f'] },
+ ],
+ }),
+ 'utf8'
+ )
+
+ const result = readHistorySourcesConfig()
+
+ 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', () => {
+ const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']!
+ writeFileSync(
+ configPath,
+ JSON.stringify({
+ sources: [{ provider: 'opencode', paths: [42, null, '/valid'] }],
+ }),
+ 'utf8'
+ )
+
+ const result = readHistorySourcesConfig()
+ 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', () => {
+ const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']!
+ writeFileSync(configPath, JSON.stringify({ sources: [] }), 'utf8')
+
+ const result = readHistorySourcesConfig()
+ expect(result).toHaveLength(3)
+ })
+
+ it('returns defaults on invalid JSON', () => {
+ const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']!
+ writeFileSync(configPath, 'not json', 'utf8')
+
+ const result = readHistorySourcesConfig()
+ expect(result).toHaveLength(3)
+ })
+ })
+
+ describe('writeHistorySourcesConfig', () => {
+ it('writes sources to JSON file', () => {
+ writeHistorySourcesConfig([
+ { provider: 'copilot', paths: ['/a'], cliPaths: ['/b'] },
+ { provider: 'gemini', paths: [] },
+ ])
+
+ const result = readHistorySourcesConfig()
+ expect(result[0]).toEqual({ provider: 'copilot', paths: ['/a'], cliPaths: ['/b'] })
+ })
+
+ it('creates parent directories as needed', () => {
+ const nested = join(tempDir, 'nested', 'deeply', 'history-sources.json')
+ process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = nested
+
+ writeHistorySourcesConfig([{ provider: 'opencode', paths: ['/foo'] }])
+
+ const result = readHistorySourcesConfig()
+ expect(result.find((s) => s.provider === 'opencode')!.paths).toEqual(['/foo'])
+ })
+ })
+
+ describe('updateHistorySource', () => {
+ it('updates paths for an existing provider', () => {
+ writeHistorySourcesConfig([{ provider: 'gemini', paths: ['/old'] }])
+
+ const result = updateHistorySource('gemini', { paths: ['/new1', '/new2'] })
+
+ expect(result.find((s) => s.provider === 'gemini')?.paths).toEqual(['/new1', '/new2'])
+ })
+
+ it('updates cliPaths for copilot without touching paths', () => {
+ writeHistorySourcesConfig([
+ { provider: 'copilot', paths: ['/vscode'], cliPaths: ['/cli-old'] },
+ ])
+
+ const result = updateHistorySource('copilot', { cliPaths: ['/cli-new'] })
+
+ const copilot = result.find((s) => s.provider === 'copilot')
+ expect(copilot?.paths).toEqual(['/vscode'])
+ expect(copilot?.cliPaths).toEqual(['/cli-new'])
+ })
+
+ it('adds provider entry when not present', () => {
+ writeHistorySourcesConfig([{ provider: 'gemini', paths: [] }])
+
+ const result = updateHistorySource('opencode', { paths: ['/foo'] })
+
+ expect(result.find((s) => s.provider === 'opencode')?.paths).toEqual(['/foo'])
+ })
+
+ it('persists changes to disk', () => {
+ writeHistorySourcesConfig([{ provider: 'gemini', paths: [] }])
+
+ updateHistorySource('gemini', { paths: ['/persisted'] })
+ const result = readHistorySourcesConfig()
+
+ expect(result.find((s) => s.provider === 'gemini')?.paths).toEqual(['/persisted'])
+ })
+ })
+
+ describe('getHistoryHintsForProvider', () => {
+ it('returns empty arrays when no paths are configured for a provider', () => {
+ readHistorySourcesConfig() // init defaults
+
+ const result = getHistoryHintsForProvider('gemini')
+ expect(result).toEqual({ historyPathHints: [], cliHistoryPathHints: [] })
+ })
+
+ it('returns configured paths and cliPaths for copilot', () => {
+ writeHistorySourcesConfig([{ provider: 'copilot', paths: ['/a', '/b'], cliPaths: ['/c'] }])
+
+ const result = getHistoryHintsForProvider('copilot')
+ expect(result).toEqual({ historyPathHints: ['/a', '/b'], cliHistoryPathHints: ['/c'] })
+ })
+
+ it('returns paths and empty cliPaths for gemini', () => {
+ writeHistorySourcesConfig([{ provider: 'gemini', paths: ['/g'] }])
+
+ const result = getHistoryHintsForProvider('gemini')
+ expect(result).toEqual({ historyPathHints: ['/g'], cliHistoryPathHints: [] })
+ })
+ })
+})
diff --git a/backend/src/history/sources-config.ts b/backend/src/history/sources-config.ts
new file mode 100644
index 0000000..09b435c
--- /dev/null
+++ b/backend/src/history/sources-config.ts
@@ -0,0 +1,149 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { dirname } from 'node:path'
+import { resolveConfigPath } from '../storage.js'
+
+export type HistoryProvider = 'gemini' | 'copilot' | 'opencode'
+
+export interface HistorySourceRecord {
+ provider: HistoryProvider
+ /** VS Code workspace storage roots (or generic search roots for non-Copilot providers). */
+ paths: string[]
+ /** CLI session-state directory paths. Only meaningful for the `copilot` provider. */
+ cliPaths?: string[]
+}
+
+interface HistorySourcesConfigFile {
+ sources?: HistorySourceRecord[]
+}
+
+function getConfigPath(): string {
+ return resolveConfigPath('history-sources.json', 'ACP_HISTORY_SOURCES_CONFIG_PATH')
+}
+
+const DEFAULT_SOURCES: HistorySourceRecord[] = [
+ { provider: 'copilot', paths: [], cliPaths: [] },
+ { provider: 'gemini', paths: [] },
+ { provider: 'opencode', paths: [] },
+]
+
+export function readHistorySourcesConfig(): HistorySourceRecord[] {
+ ensureHistorySourcesConfigExists()
+ const file = readHistorySourcesConfigFile()
+ return mergeWithDefaults(file.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)
+ }
+
+ 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 {
+ const configPath = getConfigPath()
+ const parentDir = dirname(configPath)
+ if (!existsSync(parentDir)) {
+ mkdirSync(parentDir, { recursive: true })
+ }
+
+ writeFileSync(configPath, JSON.stringify({ sources }, null, 2))
+}
+
+export function updateHistorySource(
+ provider: HistoryProvider,
+ patch: { paths?: string[]; cliPaths?: string[] }
+): HistorySourceRecord[] {
+ const config = readHistorySourcesConfig()
+ const index = config.findIndex((s) => s.provider === provider)
+
+ if (index === -1) {
+ config.push(
+ normalizeHistorySourceRecord({
+ provider,
+ paths: patch.paths ?? [],
+ cliPaths: patch.cliPaths,
+ })
+ )
+ } else {
+ const current = config[index]!
+ config[index] = normalizeHistorySourceRecord({
+ provider,
+ paths: Array.isArray(patch.paths) ? patch.paths : current.paths,
+ cliPaths: patch.cliPaths !== undefined ? patch.cliPaths : current.cliPaths,
+ })
+ }
+
+ writeHistorySourcesConfig(config)
+ return config
+}
+
+/**
+ * Returns the `BackendHistoryConfig`-compatible shape for a given provider ID so
+ * that `history/index.ts` functions can be called without change.
+ */
+export function getHistoryHintsForProvider(provider: HistoryProvider): {
+ historyPathHints: string[]
+ cliHistoryPathHints: string[]
+} {
+ const config = readHistorySourcesConfig()
+ const record = config.find((s) => s.provider === provider)
+ return {
+ historyPathHints: record?.paths ?? [],
+ cliHistoryPathHints: record?.cliPaths ?? [],
+ }
+}
+
+function readHistorySourcesConfigFile(): HistorySourcesConfigFile {
+ const configPath = getConfigPath()
+ if (!existsSync(configPath)) {
+ return {}
+ }
+
+ try {
+ return JSON.parse(readFileSync(configPath, 'utf8')) as HistorySourcesConfigFile
+ } catch {
+ return {}
+ }
+}
+
+function ensureHistorySourcesConfigExists(): void {
+ if (existsSync(getConfigPath())) {
+ return
+ }
+
+ writeHistorySourcesConfig(DEFAULT_SOURCES)
+}
+
+function normalizeHistorySourceRecord(record: HistorySourceRecord): HistorySourceRecord {
+ const normalized: HistorySourceRecord = {
+ provider: record.provider,
+ paths: Array.isArray(record.paths)
+ ? record.paths.filter((p): p is string => typeof p === 'string')
+ : [],
+ }
+
+ // 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')
+ : []
+ }
+
+ return normalized
+}
diff --git a/backend/src/routes/agents.test.ts b/backend/src/routes/agents.test.ts
index dd2828e..9eb8238 100644
--- a/backend/src/routes/agents.test.ts
+++ b/backend/src/routes/agents.test.ts
@@ -1,23 +1,33 @@
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdirSync, rmSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
import { Hono } from 'hono'
import { agentsRoutes } from './agents.js'
import type { AgentRegistry } from '../agents/registry.js'
+function makeTempDir(): string {
+ const dir = join(tmpdir(), `acp-agents-routes-test-${Date.now()}-${Math.random()}`)
+ mkdirSync(dir, { recursive: true })
+ return dir
+}
+
function createRegistryStub(): AgentRegistry {
return {
listAgents: vi.fn(() => []),
listBackends: vi.fn(() => [
{
- id: 'copilot-vscode-host',
- name: 'GitHub Copilot VS Code (Host)',
+ id: 'copilot',
+ name: 'GitHub Copilot',
status: 'active',
command: null,
detectedCommand: null,
args: [],
defaultArgs: [],
- historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'],
enabled: true,
usesCustomCommand: false,
+ canResume: false,
+ canLoad: false,
endpointSupport: {
source: 'connection',
implemented: ['session/new'],
@@ -40,9 +50,10 @@ function createRegistryStub(): AgentRegistry {
detectedCommand: 'custom-wrapper',
args: ['--acp'],
defaultArgs: ['--acp'],
- historyPathHints: [],
enabled: true,
usesCustomCommand: true,
+ canResume: false,
+ canLoad: false,
endpointSupport: {
source: 'unknown',
implemented: [],
@@ -57,17 +68,17 @@ function createRegistryStub(): AgentRegistry {
lastTestResult: null,
})),
updateBackend: vi.fn(() => ({
- id: 'copilot-vscode-host',
- name: 'GitHub Copilot VS Code (Host)',
+ id: 'copilot',
+ name: 'GitHub Copilot',
status: 'disabled',
command: null,
detectedCommand: null,
args: [],
defaultArgs: [],
- historyPathHints: ['/tmp/copilot-hints'],
- cliHistoryPathHints: ['/tmp/cli-hints'],
enabled: false,
usesCustomCommand: true,
+ canResume: false,
+ canLoad: false,
endpointSupport: {
source: 'unknown',
implemented: [],
@@ -90,6 +101,24 @@ function createRegistryStub(): AgentRegistry {
}
describe('agents routes', () => {
+ let tempDir: string
+ let origEnv: string | undefined
+
+ beforeEach(() => {
+ tempDir = makeTempDir()
+ origEnv = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']
+ process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = join(tempDir, 'history-sources.json')
+ })
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true })
+ if (origEnv === undefined) {
+ delete process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']
+ } else {
+ process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = origEnv
+ }
+ })
+
it('returns backend settings', async () => {
const registry = createRegistryStub()
const app = new Hono().route('/api', agentsRoutes(registry))
@@ -98,33 +127,26 @@ describe('agents routes', () => {
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ id: string; enabled: boolean }>
- expect(body[0]).toMatchObject({ id: 'copilot-vscode-host', enabled: true })
+ expect(body[0]).toMatchObject({ id: 'copilot', enabled: true })
})
it('updates a backend config', async () => {
const registry = createRegistryStub()
const app = new Hono().route('/api', agentsRoutes(registry))
- const res = await app.request('/api/backends/copilot-vscode-host', {
+ const res = await app.request('/api/backends/copilot', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
command: 'copilot-wrapper',
args: ['--stdio'],
- historyPathHints: ['/tmp/copilot-hints'],
- cliHistoryPathHints: ['/tmp/cli-hints'],
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; command: string }
- expect(body).toMatchObject({
- enabled: false,
- command: null,
- historyPathHints: ['/tmp/copilot-hints'],
- cliHistoryPathHints: ['/tmp/cli-hints'],
- })
+ expect(body).toMatchObject({ enabled: false, command: null })
})
it('creates a custom backend config', async () => {
@@ -141,4 +163,65 @@ describe('agents routes', () => {
const body = (await res.json()) as { id: string; command: string }
expect(body).toMatchObject({ id: 'custom-wrapper', command: 'custom-wrapper' })
})
+
+ describe('history-sources routes', () => {
+ it('GET /history-sources returns default sources', async () => {
+ const registry = createRegistryStub()
+ const app = new Hono().route('/api', agentsRoutes(registry))
+
+ const res = await app.request('/api/history-sources')
+ expect(res.status).toBe(200)
+
+ const body = (await res.json()) as Array<{ provider: string }>
+ expect(body.map((s) => s.provider)).toEqual(
+ expect.arrayContaining(['copilot', 'gemini', 'opencode'])
+ )
+ })
+
+ it('PATCH /history-sources/copilot updates copilot paths', async () => {
+ const registry = createRegistryStub()
+ const app = new Hono().route('/api', agentsRoutes(registry))
+
+ const res = await app.request('/api/history-sources/copilot', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paths: ['/a/b'], cliPaths: ['/c/d'] }),
+ })
+
+ expect(res.status).toBe(200)
+ const body = (await res.json()) as { provider: string; paths: string[]; cliPaths: string[] }
+ expect(body.provider).toBe('copilot')
+ expect(body.paths).toEqual(['/a/b'])
+ expect(body.cliPaths).toEqual(['/c/d'])
+ })
+
+ it('PATCH /history-sources/gemini updates gemini paths', async () => {
+ const registry = createRegistryStub()
+ const app = new Hono().route('/api', agentsRoutes(registry))
+
+ const res = await app.request('/api/history-sources/gemini', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paths: ['/gemini/path'] }),
+ })
+
+ expect(res.status).toBe(200)
+ const body = (await res.json()) as { provider: string; paths: string[] }
+ expect(body.provider).toBe('gemini')
+ expect(body.paths).toEqual(['/gemini/path'])
+ })
+
+ it('PATCH /history-sources/:provider returns 404 for unknown provider', async () => {
+ const registry = createRegistryStub()
+ const app = new Hono().route('/api', agentsRoutes(registry))
+
+ const res = await app.request('/api/history-sources/unknown-provider', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paths: [] }),
+ })
+
+ expect(res.status).toBe(404)
+ })
+ })
})
diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts
index 09ed4f7..d3ae80a 100644
--- a/backend/src/routes/agents.ts
+++ b/backend/src/routes/agents.ts
@@ -1,5 +1,12 @@
import { Hono } from 'hono'
import type { AgentRegistry } from '../agents/registry.js'
+import {
+ readHistorySourcesConfig,
+ updateHistorySource,
+ type HistoryProvider,
+} from '../history/sources-config.js'
+
+const VALID_PROVIDERS = new Set(['gemini', 'copilot', 'opencode'])
export function agentsRoutes(registry: AgentRegistry): Hono {
const app = new Hono()
@@ -38,8 +45,6 @@ export function agentsRoutes(registry: AgentRegistry): Hono {
command?: string | null
args?: string[]
name?: string
- historyPathHints?: string[]
- cliHistoryPathHints?: string[]
}>()
try {
@@ -55,5 +60,37 @@ export function agentsRoutes(registry: AgentRegistry): Hono {
}
})
+ // --- History Sources ---
+
+ app.get('/history-sources', (c) => {
+ const sources = readHistorySourcesConfig()
+ return c.json(sources)
+ })
+
+ app.patch('/history-sources/:provider', async (c) => {
+ const provider = c.req.param('provider')
+
+ if (!VALID_PROVIDERS.has(provider)) {
+ return c.json({ error: `Unknown provider: ${provider}` }, 404)
+ }
+
+ try {
+ const body = await c.req.json<{
+ paths?: string[]
+ cliPaths?: string[]
+ }>()
+
+ const updated = updateHistorySource(provider as HistoryProvider, {
+ paths: body.paths,
+ cliPaths: body.cliPaths,
+ })
+ const record = updated.find((s) => s.provider === provider)
+ return c.json(record)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ return c.json({ error: message }, 400)
+ }
+ })
+
return app
}
diff --git a/frontend/src/hooks/useBackendSettings.ts b/frontend/src/hooks/useBackendSettings.ts
index 7c9c0b3..c4713c9 100644
--- a/frontend/src/hooks/useBackendSettings.ts
+++ b/frontend/src/hooks/useBackendSettings.ts
@@ -65,9 +65,6 @@ export interface BackendSummary {
detectedCommand: string | null
args: string[]
defaultArgs: string[]
- historyPathHints: string[]
- /** CLI session-state directory hints. Only used by the `copilot` backend. */
- cliHistoryPathHints: string[]
enabled: boolean
usesCustomCommand: boolean
endpointSupport: BackendEndpointSupport
@@ -79,11 +76,20 @@ export interface BackendSummary {
} | null
}
+export type HistoryProvider = 'gemini' | 'copilot' | 'opencode'
+
+export interface HistorySourceConfig {
+ provider: HistoryProvider
+ /** VS Code workspace storage roots (or generic search roots for non-Copilot providers). */
+ paths: string[]
+ /** CLI session-state directory paths. Only meaningful for `copilot`. */
+ cliPaths?: string[]
+}
+
export function useBackendSettings() {
const [backends, setBackends] = useState([])
const [loading, setLoading] = useState(true)
const [savingId, setSavingId] = useState(null)
- const [testingId, setTestingId] = useState(null)
const [errorMessage, setErrorMessage] = useState(null)
const loadBackends = useCallback(async () => {
@@ -117,8 +123,6 @@ export function useBackendSettings() {
command?: string | null
args?: string[]
name?: string
- historyPathHints?: string[]
- cliHistoryPathHints?: string[]
}
) => {
setSavingId(backendId)
@@ -150,32 +154,6 @@ export function useBackendSettings() {
[]
)
- const testBackend = useCallback(async (backendId: string) => {
- setTestingId(backendId)
- setErrorMessage(null)
-
- try {
- const response = await fetch(`/api/backends/${encodeURIComponent(backendId)}/test`, {
- method: 'POST',
- })
-
- if (!response.ok) {
- throw new Error(`Backend test failed with status ${response.status}`)
- }
-
- const updated = (await response.json()) as BackendSummary
- setBackends((current) =>
- current.map((backend) => (backend.id === backendId ? updated : backend))
- )
- } catch (error) {
- console.error('[useBackendSettings] test failed:', error)
- setErrorMessage('Unable to test this backend right now.')
- throw error
- } finally {
- setTestingId(null)
- }
- }, [])
-
const addBackend = useCallback(
async (input: { name: string; command: string; args?: string[] }) => {
setErrorMessage(null)
@@ -209,7 +187,77 @@ export function useBackendSettings() {
loading,
saveBackend,
savingId,
- testBackend,
- testingId,
+ }
+}
+
+export function useHistorySources() {
+ const [sources, setSources] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [savingProvider, setSavingProvider] = useState(null)
+ const [errorMessage, setErrorMessage] = useState(null)
+
+ const loadSources = useCallback(async () => {
+ setLoading(true)
+ setErrorMessage(null)
+
+ try {
+ const response = await fetch('/api/history-sources')
+ if (!response.ok) {
+ throw new Error(`History sources failed with status ${response.status}`)
+ }
+
+ setSources((await response.json()) as HistorySourceConfig[])
+ } catch (error) {
+ console.error('[useHistorySources] load failed:', error)
+ setErrorMessage('Unable to load history sources right now.')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void loadSources()
+ }, [loadSources])
+
+ const saveSource = useCallback(
+ async (provider: HistoryProvider, patch: { paths?: string[]; cliPaths?: string[] }) => {
+ setSavingProvider(provider)
+ setErrorMessage(null)
+
+ try {
+ const response = await fetch(`/api/history-sources/${encodeURIComponent(provider)}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(patch),
+ })
+
+ if (!response.ok) {
+ throw new Error(`History source save failed with status ${response.status}`)
+ }
+
+ const updated = (await response.json()) as HistorySourceConfig
+ 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.')
+ throw error
+ } finally {
+ setSavingProvider(null)
+ }
+ },
+ []
+ )
+
+ return {
+ sources,
+ loading,
+ saveSource,
+ savingProvider,
+ errorMessage,
}
}
diff --git a/frontend/src/router.test.tsx b/frontend/src/router.test.tsx
index 77d8dae..f3e1d8f 100644
--- a/frontend/src/router.test.tsx
+++ b/frontend/src/router.test.tsx
@@ -25,8 +25,6 @@ function mockFetch() {
detectedCommand: null,
args: [],
defaultArgs: [],
- historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'],
- cliHistoryPathHints: [],
enabled: true,
usesCustomCommand: false,
endpointSupport: {
@@ -79,39 +77,19 @@ function mockFetch() {
} as Response)
}
- if (url === '/api/backends/copilot-vscode-host/test' && opts?.method === 'POST') {
+ if (url === '/api/history-sources') {
return Promise.resolve({
ok: true,
json: () =>
- Promise.resolve({
- id: 'copilot-vscode-host',
- name: 'GitHub Copilot VS Code (Host)',
- status: 'active',
- command: null,
- detectedCommand: null,
- args: [],
- defaultArgs: [],
- historyPathHints: [],
- cliHistoryPathHints: [],
- enabled: true,
- usesCustomCommand: false,
- endpointSupport: {
- source: 'connection',
- implemented: ['session/new', 'session/list'],
- unknown: [],
- },
- historySupport: {
- source: 'derived',
- supported: ['text', 'markdown'],
- discoveredSources: [],
- discoverySummary: [],
- },
- lastTestResult: {
- ok: true,
- message: 'ACP initialize succeeded.',
- testedAt: '2026-03-18T18:00:00.000Z',
+ Promise.resolve([
+ {
+ provider: 'copilot',
+ paths: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'],
+ cliPaths: [],
},
- }),
+ { provider: 'gemini', paths: [] },
+ { provider: 'opencode', paths: [] },
+ ]),
} as Response)
}
@@ -128,8 +106,6 @@ function mockFetch() {
detectedCommand: 'custom-wrapper',
args: ['--acp'],
defaultArgs: ['--acp'],
- historyPathHints: [],
- cliHistoryPathHints: [],
enabled: true,
usesCustomCommand: true,
endpointSupport: {
@@ -292,15 +268,17 @@ describe('app router', () => {
await waitFor(() => expect(screen.getByText('ACP Backends')).toBeDefined())
expect(screen.getByDisplayValue('GitHub Copilot VS Code (Host)')).toBeDefined()
expect(screen.getByText('Add Backend')).toBeDefined()
- expect(
- screen.getByDisplayValue('/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage')
- ).toBeDefined()
- expect(screen.getByText('History Sources')).toBeDefined()
+ expect(screen.getAllByText('History Sources').length).toBeGreaterThan(0)
expect(screen.getByText('vscode_workspace_db')).toBeDefined()
expect(screen.getByText('vscode_chat_sessions')).toBeDefined()
expect(screen.getByText('42 sessions')).toBeDefined()
expect(screen.getByRole('link', { name: 'Back To Chat' })).toBeDefined()
- expect(screen.getByRole('button', { name: 'Test' })).toBeDefined()
+ // History Sources section is present (separate from backend cards)
+ await waitFor(() =>
+ expect(
+ screen.getByDisplayValue('/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage')
+ ).toBeDefined()
+ )
})
it('normalizes blank chat search params to undefined', async () => {
diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx
index a4b8038..e8e3c1d 100644
--- a/frontend/src/routes/settings.tsx
+++ b/frontend/src/routes/settings.tsx
@@ -2,52 +2,37 @@ import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import {
useBackendSettings,
+ useHistorySources,
type BackendSummary,
+ type HistoryProvider,
+ type HistorySourceConfig,
type HistorySourceDescriptor,
} from '../hooks/useBackendSettings.js'
-// Map each path-hint textarea to the source kinds that feed it so we can
-// derive a meaningful placeholder from auto-discovered paths.
-const VSCODE_ROOT_KINDS: HistorySourceDescriptor['kind'][] = [
- 'vscode_workspace_db',
- 'vscode_chat_sessions',
- 'vscode_chat_editing_sessions',
- 'vscode_extension_resources',
-]
-const CLI_DIR_KINDS: HistorySourceDescriptor['kind'][] = ['cli_session_dir', 'cli_history_dir']
-
-/** Extract unique parent directory paths for a set of source kinds. */
-function autoDiscoveredRoots(
- sources: HistorySourceDescriptor[],
- kinds: HistorySourceDescriptor['kind'][]
-): string[] {
- const kindSet = new Set(kinds)
- const paths = sources
- .filter((s) => kindSet.has(s.kind) && s.discoveredBy === 'auto')
- .map((s) => {
- // For workspace-db style sources the configured root is the *parent* of
- // the discovered path (e.g. /foo/workspaceStorage/abc → /foo/workspaceStorage).
- const parts = s.path.split('/')
- return parts.slice(0, -1).join('/') || s.path
- })
- return [...new Set(paths)]
-}
-
export function SettingsPage() {
const {
backends,
- errorMessage,
- loading,
+ errorMessage: backendError,
+ loading: backendLoading,
saveBackend,
savingId,
addBackend,
- testBackend,
- testingId,
} = useBackendSettings()
+
+ const {
+ sources,
+ loading: sourcesLoading,
+ saveSource,
+ savingProvider,
+ errorMessage: sourcesError,
+ } = useHistorySources()
+
const [newName, setNewName] = useState('')
const [newCommand, setNewCommand] = useState('')
const [newArgs, setNewArgs] = useState('')
+ const errorMessage = backendError ?? sourcesError
+
const handleAddBackend = async () => {
if (!newName.trim() || !newCommand.trim()) {
return
@@ -110,7 +95,7 @@ export function SettingsPage() {
instead of guessing.
- {loading ? (
+ {backendLoading ? (
Loading backend settings...
@@ -121,9 +106,7 @@ export function SettingsPage() {
key={backend.id}
backend={backend}
busy={savingId === backend.id}
- testing={testingId === backend.id}
onSave={saveBackend}
- onTest={testBackend}
/>
))}
@@ -179,6 +162,34 @@ export function SettingsPage() {
+
+
+ History Sources
+
+
+ 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.
+
+
+ {sourcesLoading ? (
+
+ Loading history sources...
+
+ ) : (
+
+ {sources.map((source) => (
+
+ ))}
+
+ )}
+
+
MCP Servers
@@ -200,7 +211,6 @@ export const BackendSettingsPage = SettingsPage
interface BackendCardProps {
backend: BackendSummary
busy: boolean
- testing: boolean
onSave: (
backendId: string,
patch: {
@@ -208,51 +218,26 @@ interface BackendCardProps {
command?: string | null
args?: string[]
name?: string
- historyPathHints?: string[]
- cliHistoryPathHints?: string[]
}
) => Promise
- onTest: (backendId: string) => Promise
}
-function BackendCard({ backend, busy, testing, onSave, onTest }: BackendCardProps) {
+function BackendCard({ backend, busy, onSave }: BackendCardProps) {
const [enabled, setEnabled] = useState(backend.enabled)
const [name, setName] = useState(backend.name)
const [command, setCommand] = useState(backend.command ?? '')
const [args, setArgs] = useState(backend.args.join(' '))
- const [historyPathHints, setHistoryPathHints] = useState(backend.historyPathHints.join('\n'))
- const [cliHistoryPathHints, setCliHistoryPathHints] = useState(
- backend.cliHistoryPathHints.join('\n')
- )
const detectedLabel = backend.detectedCommand
? `Detected: ${backend.detectedCommand}`
: 'Not detected'
- const isCopilot = backend.id === 'copilot'
-
- // Build placeholder text from auto-discovered paths so the textarea doesn't
- // look empty when the backend has already found the right directories.
- const vscodeAutoRoots = isCopilot
- ? autoDiscoveredRoots(backend.historySupport.discoveredSources, VSCODE_ROOT_KINDS)
- : []
- const cliAutoRoots = isCopilot
- ? autoDiscoveredRoots(backend.historySupport.discoveredSources, CLI_DIR_KINDS)
- : []
-
- const vscodeRootsPlaceholder =
- vscodeAutoRoots.length > 0 ? vscodeAutoRoots.join('\n') : 'One path per line'
- const cliRootsPlaceholder =
- cliAutoRoots.length > 0 ? cliAutoRoots.join('\n') : 'One path per line'
-
const handleSave = async () => {
await onSave(backend.id, {
name,
enabled,
command: command.trim() || null,
args: parseArgs(args),
- historyPathHints: parseLines(historyPathHints),
- cliHistoryPathHints: parseLines(cliHistoryPathHints),
})
}
@@ -306,22 +291,80 @@ function BackendCard({ backend, busy, testing, onSave, onTest }: BackendCardProp
className="mt-2 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none focus:border-teal-500"
/>
+
+
+
+
+
+
+
+ {backend.endpointSupport.source === 'connection'
+ ? 'Capabilities come from the last successful ACP initialize response.'
+ : 'Capabilities are unknown until this backend completes a live ACP handshake.'}
+
+
void handleSave()}
+ disabled={busy}
+ className="rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm font-semibold text-slate-50 transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:text-slate-500"
+ >
+ {busy ? 'Saving...' : 'Save'}
+
+
+
+ )
+}
+
+interface HistorySourceCardProps {
+ source: HistorySourceConfig
+ busy: boolean
+ onSave: (
+ provider: HistoryProvider,
+ patch: { paths?: string[]; cliPaths?: string[] }
+ ) => Promise
+}
+
+function HistorySourceCard({ source, busy, onSave }: HistorySourceCardProps) {
+ const [paths, setPaths] = useState(source.paths.join('\n'))
+ const [cliPaths, setCliPaths] = useState((source.cliPaths ?? []).join('\n'))
+
+ const isCopilot = source.provider === 'copilot'
+
+ const handleSave = async () => {
+ await onSave(source.provider, {
+ paths: parseLines(paths),
+ ...(isCopilot ? { cliPaths: parseLines(cliPaths) } : {}),
+ })
+ }
+
+ const providerLabel: Record = {
+ copilot: 'GitHub Copilot',
+ gemini: 'Gemini CLI',
+ opencode: 'OpenCode',
+ }
+
+ return (
+
+ {providerLabel[source.provider]}
+ {source.provider}
+
+
{isCopilot ? 'VS Code Workspace Storage Roots' : 'History Path Hints'}
@@ -331,10 +374,10 @@ function BackendCard({ backend, busy, testing, onSave, onTest }: BackendCardProp
CLI Session Directories
-
-
-
-
-
-
-
- {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}
-
-
- void onTest(backend.id)}
- disabled={testing}
- className="rounded-lg border border-teal-500/30 bg-teal-500/10 px-3 py-2 text-sm font-semibold text-teal-100 transition hover:bg-teal-500/20 disabled:cursor-not-allowed disabled:text-slate-500"
- >
- {testing ? 'Testing...' : 'Test'}
-
- void handleSave()}
- disabled={busy}
- className="rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm font-semibold text-slate-50 transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:text-slate-500"
- >
- {busy ? 'Saving...' : 'Save'}
-
-
+
+ void handleSave()}
+ disabled={busy}
+ className="rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm font-semibold text-slate-50 transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:text-slate-500"
+ >
+ {busy ? 'Saving...' : 'Save'}
+
)