refactor: require session state for directive handling
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.22
|
||||
## 2026.1.22 (unreleased)
|
||||
|
||||
### Fixes
|
||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||
|
||||
@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
isGroup: boolean;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
elevatedEnabled: boolean;
|
||||
|
||||
@@ -77,100 +77,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
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 = {
|
||||
|
||||
@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
|
||||
export async function handleDirectiveOnly(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
directives: InlineDirectives;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
elevatedEnabled: boolean;
|
||||
@@ -288,115 +288,111 @@ export async function handleDirectiveOnly(params: {
|
||||
nextThinkLevel === "xhigh" &&
|
||||
!supportsXHighThinking(resolvedProvider, resolvedModel);
|
||||
|
||||
let didPersistModel = false;
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
const prevElevatedLevel =
|
||||
currentElevatedLevel ??
|
||||
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
||||
const prevReasoningLevel =
|
||||
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
let elevatedChanged =
|
||||
directives.hasElevatedDirective &&
|
||||
directives.elevatedLevel !== undefined &&
|
||||
elevatedEnabled &&
|
||||
elevatedAllowed;
|
||||
let reasoningChanged =
|
||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
|
||||
else sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
const prevElevatedLevel =
|
||||
currentElevatedLevel ??
|
||||
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
||||
const prevReasoningLevel =
|
||||
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
let elevatedChanged =
|
||||
directives.hasElevatedDirective &&
|
||||
directives.elevatedLevel !== undefined &&
|
||||
elevatedEnabled &&
|
||||
elevatedAllowed;
|
||||
let reasoningChanged =
|
||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||
if (directives.hasThinkDirective && 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) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
if (directives.execSecurity) {
|
||||
sessionEntry.execSecurity = directives.execSecurity;
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
if (directives.execAsk) {
|
||||
sessionEntry.execAsk = directives.execAsk;
|
||||
}
|
||||
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.execNode) {
|
||||
sessionEntry.execNode = directives.execNode;
|
||||
}
|
||||
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 (modelSelection) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
}
|
||||
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 (directives.execHost) {
|
||||
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 (typeof directives.cap === "number") {
|
||||
sessionEntry.queueCap = directives.cap;
|
||||
}
|
||||
if (modelSelection) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
didPersistModel = true;
|
||||
if (directives.dropPolicy) {
|
||||
sessionEntry.queueDrop = directives.dropPolicy;
|
||||
}
|
||||
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 (typeof directives.cap === "number") {
|
||||
sessionEntry.queueCap = directives.cap;
|
||||
}
|
||||
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), {
|
||||
}
|
||||
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: "mode:elevated",
|
||||
});
|
||||
}
|
||||
if (reasoningChanged) {
|
||||
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
|
||||
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
|
||||
sessionKey,
|
||||
contextKey: "mode:reasoning",
|
||||
contextKey: `model:${nextLabel}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
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[] = [];
|
||||
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}).`,
|
||||
);
|
||||
}
|
||||
if (modelSelection && didPersistModel) {
|
||||
if (modelSelection) {
|
||||
const label = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
|
||||
parts.push(
|
||||
@@ -460,10 +456,6 @@ 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}.`));
|
||||
|
||||
@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
|
||||
|
||||
@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
|
||||
workspaceDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
|
||||
|
||||
Reference in New Issue
Block a user