refactor: require session state for directive handling
This commit is contained in:
parent
c0c8ee217f
commit
da3a141c58
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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}.`));
|
||||||
|
|||||||
@ -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"];
|
||||||
|
|||||||
@ -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"];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user