Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e00a2dc6f | ||
|
|
784ea4f7d5 | ||
|
|
f07a58965e |
No files matched your search
+5
-1
@@ -2,11 +2,15 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
### Fixes
|
||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||
|
||||
## 2026.1.21-2
|
||||
|
||||
### Fixes
|
||||
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
||||
- Hooks: suppress session-memory confirmation output. (#1464) Thanks @alfranli123.
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ModelAliasIndex } from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
import { handleDirectiveOnly } from "./directive-handling.impl.js";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentConfig: vi.fn(() => ({})),
|
||||
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
||||
resolveSessionAgentId: vi.fn(() => "main"),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/sandbox.js", () => ({
|
||||
resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", () => ({
|
||||
updateSessionStore: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
function baseAliasIndex(): ModelAliasIndex {
|
||||
return { byAlias: new Map(), byKey: new Map() };
|
||||
}
|
||||
|
||||
function baseConfig(): ClawdbotConfig {
|
||||
return {
|
||||
commands: { text: true },
|
||||
agents: { defaults: {} },
|
||||
} as unknown as ClawdbotConfig;
|
||||
}
|
||||
|
||||
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
|
||||
const allowedModelCatalog = [
|
||||
{ provider: "anthropic", id: "claude-opus-4-5" },
|
||||
{ provider: "openai", id: "gpt-4o" },
|
||||
];
|
||||
|
||||
it("shows success message when session state is available", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
|
||||
const result = await handleDirectiveOnly({
|
||||
cfg: baseConfig(),
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
storePath: "/tmp/sessions.json",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride: false,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
initialModelLabel: "anthropic/claude-opus-4-5",
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
expect(result?.text).toContain("Model set to");
|
||||
expect(result?.text).toContain("openai/gpt-4o");
|
||||
expect(result?.text).not.toContain("failed");
|
||||
});
|
||||
|
||||
it("shows error message when sessionEntry is missing", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionStore = {};
|
||||
|
||||
const result = await handleDirectiveOnly({
|
||||
cfg: baseConfig(),
|
||||
directives,
|
||||
sessionEntry: undefined, // Missing!
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
storePath: "/tmp/sessions.json",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride: false,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
initialModelLabel: "anthropic/claude-opus-4-5",
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
expect(result?.text).toContain("failed");
|
||||
expect(result?.text).toContain("session state unavailable");
|
||||
});
|
||||
|
||||
it("shows error message when sessionStore is missing", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const result = await handleDirectiveOnly({
|
||||
cfg: baseConfig(),
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore: undefined, // Missing!
|
||||
sessionKey: "agent:main:dm:1",
|
||||
storePath: "/tmp/sessions.json",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride: false,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
initialModelLabel: "anthropic/claude-opus-4-5",
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
expect(result?.text).toContain("failed");
|
||||
expect(result?.text).toContain("session state unavailable");
|
||||
});
|
||||
|
||||
it("shows error message when sessionKey is missing", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
|
||||
const result = await handleDirectiveOnly({
|
||||
cfg: baseConfig(),
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: undefined, // Missing!
|
||||
storePath: "/tmp/sessions.json",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride: false,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
initialModelLabel: "anthropic/claude-opus-4-5",
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
expect(result?.text).toContain("failed");
|
||||
expect(result?.text).toContain("session state unavailable");
|
||||
});
|
||||
|
||||
it("shows no model message when no /model directive", async () => {
|
||||
const directives = parseInlineDirectives("hello world");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
|
||||
const result = await handleDirectiveOnly({
|
||||
cfg: baseConfig(),
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
storePath: "/tmp/sessions.json",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride: false,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
initialModelLabel: "anthropic/claude-opus-4-5",
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
// No model directive = no model message
|
||||
expect(result?.text ?? "").not.toContain("Model set to");
|
||||
expect(result?.text ?? "").not.toContain("failed");
|
||||
});
|
||||
});
|
||||
@@ -288,6 +288,7 @@ export async function handleDirectiveOnly(params: {
|
||||
nextThinkLevel === "xhigh" &&
|
||||
!supportsXHighThinking(resolvedProvider, resolvedModel);
|
||||
|
||||
let didPersistModel = false;
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
const prevElevatedLevel =
|
||||
currentElevatedLevel ??
|
||||
@@ -346,6 +347,7 @@ export async function handleDirectiveOnly(params: {
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
didPersistModel = true;
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
@@ -447,7 +449,7 @@ export async function handleDirectiveOnly(params: {
|
||||
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
|
||||
);
|
||||
}
|
||||
if (modelSelection) {
|
||||
if (modelSelection && didPersistModel) {
|
||||
const label = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
|
||||
parts.push(
|
||||
@@ -458,6 +460,10 @@ export async function handleDirectiveOnly(params: {
|
||||
if (profileOverride) {
|
||||
parts.push(`Auth profile set to ${profileOverride}.`);
|
||||
}
|
||||
} else if (modelSelection && !didPersistModel) {
|
||||
parts.push(
|
||||
`Model switch to ${modelSelection.provider}/${modelSelection.model} failed (session state unavailable).`,
|
||||
);
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueMode) {
|
||||
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
|
||||
|
||||
@@ -26,7 +26,7 @@ When you run `/new` to start a fresh session:
|
||||
2. **Extracts conversation** - Reads the last 15 lines of conversation from the session
|
||||
3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content
|
||||
4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md`
|
||||
5. **Logs completion** - Writes the file path to logs (no user-visible confirmation)
|
||||
5. **Sends confirmation** - Notifies you with the file path
|
||||
|
||||
## Output Format
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import handler from "./handler.js";
|
||||
import { createHookEvent } from "../../hooks.js";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { makeTempWorkspace } from "../../../test-helpers/workspace.js";
|
||||
|
||||
describe("session-memory hook", () => {
|
||||
it("writes a memory entry without queuing a user confirmation", async () => {
|
||||
const workspaceDir = await makeTempWorkspace("clawdbot-session-memory-");
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
},
|
||||
};
|
||||
const context: Record<string, unknown> = {
|
||||
cfg,
|
||||
sessionEntry: {
|
||||
sessionId: "session-123",
|
||||
sessionFile: "",
|
||||
},
|
||||
commandSource: "test",
|
||||
};
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", context);
|
||||
await handler(event);
|
||||
|
||||
expect(event.messages).toEqual([]);
|
||||
|
||||
const memoryDir = path.join(workspaceDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
expect(files).toHaveLength(1);
|
||||
|
||||
const entry = await fs.readFile(path.join(memoryDir, files[0] ?? ""), "utf-8");
|
||||
expect(entry).toContain("Session Key");
|
||||
expect(entry).toContain("session-123");
|
||||
});
|
||||
});
|
||||
@@ -160,9 +160,11 @@ const saveSessionToMemory: HookHandler = async (event) => {
|
||||
await fs.writeFile(memoryFilePath, entry, "utf-8");
|
||||
console.log("[session-memory] Memory file written successfully");
|
||||
|
||||
// Log completion (but don't send user-visible confirmation - it's internal housekeeping)
|
||||
// Send confirmation message to user with filename
|
||||
const relPath = memoryFilePath.replace(os.homedir(), "~");
|
||||
console.log(`[session-memory] Session context saved to ${relPath}`);
|
||||
const confirmMsg = `💾 Session context saved to memory before reset.\n📄 ${relPath}`;
|
||||
event.messages.push(confirmMsg);
|
||||
console.log("[session-memory] Confirmation message queued:", confirmMsg);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[session-memory] Failed to save session memory:",
|
||||
|
||||
Reference in new issue
Block a user