test: add vitest tests for slug generator model resolution
Verifies that generateSlugViaLLM respects the configured default model instead of using hardcoded Opus. Tests cover: - Model resolution from agents.defaults.model.primary - Custom provider support (e.g., OpenRouter) - Fallback to DEFAULT_MODEL when no config - Alternative models (Haiku, Sonnet) - Slug generation and cleanup logic - Parameter passing to runEmbeddedPiAgent All tests use mocked runEmbeddedPiAgent to avoid actual LLM calls. Relates to #4315
This commit is contained in:
parent
080a4dbd86
commit
d03adcc6a2
313
src/hooks/llm-slug-generator.test.ts
Normal file
313
src/hooks/llm-slug-generator.test.ts
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { generateSlugViaLLM } from "./llm-slug-generator.js";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import type { EmbeddedPiRunResult } from "../agents/pi-embedded.js";
|
||||||
|
|
||||||
|
const runEmbeddedPiAgentMock = vi.fn<
|
||||||
|
Parameters<typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent>,
|
||||||
|
Promise<EmbeddedPiRunResult>
|
||||||
|
>();
|
||||||
|
|
||||||
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
|
runEmbeddedPiAgent: (...args: unknown[]) => runEmbeddedPiAgentMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("generateSlugViaLLM", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runEmbeddedPiAgentMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("model resolution", () => {
|
||||||
|
it("uses configured default model from agents.defaults.model.primary", async () => {
|
||||||
|
// Arrange: Config with custom default model (Sonnet instead of Opus)
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: "anthropic/claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: "test-slug" }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await generateSlugViaLLM({
|
||||||
|
sessionContent: "Some conversation about testing",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert: Should use Sonnet, not hardcoded Opus
|
||||||
|
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||||
|
const callArgs = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
|
||||||
|
expect(callArgs).toMatchObject({
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
config: cfg,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses configured default model with custom provider", async () => {
|
||||||
|
// Arrange: Config with OpenRouter model
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: "openrouter/anthropic/claude-3.5-sonnet",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: "custom-provider-slug" }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await generateSlugViaLLM({
|
||||||
|
sessionContent: "Testing custom provider",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert: Should use OpenRouter provider
|
||||||
|
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||||
|
const callArgs = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
|
||||||
|
expect(callArgs).toMatchObject({
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "anthropic/claude-3.5-sonnet",
|
||||||
|
config: cfg,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to DEFAULT_MODEL when no model.primary is configured", async () => {
|
||||||
|
// Arrange: Empty config (no custom model)
|
||||||
|
const cfg: OpenClawConfig = {};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: "default-model-slug" }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await generateSlugViaLLM({
|
||||||
|
sessionContent: "Testing default fallback",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert: Should fall back to DEFAULT_MODEL (claude-opus-4-5)
|
||||||
|
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||||
|
const callArgs = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
|
||||||
|
expect(callArgs).toMatchObject({
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-5",
|
||||||
|
config: cfg,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Haiku when configured as default model", async () => {
|
||||||
|
// Arrange: Config with Haiku as default
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: "anthropic/claude-3-5-haiku-20241022",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: "haiku-slug" }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await generateSlugViaLLM({
|
||||||
|
sessionContent: "Budget-friendly slug generation",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert: Should use Haiku
|
||||||
|
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||||
|
const callArgs = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
|
||||||
|
expect(callArgs).toMatchObject({
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-3-5-haiku-20241022",
|
||||||
|
config: cfg,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("slug generation", () => {
|
||||||
|
it("generates valid slug from LLM response", async () => {
|
||||||
|
const cfg: OpenClawConfig = {};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: "api-design" }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const slug = await generateSlugViaLLM({
|
||||||
|
sessionContent: "Discussion about API design patterns",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(slug).toBe("api-design");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up LLM response with extra characters", async () => {
|
||||||
|
const cfg: OpenClawConfig = {};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: " Bug Fix! " }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const slug = await generateSlugViaLLM({
|
||||||
|
sessionContent: "Bug fix discussion",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(slug).toBe("bug-fix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates long slugs to 30 characters", async () => {
|
||||||
|
const cfg: OpenClawConfig = {};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: "this-is-a-very-long-slug-that-should-be-truncated-significantly" }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const slug = await generateSlugViaLLM({
|
||||||
|
sessionContent: "Long discussion",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify length is enforced (implementation slices before final cleanup)
|
||||||
|
expect(slug?.length).toBeLessThanOrEqual(30);
|
||||||
|
expect(slug).toMatch(/^this-is-a-very-long-slug/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when LLM returns no payloads", async () => {
|
||||||
|
const cfg: OpenClawConfig = {};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const slug = await generateSlugViaLLM({
|
||||||
|
sessionContent: "Test content",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(slug).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when LLM returns empty text", async () => {
|
||||||
|
const cfg: OpenClawConfig = {};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: "" }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const slug = await generateSlugViaLLM({
|
||||||
|
sessionContent: "Test content",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(slug).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when embedded agent throws error", async () => {
|
||||||
|
const cfg: OpenClawConfig = {};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockRejectedValue(new Error("LLM timeout"));
|
||||||
|
|
||||||
|
const slug = await generateSlugViaLLM({
|
||||||
|
sessionContent: "Test content",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(slug).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("embedded agent parameters", () => {
|
||||||
|
it("passes correct parameters to runEmbeddedPiAgent", async () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: {
|
||||||
|
primary: "anthropic/claude-sonnet-4-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: "test-slug" }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateSlugViaLLM({
|
||||||
|
sessionContent: "Testing parameters",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||||
|
const callArgs = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
|
||||||
|
|
||||||
|
// Verify all expected parameters are present
|
||||||
|
expect(callArgs).toMatchObject({
|
||||||
|
config: cfg,
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify prompt includes session content
|
||||||
|
expect(callArgs?.prompt).toContain("Testing parameters");
|
||||||
|
expect(callArgs?.prompt).toContain("1-2 word filename slug");
|
||||||
|
|
||||||
|
// Verify session parameters
|
||||||
|
expect(callArgs?.sessionId).toMatch(/^slug-generator-\d+$/);
|
||||||
|
expect(callArgs?.sessionKey).toBe("temp:slug-generator");
|
||||||
|
expect(callArgs?.runId).toMatch(/^slug-gen-\d+$/);
|
||||||
|
|
||||||
|
// Verify temp session file path
|
||||||
|
expect(callArgs?.sessionFile).toContain("openclaw-slug-");
|
||||||
|
expect(callArgs?.sessionFile).toContain("session.jsonl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates session content to 2000 characters in prompt", async () => {
|
||||||
|
const cfg: OpenClawConfig = {};
|
||||||
|
const longContent = "a".repeat(3000);
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockResolvedValue({
|
||||||
|
payloads: [{ text: "truncated-slug" }],
|
||||||
|
meta: { runId: "test-run", sessionId: "test-session" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await generateSlugViaLLM({
|
||||||
|
sessionContent: longContent,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
const callArgs = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
|
||||||
|
const promptContentMatch = callArgs?.prompt.match(
|
||||||
|
/Conversation summary:\n([\s\S]+)\n\nReply/,
|
||||||
|
);
|
||||||
|
const extractedContent = promptContentMatch?.[1] ?? "";
|
||||||
|
|
||||||
|
expect(extractedContent.length).toBeLessThanOrEqual(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user