mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 13:11:23 +00:00
refactor(agent): dedupe harness and command workflows
This commit is contained in:
@@ -38,6 +38,27 @@ vi.mock("../../agents/subagent-registry.js", () => ({
|
||||
}));
|
||||
|
||||
describe("abort detection", () => {
|
||||
async function runStopCommand(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}) {
|
||||
return tryFastAbortFromMessage({
|
||||
ctx: buildTestCtx({
|
||||
CommandBody: "/stop",
|
||||
RawBody: "/stop",
|
||||
CommandAuthorized: true,
|
||||
SessionKey: params.sessionKey,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
From: params.from,
|
||||
To: params.to,
|
||||
}),
|
||||
cfg: params.cfg,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetAbortMemoryForTest();
|
||||
});
|
||||
@@ -109,18 +130,11 @@ describe("abort detection", () => {
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath }, commands: { text: false } } as OpenClawConfig;
|
||||
|
||||
const result = await tryFastAbortFromMessage({
|
||||
ctx: buildTestCtx({
|
||||
CommandBody: "/stop",
|
||||
RawBody: "/stop",
|
||||
CommandAuthorized: true,
|
||||
SessionKey: "telegram:123",
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
From: "telegram:123",
|
||||
To: "telegram:123",
|
||||
}),
|
||||
const result = await runStopCommand({
|
||||
cfg,
|
||||
sessionKey: "telegram:123",
|
||||
from: "telegram:123",
|
||||
to: "telegram:123",
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
@@ -172,18 +186,11 @@ describe("abort detection", () => {
|
||||
);
|
||||
expect(getFollowupQueueDepth(sessionKey)).toBe(1);
|
||||
|
||||
const result = await tryFastAbortFromMessage({
|
||||
ctx: buildTestCtx({
|
||||
CommandBody: "/stop",
|
||||
RawBody: "/stop",
|
||||
CommandAuthorized: true,
|
||||
SessionKey: sessionKey,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
From: "telegram:123",
|
||||
To: "telegram:123",
|
||||
}),
|
||||
const result = await runStopCommand({
|
||||
cfg,
|
||||
sessionKey,
|
||||
from: "telegram:123",
|
||||
to: "telegram:123",
|
||||
});
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
@@ -229,18 +236,11 @@ describe("abort detection", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await tryFastAbortFromMessage({
|
||||
ctx: buildTestCtx({
|
||||
CommandBody: "/stop",
|
||||
RawBody: "/stop",
|
||||
CommandAuthorized: true,
|
||||
SessionKey: sessionKey,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
From: "telegram:parent",
|
||||
To: "telegram:parent",
|
||||
}),
|
||||
const result = await runStopCommand({
|
||||
cfg,
|
||||
sessionKey,
|
||||
from: "telegram:parent",
|
||||
to: "telegram:parent",
|
||||
});
|
||||
|
||||
expect(result.stoppedSubagents).toBe(1);
|
||||
@@ -307,18 +307,11 @@ describe("abort detection", () => {
|
||||
])
|
||||
.mockReturnValueOnce([]);
|
||||
|
||||
const result = await tryFastAbortFromMessage({
|
||||
ctx: buildTestCtx({
|
||||
CommandBody: "/stop",
|
||||
RawBody: "/stop",
|
||||
CommandAuthorized: true,
|
||||
SessionKey: sessionKey,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
From: "telegram:parent",
|
||||
To: "telegram:parent",
|
||||
}),
|
||||
const result = await runStopCommand({
|
||||
cfg,
|
||||
sessionKey,
|
||||
from: "telegram:parent",
|
||||
to: "telegram:parent",
|
||||
});
|
||||
|
||||
// Should stop both depth-1 and depth-2 agents (cascade)
|
||||
@@ -389,18 +382,11 @@ describe("abort detection", () => {
|
||||
])
|
||||
.mockReturnValueOnce([]);
|
||||
|
||||
const result = await tryFastAbortFromMessage({
|
||||
ctx: buildTestCtx({
|
||||
CommandBody: "/stop",
|
||||
RawBody: "/stop",
|
||||
CommandAuthorized: true,
|
||||
SessionKey: sessionKey,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
From: "telegram:parent",
|
||||
To: "telegram:parent",
|
||||
}),
|
||||
const result = await runStopCommand({
|
||||
cfg,
|
||||
sessionKey,
|
||||
from: "telegram:parent",
|
||||
to: "telegram:parent",
|
||||
});
|
||||
|
||||
// Should skip killing the ended depth-1 run itself, but still kill depth-2.
|
||||
|
||||
@@ -11,28 +11,18 @@ import { createOpenClawTools } from "../../agents/openclaw-tools.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
import { listChatCommands } from "../commands-registry.js";
|
||||
import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js";
|
||||
import {
|
||||
listReservedChatSlashCommandNames,
|
||||
listSkillCommandsForWorkspace,
|
||||
resolveSkillCommandInvocation,
|
||||
} from "../skill-commands.js";
|
||||
import { getAbortMemory } from "./abort.js";
|
||||
import { buildStatusReply, handleCommands } from "./commands.js";
|
||||
import { isDirectiveOnly } from "./directive-handling.js";
|
||||
import { extractInlineSimpleCommand } from "./reply-inline.js";
|
||||
|
||||
const builtinSlashCommands = (() => {
|
||||
const reserved = new Set<string>();
|
||||
for (const command of listChatCommands()) {
|
||||
if (command.nativeName) {
|
||||
reserved.add(command.nativeName.toLowerCase());
|
||||
}
|
||||
for (const alias of command.textAliases) {
|
||||
const trimmed = alias.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
continue;
|
||||
}
|
||||
reserved.add(trimmed.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
for (const name of [
|
||||
return listReservedChatSlashCommandNames([
|
||||
"think",
|
||||
"verbose",
|
||||
"reasoning",
|
||||
@@ -41,10 +31,7 @@ const builtinSlashCommands = (() => {
|
||||
"model",
|
||||
"status",
|
||||
"queue",
|
||||
]) {
|
||||
reserved.add(name);
|
||||
}
|
||||
return reserved;
|
||||
]);
|
||||
})();
|
||||
|
||||
function resolveSlashCommandName(commandBodyNormalized: string): string | null {
|
||||
|
||||
@@ -44,6 +44,30 @@ describe("createModelSelectionState parent inheritance", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveHeartbeatStoredOverrideState(hasResolvedHeartbeatModelOverride: boolean) {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:discord:channel:c1";
|
||||
const sessionEntry = makeEntry({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4o",
|
||||
});
|
||||
const sessionStore = { [sessionKey]: sessionEntry };
|
||||
|
||||
return createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg: cfg.agents?.defaults,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
hasModelDirective: false,
|
||||
hasResolvedHeartbeatModelOverride,
|
||||
});
|
||||
}
|
||||
|
||||
it("inherits parent override from explicit parentSessionKey", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const parentKey = "agent:main:discord:channel:c1";
|
||||
@@ -157,58 +181,14 @@ describe("createModelSelectionState parent inheritance", () => {
|
||||
});
|
||||
|
||||
it("applies stored override when heartbeat override was not resolved", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:discord:channel:c1";
|
||||
const sessionEntry = makeEntry({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4o",
|
||||
});
|
||||
const sessionStore = {
|
||||
[sessionKey]: sessionEntry,
|
||||
};
|
||||
|
||||
const state = await createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg: cfg.agents?.defaults,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
hasModelDirective: false,
|
||||
hasResolvedHeartbeatModelOverride: false,
|
||||
});
|
||||
const state = await resolveHeartbeatStoredOverrideState(false);
|
||||
|
||||
expect(state.provider).toBe("openai");
|
||||
expect(state.model).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("skips stored override when heartbeat override was resolved", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:discord:channel:c1";
|
||||
const sessionEntry = makeEntry({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4o",
|
||||
});
|
||||
const sessionStore = {
|
||||
[sessionKey]: sessionEntry,
|
||||
};
|
||||
|
||||
const state = await createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg: cfg.agents?.defaults,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
hasModelDirective: false,
|
||||
hasResolvedHeartbeatModelOverride: true,
|
||||
});
|
||||
const state = await resolveHeartbeatStoredOverrideState(true);
|
||||
|
||||
expect(state.provider).toBe("anthropic");
|
||||
expect(state.model).toBe("claude-opus-4-5");
|
||||
@@ -219,16 +199,12 @@ describe("createModelSelectionState respects session model override", () => {
|
||||
const defaultProvider = "inferencer";
|
||||
const defaultModel = "deepseek-v3-4bit-mlx";
|
||||
|
||||
it("applies session modelOverride when set", async () => {
|
||||
async function resolveState(sessionEntry: ReturnType<typeof makeEntry>) {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:main";
|
||||
const sessionEntry = makeEntry({
|
||||
providerOverride: "kimi-coding",
|
||||
modelOverride: "k2p5",
|
||||
});
|
||||
const sessionStore = { [sessionKey]: sessionEntry };
|
||||
|
||||
const state = await createModelSelectionState({
|
||||
return createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg: undefined,
|
||||
sessionEntry,
|
||||
@@ -240,29 +216,22 @@ describe("createModelSelectionState respects session model override", () => {
|
||||
model: defaultModel,
|
||||
hasModelDirective: false,
|
||||
});
|
||||
}
|
||||
|
||||
it("applies session modelOverride when set", async () => {
|
||||
const state = await resolveState(
|
||||
makeEntry({
|
||||
providerOverride: "kimi-coding",
|
||||
modelOverride: "k2p5",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.provider).toBe("kimi-coding");
|
||||
expect(state.model).toBe("k2p5");
|
||||
});
|
||||
|
||||
it("falls back to default when no modelOverride is set", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:main";
|
||||
const sessionEntry = makeEntry();
|
||||
const sessionStore = { [sessionKey]: sessionEntry };
|
||||
|
||||
const state = await createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg: undefined,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: defaultProvider,
|
||||
model: defaultModel,
|
||||
hasModelDirective: false,
|
||||
});
|
||||
const state = await resolveState(makeEntry());
|
||||
|
||||
expect(state.provider).toBe(defaultProvider);
|
||||
expect(state.model).toBe(defaultModel);
|
||||
@@ -270,54 +239,26 @@ describe("createModelSelectionState respects session model override", () => {
|
||||
|
||||
it("respects modelOverride even when session model field differs", async () => {
|
||||
// From issue #14783: stored override should beat last-used fallback model.
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:main";
|
||||
const sessionEntry = makeEntry({
|
||||
model: "k2p5",
|
||||
modelProvider: "kimi-coding",
|
||||
contextTokens: 262_000,
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-opus-4-5",
|
||||
});
|
||||
const sessionStore = { [sessionKey]: sessionEntry };
|
||||
|
||||
const state = await createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg: undefined,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: defaultProvider,
|
||||
model: defaultModel,
|
||||
hasModelDirective: false,
|
||||
});
|
||||
const state = await resolveState(
|
||||
makeEntry({
|
||||
model: "k2p5",
|
||||
modelProvider: "kimi-coding",
|
||||
contextTokens: 262_000,
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-opus-4-5",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.provider).toBe("anthropic");
|
||||
expect(state.model).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("uses default provider when providerOverride is not set but modelOverride is", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:main";
|
||||
const sessionEntry = makeEntry({
|
||||
modelOverride: "deepseek-v3-4bit-mlx",
|
||||
});
|
||||
const sessionStore = { [sessionKey]: sessionEntry };
|
||||
|
||||
const state = await createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg: undefined,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: defaultProvider,
|
||||
model: defaultModel,
|
||||
hasModelDirective: false,
|
||||
});
|
||||
const state = await resolveState(
|
||||
makeEntry({
|
||||
modelOverride: "deepseek-v3-4bit-mlx",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.provider).toBe(defaultProvider);
|
||||
expect(state.model).toBe("deepseek-v3-4bit-mlx");
|
||||
|
||||
Reference in New Issue
Block a user