From 3d8a759eba36e03cbf024643cc1c9e54f3ed8749 Mon Sep 17 00:00:00 2001 From: Tobias Bischoff <711564+tobiasbischoff@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:04:56 +0100 Subject: [PATCH 1/4] fix(auth): skip auth profiles in cooldown during selection and rotation Auth profiles in cooldown (due to rate limiting) were being attempted, causing unnecessary retries and delays. This fix ensures: 1. Initial profile selection skips profiles in cooldown 2. Profile rotation (after failures) skips cooldown profiles 3. Clear error message when all profiles are unavailable Tests added: - Skips profiles in cooldown during initial selection - Skips profiles in cooldown when rotating after failure Fixes #1316 --- CHANGELOG.md | 1 + ...ded-pi-agent.auth-profile-rotation.test.ts | 139 ++++++++++++++++++ src/agents/pi-embedded-runner/run.ts | 18 ++- .../bot-message-context.sender-prefix.test.ts | 92 ++++++++++++ 4 files changed, 249 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b84c324b5..46ade97f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.clawd.bot - **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. ### Fixes +- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. - Media: accept MEDIA paths with spaces/tilde and prefer the message tool hint for image replies. - Google Antigravity: drop unsigned thinking blocks for Claude models to avoid signature errors. - Config: avoid stack traces for invalid configs and log the config path. diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts index b931230af..27bd96419 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts @@ -248,4 +248,143 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); } }); + + it("skips profiles in cooldown during initial selection", async () => { + vi.useFakeTimers(); + try { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); + const now = Date.now(); + vi.setSystemTime(now); + + try { + const authPath = path.join(agentDir, "auth-profiles.json"); + const payload = { + version: 1, + profiles: { + "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" }, + "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" }, + }, + usageStats: { + "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, // p1 in cooldown for 1 hour + "openai:p2": { lastUsed: 2 }, + }, + }; + await fs.writeFile(authPath, JSON.stringify(payload)); + + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:skip-cooldown", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: undefined, + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: "run:skip-cooldown", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + + const stored = JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), + ) as { usageStats?: Record }; + expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000); + expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + } finally { + vi.useRealTimers(); + } + }); + + it("skips profiles in cooldown when rotating after failure", async () => { + vi.useFakeTimers(); + try { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); + const now = Date.now(); + vi.setSystemTime(now); + + try { + const authPath = path.join(agentDir, "auth-profiles.json"); + const payload = { + version: 1, + profiles: { + "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" }, + "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" }, + }, + usageStats: { + "openai:p1": { lastUsed: 1 }, + "openai:p2": { cooldownUntil: now + 60 * 60 * 1000 }, // p2 in cooldown + }, + }; + await fs.writeFile(authPath, JSON.stringify(payload)); + + runEmbeddedAttemptMock + .mockResolvedValueOnce( + makeAttempt({ + assistantTexts: [], + lastAssistant: buildAssistant({ + stopReason: "error", + errorMessage: "rate limit", + }), + }), + ) + .mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:rotate-skip-cooldown", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: "openai:p1", + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: "run:rotate-skip-cooldown", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); + + const stored = JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), + ) as { usageStats?: Record }; + expect(typeof stored.usageStats?.["openai:p1"]?.lastUsed).toBe("number"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 174178b09..a2af256cc 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -5,6 +5,7 @@ import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { + isProfileInCooldown, markAuthProfileFailure, markAuthProfileGood, markAuthProfileUsed, @@ -196,6 +197,10 @@ export async function runEmbeddedPiAgent( let nextIndex = profileIndex + 1; while (nextIndex < profileCandidates.length) { const candidate = profileCandidates[nextIndex]; + if (candidate && isProfileInCooldown(authStore, candidate)) { + nextIndex += 1; + continue; + } try { await applyApiKeyInfo(candidate); profileIndex = nextIndex; @@ -211,7 +216,18 @@ export async function runEmbeddedPiAgent( }; try { - await applyApiKeyInfo(profileCandidates[profileIndex]); + while (profileIndex < profileCandidates.length) { + const candidate = profileCandidates[profileIndex]; + if (candidate && isProfileInCooldown(authStore, candidate)) { + profileIndex += 1; + continue; + } + await applyApiKeyInfo(profileCandidates[profileIndex]); + break; + } + if (profileIndex >= profileCandidates.length) { + throw new Error(`No available auth profile for ${provider} (all in cooldown or unavailable).`); + } } catch (err) { if (profileCandidates[profileIndex] === lockedProfileId) throw err; const advanced = await advanceAuthProfile(); diff --git a/src/telegram/bot-message-context.sender-prefix.test.ts b/src/telegram/bot-message-context.sender-prefix.test.ts index 12d7b09e5..c7f0e5de9 100644 --- a/src/telegram/bot-message-context.sender-prefix.test.ts +++ b/src/telegram/bot-message-context.sender-prefix.test.ts @@ -49,4 +49,96 @@ describe("buildTelegramMessageContext sender prefix", () => { const body = ctx?.ctxPayload?.Body ?? ""; expect(body).toContain("Alice (42): hello"); }); + + it("sets MessageSid from message_id", async () => { + const ctx = await buildTelegramMessageContext({ + primaryCtx: { + message: { + message_id: 12345, + chat: { id: -99, type: "supergroup", title: "Dev Chat" }, + date: 1700000000, + text: "hello", + from: { id: 42, first_name: "Alice" }, + }, + me: { id: 7, username: "bot" }, + } as never, + allMedia: [], + storeAllowFrom: [], + options: {}, + bot: { + api: { + sendChatAction: vi.fn(), + setMessageReaction: vi.fn(), + }, + } as never, + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + } as never, + account: { accountId: "default" } as never, + historyLimit: 0, + groupHistories: new Map(), + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ackReactionScope: "off", + logger: { info: vi.fn() }, + resolveGroupActivation: () => undefined, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: undefined, + }), + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.MessageSid).toBe("12345"); + }); + + it("respects messageIdOverride option", async () => { + const ctx = await buildTelegramMessageContext({ + primaryCtx: { + message: { + message_id: 12345, + chat: { id: -99, type: "supergroup", title: "Dev Chat" }, + date: 1700000000, + text: "hello", + from: { id: 42, first_name: "Alice" }, + }, + me: { id: 7, username: "bot" }, + } as never, + allMedia: [], + storeAllowFrom: [], + options: { messageIdOverride: "67890" }, + bot: { + api: { + sendChatAction: vi.fn(), + setMessageReaction: vi.fn(), + }, + } as never, + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + } as never, + account: { accountId: "default" } as never, + historyLimit: 0, + groupHistories: new Map(), + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ackReactionScope: "off", + logger: { info: vi.fn() }, + resolveGroupActivation: () => undefined, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: undefined, + }), + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.MessageSid).toBe("67890"); + }); }); From 917bcb714e820388eaee0cc1b5c56c0bd62fd5cc Mon Sep 17 00:00:00 2001 From: Tobias Bischoff <711564+tobiasbischoff@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:29:37 +0100 Subject: [PATCH 2/4] perf(tui): optimize searchable select list filtering - Add regex caching to avoid creating new RegExp objects on each render - Optimize smartFilter to use single array with tier-based scoring - Replace non-existent fuzzyFilter import with local fuzzyFilterLower - Reduces from 4 array allocations and 4 sorts to 1 array and 1 sort Fixes pre-existing bug where fuzzyFilter was imported from pi-tui but not exported. --- src/tui/components/searchable-select-list.ts | 45 ++++++++++---------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index 37ff21ebc..886cc0170 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -1,6 +1,5 @@ import { type Component, - fuzzyFilter, getEditorKeybindings, Input, isKeyRelease, @@ -10,7 +9,7 @@ import { truncateToWidth, } from "@mariozechner/pi-tui"; import { visibleWidth } from "../../terminal/ansi.js"; -import { findWordBoundaryIndex } from "./fuzzy-filter.js"; +import { findWordBoundaryIndex, fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js"; export interface SearchableSelectListTheme extends SelectListTheme { searchPrompt: (text: string) => string; @@ -28,6 +27,7 @@ export class SearchableSelectList implements Component { private maxVisible: number; private theme: SearchableSelectListTheme; private searchInput: Input; + private regexCache = new Map(); onSelect?: (item: SelectItem) => void; onCancel?: () => void; @@ -41,6 +41,15 @@ export class SearchableSelectList implements Component { this.searchInput = new Input(); } + private getCachedRegex(pattern: string): RegExp { + let regex = this.regexCache.get(pattern); + if (!regex) { + regex = new RegExp(this.escapeRegex(pattern), "gi"); + this.regexCache.set(pattern, regex); + } + return regex; + } + private updateFilter() { const query = this.searchInput.getValue().trim(); @@ -59,15 +68,13 @@ export class SearchableSelectList implements Component { * Smart filtering that prioritizes: * 1. Exact substring match in label (highest priority) * 2. Word-boundary prefix match in label - * 3. Exact substring match in description + * 3. Exact substring in description * 4. Fuzzy match (lowest priority) */ private smartFilter(query: string): SelectItem[] { const q = query.toLowerCase(); type ScoredItem = { item: SelectItem; score: number }; - const exactLabel: ScoredItem[] = []; - const wordBoundary: ScoredItem[] = []; - const descriptionMatches: ScoredItem[] = []; + const scoredItems: ScoredItem[] = []; const fuzzyCandidates: SelectItem[] = []; for (const item of this.items) { @@ -77,38 +84,32 @@ export class SearchableSelectList implements Component { // Tier 1: Exact substring in label (score 0-99) const labelIndex = label.indexOf(q); if (labelIndex !== -1) { - // Earlier match = better score - exactLabel.push({ item, score: labelIndex }); + scoredItems.push({ item, score: labelIndex }); continue; } // Tier 2: Word-boundary prefix in label (score 100-199) const wordBoundaryIndex = findWordBoundaryIndex(label, q); if (wordBoundaryIndex !== null) { - wordBoundary.push({ item, score: wordBoundaryIndex }); + scoredItems.push({ item, score: 100 + wordBoundaryIndex }); continue; } // Tier 3: Exact substring in description (score 200-299) const descIndex = desc.indexOf(q); if (descIndex !== -1) { - descriptionMatches.push({ item, score: descIndex }); + scoredItems.push({ item, score: 200 + descIndex }); continue; } // Tier 4: Fuzzy match (score 300+) fuzzyCandidates.push(item); } - exactLabel.sort(this.compareByScore); - wordBoundary.sort(this.compareByScore); - descriptionMatches.sort(this.compareByScore); - const fuzzyMatches = fuzzyFilter( - fuzzyCandidates, - query, - (i) => `${i.label} ${i.description ?? ""}`, - ); + scoredItems.sort(this.compareByScore); + + const preparedCandidates = prepareSearchItems(fuzzyCandidates); + const fuzzyMatches = fuzzyFilterLower(preparedCandidates, q); + return [ - ...exactLabel.map((s) => s.item), - ...wordBoundary.map((s) => s.item), - ...descriptionMatches.map((s) => s.item), + ...scoredItems.map((s) => s.item), ...fuzzyMatches, ]; } @@ -140,7 +141,7 @@ export class SearchableSelectList implements Component { const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length); let result = text; for (const token of uniqueTokens) { - const regex = new RegExp(this.escapeRegex(token), "gi"); + const regex = this.getCachedRegex(token); result = result.replace(regex, (match) => this.theme.matchHighlight(match)); } return result; From 91ca52d3c5331f97c377c86a3ba554cf8b3c268b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 03:05:01 +0000 Subject: [PATCH 3/4] fix: honor user-pinned profiles and search ranking --- ...ded-pi-agent.auth-profile-rotation.test.ts | 76 ++++++++++++++++++- src/agents/pi-embedded-runner/run.ts | 23 ++++-- .../components/searchable-select-list.test.ts | 16 ++++ src/tui/components/searchable-select-list.ts | 24 +++--- 4 files changed, 120 insertions(+), 19 deletions(-) diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts index 27bd96419..f6f395746 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts @@ -210,6 +210,74 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { } }); + it("honors user-pinned profiles even when in cooldown", async () => { + vi.useFakeTimers(); + try { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); + const now = Date.now(); + vi.setSystemTime(now); + + try { + const authPath = path.join(agentDir, "auth-profiles.json"); + const payload = { + version: 1, + profiles: { + "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" }, + "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" }, + }, + usageStats: { + "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, + "openai:p2": { lastUsed: 2 }, + }, + }; + await fs.writeFile(authPath, JSON.stringify(payload)); + + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:user-cooldown", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: "openai:p1", + authProfileIdSource: "user", + timeoutMs: 5_000, + runId: "run:user-cooldown", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + + const stored = JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), + ) as { + usageStats?: Record; + }; + expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBeUndefined(); + expect(stored.usageStats?.["openai:p1"]?.lastUsed).not.toBe(1); + expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + } finally { + vi.useRealTimers(); + } + }); + it("ignores user-locked profile when provider mismatches", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); @@ -329,10 +397,12 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { profiles: { "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" }, "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" }, + "openai:p3": { type: "api_key", provider: "openai", key: "sk-three" }, }, usageStats: { "openai:p1": { lastUsed: 1 }, "openai:p2": { cooldownUntil: now + 60 * 60 * 1000 }, // p2 in cooldown + "openai:p3": { lastUsed: 3 }, }, }; await fs.writeFile(authPath, JSON.stringify(payload)); @@ -377,8 +447,12 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { const stored = JSON.parse( await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"), - ) as { usageStats?: Record }; + ) as { + usageStats?: Record; + }; expect(typeof stored.usageStats?.["openai:p1"]?.lastUsed).toBe("number"); + expect(typeof stored.usageStats?.["openai:p3"]?.lastUsed).toBe("number"); + expect(stored.usageStats?.["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000); } finally { await fs.rm(agentDir, { recursive: true, force: true }); await fs.rm(workspaceDir, { recursive: true, force: true }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index a2af256cc..a39b6fd96 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -149,7 +149,11 @@ export async function runEmbeddedPiAgent( if (lockedProfileId && !profileOrder.includes(lockedProfileId)) { throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`); } - const profileCandidates = profileOrder.length > 0 ? profileOrder : [undefined]; + const profileCandidates = lockedProfileId + ? [lockedProfileId] + : profileOrder.length > 0 + ? profileOrder + : [undefined]; let profileIndex = 0; const initialThinkLevel = params.thinkLevel ?? "off"; @@ -170,13 +174,14 @@ export async function runEmbeddedPiAgent( const applyApiKeyInfo = async (candidate?: string): Promise => { apiKeyInfo = await resolveApiKeyForCandidate(candidate); + const resolvedProfileId = apiKeyInfo.profileId ?? candidate; if (!apiKeyInfo.apiKey) { if (apiKeyInfo.mode !== "aws-sdk") { throw new Error( `No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`, ); } - lastProfileId = apiKeyInfo.profileId; + lastProfileId = resolvedProfileId; return; } if (model.provider === "github-copilot") { @@ -189,7 +194,7 @@ export async function runEmbeddedPiAgent( } else { authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); } - lastProfileId = apiKeyInfo.profileId; + lastProfileId = resolvedProfileId; }; const advanceAuthProfile = async (): Promise => { @@ -218,7 +223,11 @@ export async function runEmbeddedPiAgent( try { while (profileIndex < profileCandidates.length) { const candidate = profileCandidates[profileIndex]; - if (candidate && isProfileInCooldown(authStore, candidate)) { + if ( + candidate && + candidate !== lockedProfileId && + isProfileInCooldown(authStore, candidate) + ) { profileIndex += 1; continue; } @@ -226,7 +235,9 @@ export async function runEmbeddedPiAgent( break; } if (profileIndex >= profileCandidates.length) { - throw new Error(`No available auth profile for ${provider} (all in cooldown or unavailable).`); + throw new Error( + `No available auth profile for ${provider} (all in cooldown or unavailable).`, + ); } } catch (err) { if (profileCandidates[profileIndex] === lockedProfileId) throw err; @@ -518,10 +529,12 @@ export async function runEmbeddedPiAgent( store: authStore, provider, profileId: lastProfileId, + agentDir: params.agentDir, }); await markAuthProfileUsed({ store: authStore, profileId: lastProfileId, + agentDir: params.agentDir, }); } return { diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts index a16d77320..cf1da265e 100644 --- a/src/tui/components/searchable-select-list.test.ts +++ b/src/tui/components/searchable-select-list.test.ts @@ -76,6 +76,22 @@ describe("SearchableSelectList", () => { expect(selected?.value).toBe("opus-direct"); }); + it("keeps exact label matches ahead of description matches", () => { + const longPrefix = "x".repeat(250); + const items = [ + { value: "late-label", label: `${longPrefix}opus`, description: "late exact match" }, + { value: "desc-first", label: "provider/other", description: "opus in description" }, + ]; + const list = new SearchableSelectList(items, 5, mockTheme); + + for (const ch of "opus") { + list.handleInput(ch); + } + + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("late-label"); + }); + it("exact label match beats description match", () => { const items = [ { diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index 886cc0170..f8e07e790 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -73,7 +73,7 @@ export class SearchableSelectList implements Component { */ private smartFilter(query: string): SelectItem[] { const q = query.toLowerCase(); - type ScoredItem = { item: SelectItem; score: number }; + type ScoredItem = { item: SelectItem; tier: number; score: number }; const scoredItems: ScoredItem[] = []; const fuzzyCandidates: SelectItem[] = []; @@ -81,22 +81,22 @@ export class SearchableSelectList implements Component { const label = item.label.toLowerCase(); const desc = (item.description ?? "").toLowerCase(); - // Tier 1: Exact substring in label (score 0-99) + // Tier 1: Exact substring in label const labelIndex = label.indexOf(q); if (labelIndex !== -1) { - scoredItems.push({ item, score: labelIndex }); + scoredItems.push({ item, tier: 0, score: labelIndex }); continue; } - // Tier 2: Word-boundary prefix in label (score 100-199) + // Tier 2: Word-boundary prefix in label const wordBoundaryIndex = findWordBoundaryIndex(label, q); if (wordBoundaryIndex !== null) { - scoredItems.push({ item, score: 100 + wordBoundaryIndex }); + scoredItems.push({ item, tier: 1, score: wordBoundaryIndex }); continue; } - // Tier 3: Exact substring in description (score 200-299) + // Tier 3: Exact substring in description const descIndex = desc.indexOf(q); if (descIndex !== -1) { - scoredItems.push({ item, score: 200 + descIndex }); + scoredItems.push({ item, tier: 2, score: descIndex }); continue; } // Tier 4: Fuzzy match (score 300+) @@ -108,10 +108,7 @@ export class SearchableSelectList implements Component { const preparedCandidates = prepareSearchItems(fuzzyCandidates); const fuzzyMatches = fuzzyFilterLower(preparedCandidates, q); - return [ - ...scoredItems.map((s) => s.item), - ...fuzzyMatches, - ]; + return [...scoredItems.map((s) => s.item), ...fuzzyMatches]; } private escapeRegex(str: string): string { @@ -119,9 +116,10 @@ export class SearchableSelectList implements Component { } private compareByScore = ( - a: { item: SelectItem; score: number }, - b: { item: SelectItem; score: number }, + a: { item: SelectItem; tier: number; score: number }, + b: { item: SelectItem; tier: number; score: number }, ) => { + if (a.tier !== b.tier) return a.tier - b.tier; if (a.score !== b.score) return a.score - b.score; return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item)); }; From 6eb355954ccc3870d79471359ec158d961259993 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 03:06:10 +0000 Subject: [PATCH 4/4] docs: add changelog entry for #1432 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ade97f2..4b0f914cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.clawd.bot ### Fixes - Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. +- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff. - Media: accept MEDIA paths with spaces/tilde and prefer the message tool hint for image replies. - Google Antigravity: drop unsigned thinking blocks for Claude models to avoid signature errors. - Config: avoid stack traces for invalid configs and log the config path.