refactor: require session state for directive handling

This commit is contained in:
Peter Steinberger 2026-01-22 22:11:12 +00:00
parent c0c8ee217f
commit da3a141c58
6 changed files with 104 additions and 206 deletions

View File

@ -2,7 +2,7 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.22 ## 2026.1.22 (unreleased)
### Fixes ### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell. - BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.

View File

@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
agentId?: string; agentId?: string;
isGroup: boolean; isGroup: boolean;
sessionEntry?: SessionEntry; sessionEntry: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
elevatedEnabled: boolean; elevatedEnabled: boolean;

View File

@ -77,100 +77,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(result?.text).not.toContain("failed"); 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 () => { it("shows no model message when no /model directive", async () => {
const directives = parseInlineDirectives("hello world"); const directives = parseInlineDirectives("hello world");
const sessionEntry: SessionEntry = { const sessionEntry: SessionEntry = {

View File

@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
export async function handleDirectiveOnly(params: { export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
directives: InlineDirectives; directives: InlineDirectives;
sessionEntry?: SessionEntry; sessionEntry: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
elevatedEnabled: boolean; elevatedEnabled: boolean;
@ -288,115 +288,111 @@ export async function handleDirectiveOnly(params: {
nextThinkLevel === "xhigh" && nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel); !supportsXHighThinking(resolvedProvider, resolvedModel);
let didPersistModel = false; const prevElevatedLevel =
if (sessionEntry && sessionStore && sessionKey) { currentElevatedLevel ??
const prevElevatedLevel = (sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
currentElevatedLevel ?? (elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ?? const prevReasoningLevel =
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel)); currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
const prevReasoningLevel = let elevatedChanged =
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off"; directives.hasElevatedDirective &&
let elevatedChanged = directives.elevatedLevel !== undefined &&
directives.hasElevatedDirective && elevatedEnabled &&
directives.elevatedLevel !== undefined && elevatedAllowed;
elevatedEnabled && let reasoningChanged =
elevatedAllowed; directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
let reasoningChanged = if (directives.hasThinkDirective && directives.thinkLevel) {
directives.hasReasoningDirective && directives.reasoningLevel !== undefined; if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
if (directives.hasThinkDirective && directives.thinkLevel) { else sessionEntry.thinkingLevel = directives.thinkLevel;
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel; }
else sessionEntry.thinkingLevel = directives.thinkLevel; if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
} }
if (shouldDowngradeXHigh) { if (directives.execSecurity) {
sessionEntry.thinkingLevel = "high"; sessionEntry.execSecurity = directives.execSecurity;
} }
if (directives.hasVerboseDirective && directives.verboseLevel) { if (directives.execAsk) {
applyVerboseOverride(sessionEntry, directives.verboseLevel); sessionEntry.execAsk = directives.execAsk;
} }
if (directives.hasReasoningDirective && directives.reasoningLevel) { if (directives.execNode) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel; sessionEntry.execNode = directives.execNode;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
} }
if (directives.hasElevatedDirective && directives.elevatedLevel) { }
// Unlike other toggles, elevated defaults can be "on". if (modelSelection) {
// Persist "off" explicitly so `/elevated off` actually overrides defaults. applyModelOverrideToSessionEntry({
sessionEntry.elevatedLevel = directives.elevatedLevel; entry: sessionEntry,
elevatedChanged = selection: modelSelection,
elevatedChanged || profileOverride,
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined); });
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
} }
if (directives.hasExecDirective && directives.hasExecOptions) { if (typeof directives.cap === "number") {
if (directives.execHost) { sessionEntry.queueCap = directives.cap;
sessionEntry.execHost = directives.execHost;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
} }
if (modelSelection) { if (directives.dropPolicy) {
applyModelOverrideToSessionEntry({ sessionEntry.queueDrop = directives.dropPolicy;
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
didPersistModel = true;
} }
if (directives.hasQueueDirective && directives.queueReset) { }
delete sessionEntry.queueMode; sessionEntry.updatedAt = Date.now();
delete sessionEntry.queueDebounceMs; sessionStore[sessionKey] = sessionEntry;
delete sessionEntry.queueCap; if (storePath) {
delete sessionEntry.queueDrop; await updateSessionStore(storePath, (store) => {
} else if (directives.hasQueueDirective) { store[sessionKey] = sessionEntry;
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode; });
if (typeof directives.debounceMs === "number") { }
sessionEntry.queueDebounceMs = directives.debounceMs; if (modelSelection) {
} const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (typeof directives.cap === "number") { if (nextLabel !== initialModelLabel) {
sessionEntry.queueCap = directives.cap; enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
}
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
}
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
sessionKey,
contextKey: `model:${nextLabel}`,
});
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey, sessionKey,
contextKey: "mode:elevated", contextKey: `model:${nextLabel}`,
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
}); });
} }
} }
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
});
}
const parts: string[] = []; const parts: string[] = [];
if (directives.hasThinkDirective && directives.thinkLevel) { if (directives.hasThinkDirective && directives.thinkLevel) {
@ -449,7 +445,7 @@ export async function handleDirectiveOnly(params: {
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`, `Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
); );
} }
if (modelSelection && didPersistModel) { if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`; const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label; const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
parts.push( parts.push(
@ -460,10 +456,6 @@ export async function handleDirectiveOnly(params: {
if (profileOverride) { if (profileOverride) {
parts.push(`Auth profile set to ${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) { if (directives.hasQueueDirective && directives.queueMode) {
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`)); parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));

View File

@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
agentId: string; agentId: string;
agentDir: string; agentDir: string;
agentCfg: AgentDefaults; agentCfg: AgentDefaults;
sessionEntry?: SessionEntry; sessionEntry: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"]; sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];

View File

@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
workspaceDir: string; workspaceDir: string;
agentCfg: AgentDefaults; agentCfg: AgentDefaults;
sessionCtx: TemplateContext; sessionCtx: TemplateContext;
sessionEntry?: SessionEntry; sessionEntry: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"]; sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];