Followups: fix queue and GigaChat scope edge cases

This commit is contained in:
Alexander Davydov 2026-03-20 14:01:59 +03:00
parent fb08d4e434
commit 06d1824fa0
9 changed files with 204 additions and 9 deletions

View File

@ -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) {

View File

@ -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 => {

View File

@ -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);
}

View File

@ -797,6 +797,15 @@ function createRun(params: {
};
}
function createLegacyFlattenedRun(params: Parameters<typeof createRun>[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<void>();
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<void>();
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", () => {

View File

@ -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() ?? "";

View File

@ -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();

View File

@ -23,7 +23,7 @@ async function resolveApiKeyFromProfiles(params: {
provider: string;
cfg: OpenClawConfig;
agentDir?: string;
}): Promise<string | null> {
}): Promise<{ key: string; profileId: string; metadata?: Record<string, string> } | 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<string, string>;
} | 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,
};
}
}

View File

@ -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;

View File

@ -17,6 +17,8 @@ type ApiKeyStorageOptions = {
type ResolvedNonInteractiveApiKey = {
key: string;
source: "profile" | "env" | "flag";
profileId?: string;
metadata?: Record<string, string>;
};
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, {