diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c1e97ef6e..2d80adafc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed a module-load crash (`ReferenceError: Cannot access 'evalToolRenderer' before initialization`) triggered whenever `tools/eval` was imported before `tools/renderers`. The eval JS backend statically pulls the agent/task/sdk/extension chain, which re-enters the root barrel → `modes/components` → `tool-execution` → `renderers` while `eval.ts` was still initializing, so `renderers.ts` read `evalToolRenderer` in its TDZ. The eval TUI renderer is now split into a dependency-light `tools/eval-render.ts` that `renderers.ts` imports directly (decoupling pure rendering from the eval runtime); `eval.ts` re-exports `evalToolRenderer`/`EVAL_DEFAULT_PREVIEW_LINES` for compatibility. +- Fixed `history.db` never recording the originating session id: the `session_id` column documented for 15.6.0 was missing from the shipped storage layer, so the column was never created/populated on the write path and every prompt row had `session_id` `NULL`. Restored the `session_id` column, schema migration (`ALTER TABLE history ADD COLUMN session_id` for pre-existing databases), and `HistoryEntry.sessionId`; wired interactive mode to register `setSessionResolver(...)` so prompts are stamped with the session active at submission time (tracking fork/resume switches); and re-enabled prompt-history ranking in the `--resume` and in-session session pickers via `HistoryStorage.matchingSessionIds()`. ## [15.7.6] - 2026-06-01 ### Added diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index f86a7e7ff..849536d79 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -323,7 +323,8 @@ This separation keeps `SessionManager` logic independent from storage backend an `packages/coding-agent/src/session/history-storage.ts` (`HistoryStorage`) is not conversation state restoration. - Stores prompt history in SQLite (`history.db`) with FTS5 index (`history_fts`). -- APIs are `add(prompt, cwd?)`, `getRecent(limit)`, `search(query, limit)`. +- APIs are `add(prompt, cwd?, sessionId?)`, `getRecent(limit)`, `search(query, limit)`, `matchingSessionIds(query, limit?)`. +- Each row records the originating session via the `session_id` column (surfaced as `HistoryEntry.sessionId`), so prompts can be traced back to the session they were submitted from (e.g. for `--resume`). Interactive mode registers a resolver via `setSessionResolver(...)` so prompts added without an explicit id are stamped with the session active at `add()` time, tracking fork/resume switches. - Uses singleton `HistoryStorage.open(...)` and asynchronous insert (`setImmediate`) with duplicate-last-prompt suppression. This is command/input recall data; it does not rebuild agent message trees. diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 822aacff8..1ebad115e 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -1,5 +1,7 @@ import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; import { SessionSelectorComponent } from "../modes/components/session-selector"; +import { HistoryStorage } from "../session/history-storage"; import type { SessionInfo } from "../session/session-manager"; import { FileSessionStorage } from "../session/session-storage"; @@ -10,6 +12,17 @@ export async function selectSession(sessions: SessionInfo[]): Promise string[]) | undefined; + try { + const history = HistoryStorage.open(); + historyMatcher = (query: string) => history.matchingSessionIds(query); + } catch (error) { + logger.warn("History storage unavailable for session ranking", { error: String(error) }); + } + const showSelector = () => { const selector = new SessionSelectorComponent( sessions, @@ -39,6 +52,7 @@ export async function selectSession(sessions: SessionInfo[]): Promise string[]; + +/** + * Combine fuzzy session matches with prompt-history matches for ranking, using + * both signals rather than replacing one with the other. + * + * - `fuzzy` is the ordered fuzzy-filter result over session metadata (best first). + * - `historyIds` are session IDs whose recorded prompts matched the query, + * ordered by history relevance (best first); duplicates are tolerated. + * + * Ranking: sessions matched by **both** signals lead (keeping fuzzy order), then + * fuzzy-only matches, then history-only matches (by history order). A fuzzy match + * is never dropped, and history matches not present in `allSessions` (e.g. deleted + * or out-of-scope sessions) are ignored since they cannot be resumed from here. + */ +export function mergeSessionRanking( + allSessions: SessionInfo[], + fuzzy: SessionInfo[], + historyIds: string[], +): SessionInfo[] { + const historyRank = new Map(); + historyIds.forEach((id, index) => { + if (!historyRank.has(id)) historyRank.set(id, index); + }); + if (historyRank.size === 0) return fuzzy; + + const both: SessionInfo[] = []; + const fuzzyOnly: SessionInfo[] = []; + const fuzzyPaths = new Set(); + for (const session of fuzzy) { + fuzzyPaths.add(session.path); + (historyRank.has(session.id) ? both : fuzzyOnly).push(session); + } + + const historyOnly = allSessions + .filter(session => historyRank.has(session.id) && !fuzzyPaths.has(session.path)) + .sort((a, b) => (historyRank.get(a.id) ?? 0) - (historyRank.get(b.id) ?? 0)); + + return [...both, ...fuzzyOnly, ...historyOnly]; +} + /** * Custom session list component with multi-line items and search */ @@ -35,6 +77,7 @@ class SessionList implements Component { constructor( private readonly allSessions: SessionInfo[], private readonly showCwd = false, + private readonly historyMatcher?: SessionHistoryMatcher, ) { this.#filteredSessions = allSessions; this.#searchInput = new Input(); @@ -51,7 +94,7 @@ class SessionList implements Component { } #filterSessions(query: string): void { - this.#filteredSessions = fuzzyFilter(this.allSessions, query, session => { + const fuzzy = fuzzyFilter(this.allSessions, query, session => { const parts = [ session.id, session.title ?? "", @@ -62,9 +105,24 @@ class SessionList implements Component { ]; return parts.filter(Boolean).join(" "); }); + this.#filteredSessions = this.#mergeHistoryMatches(query, fuzzy); this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1)); } + /** + * Augment fuzzy results with prompt-history matches without replacing them. + * The session-list corpus only sees the first 4KB of each session, so a prompt + * typed deep into a long session is invisible to fuzzy search; `historyMatcher` + * recovers those via `history.db`. + */ + #mergeHistoryMatches(query: string, fuzzy: SessionInfo[]): SessionInfo[] { + const trimmed = query.trim(); + if (!trimmed || !this.historyMatcher) return fuzzy; + const historyIds = this.historyMatcher(trimmed); + if (historyIds.length === 0) return fuzzy; + return mergeSessionRanking(this.allSessions, fuzzy, historyIds); + } + removeSession(sessionPath: string): void { const index = this.allSessions.findIndex(s => s.path === sessionPath); if (index === -1) return; @@ -253,6 +311,7 @@ export class SessionSelectorComponent extends Container { onCancel: () => void, onExit: () => void, onDelete?: (session: SessionInfo) => Promise, + historyMatcher?: SessionHistoryMatcher, ) { super(); @@ -266,7 +325,7 @@ export class SessionSelectorComponent extends Container { this.addChild(new Spacer(1)); this.addChild(this.#messageContainer); // Create session list - this.#sessionList = new SessionList(sessions); + this.#sessionList = new SessionList(sessions, false, historyMatcher); this.#sessionList.onSelect = onSelect; this.#sessionList.onCancel = onCancel; this.#sessionList.onExit = onExit; diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index ba7676e0e..ae024eacf 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -719,6 +719,8 @@ export class SelectorController { this.ctx.sessionManager.getCwd(), this.ctx.sessionManager.getSessionDir(), ); + const historyStorage = this.ctx.historyStorage; + const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined; this.showSelector(done => { const selector = new SessionSelectorComponent( sessions, @@ -747,6 +749,7 @@ export class SelectorController { }); } }, + historyMatcher, ); selector.setOnRequestRender(() => this.ctx.ui.requestRender()); return { component: selector, focus: selector }; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index e3792dc26..ce4a53116 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -392,6 +392,7 @@ export class InteractiveMode implements InteractiveModeContext { try { this.historyStorage = HistoryStorage.open(); this.editor.setHistoryStorage(this.historyStorage); + this.historyStorage.setSessionResolver(() => this.sessionManager.getSessionId()); } catch (error) { logger.warn("History storage unavailable", { error: String(error) }); } diff --git a/packages/coding-agent/src/session/history-storage.ts b/packages/coding-agent/src/session/history-storage.ts index 1306cf1e9..1001d788e 100644 --- a/packages/coding-agent/src/session/history-storage.ts +++ b/packages/coding-agent/src/session/history-storage.ts @@ -8,6 +8,8 @@ export interface HistoryEntry { prompt: string; created_at: number; cwd?: string; + /** ID of the session the prompt was submitted from, if known. */ + sessionId?: string; } type HistoryRow = { @@ -15,6 +17,7 @@ type HistoryRow = { prompt: string; created_at: number; cwd: string | null; + session_id: string | null; }; const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; @@ -62,7 +65,8 @@ class AsyncDrain { export class HistoryStorage { #db: Database; static #instance?: HistoryStorage; - #drain = new AsyncDrain>(100); + #drain = new AsyncDrain>(100); + #sessionResolver?: () => string | undefined; // Prepared statements #insertRowStmt: Statement; @@ -91,7 +95,8 @@ CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - cwd TEXT + cwd TEXT, + session_id TEXT ); CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC); @@ -106,6 +111,10 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN this.#migrateHistorySchema(); } + if (!this.#historySchemaHasColumn("session_id")) { + this.#db.run("ALTER TABLE history ADD COLUMN session_id TEXT"); + } + if (!hasFts) { try { this.#db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')"); @@ -115,14 +124,14 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN } this.#recentStmt = this.#db.prepare( - "SELECT id, prompt, created_at, cwd FROM history ORDER BY created_at DESC, id DESC LIMIT ?", + "SELECT id, prompt, created_at, cwd, session_id FROM history ORDER BY created_at DESC, id DESC LIMIT ?", ); this.#searchStmt = this.#db.prepare( - "SELECT h.id, h.prompt, h.created_at, h.cwd FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?", + "SELECT h.id, h.prompt, h.created_at, h.cwd, h.session_id FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?", ); this.#lastPromptStmt = this.#db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1"); - this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)"); + this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd, session_id) VALUES (?, ?, ?)"); const last = this.#lastPromptStmt.get() as { prompt?: string } | undefined; this.#lastPromptCache = last?.prompt ?? null; @@ -140,20 +149,30 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN HistoryStorage.#instance = undefined; } - #insertBatch(rows: Array>): void { - this.#db.transaction((rows: Array>) => { + #insertBatch(rows: Array>): void { + this.#db.transaction((rows: Array>) => { for (const row of rows) { - this.#insertRowStmt.run(row.prompt, row.cwd ?? null); + this.#insertRowStmt.run(row.prompt, row.cwd ?? null, row.sessionId ?? null); } })(rows); } - add(prompt: string, cwd?: string): Promise { + /** + * Register a resolver that supplies the current session ID for prompts added + * without an explicit `sessionId`. Evaluated synchronously at `add()` time so + * batched writes capture the session active when the prompt was submitted. + */ + setSessionResolver(resolver: () => string | undefined): void { + this.#sessionResolver = resolver; + } + + add(prompt: string, cwd?: string, sessionId?: string): Promise { const trimmed = prompt.trim(); if (!trimmed) return Promise.resolve(); if (this.#lastPromptCache === trimmed) return Promise.resolve(); this.#lastPromptCache = trimmed; - return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined }, rows => { + const session = sessionId ?? this.#sessionResolver?.(); + return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined, sessionId: session || undefined }, rows => { this.#insertBatch(rows); }); } @@ -224,6 +243,24 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN return merged; } + /** + * IDs of the sessions whose stored prompts match `query`, ordered by match + * relevance (most relevant/recent first) and de-duplicated. Prompts with no + * recorded session are skipped. Used to augment session ranking in the + * resume picker with prompts that the 4KB session-list prefix never sees. + */ + matchingSessionIds(query: string, limit = 500): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const entry of this.search(query, limit)) { + const id = entry.sessionId; + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; + } + #ensureDir(dbPath: string): void { const dir = path.dirname(dbPath); fs.mkdirSync(dir, { recursive: true }); @@ -236,6 +273,11 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN return row?.sql?.includes("unixepoch(") ?? false; } + #historySchemaHasColumn(column: string): boolean { + const columns = this.#db.prepare("PRAGMA table_info(history)").all() as Array<{ name: string }>; + return columns.some(col => col.name === column); + } + #migrateHistorySchema(): void { const migrate = this.#db.transaction(() => { this.#db.run("ALTER TABLE history RENAME TO history_legacy"); @@ -247,7 +289,8 @@ CREATE TABLE history ( id INTEGER PRIMARY KEY AUTOINCREMENT, prompt TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}), - cwd TEXT + cwd TEXT, + session_id TEXT ); CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC); INSERT INTO history (id, prompt, created_at, cwd) @@ -294,7 +337,7 @@ END; if (stmt) return stmt; const whereClause = Array(tokenCount).fill("prompt LIKE ? ESCAPE '\\' COLLATE NOCASE").join(" AND "); stmt = this.#db.prepare( - `SELECT id, prompt, created_at, cwd FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`, + `SELECT id, prompt, created_at, cwd, session_id FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`, ); this.#substringStmts.set(tokenCount, stmt); return stmt; @@ -306,6 +349,7 @@ END; prompt: row.prompt, created_at: row.created_at, cwd: row.cwd ?? undefined, + sessionId: row.session_id ?? undefined, }; } } diff --git a/packages/coding-agent/test/history-storage-session.test.ts b/packages/coding-agent/test/history-storage-session.test.ts new file mode 100644 index 000000000..2c83368d6 --- /dev/null +++ b/packages/coding-agent/test/history-storage-session.test.ts @@ -0,0 +1,145 @@ +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { HistoryStorage } from "../src/session/history-storage"; + +let tempDir = ""; + +async function freshStorage(prefix = "omp-history-session-"): Promise<{ storage: HistoryStorage; dbPath: string }> { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const dbPath = path.join(tempDir, "history.db"); + HistoryStorage.resetInstance(); + return { storage: HistoryStorage.open(dbPath), dbPath }; +} + +/** Drain the 100ms insert batch window, then await the pending writes. */ +async function flush(...writes: Promise[]): Promise { + vi.advanceTimersByTime(100); + await Promise.all(writes); +} + +beforeEach(() => { + HistoryStorage.resetInstance(); + vi.useFakeTimers(); +}); + +afterEach(async () => { + HistoryStorage.resetInstance(); + vi.useRealTimers(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ""; + } +}); + +describe("HistoryStorage session linkage", () => { + it("persists the originating session id and surfaces it on recent + search", async () => { + const { storage } = await freshStorage(); + await flush(storage.add("deploy the service", "/repo", "session-abc")); + + expect(storage.getRecent(10)[0]?.sessionId).toBe("session-abc"); + expect(storage.search("deploy", 10)[0]?.sessionId).toBe("session-abc"); + }); + + it("falls back to the session resolver when no explicit id is passed", async () => { + const { storage } = await freshStorage(); + storage.setSessionResolver(() => "resolved-session"); + await flush(storage.add("run the tests", "/repo")); + + expect(storage.getRecent(10)[0]?.sessionId).toBe("resolved-session"); + }); + + it("prefers an explicit session id over the resolver", async () => { + const { storage } = await freshStorage(); + storage.setSessionResolver(() => "resolved-session"); + await flush(storage.add("explicit wins", "/repo", "explicit-session")); + + expect(storage.getRecent(10)[0]?.sessionId).toBe("explicit-session"); + }); + + it("captures the session active at add() time, not at flush time", async () => { + const { storage } = await freshStorage(); + let current = "first-session"; + storage.setSessionResolver(() => current); + // Both adds land in the same batch window; the session must be bound when + // each prompt is submitted, not when the shared batch is written. + const a = storage.add("prompt in first", "/repo"); + current = "second-session"; + const b = storage.add("prompt in second", "/repo"); + await flush(a, b); + + const byPrompt = new Map(storage.getRecent(10).map(e => [e.prompt, e.sessionId])); + expect(byPrompt.get("prompt in first")).toBe("first-session"); + expect(byPrompt.get("prompt in second")).toBe("second-session"); + }); + + it("normalizes an empty session id to null", async () => { + const { storage } = await freshStorage(); + storage.setSessionResolver(() => ""); + await flush(storage.add("no session", "/repo")); + + expect(storage.getRecent(10)[0]?.sessionId).toBeUndefined(); + }); + + it("adds session_id to a pre-existing schema and leaves legacy rows unstamped", async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-history-session-migrate-")); + const dbPath = path.join(tempDir, "history.db"); + const legacyDb = new Database(dbPath); + legacyDb.exec(` + CREATE TABLE history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)), + cwd TEXT + ); + `); + legacyDb.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)").run("legacy prompt", "/legacy"); + legacyDb.close(); + + HistoryStorage.resetInstance(); + const storage = HistoryStorage.open(dbPath); + await flush(storage.add("new prompt", "/new", "session-xyz")); + + const byPrompt = new Map(storage.getRecent(10).map(e => [e.prompt, e.sessionId])); + expect(byPrompt.get("legacy prompt")).toBeUndefined(); + expect(byPrompt.get("new prompt")).toBe("session-xyz"); + + const verify = new Database(dbPath, { readonly: true }); + try { + const columns = verify.prepare("PRAGMA table_info(history)").all() as Array<{ name: string }>; + expect(columns.some(col => col.name === "session_id")).toBe(true); + } finally { + verify.close(); + } + }); +}); + +describe("HistoryStorage.matchingSessionIds", () => { + it("returns matching session ids ordered by recency, de-duplicated", async () => { + const { storage } = await freshStorage(); + await flush( + storage.add("deploy alpha", "/r", "sess-1"), + storage.add("deploy beta", "/r", "sess-1"), + storage.add("deploy gamma", "/r", "sess-2"), + ); + + // Most recent matching prompt first; sess-1 appears once despite two prompts. + expect(storage.matchingSessionIds("deploy", 100)).toEqual(["sess-2", "sess-1"]); + }); + + it("skips prompts that have no recorded session", async () => { + const { storage } = await freshStorage(); + await flush(storage.add("orphan prompt", "/r")); + + expect(storage.matchingSessionIds("orphan", 100)).toEqual([]); + }); + + it("returns no session ids when nothing matches", async () => { + const { storage } = await freshStorage(); + await flush(storage.add("deploy alpha", "/r", "sess-1")); + + expect(storage.matchingSessionIds("nonexistent", 100)).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/session-ranking.test.ts b/packages/coding-agent/test/session-ranking.test.ts new file mode 100644 index 000000000..109162260 --- /dev/null +++ b/packages/coding-agent/test/session-ranking.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; +import { mergeSessionRanking } from "../src/modes/components/session-selector"; +import type { SessionInfo } from "../src/session/session-manager"; + +function makeSession(id: string): SessionInfo { + return { + path: `${id}.jsonl`, + id, + cwd: "/repo", + created: new Date(0), + modified: new Date(0), + messageCount: 1, + size: 100, + firstMessage: "", + allMessagesText: "", + }; +} + +const ids = (sessions: SessionInfo[]): string[] => sessions.map(s => s.id); + +describe("mergeSessionRanking", () => { + it("orders dual matches first (in fuzzy order), then fuzzy-only, then history-only", () => { + const all = ["a", "b", "c", "d", "e"].map(makeSession); + const byId = new Map(all.map(s => [s.id, s])); + const fuzzy = ["a", "b", "c"].map(id => byId.get(id)!); // metadata matches, best→worst + const historyIds = ["c", "a", "e"]; // prompt matches, best→worst + + // a,c matched both → lead in their fuzzy order [a, c]; b fuzzy-only; e history-only. + expect(ids(mergeSessionRanking(all, fuzzy, historyIds))).toEqual(["a", "c", "b", "e"]); + }); + + it("never drops a fuzzy match and appends history-only matches after it", () => { + const all = ["a", "b"].map(makeSession); + const byId = new Map(all.map(s => [s.id, s])); + const fuzzy = [byId.get("a")!]; + + expect(ids(mergeSessionRanking(all, fuzzy, ["b"]))).toEqual(["a", "b"]); + }); + + it("surfaces purely history-matched sessions ordered by history relevance", () => { + const all = ["a", "b", "c"].map(makeSession); + + // No fuzzy match at all; c is the most relevant prompt match, then a. b is excluded. + expect(ids(mergeSessionRanking(all, [], ["c", "a"]))).toEqual(["c", "a"]); + }); + + it("ignores history matches for sessions absent from the list", () => { + const all = [makeSession("a")]; + const byId = new Map(all.map(s => [s.id, s])); + + // "z" is matched in history but not resumable from this list → dropped. + expect(ids(mergeSessionRanking(all, [byId.get("a")!], ["a", "z"]))).toEqual(["a"]); + }); + + it("returns the fuzzy result unchanged when there are no history matches", () => { + const all = ["a", "b"].map(makeSession); + const byId = new Map(all.map(s => [s.id, s])); + const fuzzy = ["b", "a"].map(id => byId.get(id)!); + + expect(ids(mergeSessionRanking(all, fuzzy, []))).toEqual(["b", "a"]); + }); +});