Followups: fix queue and GigaChat scope edge cases
This commit is contained in:
parent
fb08d4e434
commit
06d1824fa0
@ -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) {
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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() ?? "";
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user