From 510fe8b95d092664bf80875ecfc762ed969cffce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 14:14:31 +0100 Subject: [PATCH] perf(test): speed up reply trigger hotspots --- src/agents/auth-profiles.ts | 1 + src/agents/auth-profiles/store.ts | 32 +++++++ src/agents/model-auth-label.test.ts | 27 ++++++ src/agents/model-auth-label.ts | 11 ++- ...ets-active-session-native-stop.e2e.test.ts | 7 +- .../reply/directive-handling.auth-profile.ts | 18 +++- .../reply/directive-handling.persist.ts | 2 + .../reply/get-reply-directives-apply.ts | 79 +++++++++++++++ src/auto-reply/reply/get-reply-directives.ts | 95 +++++++++++++------ src/auto-reply/reply/get-reply-run.ts | 21 ++-- src/auto-reply/reply/get-reply.ts | 35 +++---- .../reply/session-reset-prompt.test.ts | 17 ++++ src/auto-reply/reply/session-reset-prompt.ts | 9 +- src/status/status-text.ts | 2 + 14 files changed, 288 insertions(+), 68 deletions(-) diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 174951ce0ae..51f50bdf1b9 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -29,6 +29,7 @@ export { ensureAuthProfileStore, hasAnyAuthProfileStoreSource, loadAuthProfileStoreForSecretsRuntime, + loadAuthProfileStoreWithoutExternalProfiles, loadAuthProfileStoreForRuntime, replaceRuntimeAuthProfileStoreSnapshots, loadAuthProfileStore, diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index a036e56b1dc..0fb29f683be 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -282,6 +282,19 @@ export function loadAuthProfileStoreForSecretsRuntime(agentDir?: string): AuthPr return loadAuthProfileStoreForRuntime(agentDir, { readOnly: true, allowKeychainPrompt: false }); } +export function loadAuthProfileStoreWithoutExternalProfiles(agentDir?: string): AuthProfileStore { + const options: LoadAuthProfileStoreOptions = { readOnly: true, allowKeychainPrompt: false }; + const store = loadAuthProfileStoreForAgent(agentDir, options); + const authPath = resolveAuthStorePath(agentDir); + const mainAuthPath = resolveAuthStorePath(); + if (!agentDir || authPath === mainAuthPath) { + return store; + } + + const mainStore = loadAuthProfileStoreForAgent(undefined, options); + return mergeAuthProfileStores(mainStore, store); +} + export function ensureAuthProfileStore( agentDir?: string, options?: { allowKeychainPrompt?: boolean }, @@ -304,6 +317,25 @@ export function ensureAuthProfileStore( return overlayExternalAuthProfiles(merged, { agentDir }); } +export function findPersistedAuthProfileCredential(params: { + agentDir?: string; + profileId: string; +}): AuthProfileStore["profiles"][string] | undefined { + const requestedStore = loadPersistedAuthProfileStore(params.agentDir); + const requestedProfile = requestedStore?.profiles[params.profileId]; + if (requestedProfile || !params.agentDir) { + return requestedProfile; + } + + const requestedPath = resolveAuthStorePath(params.agentDir); + const mainPath = resolveAuthStorePath(); + if (requestedPath === mainPath) { + return requestedProfile; + } + + return loadPersistedAuthProfileStore()?.profiles[params.profileId]; +} + export function ensureAuthProfileStoreForLocalUpdate(agentDir?: string): AuthProfileStore { const options: LoadAuthProfileStoreOptions = { syncExternalCli: false }; const store = loadAuthProfileStoreForAgent(agentDir, options); diff --git a/src/agents/model-auth-label.test.ts b/src/agents/model-auth-label.test.ts index ec102b66e7c..6df2115725d 100644 --- a/src/agents/model-auth-label.test.ts +++ b/src/agents/model-auth-label.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ ensureAuthProfileStore: vi.fn(), + loadAuthProfileStoreWithoutExternalProfiles: vi.fn(), resolveAuthProfileOrder: vi.fn(), resolveAuthProfileDisplayLabel: vi.fn(), resolveUsableCustomProviderApiKey: vi.fn(() => null), @@ -10,6 +11,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("./auth-profiles.js", () => ({ ensureAuthProfileStore: mocks.ensureAuthProfileStore, + loadAuthProfileStoreWithoutExternalProfiles: mocks.loadAuthProfileStoreWithoutExternalProfiles, resolveAuthProfileOrder: mocks.resolveAuthProfileOrder, resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, })); @@ -27,6 +29,7 @@ describe("resolveModelAuthLabel", () => { ({ resolveModelAuthLabel } = await import("./model-auth-label.js")); } mocks.ensureAuthProfileStore.mockReset(); + mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReset(); mocks.resolveAuthProfileOrder.mockReset(); mocks.resolveAuthProfileDisplayLabel.mockReset(); mocks.resolveUsableCustomProviderApiKey.mockReset(); @@ -108,4 +111,28 @@ describe("resolveModelAuthLabel", () => { expect(label).toBe("oauth (anthropic:oauth)"); }); + + it("can skip external auth profile overlays for status labels", () => { + mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ + version: 1, + profiles: { + "anthropic:oauth": { + type: "oauth", + provider: "anthropic", + }, + }, + } as never); + mocks.resolveAuthProfileOrder.mockReturnValue(["anthropic:oauth"]); + mocks.resolveAuthProfileDisplayLabel.mockReturnValue("anthropic:oauth"); + + const label = resolveModelAuthLabel({ + provider: "anthropic", + cfg: {}, + includeExternalProfiles: false, + }); + + expect(label).toBe("oauth (anthropic:oauth)"); + expect(mocks.loadAuthProfileStoreWithoutExternalProfiles).toHaveBeenCalledOnce(); + expect(mocks.ensureAuthProfileStore).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/model-auth-label.ts b/src/agents/model-auth-label.ts index 187c4a61615..e8c9fa2a737 100644 --- a/src/agents/model-auth-label.ts +++ b/src/agents/model-auth-label.ts @@ -2,6 +2,7 @@ import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { ensureAuthProfileStore, + loadAuthProfileStoreWithoutExternalProfiles, resolveAuthProfileDisplayLabel, resolveAuthProfileOrder, } from "./auth-profiles.js"; @@ -13,6 +14,7 @@ export function resolveModelAuthLabel(params: { cfg?: OpenClawConfig; sessionEntry?: Partial>; agentDir?: string; + includeExternalProfiles?: boolean; }): string | undefined { const resolvedProvider = params.provider?.trim(); if (!resolvedProvider) { @@ -20,9 +22,12 @@ export function resolveModelAuthLabel(params: { } const providerKey = normalizeProviderId(resolvedProvider); - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); + const store = + params.includeExternalProfiles === false + ? loadAuthProfileStoreWithoutExternalProfiles(params.agentDir) + : ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); const profileOverride = params.sessionEntry?.authProfileOverride?.trim(); const order = resolveAuthProfileOrder({ cfg: params.cfg, diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts index 7ae187d02d4..fbbdec482df 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts @@ -17,7 +17,6 @@ import { import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js"; import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js"; -import { withFullRuntimeReplyConfig } from "./reply/get-reply-fast-path.js"; import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; @@ -672,10 +671,8 @@ describe("trigger handling", () => { it("applies native model auth profile overrides to the target session", async () => { await withTempHome(async (home) => { - const cfg = withFullRuntimeReplyConfig({ - ...makeCfg(home), - session: { store: join(home, "native-model-auth.sessions.json") }, - }); + const cfg = makeCfg(home); + cfg.session = { ...cfg.session, store: join(home, "native-model-auth.sessions.json") }; const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); runEmbeddedPiAgentMock.mockReset(); const storePath = cfg.session?.store; diff --git a/src/auto-reply/reply/directive-handling.auth-profile.ts b/src/auto-reply/reply/directive-handling.auth-profile.ts index 78107478df2..73e348af097 100644 --- a/src/auto-reply/reply/directive-handling.auth-profile.ts +++ b/src/auto-reply/reply/directive-handling.auth-profile.ts @@ -1,4 +1,7 @@ -import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; +import { + ensureAuthProfileStore, + findPersistedAuthProfileCredential, +} from "../../agents/auth-profiles/store.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -12,6 +15,19 @@ export function resolveProfileOverride(params: { if (!raw) { return {}; } + const persistedProfile = findPersistedAuthProfileCredential({ + agentDir: params.agentDir, + profileId: raw, + }); + if (persistedProfile) { + if (persistedProfile.provider !== params.provider) { + return { + error: `Auth profile "${raw}" is for ${persistedProfile.provider}, not ${params.provider}.`, + }; + } + return { profileId: raw }; + } + const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index 19951eb0cc5..32a87edf1fe 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -45,6 +45,7 @@ export async function persistInlineDirectives(params: { surface?: string; gatewayClientScopes?: string[]; senderIsOwner?: boolean; + markLiveSwitchPending?: boolean; }): Promise<{ provider: string; model: string; contextTokens: number }> { const { directives, @@ -185,6 +186,7 @@ export async function persistInlineDirectives(params: { entry: sessionEntry, selection: modelResolution.modelSelection, profileOverride: modelResolution.profileOverride, + markLiveSwitchPending: params.markLiveSwitchPending, }); provider = modelResolution.modelSelection.provider; model = modelResolution.modelSelection.model; diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 11af1851bb3..697b79eb4e9 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -6,6 +6,7 @@ import type { ElevatedLevel } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import type { CommandContext } from "./commands-types.js"; import { isDirectiveOnly } from "./directive-handling.directive-only.js"; +import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import { clearInlineDirectives } from "./get-reply-directives-utils.js"; @@ -49,6 +50,21 @@ function loadDirectivePersist() { return directivePersistPromise; } +function hasOnlyModelDirective(directives: InlineDirectives): boolean { + return ( + directives.hasModelDirective && + !directives.hasThinkDirective && + !directives.hasFastDirective && + !directives.hasVerboseDirective && + !directives.hasTraceDirective && + !directives.hasReasoningDirective && + !directives.hasElevatedDirective && + !directives.hasExecDirective && + !directives.hasQueueDirective && + !directives.hasStatusDirective + ); +} + export type ApplyDirectiveResult = | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } | { @@ -211,6 +227,69 @@ export async function applyInlineDirectiveOverrides(params: { typing.cleanup(); return { kind: "reply", reply: undefined }; } + if (hasOnlyModelDirective(directives) && effectiveModelDirective) { + const modelResolution = resolveModelSelectionFromDirective({ + directives: { + ...directives, + rawModelDirective: effectiveModelDirective, + }, + cfg, + agentDir, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys: modelState.allowedModelKeys, + allowedModelCatalog: modelState.allowedModelCatalog, + provider, + }); + if (modelResolution.errorText) { + typing.cleanup(); + return { kind: "reply", reply: { text: modelResolution.errorText } }; + } + const modelSelection = modelResolution.modelSelection; + if (modelSelection) { + await ( + await loadDirectivePersist() + ).persistInlineDirectives({ + directives, + effectiveModelDirective, + cfg, + agentDir, + sessionEntry, + sessionStore, + sessionKey, + storePath, + elevatedEnabled, + elevatedAllowed, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys: modelState.allowedModelKeys, + provider, + model, + initialModelLabel, + formatModelSwitchEvent, + agentCfg, + messageProvider: ctx.Provider, + surface: ctx.Surface, + gatewayClientScopes: ctx.GatewayClientScopes, + senderIsOwner: command.senderIsOwner, + markLiveSwitchPending: true, + }); + const label = `${modelSelection.provider}/${modelSelection.model}`; + const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label; + const parts = [ + modelSelection.isDefault + ? `Model reset to default (${labelWithAlias}).` + : `Model set to ${labelWithAlias}.`, + modelResolution.profileOverride + ? `Auth profile set to ${modelResolution.profileOverride}.` + : undefined, + ].filter(Boolean); + typing.cleanup(); + return { kind: "reply", reply: { text: parts.join(" ") } }; + } + } const { currentThinkLevel: resolvedDefaultThinkLevel, currentFastMode, diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index e7dfaa28cbb..d7b9d0d0f48 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -1,7 +1,7 @@ import { listAgentEntries } from "../../agents/agent-scope.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { resolveFastModeState } from "../../agents/fast-mode.js"; -import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import { type ModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -53,6 +53,24 @@ function loadSkillCommands() { return skillCommandsPromise; } +function canUseFastExplicitModelDirective(params: { + directives: InlineDirectives; + defaultProvider: string; + aliasIndex: ModelAliasIndex; +}): boolean { + const raw = normalizeOptionalString(params.directives.rawModelDirective); + if (!raw || /^[0-9]+$/.test(raw)) { + return false; + } + return Boolean( + resolveModelRefFromString({ + raw, + defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, + }), + ); +} + function resolveDirectiveCommandText(params: { ctx: MsgContext; sessionCtx: TemplateContext }) { const commandSource = params.sessionCtx.BodyForCommands ?? @@ -202,8 +220,13 @@ export async function resolveReplyDirectives(params: { commandSource: ctx.CommandSource, }); const commandTextHasSlash = commandText.includes("/"); + const hasConfiguredModelAliases = + commandTextHasSlash && + Object.values(cfg.agents?.defaults?.models ?? {}).some((entry) => + Boolean(normalizeOptionalString(entry.alias)), + ); const reservedCommands = new Set(); - if (commandTextHasSlash) { + if (hasConfiguredModelAliases) { const { listChatCommands } = await loadCommandsRegistry(); for (const chatCommand of listChatCommands()) { for (const alias of chatCommand.textAliases) { @@ -212,11 +235,13 @@ export async function resolveReplyDirectives(params: { } } - const rawAliases = resolveConfiguredDirectiveAliases({ - cfg, - commandTextHasSlash, - reservedCommands, - }); + const rawAliases = hasConfiguredModelAliases + ? resolveConfiguredDirectiveAliases({ + cfg, + commandTextHasSlash, + reservedCommands, + }) + : []; // Only load workspace skill commands when we actually need them to filter aliases. // This avoids scanning skills for messages that only use plain text with no slash syntax. @@ -428,33 +453,41 @@ export async function resolveReplyDirectives(params: { isFastTestEnv: process.env.OPENCLAW_TEST_FAST === "1", }); - const modelState = + const useFastModelSelection = useFastReplyRuntime && - !directives.hasModelDirective && !hasResolvedHeartbeatModelOverride && + !(agentCfg?.models && Object.keys(agentCfg.models).length > 0) && !normalizeOptionalString(targetSessionEntry?.modelOverride) && - !normalizeOptionalString(targetSessionEntry?.providerOverride) - ? createFastTestModelSelectionState({ - agentCfg, - provider, - model, - }) - : await createModelSelectionState({ - cfg, - agentId, - agentCfg, - sessionEntry: targetSessionEntry, - sessionStore, - sessionKey, - parentSessionKey: targetSessionEntry?.parentSessionKey ?? ctx.ParentSessionKey, - storePath, - defaultProvider, - defaultModel, - provider, - model, - hasModelDirective: directives.hasModelDirective, - hasResolvedHeartbeatModelOverride, - }); + !normalizeOptionalString(targetSessionEntry?.providerOverride) && + (!directives.hasModelDirective || + canUseFastExplicitModelDirective({ + directives, + defaultProvider, + aliasIndex: params.aliasIndex, + })); + + const modelState = useFastModelSelection + ? createFastTestModelSelectionState({ + agentCfg, + provider, + model, + }) + : await createModelSelectionState({ + cfg, + agentId, + agentCfg, + sessionEntry: targetSessionEntry, + sessionStore, + sessionKey, + parentSessionKey: targetSessionEntry?.parentSessionKey ?? ctx.ParentSessionKey, + storePath, + defaultProvider, + defaultModel, + provider, + model, + hasModelDirective: directives.hasModelDirective, + hasResolvedHeartbeatModelOverride, + }); provider = modelState.provider; model = modelState.model; const resolvedThinkLevelWithDefault = diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index c2c0704e03a..a0c26d83c93 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -337,14 +337,15 @@ export async function runPreparedReply( workspaceDir, isPrimaryRun: !isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey), isCanonicalWorkspace: !spawnedWorkspaceOverride, - hasBootstrapFileAccess: resolveBareResetBootstrapFileAccess({ - cfg, - agentId, - sessionKey, - workspaceDir, - modelProvider: provider, - modelId: model, - }), + hasBootstrapFileAccess: () => + resolveBareResetBootstrapFileAccess({ + cfg, + agentId, + sessionKey, + workspaceDir, + modelProvider: provider, + modelId: model, + }), }) : null; const startupContextPrelude = @@ -559,7 +560,7 @@ export async function runPreparedReply( logVerbose(`Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`); } let authProfileId = useFastReplyRuntime - ? undefined + ? preparedSessionState.sessionEntry?.authProfileOverride : await resolveSessionAuthProfileOverride({ cfg, provider, @@ -612,7 +613,7 @@ export async function runPreparedReply( refreshPreparedState: async () => { preparedSessionState = resolvePreparedSessionState(); authProfileId = useFastReplyRuntime - ? undefined + ? preparedSessionState.sessionEntry?.authProfileOverride : await resolveSessionAuthProfileOverride({ cfg, provider, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index ca308d6463e..e0255acd663 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -323,22 +323,25 @@ export async function getReplyFromConfig( }); } - const channelModelOverride = resolveChannelModelOverride({ - cfg, - channel: - groupResolution?.channel ?? - sessionEntry.channel ?? - sessionEntry.origin?.provider ?? - (typeof finalized.OriginatingChannel === "string" - ? finalized.OriginatingChannel - : undefined) ?? - finalized.Provider, - groupId: groupResolution?.id ?? sessionEntry.groupId, - groupChatType: sessionEntry.chatType ?? sessionCtx.ChatType ?? finalized.ChatType, - groupChannel: sessionEntry.groupChannel ?? sessionCtx.GroupChannel ?? finalized.GroupChannel, - groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject, - parentSessionKey: sessionCtx.ParentSessionKey, - }); + const channelModelOverride = cfg.channels?.modelByChannel + ? resolveChannelModelOverride({ + cfg, + channel: + groupResolution?.channel ?? + sessionEntry.channel ?? + sessionEntry.origin?.provider ?? + (typeof finalized.OriginatingChannel === "string" + ? finalized.OriginatingChannel + : undefined) ?? + finalized.Provider, + groupId: groupResolution?.id ?? sessionEntry.groupId, + groupChatType: sessionEntry.chatType ?? sessionCtx.ChatType ?? finalized.ChatType, + groupChannel: + sessionEntry.groupChannel ?? sessionCtx.GroupChannel ?? finalized.GroupChannel, + groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject, + parentSessionKey: sessionCtx.ParentSessionKey, + }) + : null; const hasSessionModelOverride = Boolean( normalizeOptionalString(sessionEntry.modelOverride) || normalizeOptionalString(sessionEntry.providerOverride), diff --git a/src/auto-reply/reply/session-reset-prompt.test.ts b/src/auto-reply/reply/session-reset-prompt.test.ts index 2230fc3bdf5..cd6680556d9 100644 --- a/src/auto-reply/reply/session-reset-prompt.test.ts +++ b/src/auto-reply/reply/session-reset-prompt.test.ts @@ -84,6 +84,23 @@ describe("buildBareSessionResetPrompt", () => { expect(complete.prompt).toContain("Execute your Session Startup sequence now"); }); + it("does not resolve bootstrap file access when bootstrap is complete", async () => { + const workspaceDir = await makeTempWorkspace("openclaw-reset-bootstrap-complete-"); + let resolvedAccess = false; + + const complete = await resolveBareSessionResetPromptState({ + workspaceDir, + hasBootstrapFileAccess: () => { + resolvedAccess = true; + return false; + }, + }); + + expect(complete.bootstrapMode).toBe("none"); + expect(complete.shouldPrependStartupContext).toBe(true); + expect(resolvedAccess).toBe(false); + }); + it("suppresses bootstrap mode for non-primary bare reset sessions", async () => { const workspaceDir = await makeTempWorkspace("openclaw-reset-non-primary-"); await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "ritual", "utf8"); diff --git a/src/auto-reply/reply/session-reset-prompt.ts b/src/auto-reply/reply/session-reset-prompt.ts index 673b4826e4d..c232d59d21e 100644 --- a/src/auto-reply/reply/session-reset-prompt.ts +++ b/src/auto-reply/reply/session-reset-prompt.ts @@ -63,7 +63,7 @@ export async function resolveBareSessionResetPromptState(params: { nowMs?: number; isPrimaryRun?: boolean; isCanonicalWorkspace?: boolean; - hasBootstrapFileAccess?: boolean; + hasBootstrapFileAccess?: boolean | (() => boolean); }): Promise<{ bootstrapMode: BootstrapMode; prompt: string; @@ -72,13 +72,18 @@ export async function resolveBareSessionResetPromptState(params: { const bootstrapPending = params.workspaceDir ? await isWorkspaceBootstrapPending(params.workspaceDir) : false; + const hasBootstrapFileAccess = bootstrapPending + ? typeof params.hasBootstrapFileAccess === "function" + ? params.hasBootstrapFileAccess() + : (params.hasBootstrapFileAccess ?? true) + : true; const bootstrapMode = resolveBootstrapMode({ bootstrapPending, runKind: "default", isInteractiveUserFacing: true, isPrimaryRun: params.isPrimaryRun ?? true, isCanonicalWorkspace: params.isCanonicalWorkspace ?? true, - hasBootstrapFileAccess: params.hasBootstrapFileAccess ?? true, + hasBootstrapFileAccess, }); return { bootstrapMode, diff --git a/src/status/status-text.ts b/src/status/status-text.ts index b00dca5625f..530d70d51e4 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -176,6 +176,7 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise {