From 06d1824fa04cab75b072a12eb62c25d6c6ee164f Mon Sep 17 00:00:00 2001 From: Alexander Davydov Date: Fri, 20 Mar 2026 14:01:59 +0300 Subject: [PATCH] Followups: fix queue and GigaChat scope edge cases --- src/agents/pi-embedded-utils.test.ts | 5 ++ src/agents/pi-embedded-utils.ts | 2 +- src/auto-reply/reply/queue/enqueue.ts | 11 ++-- src/auto-reply/reply/reply-flow.test.ts | 64 +++++++++++++++++++ .../auth-choice.apply.api-providers.ts | 12 ++++ src/commands/auth-choice.test.ts | 34 ++++++++++ .../onboard-non-interactive/api-keys.ts | 23 +++++-- .../auth-choice.api-key-providers.test.ts | 45 +++++++++++++ .../local/auth-choice.api-key-providers.ts | 17 +++++ 9 files changed, 204 insertions(+), 9 deletions(-) diff --git a/src/agents/pi-embedded-utils.test.ts b/src/agents/pi-embedded-utils.test.ts index 18b2bd6a7bc..c1fbb6b767e 100644 --- a/src/agents/pi-embedded-utils.test.ts +++ b/src/agents/pi-embedded-utils.test.ts @@ -574,6 +574,11 @@ describe("stripDowngradedToolCallText", () => { text: "Just a normal response with no markers.", expected: "Just a normal response with no markers.", }, + { + name: "preserves leading whitespace when no markers are present", + text: " \n code block", + expected: " \n code block", + }, ] as const; for (const testCase of cases) { diff --git a/src/agents/pi-embedded-utils.ts b/src/agents/pi-embedded-utils.ts index f021b9f3ecb..ebe6ca74e39 100644 --- a/src/agents/pi-embedded-utils.ts +++ b/src/agents/pi-embedded-utils.ts @@ -158,7 +158,7 @@ export function stripDowngradedToolCallText(text: string): string { let cleaned = stripLeakedFunctionCallPrelude(text); if (!/\[Tool (?:Call|Result)/i.test(cleaned) && !/\[Historical context/i.test(cleaned)) { - return cleaned.trim(); + return cleaned; } const stripToolCalls = (input: string): string => { diff --git a/src/auto-reply/reply/queue/enqueue.ts b/src/auto-reply/reply/queue/enqueue.ts index 11da0db98fc..80e526b4013 100644 --- a/src/auto-reply/reply/queue/enqueue.ts +++ b/src/auto-reply/reply/queue/enqueue.ts @@ -1,6 +1,7 @@ import { createDedupeCache } from "../../../infra/dedupe.js"; import { resolveGlobalSingleton } from "../../../shared/global-singleton.js"; import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js"; +import { normalizeFollowupRun } from "../agent-runner-utils.js"; import { kickFollowupDrainIfIdle } from "./drain.js"; import { getExistingFollowupQueue, getFollowupQueue } from "./state.js"; import type { FollowupRun, QueueDedupeMode, QueueSettings } from "./types.js"; @@ -63,8 +64,10 @@ export function enqueueFollowupRun( settings: QueueSettings, dedupeMode: QueueDedupeMode = "message-id", ): boolean { + const normalizedRun = normalizeFollowupRun(run); const queue = getFollowupQueue(key, settings); - const recentMessageIdKey = dedupeMode !== "none" ? buildRecentMessageIdKey(run, key) : undefined; + const recentMessageIdKey = + dedupeMode !== "none" ? buildRecentMessageIdKey(normalizedRun, key) : undefined; if (recentMessageIdKey && RECENT_QUEUE_MESSAGE_IDS.peek(recentMessageIdKey)) { return false; } @@ -76,12 +79,12 @@ export function enqueueFollowupRun( isRunAlreadyQueued(item, items, dedupeMode === "prompt"); // Deduplicate: skip if the same message is already queued. - if (shouldSkipQueueItem({ item: run, items: queue.items, dedupe })) { + if (shouldSkipQueueItem({ item: normalizedRun, items: queue.items, dedupe })) { return false; } queue.lastEnqueuedAt = Date.now(); - queue.lastRun = run.run; + queue.lastRun = normalizedRun.run; const shouldEnqueue = applyQueueDropPolicy({ queue, @@ -91,7 +94,7 @@ export function enqueueFollowupRun( return false; } - queue.items.push(run); + queue.items.push(normalizedRun); if (recentMessageIdKey) { RECENT_QUEUE_MESSAGE_IDS.check(recentMessageIdKey); } diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 21a22faf8b2..bb7842dff09 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -797,6 +797,15 @@ function createRun(params: { }; } +function createLegacyFlattenedRun(params: Parameters[0]): FollowupRun { + const nestedRun = createRun(params); + return { + ...nestedRun, + ...nestedRun.run, + run: undefined as never, + } as unknown as FollowupRun; +} + describe("followup queue deduplication", () => { beforeEach(() => { resetRecentQueuedMessageIdDedupe(); @@ -1614,6 +1623,61 @@ describe("followup queue drain restart after idle window", () => { }); }); +describe("legacy flattened followup queue compatibility", () => { + beforeEach(() => { + resetRecentQueuedMessageIdDedupe(); + }); + + it("normalizes collect-mode legacy runs before queue bookkeeping", async () => { + const key = `test-legacy-collect-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun(key, createLegacyFlattenedRun({ prompt: "legacy item" }), settings); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + done.resolve(); + }); + + await done.promise; + + expect(calls[0]?.prompt).toContain("Queued #1\nlegacy item"); + expect(calls[0]?.run.provider).toBe("openai"); + }); + + it("normalizes summary-mode legacy runs before overflow delivery", async () => { + const key = `test-legacy-summary-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun(key, createRun({ prompt: "first" }), settings); + enqueueFollowupRun(key, createLegacyFlattenedRun({ prompt: "second" }), settings); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + done.resolve(); + }); + + await done.promise; + + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[0]?.run.provider).toBe("openai"); + }); +}); + const emptyCfg = {} as OpenClawConfig; describe("createReplyDispatcher", () => { diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 15694787aa0..3bf0c7402f4 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -202,6 +202,18 @@ export async function applyAuthChoiceApiProviders( } if (authChoice === "gigachat-basic") { + if (!gigachatBasicScope) { + gigachatBasicScope = String( + await params.prompter.select({ + message: "Select billing type", + options: [ + { value: "GIGACHAT_API_PERS", label: "Personal" }, + { value: "GIGACHAT_API_B2B", label: "Business (Prepaid)" }, + { value: "GIGACHAT_API_CORP", label: "Business (Postpaid)" }, + ], + }), + ); + } const envBaseUrl = process.env.GIGACHAT_BASE_URL?.trim() ?? ""; const envUser = process.env.GIGACHAT_USER?.trim() ?? ""; const envPassword = process.env.GIGACHAT_PASSWORD?.trim() ?? ""; diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 2df356da6f1..03fa9a0320c 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -343,11 +343,45 @@ describe("applyAuthChoice", () => { metadata: { authMode: "basic", insecureTls: "false", + scope: "GIGACHAT_API_PERS", }, }); expect((await readAuthProfile("gigachat:default"))?.keyRef).toBeUndefined(); }); + it("captures business scope for direct GigaChat Basic onboarding", async () => { + await setupTempState(); + + process.env.GIGACHAT_CREDENTIALS = "env-oauth-credentials"; // pragma: allowlist secret + delete process.env.GIGACHAT_USER; + delete process.env.GIGACHAT_PASSWORD; + delete process.env.GIGACHAT_BASE_URL; + + const select: WizardPrompter["select"] = vi.fn(async () => "GIGACHAT_API_B2B" as never); + const text = vi + .fn() + .mockResolvedValueOnce("https://gigachat.ift.sberdevices.ru/v1") + .mockResolvedValueOnce("basic-user") + .mockResolvedValueOnce("basic-pass"); + const { prompter, runtime } = createApiKeyPromptHarness({ select, text }); + + await applyAuthChoice({ + authChoice: "gigachat-basic", + config: {}, + prompter, + runtime, + setDefaultModel: false, + }); + + expect(await readAuthProfile("gigachat:default")).toMatchObject({ + metadata: { + authMode: "basic", + insecureTls: "false", + scope: "GIGACHAT_API_B2B", + }, + }); + }); + it("rejects Basic-shaped GigaChat credentials on the interactive OAuth path", async () => { await setupTempState(); diff --git a/src/commands/onboard-non-interactive/api-keys.ts b/src/commands/onboard-non-interactive/api-keys.ts index 1ee88e678dd..2257d3ab885 100644 --- a/src/commands/onboard-non-interactive/api-keys.ts +++ b/src/commands/onboard-non-interactive/api-keys.ts @@ -23,7 +23,7 @@ async function resolveApiKeyFromProfiles(params: { provider: string; cfg: OpenClawConfig; agentDir?: string; -}): Promise { +}): Promise<{ key: string; profileId: string; metadata?: Record } | null> { const store = ensureAuthProfileStore(params.agentDir); const order = resolveAuthProfileOrder({ cfg: params.cfg, @@ -42,7 +42,11 @@ async function resolveApiKeyFromProfiles(params: { agentDir: params.agentDir, }); if (resolved?.apiKey) { - return resolved.apiKey; + return { + key: resolved.apiKey, + profileId, + metadata: cred.metadata, + }; } } return null; @@ -60,7 +64,13 @@ export async function resolveNonInteractiveApiKey(params: { allowProfile?: boolean; required?: boolean; secretInputMode?: SecretInputMode; -}): Promise<{ key: string; source: NonInteractiveApiKeySource; envVarName?: string } | null> { +}): Promise<{ + key: string; + source: NonInteractiveApiKeySource; + envVarName?: string; + profileId?: string; + metadata?: Record; +} | null> { const flagKey = normalizeOptionalSecretInput(params.flagValue); const envResolved = resolveEnvApiKey(params.provider); const explicitEnvVar = params.envVarName?.trim(); @@ -112,7 +122,12 @@ export async function resolveNonInteractiveApiKey(params: { agentDir: params.agentDir, }); if (profileKey) { - return { key: profileKey, source: "profile" }; + return { + key: profileKey.key, + source: "profile", + profileId: profileKey.profileId, + metadata: profileKey.metadata, + }; } } diff --git a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts index dfc8aa9d40f..57ebc329fd9 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts @@ -49,6 +49,11 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { const resolveApiKey = vi.fn(async () => ({ key: "gigachat-oauth-credentials", source: "profile" as const, + profileId: "gigachat:default", + metadata: { + authMode: "oauth", + scope: "GIGACHAT_API_PERS", + }, })); const maybeSetResolvedApiKey = vi.fn(async (resolved, setter) => { if (resolved.source === "profile") { @@ -83,6 +88,46 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { expect(setGigachatApiKey).not.toHaveBeenCalled(); }); + it("rejects business-scoped stored profiles for GigaChat personal OAuth onboarding", async () => { + const agentDir = "/tmp/openclaw-agents/work/agent"; + const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; + const runtime: RuntimeEnv = { + error: vi.fn(), + exit: vi.fn(), + log: vi.fn(), + }; + const resolveApiKey = vi.fn(async () => ({ + key: "gigachat-business-credentials", + source: "profile" as const, + profileId: "gigachat:business", + metadata: { + authMode: "oauth", + scope: "GIGACHAT_API_B2B", + }, + })); + const maybeSetResolvedApiKey = vi.fn(); + + const result = await applySimpleNonInteractiveApiKeyChoice({ + authChoice: "gigachat-api-key", + nextConfig, + baseConfig: nextConfig, + opts: {} as never, + runtime, + agentDir, + apiKeyStorageOptions: undefined, + resolveApiKey, + maybeSetResolvedApiKey, + }); + + expect(result).toBeNull(); + expect(maybeSetResolvedApiKey).not.toHaveBeenCalled(); + expect(setGigachatApiKey).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("scoped for business billing"), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + it("accepts the generic --token input for GigaChat non-interactive OAuth", async () => { const agentDir = "/tmp/openclaw-agents/work/agent"; const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts index 8fcaf635cdb..1dd99adcd12 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts @@ -17,6 +17,8 @@ type ApiKeyStorageOptions = { type ResolvedNonInteractiveApiKey = { key: string; source: "profile" | "env" | "flag"; + profileId?: string; + metadata?: Record; }; function hadStoredGigachatBasicProfile(agentDir?: string): boolean { @@ -77,6 +79,21 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: { params.runtime.exit(1); return null; } + if ( + resolved.source === "profile" && + resolved.metadata?.scope && + resolved.metadata.scope !== "GIGACHAT_API_PERS" + ) { + params.runtime.error( + [ + `Stored GigaChat profile "${resolved.profileId ?? "gigachat profile"}" is scoped for business billing (${resolved.metadata.scope}).`, + 'Non-interactive "--gigachat-api-key" only supports personal OAuth credentials keys.', + "Use a personal-scope key/profile for this path, or run interactive onboarding for business GigaChat setup.", + ].join("\n"), + ); + params.runtime.exit(1); + return null; + } if ( !(await params.maybeSetResolvedApiKey(resolved, (value) => setGigachatApiKey(value, params.agentDir, params.apiKeyStorageOptions, {