mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(gateway): honor session title model routing (#111757)
Route automatic dashboard titles through the effective session model and auth profile while preserving explicit cross-provider utility ownership and the existing generic title fallback.
This commit is contained in:
@@ -57,7 +57,7 @@ Related model-config surfaces:
|
||||
|
||||
- `agents.defaults.models` stores aliases and per-model settings. Adding an entry does not restrict model overrides.
|
||||
- `agents.defaults.modelPolicy.allow` is the optional override allowlist. Use exact refs or trailing prefix wildcards such as `provider/*` and `provider/namespace/*`; omit it or set `[]` to allow any model. Per-agent `agents.list[].modelPolicy.allow` replaces the default policy for that agent.
|
||||
- `agents.defaults.utilityModel` is an optional lower-cost model for short internal tasks such as generated dashboard session titles, supported channel thread/topic titles, and progress narration. Per-agent `agents.list[].utilityModel` overrides it. When unset, OpenClaw uses the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`), otherwise the agent's primary model; set it to an empty string to disable utility routing. Generated title tasks retry once with the primary model when a distinct utility model fails. Utility tasks are separate model calls and may send bounded task content to the selected model provider.
|
||||
- `agents.defaults.utilityModel` is an optional lower-cost model for short internal tasks such as generated dashboard session titles, supported channel thread/topic titles, and progress narration. Per-agent `agents.list[].utilityModel` overrides it. When unset, OpenClaw uses the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`), otherwise the agent's primary model; set it to an empty string to disable utility routing. Generated titles retry once with the primary model when a distinct utility model fails. For dashboard titles, automatic utility derivation and the regular fallback follow the effective session provider and auth profile; an explicit utility model keeps its configured provider/auth. An empty utility model skips only the alternate small-model route, not dashboard title generation. Utility tasks are separate model calls and may send bounded task content to the selected model provider.
|
||||
- `agents.defaults.imageModel` is used only when the primary model cannot accept images.
|
||||
- `agents.defaults.pdfModel` is used by the `pdf` tool. If unset, the tool falls back to `imageModel`, then the resolved session/default model.
|
||||
- `agents.defaults.imageGenerationModel`, `musicGenerationModel`, and `videoGenerationModel` back the shared media-generation tools. If unset, each tool infers an auth-backed provider default: current default provider first, then the remaining registered providers for that capability in provider-id order. Set `agents.defaults.mediaGenerationAutoProviderFallback: false` to disable that cross-provider inference while keeping explicit fallbacks.
|
||||
|
||||
@@ -401,7 +401,7 @@ Time format in system prompt. Default: `auto` (OS preference).
|
||||
- `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
|
||||
- String form sets only the primary model.
|
||||
- Object form sets primary plus ordered failover models.
|
||||
- `utilityModel`: optional `provider/model` ref or alias for short internal tasks. It currently powers generated Control UI session titles, Telegram DM topic titles, Discord auto-thread titles, and [progress-draft narration](/concepts/progress-drafts#narrated-status). When unset, OpenClaw derives the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`); title tasks otherwise use the agent's primary model, and narration stays off. If a distinct utility model cannot prepare or complete a generated title, OpenClaw retries that title once with the primary model. Set `utilityModel: ""` to disable utility routing entirely. `agents.list[].utilityModel` overrides the default (an empty per-agent value disables it for that agent), and an operation-specific model override wins over both. Utility tasks make separate model calls and send task-specific content to the selected model provider. Dashboard title generation sends at most the first 1,000 characters of the first non-command message; narration sends the inbound request plus compact redacted tool summaries. Choose a provider that matches your cost and data-handling requirements.
|
||||
- `utilityModel`: optional `provider/model` ref or alias for short internal tasks. It currently powers generated Control UI session titles, Telegram DM topic titles, Discord auto-thread titles, and [progress-draft narration](/concepts/progress-drafts#narrated-status). When unset, OpenClaw derives the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`); title tasks otherwise use the agent's primary model, and narration stays off. If a distinct utility model cannot prepare or complete a generated title, OpenClaw retries that title once with the primary model. For dashboard titles, automatic utility derivation and the regular fallback use the effective session provider and auth profile; an explicit utility model keeps its configured provider/auth. Set `utilityModel: ""` to skip the alternate utility route; dashboard title generation still proceeds directly to the regular session model. `agents.list[].utilityModel` overrides the default, and an operation-specific model override wins over both. Utility tasks make separate model calls and send task-specific content to the selected model provider. Dashboard title generation sends at most the first 1,000 characters of the first non-command message; narration sends the inbound request plus compact redacted tool summaries. Choose a provider that matches your cost and data-handling requirements.
|
||||
- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
|
||||
- Used by the `image` tool path as its vision-model config when the active model cannot accept images. Native-vision models receive loaded image bytes directly instead.
|
||||
- Also used as fallback routing when the selected/default model cannot accept image input.
|
||||
@@ -1048,7 +1048,7 @@ for provider examples and precedence.
|
||||
- `id`: stable agent id (required).
|
||||
- `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
|
||||
- `model`: string form sets a strict per-agent primary with no model fallback; object form `{ primary }` is also strict unless you add `fallbacks`. Use `{ primary, fallbacks: [...] }` to opt that agent into fallback, or `{ primary, fallbacks: [] }` to make strict behavior explicit. Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`.
|
||||
- `utilityModel`: optional per-agent override for short internal tasks such as generated session and thread titles. Falls back to `agents.defaults.utilityModel`, then the primary provider's declared small-model default, then this agent's primary model. An empty string disables utility routing for this agent.
|
||||
- `utilityModel`: optional per-agent override for short internal tasks such as generated session and thread titles. Falls back to `agents.defaults.utilityModel`, then the effective session provider's declared small-model default. Dashboard titles retry once with the effective regular session model. An empty string skips the alternate utility route for this agent without disabling dashboard title generation.
|
||||
- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog.
|
||||
- `tts`: optional per-agent text-to-speech overrides. The block deep-merges over `messages.tts`, so keep shared provider credentials and fallback policy in `messages.tts` and set only persona-specific values such as provider, voice, model, style, or auto mode here.
|
||||
- `skills`: optional per-agent skill allowlist. If omitted, the agent inherits `agents.defaults.skills` when set; an explicit list replaces defaults instead of merging, and `[]` means no skills.
|
||||
|
||||
@@ -100,15 +100,20 @@ describe("resolveUtilityModelRefForAgent", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers a caller-resolved primary provider over re-derivation", () => {
|
||||
it("prefers caller-resolved session provider and profile context", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { model: "anthropic/claude-fable-5@personal" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
resolveUtilityModelRefForAgent({
|
||||
cfg: {} as OpenClawConfig,
|
||||
cfg,
|
||||
agentId: "main",
|
||||
primaryProvider: "OpenAI",
|
||||
primaryModelRef: "openai/gpt-5.5@work",
|
||||
metadataSnapshot,
|
||||
}),
|
||||
).toBe("openai/gpt-5.6-luna");
|
||||
).toBe("openai/gpt-5.6-luna@work");
|
||||
});
|
||||
|
||||
it("returns undefined for providers without a declared default", () => {
|
||||
|
||||
@@ -65,14 +65,17 @@ function resolveProviderDefaultUtilityModelRef(params: {
|
||||
|
||||
/**
|
||||
* The utility model ref to use for the agent, or undefined when utility
|
||||
* routing is disabled or no default exists. Derivation uses the agent's
|
||||
* primary provider, so usable auth is already established by construction.
|
||||
* routing is disabled or no default exists. Callers with a session-specific
|
||||
* selection pass both primary fields so automatic routing keeps that session's
|
||||
* provider and auth owner.
|
||||
*/
|
||||
export function resolveUtilityModelRefForAgent(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
/** Pass when the caller already resolved the primary provider. */
|
||||
primaryProvider?: string;
|
||||
/** Pass with primaryProvider to carry a session-specific auth profile. */
|
||||
primaryModelRef?: string;
|
||||
metadataSnapshot?: PluginMetadataSnapshot;
|
||||
}): string | undefined {
|
||||
const setting = readUtilityModelSetting(params.cfg, params.agentId);
|
||||
@@ -99,7 +102,10 @@ export function resolveUtilityModelRefForAgent(params: {
|
||||
// The derived default shares the primary's provider, so a trailing auth
|
||||
// profile on the primary ref must carry over; otherwise profile-isolated
|
||||
// setups would route utility calls through default credentials.
|
||||
const primaryRef = resolveAgentEffectiveModelPrimary(params.cfg, params.agentId) ?? "";
|
||||
const primaryRef =
|
||||
params.primaryModelRef?.trim() ||
|
||||
resolveAgentEffectiveModelPrimary(params.cfg, params.agentId) ||
|
||||
"";
|
||||
const primaryProfile = primaryRef ? splitTrailingAuthProfile(primaryRef)?.profile : undefined;
|
||||
return primaryProfile ? `${derived}@${primaryProfile}` : derived;
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const completeWithPreparedSimpleCompletionModel = vi.hoisted(() => vi.fn());
|
||||
const logVerbose = vi.hoisted(() => vi.fn());
|
||||
const prepareSimpleCompletionModelForAgent = vi.hoisted(() => vi.fn());
|
||||
const resolveSimpleCompletionSelectionForAgent = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../agents/simple-completion-runtime.js", () => ({
|
||||
completeWithPreparedSimpleCompletionModel,
|
||||
prepareSimpleCompletionModelForAgent,
|
||||
resolveSimpleCompletionSelectionForAgent,
|
||||
}));
|
||||
|
||||
vi.mock("../../globals.js", () => ({ logVerbose }));
|
||||
|
||||
import { generateConversationLabel } from "./conversation-label-generator.js";
|
||||
import {
|
||||
generateConversationLabel,
|
||||
generateConversationLabelWithFallback,
|
||||
} from "./conversation-label-generator.js";
|
||||
|
||||
function firstCompletionArgs() {
|
||||
const call = completeWithPreparedSimpleCompletionModel.mock.calls.at(0);
|
||||
@@ -321,3 +326,305 @@ describe("generateConversationLabel", () => {
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateConversationLabelWithFallback", () => {
|
||||
const params = {
|
||||
userMessage: "Need help with invoices",
|
||||
prompt: "Generate a label",
|
||||
cfg: {},
|
||||
agentId: "billing",
|
||||
utilityModelRef: "openai/gpt-mini@work",
|
||||
regularModelRef: "openai/gpt-main@work",
|
||||
preferredProfile: "work",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
completeWithPreparedSimpleCompletionModel.mockReset();
|
||||
logVerbose.mockReset();
|
||||
prepareSimpleCompletionModelForAgent.mockReset();
|
||||
resolveSimpleCompletionSelectionForAgent.mockReset();
|
||||
resolveSimpleCompletionSelectionForAgent.mockImplementation(({ modelRef }) => {
|
||||
const [model, profileId] = modelRef.split("@");
|
||||
const slash = model.indexOf("/");
|
||||
return {
|
||||
provider: model.slice(0, slash),
|
||||
modelId: model.slice(slash + 1),
|
||||
profileId,
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
};
|
||||
});
|
||||
prepareSimpleCompletionModelForAgent.mockImplementation(async ({ modelRef }) => {
|
||||
const [model] = modelRef.split("@");
|
||||
const slash = model.indexOf("/");
|
||||
return {
|
||||
selection: {
|
||||
provider: model.slice(0, slash),
|
||||
modelId: model.slice(slash + 1),
|
||||
profileId: "work",
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
},
|
||||
model: {
|
||||
provider: model.slice(0, slash),
|
||||
id: model.slice(slash + 1),
|
||||
maxTokens: 8192,
|
||||
},
|
||||
auth: { apiKey: "resolved-key", mode: "api-key" },
|
||||
};
|
||||
});
|
||||
completeWithPreparedSimpleCompletionModel.mockResolvedValue({
|
||||
content: [{ type: "text", text: "Utility title" }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses the utility candidate once with the selected auth owner", async () => {
|
||||
await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Utility title");
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce();
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
agentId: "billing",
|
||||
agentDir: undefined,
|
||||
modelRef: "openai/gpt-mini@work",
|
||||
bindAuthOwner: true,
|
||||
useAsyncModelResolution: true,
|
||||
allowMissingApiKeyModes: ["aws-sdk"],
|
||||
});
|
||||
});
|
||||
|
||||
it("locks an inherited profile onto a same-provider utility ref", async () => {
|
||||
await expect(
|
||||
generateConversationLabelWithFallback({
|
||||
...params,
|
||||
utilityModelRef: "openai/gpt-mini",
|
||||
}),
|
||||
).resolves.toBe("Utility title");
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toMatchObject({
|
||||
modelRef: "openai/gpt-mini@work",
|
||||
bindAuthOwner: true,
|
||||
});
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"preferredProfile",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not force the regular profile onto a cross-provider utility model", async () => {
|
||||
await expect(
|
||||
generateConversationLabelWithFallback({
|
||||
...params,
|
||||
utilityModelRef: "anthropic/claude-haiku-4-5",
|
||||
}),
|
||||
).resolves.toBe("Utility title");
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toEqual({
|
||||
cfg: {},
|
||||
agentId: "billing",
|
||||
agentDir: undefined,
|
||||
modelRef: "anthropic/claude-haiku-4-5",
|
||||
bindAuthOwner: true,
|
||||
useAsyncModelResolution: true,
|
||||
allowMissingApiKeyModes: ["aws-sdk"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit profiles across logical providers sharing one runtime", async () => {
|
||||
resolveSimpleCompletionSelectionForAgent.mockImplementation(({ modelRef }) => ({
|
||||
provider: modelRef.startsWith("anthropic/") ? "anthropic" : "openai",
|
||||
runtimeProvider: "openai",
|
||||
modelId: modelRef.split("/").slice(1).join("/"),
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
generateConversationLabelWithFallback({
|
||||
...params,
|
||||
utilityModelRef: "anthropic/claude-haiku-4-5",
|
||||
}),
|
||||
).resolves.toBe("Utility title");
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"preferredProfile",
|
||||
);
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]?.modelRef).toBe(
|
||||
"anthropic/claude-haiku-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when utility preparation fails", async () => {
|
||||
prepareSimpleCompletionModelForAgent.mockResolvedValueOnce({ error: "missing auth" });
|
||||
completeWithPreparedSimpleCompletionModel.mockResolvedValueOnce({
|
||||
content: [{ type: "text", text: "Regular title" }],
|
||||
});
|
||||
|
||||
await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title");
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2);
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[1]?.[0]?.modelRef).toBe(
|
||||
"openai/gpt-main@work",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "error stop reason",
|
||||
first: { content: [], stopReason: "error", errorMessage: "utility failed" },
|
||||
},
|
||||
{ name: "empty output", first: { content: [] } },
|
||||
])("falls back after utility $name", async ({ first }) => {
|
||||
completeWithPreparedSimpleCompletionModel
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] });
|
||||
|
||||
await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title");
|
||||
|
||||
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back when utility output fails operation-specific normalization", async () => {
|
||||
completeWithPreparedSimpleCompletionModel
|
||||
.mockResolvedValueOnce({ content: [{ type: "text", text: "Title:" }] })
|
||||
.mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] });
|
||||
|
||||
await expect(
|
||||
generateConversationLabelWithFallback({
|
||||
...params,
|
||||
normalizeLabel: (label) => (label === "Title:" ? null : label),
|
||||
}),
|
||||
).resolves.toBe("Regular title");
|
||||
});
|
||||
|
||||
it("falls back after a utility completion exception", async () => {
|
||||
completeWithPreparedSimpleCompletionModel
|
||||
.mockRejectedValueOnce(new Error("transport failed"))
|
||||
.mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] });
|
||||
|
||||
await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title");
|
||||
});
|
||||
|
||||
it("falls back after the utility attempt times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
completeWithPreparedSimpleCompletionModel
|
||||
.mockImplementationOnce(
|
||||
({ options }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener("abort", () => reject(new Error("aborted")));
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] });
|
||||
|
||||
const generated = generateConversationLabelWithFallback(params);
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
await expect(generated).resolves.toBe("Regular title");
|
||||
});
|
||||
|
||||
it("returns null when both explicit candidates fail", async () => {
|
||||
prepareSimpleCompletionModelForAgent
|
||||
.mockResolvedValueOnce({ error: "utility auth failed" })
|
||||
.mockResolvedValueOnce({ error: "regular auth failed" });
|
||||
|
||||
await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull();
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2);
|
||||
expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a regular candidate that resolves to the same model and profile", async () => {
|
||||
resolveSimpleCompletionSelectionForAgent.mockReturnValue({
|
||||
provider: "openai",
|
||||
modelId: "same-model",
|
||||
profileId: "work",
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] });
|
||||
|
||||
await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull();
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce();
|
||||
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("deduplicates candidates after asynchronous preparation resolves them identically", async () => {
|
||||
prepareSimpleCompletionModelForAgent.mockResolvedValue({
|
||||
selection: {
|
||||
provider: "openai",
|
||||
modelId: "resolved-same-model",
|
||||
profileId: "work",
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
},
|
||||
model: { provider: "openai", id: "resolved-same-model", maxTokens: 8192 },
|
||||
auth: { apiKey: "resolved-key", mode: "api-key" },
|
||||
});
|
||||
completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] });
|
||||
|
||||
await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull();
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2);
|
||||
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("inherits the regular profile for unresolved same-provider utility refs", async () => {
|
||||
resolveSimpleCompletionSelectionForAgent.mockReturnValue(null);
|
||||
|
||||
await expect(
|
||||
generateConversationLabelWithFallback({
|
||||
...params,
|
||||
utilityModelRef: "openai/gpt-mini",
|
||||
}),
|
||||
).resolves.toBe("Utility title");
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toMatchObject({
|
||||
modelRef: "openai/gpt-mini@work",
|
||||
bindAuthOwner: true,
|
||||
});
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"preferredProfile",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not inherit the regular profile for unresolved cross-provider utility refs", async () => {
|
||||
resolveSimpleCompletionSelectionForAgent.mockReturnValue(null);
|
||||
|
||||
await expect(
|
||||
generateConversationLabelWithFallback({
|
||||
...params,
|
||||
utilityModelRef: "anthropic/claude-haiku-4-5",
|
||||
}),
|
||||
).resolves.toBe("Utility title");
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"preferredProfile",
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates identical raw refs when selection resolution is unavailable", async () => {
|
||||
resolveSimpleCompletionSelectionForAgent.mockReturnValue(null);
|
||||
completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] });
|
||||
|
||||
await expect(
|
||||
generateConversationLabelWithFallback({
|
||||
...params,
|
||||
utilityModelRef: params.regularModelRef,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the regular candidate directly when no utility model is available", async () => {
|
||||
const { utilityModelRef: _utilityModelRef, ...regularOnlyParams } = params;
|
||||
|
||||
await expect(generateConversationLabelWithFallback(regularOnlyParams)).resolves.toBe(
|
||||
"Utility title",
|
||||
);
|
||||
|
||||
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce();
|
||||
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]?.modelRef).toBe(
|
||||
"openai/gpt-main@work",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Generates short labels for sessions from conversation context.
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js";
|
||||
import {
|
||||
completeWithPreparedSimpleCompletionModel,
|
||||
prepareSimpleCompletionModelForAgent,
|
||||
resolveSimpleCompletionSelectionForAgent,
|
||||
} from "../../agents/simple-completion-runtime.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
@@ -19,6 +21,12 @@ const TIMEOUT_MS = 15_000;
|
||||
type PreparedLabelModel = Awaited<ReturnType<typeof prepareSimpleCompletionModelForAgent>>;
|
||||
type ReadyLabelModel = Extract<PreparedLabelModel, { model: unknown }>;
|
||||
type LabelModelPhase = "utility" | "primary fallback";
|
||||
type ConversationLabelAttempt = {
|
||||
modelRef?: string;
|
||||
useUtilityModel?: boolean;
|
||||
preferredProfile?: string;
|
||||
bindAuthOwner?: boolean;
|
||||
};
|
||||
|
||||
/** Inputs for generating a short conversation label from the configured utility model. */
|
||||
export type ConversationLabelParams = {
|
||||
@@ -30,6 +38,13 @@ export type ConversationLabelParams = {
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
type ConversationLabelFallbackParams = ConversationLabelParams & {
|
||||
utilityModelRef?: string;
|
||||
regularModelRef: string;
|
||||
preferredProfile?: string;
|
||||
normalizeLabel?: (label: string) => string | null;
|
||||
};
|
||||
|
||||
function isTextContentBlock(block: { type: string }): block is TextContent {
|
||||
return block.type === "text";
|
||||
}
|
||||
@@ -48,6 +63,12 @@ function extractSimpleCompletionError(result: {
|
||||
return result.errorMessage?.trim() || "unknown error";
|
||||
}
|
||||
|
||||
function resolveMaxLabelLength(value: number | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: DEFAULT_MAX_LABEL_LENGTH;
|
||||
}
|
||||
|
||||
function logLabelFailure(phase: LabelModelPhase, message: string): void {
|
||||
const prefix = phase === "utility" ? "" : `${phase} `;
|
||||
logVerbose(`conversation-label-generator: ${prefix}${message}`);
|
||||
@@ -57,7 +78,7 @@ async function prepareLabelModel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
agentDir?: string;
|
||||
useUtilityModel: boolean;
|
||||
attempt: ConversationLabelAttempt;
|
||||
phase: LabelModelPhase;
|
||||
}): Promise<PreparedLabelModel | null> {
|
||||
try {
|
||||
@@ -65,7 +86,16 @@ async function prepareLabelModel(params: {
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
agentDir: params.agentDir,
|
||||
useUtilityModel: params.useUtilityModel,
|
||||
...(params.attempt.modelRef ? { modelRef: params.attempt.modelRef } : {}),
|
||||
...(params.attempt.useUtilityModel !== undefined
|
||||
? { useUtilityModel: params.attempt.useUtilityModel }
|
||||
: {}),
|
||||
...(params.attempt.preferredProfile
|
||||
? { preferredProfile: params.attempt.preferredProfile }
|
||||
: {}),
|
||||
...(params.attempt.bindAuthOwner !== undefined
|
||||
? { bindAuthOwner: params.attempt.bindAuthOwner }
|
||||
: {}),
|
||||
useAsyncModelResolution: true,
|
||||
allowMissingApiKeyModes: ["aws-sdk"],
|
||||
});
|
||||
@@ -95,6 +125,50 @@ function selectedLabelModelsMatch(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAttemptSelection(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
agentDir?: string;
|
||||
attempt: ConversationLabelAttempt;
|
||||
}) {
|
||||
return resolveSimpleCompletionSelectionForAgent({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
agentDir: params.agentDir,
|
||||
...(params.attempt.modelRef ? { modelRef: params.attempt.modelRef } : {}),
|
||||
...(params.attempt.useUtilityModel !== undefined
|
||||
? { useUtilityModel: params.attempt.useUtilityModel }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRawModelProvider(modelRef: string | undefined): string | undefined {
|
||||
const model = splitTrailingAuthProfile(modelRef?.trim() ?? "").model;
|
||||
const separator = model.indexOf("/");
|
||||
const provider = separator > 0 ? model.slice(0, separator).trim().toLowerCase() : "";
|
||||
return provider || undefined;
|
||||
}
|
||||
|
||||
function resolveAttemptKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
agentDir?: string;
|
||||
attempt: ConversationLabelAttempt;
|
||||
}): string {
|
||||
const selection = resolveAttemptSelection(params);
|
||||
if (selection) {
|
||||
return [
|
||||
"resolved",
|
||||
selection.provider,
|
||||
selection.runtimeProvider ?? "",
|
||||
selection.modelId,
|
||||
selection.profileId ?? params.attempt.preferredProfile ?? "",
|
||||
].join("\0");
|
||||
}
|
||||
const rawRef = splitTrailingAuthProfile(params.attempt.modelRef?.trim() ?? "");
|
||||
return ["raw", rawRef.model, rawRef.profile ?? params.attempt.preferredProfile ?? ""].join("\0");
|
||||
}
|
||||
|
||||
async function completeLabel(params: {
|
||||
prepared: ReadyLabelModel;
|
||||
cfg: OpenClawConfig;
|
||||
@@ -142,12 +216,7 @@ async function completeLabel(params: {
|
||||
.map((block) => block.text)
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return truncateUtf16Safe(text, params.maxLength) || null;
|
||||
return text ? truncateUtf16Safe(text, params.maxLength) || null : null;
|
||||
} catch (err) {
|
||||
logLabelFailure(params.phase, `completion failed: ${String(err)}`);
|
||||
return null;
|
||||
@@ -161,18 +230,13 @@ export async function generateConversationLabel(
|
||||
params: ConversationLabelParams,
|
||||
): Promise<string | null> {
|
||||
const { userMessage, prompt, cfg, agentId, agentDir } = params;
|
||||
const maxLength =
|
||||
typeof params.maxLength === "number" &&
|
||||
Number.isFinite(params.maxLength) &&
|
||||
params.maxLength > 0
|
||||
? Math.floor(params.maxLength)
|
||||
: DEFAULT_MAX_LABEL_LENGTH;
|
||||
const maxLength = resolveMaxLabelLength(params.maxLength);
|
||||
const resolvedAgentId = agentId ?? resolveDefaultAgentId(cfg);
|
||||
const utilityPrepared = await prepareLabelModel({
|
||||
cfg,
|
||||
agentId: resolvedAgentId,
|
||||
agentDir,
|
||||
useUtilityModel: true,
|
||||
attempt: { useUtilityModel: true },
|
||||
phase: "utility",
|
||||
});
|
||||
const utilityCompletionAttempted = Boolean(utilityPrepared && !("error" in utilityPrepared));
|
||||
@@ -194,7 +258,7 @@ export async function generateConversationLabel(
|
||||
cfg,
|
||||
agentId: resolvedAgentId,
|
||||
agentDir,
|
||||
useUtilityModel: false,
|
||||
attempt: { useUtilityModel: false },
|
||||
phase: "primary fallback",
|
||||
});
|
||||
if (
|
||||
@@ -213,3 +277,94 @@ export async function generateConversationLabel(
|
||||
phase: "primary fallback",
|
||||
});
|
||||
}
|
||||
|
||||
/** Tries an explicit utility model once, then the regular model once when needed. */
|
||||
export async function generateConversationLabelWithFallback(
|
||||
params: ConversationLabelFallbackParams,
|
||||
): Promise<string | null> {
|
||||
const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg);
|
||||
const regularAttempt: ConversationLabelAttempt = {
|
||||
modelRef: params.regularModelRef,
|
||||
...(params.preferredProfile ? { preferredProfile: params.preferredProfile } : {}),
|
||||
bindAuthOwner: true,
|
||||
};
|
||||
const utilityRef = params.utilityModelRef?.trim();
|
||||
let utilityAttempt: ConversationLabelAttempt | undefined;
|
||||
if (utilityRef) {
|
||||
const candidate: ConversationLabelAttempt = { modelRef: utilityRef, bindAuthOwner: true };
|
||||
const utilitySelection = resolveAttemptSelection({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
agentDir: params.agentDir,
|
||||
attempt: candidate,
|
||||
});
|
||||
const regularSelection = resolveAttemptSelection({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
agentDir: params.agentDir,
|
||||
attempt: regularAttempt,
|
||||
});
|
||||
const utilityAuthProvider = utilitySelection?.provider ?? resolveRawModelProvider(utilityRef);
|
||||
const regularAuthProvider =
|
||||
regularSelection?.provider ?? resolveRawModelProvider(params.regularModelRef);
|
||||
const utilityRawProfile = splitTrailingAuthProfile(utilityRef).profile;
|
||||
const inheritsRegularProfile =
|
||||
params.preferredProfile &&
|
||||
!utilitySelection?.profileId &&
|
||||
!utilityRawProfile &&
|
||||
utilityAuthProvider &&
|
||||
utilityAuthProvider === regularAuthProvider;
|
||||
utilityAttempt = inheritsRegularProfile
|
||||
? { modelRef: `${utilityRef}@${params.preferredProfile}`, bindAuthOwner: true }
|
||||
: candidate;
|
||||
}
|
||||
const attempts: ConversationLabelAttempt[] = [
|
||||
...(utilityAttempt ? [utilityAttempt] : []),
|
||||
regularAttempt,
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
const maxLength = resolveMaxLabelLength(params.maxLength);
|
||||
let previousCompletedModel: PreparedLabelModel | null = null;
|
||||
for (const attempt of attempts) {
|
||||
const key = resolveAttemptKey({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
agentDir: params.agentDir,
|
||||
attempt,
|
||||
});
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
const phase = attempt === regularAttempt ? "primary fallback" : "utility";
|
||||
const prepared = await prepareLabelModel({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
agentDir: params.agentDir,
|
||||
attempt,
|
||||
phase,
|
||||
});
|
||||
if (!prepared || "error" in prepared) {
|
||||
continue;
|
||||
}
|
||||
if (previousCompletedModel && selectedLabelModelsMatch(previousCompletedModel, prepared)) {
|
||||
continue;
|
||||
}
|
||||
previousCompletedModel = prepared;
|
||||
const label = await completeLabel({
|
||||
prepared,
|
||||
cfg: params.cfg,
|
||||
userMessage: params.userMessage,
|
||||
prompt: params.prompt,
|
||||
maxLength,
|
||||
phase,
|
||||
});
|
||||
if (label) {
|
||||
const normalized = params.normalizeLabel ? params.normalizeLabel(label) : label;
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
// Dashboard title tests cover eligibility, normalization, and guarded persistence.
|
||||
// Dashboard title tests cover eligibility, routing, normalization, and guarded persistence.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const generateConversationLabel = vi.hoisted(() => vi.fn());
|
||||
const generateConversationLabelWithFallback = vi.hoisted(() => vi.fn());
|
||||
const resolveUtilityModelRefForAgent = vi.hoisted(() => vi.fn());
|
||||
const updateSessionEntry = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../agents/utility-model.js", () => ({ resolveUtilityModelRefForAgent }));
|
||||
vi.mock("../auto-reply/reply/conversation-label-generator.js", () => ({
|
||||
generateConversationLabel,
|
||||
generateConversationLabelWithFallback,
|
||||
}));
|
||||
vi.mock("../config/sessions/session-accessor.js", () => ({ updateSessionEntry }));
|
||||
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { maybeGenerateDashboardSessionTitle } from "./dashboard-session-title.js";
|
||||
|
||||
const cfg = {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
|
||||
} as OpenClawConfig;
|
||||
const baseEntry: SessionEntry = {
|
||||
sessionId: "session-1",
|
||||
updatedAt: 1,
|
||||
@@ -19,7 +25,7 @@ const baseEntry: SessionEntry = {
|
||||
|
||||
function titleParams(entry: SessionEntry | undefined = baseEntry) {
|
||||
return {
|
||||
cfg: {},
|
||||
cfg,
|
||||
agentId: "main",
|
||||
entry,
|
||||
sessionId: "session-1",
|
||||
@@ -38,21 +44,32 @@ function mockSessionUpdate(current: SessionEntry): void {
|
||||
|
||||
describe("maybeGenerateDashboardSessionTitle", () => {
|
||||
beforeEach(() => {
|
||||
generateConversationLabel.mockReset();
|
||||
generateConversationLabelWithFallback.mockReset();
|
||||
resolveUtilityModelRefForAgent.mockReset();
|
||||
updateSessionEntry.mockReset();
|
||||
generateConversationLabel.mockResolvedValue("Release Planning");
|
||||
generateConversationLabelWithFallback.mockResolvedValue("Release Planning");
|
||||
resolveUtilityModelRefForAgent.mockReturnValue("openai/gpt-5.6-luna");
|
||||
mockSessionUpdate(baseEntry);
|
||||
});
|
||||
|
||||
it("generates and persists a dashboard display name", async () => {
|
||||
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true);
|
||||
|
||||
expect(generateConversationLabel).toHaveBeenCalledWith({
|
||||
expect(resolveUtilityModelRefForAgent).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
primaryProvider: "openai",
|
||||
primaryModelRef: "openai/gpt-5.5",
|
||||
});
|
||||
expect(generateConversationLabelWithFallback).toHaveBeenCalledWith({
|
||||
userMessage: "Help me plan the release",
|
||||
prompt:
|
||||
"Generate a concise session title (3-6 words, max 60 characters) from the user's first message. Use the same language as the message. No emoji. Return only the title.",
|
||||
cfg: {},
|
||||
cfg,
|
||||
agentId: "main",
|
||||
utilityModelRef: "openai/gpt-5.6-luna",
|
||||
regularModelRef: "openai/gpt-5.5",
|
||||
normalizeLabel: expect.any(Function),
|
||||
maxLength: 60,
|
||||
});
|
||||
expect(updateSessionEntry).toHaveBeenCalledWith(
|
||||
@@ -65,22 +82,77 @@ describe("maybeGenerateDashboardSessionTitle", () => {
|
||||
{ requireWriteSuccess: true },
|
||||
);
|
||||
const update = updateSessionEntry.mock.calls[0]?.[1];
|
||||
expect(await update?.({ ...baseEntry })).toEqual({
|
||||
displayName: "Release Planning",
|
||||
});
|
||||
expect(await update?.({ ...baseEntry })).toEqual({ displayName: "Release Planning" });
|
||||
});
|
||||
|
||||
it("does not treat dashboard sender identity as an explicit session name", async () => {
|
||||
const senderEntry: SessionEntry = {
|
||||
it("routes both attempts through the effective session model and auth profile", async () => {
|
||||
const entry = {
|
||||
...baseEntry,
|
||||
origin: { label: "Peter", provider: "webchat", chatType: "direct" },
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-fable-5",
|
||||
authProfileOverride: "work",
|
||||
};
|
||||
mockSessionUpdate(senderEntry);
|
||||
resolveUtilityModelRefForAgent.mockReturnValue("anthropic/claude-haiku-4-5@work");
|
||||
mockSessionUpdate(entry);
|
||||
|
||||
await expect(maybeGenerateDashboardSessionTitle(titleParams(senderEntry))).resolves.toBe(true);
|
||||
await expect(maybeGenerateDashboardSessionTitle(titleParams(entry))).resolves.toBe(true);
|
||||
|
||||
const update = updateSessionEntry.mock.calls[0]?.[1];
|
||||
expect(await update?.({ ...senderEntry })).toEqual({ displayName: "Release Planning" });
|
||||
expect(resolveUtilityModelRefForAgent).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
primaryProvider: "anthropic",
|
||||
primaryModelRef: "anthropic/claude-fable-5@work",
|
||||
});
|
||||
expect(generateConversationLabelWithFallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
utilityModelRef: "anthropic/claude-haiku-4-5@work",
|
||||
regularModelRef: "anthropic/claude-fable-5@work",
|
||||
preferredProfile: "work",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the configured primary auth profile for explicit utility models", async () => {
|
||||
const profiledCfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5@personal" },
|
||||
utilityModel: "openai/gpt-5.6-luna",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
resolveUtilityModelRefForAgent.mockReturnValue("openai/gpt-5.6-luna");
|
||||
|
||||
await expect(
|
||||
maybeGenerateDashboardSessionTitle({ ...titleParams(), cfg: profiledCfg }),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(generateConversationLabelWithFallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
utilityModelRef: "openai/gpt-5.6-luna",
|
||||
regularModelRef: "openai/gpt-5.5@personal",
|
||||
preferredProfile: "personal",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("goes directly to the regular model when utility routing is disabled", async () => {
|
||||
resolveUtilityModelRefForAgent.mockReturnValue(undefined);
|
||||
|
||||
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true);
|
||||
|
||||
expect(generateConversationLabelWithFallback).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ utilityModelRef: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats creator attribution as metadata rather than an explicit title", async () => {
|
||||
const entry = { ...baseEntry, origin: { label: "Peter" } };
|
||||
mockSessionUpdate(entry);
|
||||
|
||||
await expect(maybeGenerateDashboardSessionTitle(titleParams(entry))).resolves.toBe(true);
|
||||
|
||||
expect(generateConversationLabelWithFallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps utility title prompt input on a UTF-16 boundary", async () => {
|
||||
@@ -91,14 +163,16 @@ describe("maybeGenerateDashboardSessionTitle", () => {
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(generateConversationLabel.mock.calls[0]?.[0]?.userMessage).toBe("m".repeat(999));
|
||||
expect(generateConversationLabelWithFallback.mock.calls[0]?.[0]?.userMessage).toBe(
|
||||
"m".repeat(999),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['```text\n"Release Planning"\n```', "Release Planning"],
|
||||
["Title: Release planning ", "Release planning"],
|
||||
])("normalizes generated title wrappers", async (generated, expected) => {
|
||||
generateConversationLabel.mockResolvedValue(generated);
|
||||
generateConversationLabelWithFallback.mockResolvedValue(generated);
|
||||
|
||||
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true);
|
||||
|
||||
@@ -107,7 +181,7 @@ describe("maybeGenerateDashboardSessionTitle", () => {
|
||||
});
|
||||
|
||||
it("keeps persisted titles on a UTF-16 boundary", async () => {
|
||||
generateConversationLabel.mockResolvedValue(`${"a".repeat(59)}🚀tail`);
|
||||
generateConversationLabelWithFallback.mockResolvedValue(`${"a".repeat(59)}🚀tail`);
|
||||
|
||||
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true);
|
||||
|
||||
@@ -119,15 +193,17 @@ describe("maybeGenerateDashboardSessionTitle", () => {
|
||||
["non-dashboard session", { sessionKey: "agent:main:main" }],
|
||||
["slash command", { userMessage: "/status" }],
|
||||
["manual label", { entry: { ...baseEntry, label: "My release" } }],
|
||||
["manual display name", { entry: { ...baseEntry, displayName: "My release" } }],
|
||||
["channel subject", { entry: { ...baseEntry, subject: "Release room" } }],
|
||||
["persisted display name", { entry: { ...baseEntry, displayName: "My release" } }],
|
||||
["group subject", { entry: { ...baseEntry, subject: "Release team" } }],
|
||||
["channel name", { entry: { ...baseEntry, groupChannel: "releases" } }],
|
||||
["space name", { entry: { ...baseEntry, space: "Engineering" } }],
|
||||
["existing session history", { entry: { ...baseEntry, systemSent: true } }],
|
||||
])("skips %s", async (_name, override) => {
|
||||
await expect(
|
||||
maybeGenerateDashboardSessionTitle({ ...titleParams(), ...override }),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(generateConversationLabel).not.toHaveBeenCalled();
|
||||
expect(generateConversationLabelWithFallback).not.toHaveBeenCalled();
|
||||
expect(updateSessionEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -136,7 +212,7 @@ describe("maybeGenerateDashboardSessionTitle", () => {
|
||||
|
||||
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(false);
|
||||
|
||||
expect(generateConversationLabel).toHaveBeenCalledOnce();
|
||||
expect(generateConversationLabelWithFallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not write into a reset session generation", async () => {
|
||||
@@ -144,12 +220,12 @@ describe("maybeGenerateDashboardSessionTitle", () => {
|
||||
|
||||
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(false);
|
||||
|
||||
expect(generateConversationLabel).toHaveBeenCalledOnce();
|
||||
expect(generateConversationLabelWithFallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("deduplicates concurrent title requests for one session generation", async () => {
|
||||
let resolveLabel!: (value: string) => void;
|
||||
generateConversationLabel.mockReturnValue(
|
||||
generateConversationLabelWithFallback.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveLabel = resolve;
|
||||
}),
|
||||
@@ -160,6 +236,6 @@ describe("maybeGenerateDashboardSessionTitle", () => {
|
||||
resolveLabel("Release Planning");
|
||||
await expect(first).resolves.toBe(true);
|
||||
|
||||
expect(generateConversationLabel).toHaveBeenCalledOnce();
|
||||
expect(generateConversationLabelWithFallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
// Dashboard session titles use the shared utility-model completion path.
|
||||
import { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js";
|
||||
import { resolveAgentEffectiveModelPrimary } from "../agents/agent-scope.js";
|
||||
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
|
||||
import { resolveSessionModelRef } from "../agents/session-model-ref.js";
|
||||
import { resolveUtilityModelRefForAgent } from "../agents/utility-model.js";
|
||||
import { generateConversationLabelWithFallback } from "../auto-reply/reply/conversation-label-generator.js";
|
||||
import { updateSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -16,7 +20,13 @@ const DASHBOARD_SESSION_TITLE_PROMPT =
|
||||
const dashboardTitleRequests = new Set<string>();
|
||||
|
||||
function hasExplicitSessionName(entry: SessionEntry | undefined): boolean {
|
||||
return Boolean(entry?.label?.trim() || entry?.displayName?.trim() || entry?.subject?.trim());
|
||||
return Boolean(
|
||||
entry?.label?.trim() ||
|
||||
entry?.displayName?.trim() ||
|
||||
entry?.subject?.trim() ||
|
||||
entry?.groupChannel?.trim() ||
|
||||
entry?.space?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function isDashboardSessionKey(sessionKey: string): boolean {
|
||||
@@ -33,6 +43,27 @@ export function isDashboardSessionTitleCandidate(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDashboardTitleAuthProfile(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
entry: SessionEntry | undefined;
|
||||
regularProvider: string;
|
||||
}): string | undefined {
|
||||
const sessionProfile = params.entry?.authProfileOverride?.trim();
|
||||
if (sessionProfile) {
|
||||
return sessionProfile;
|
||||
}
|
||||
const configuredRef = resolveAgentEffectiveModelPrimary(params.cfg, params.agentId)?.trim();
|
||||
const configuredProfile = configuredRef
|
||||
? splitTrailingAuthProfile(configuredRef).profile
|
||||
: undefined;
|
||||
if (!configuredProfile) {
|
||||
return undefined;
|
||||
}
|
||||
const configuredModel = resolveSessionModelRef(params.cfg, undefined, params.agentId);
|
||||
return configuredModel.provider === params.regularProvider ? configuredProfile : undefined;
|
||||
}
|
||||
|
||||
function normalizeDashboardSessionTitle(raw: string): string | null {
|
||||
const firstLine = raw
|
||||
.replace(/\r/g, "")
|
||||
@@ -75,11 +106,31 @@ export async function maybeGenerateDashboardSessionTitle(params: {
|
||||
}
|
||||
dashboardTitleRequests.add(requestKey);
|
||||
try {
|
||||
const generated = await generateConversationLabel({
|
||||
const regularModel = resolveSessionModelRef(params.cfg, params.entry, params.agentId);
|
||||
const preferredProfile = resolveDashboardTitleAuthProfile({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
entry: params.entry,
|
||||
regularProvider: regularModel.provider,
|
||||
});
|
||||
const regularModelRef = `${regularModel.provider}/${regularModel.model}${
|
||||
preferredProfile ? `@${preferredProfile}` : ""
|
||||
}`;
|
||||
const utilityModelRef = resolveUtilityModelRefForAgent({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
primaryProvider: regularModel.provider,
|
||||
primaryModelRef: regularModelRef,
|
||||
});
|
||||
const generated = await generateConversationLabelWithFallback({
|
||||
userMessage: truncateUtf16Safe(sourceText, DASHBOARD_SESSION_TITLE_SOURCE_MAX_CHARS),
|
||||
prompt: DASHBOARD_SESSION_TITLE_PROMPT,
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
...(utilityModelRef ? { utilityModelRef } : {}),
|
||||
regularModelRef,
|
||||
...(preferredProfile ? { preferredProfile } : {}),
|
||||
normalizeLabel: normalizeDashboardSessionTitle,
|
||||
maxLength: DASHBOARD_SESSION_TITLE_MAX_CHARS,
|
||||
});
|
||||
const displayName = generated ? normalizeDashboardSessionTitle(generated) : null;
|
||||
|
||||
Reference in New Issue
Block a user