Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion extensions/mattermost/src/mattermost/monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ describe("resolveMattermostReplyRootId with block streaming payloads", () => {
// mode, the deliver callback should still use the existing threadRootId.
expect(
resolveMattermostReplyRootId({
kind: "channel",
threadRootId: "thread-root-1",
replyToId: "streamed-reply-id",
}),
Expand All @@ -129,6 +130,7 @@ describe("resolveMattermostReplyRootId with block streaming payloads", () => {
// inbound post id as replyToId from the "all" threading mode.
expect(
resolveMattermostReplyRootId({
kind: "channel",
replyToId: "inbound-post-for-threading",
}),
).toBe("inbound-post-for-threading");
Expand All @@ -139,6 +141,7 @@ describe("resolveMattermostReplyRootId", () => {
it("uses replyToId for top-level replies", () => {
expect(
resolveMattermostReplyRootId({
kind: "channel",
replyToId: "inbound-post-123",
}),
).toBe("inbound-post-123");
Expand All @@ -147,14 +150,36 @@ describe("resolveMattermostReplyRootId", () => {
it("keeps the thread root when replying inside an existing thread", () => {
expect(
resolveMattermostReplyRootId({
kind: "channel",
threadRootId: "thread-root-456",
replyToId: "child-post-789",
}),
).toBe("thread-root-456");
});

it("falls back to undefined when neither reply target is available", () => {
expect(resolveMattermostReplyRootId({})).toBeUndefined();
expect(resolveMattermostReplyRootId({ kind: "channel" })).toBeUndefined();
});

it("returns undefined for direct messages regardless of replyToId", () => {
// DMs must never be threaded even if a downstream payload carries replyToId.
// Fixes: DM replies created threads despite replyToMode always being "off" for DMs.
expect(
resolveMattermostReplyRootId({
kind: "direct",
replyToId: "triggering-post-id",
}),
).toBeUndefined();
});

it("returns undefined for direct messages regardless of threadRootId", () => {
expect(
resolveMattermostReplyRootId({
kind: "direct",
threadRootId: "some-thread-root",
replyToId: "some-post",
}),
).toBeUndefined();
});
});

Expand Down
9 changes: 9 additions & 0 deletions extensions/mattermost/src/mattermost/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,15 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
}

export function resolveMattermostReplyRootId(params: {
kind: ChatType;
threadRootId?: string;
replyToId?: string;
}): string | undefined {
// Direct messages must never be threaded — even if a downstream payload
// carries a replyToId, honour the DM-always-off threading policy here.
if (params.kind === "direct") {
return undefined;
}
const threadRootId = params.threadRootId?.trim();
if (threadRootId) {
return threadRootId;
Expand Down Expand Up @@ -528,6 +534,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
agentId: route.agentId,
replyToId: resolveMattermostReplyRootId({
kind,
threadRootId: threadContext.effectiveReplyToId,
replyToId: payload.replyToId,
}),
Expand Down Expand Up @@ -735,6 +742,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
agentId: params.route.agentId,
replyToId: resolveMattermostReplyRootId({
kind: params.kind,
threadRootId: params.effectiveReplyToId,
replyToId: trimmedPayload.replyToId,
}),
Expand Down Expand Up @@ -1446,6 +1454,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
agentId: route.agentId,
replyToId: resolveMattermostReplyRootId({
kind,
threadRootId: effectiveReplyToId,
replyToId: payload.replyToId,
}),
Expand Down
24 changes: 24 additions & 0 deletions extensions/xiaomi/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { streamSimple } from "@mariozechner/pi-ai";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { PROVIDER_LABELS } from "openclaw/plugin-sdk/provider-usage";
import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "./onboard.js";
Expand Down Expand Up @@ -39,5 +40,28 @@ export default defineSingleProviderPluginEntry({
displayName: PROVIDER_LABELS.xiaomi,
windows: [],
}),
wrapStreamFn: (ctx) => {
// MiMo reasoning models (mimo-v2-pro, mimo-v2-omni) output the full
// response to `reasoning_content` with an empty `content` field, which
// causes OpenClaw to emit no visible text to the user. Setting
// `enable_thinking: false` in the request payload tells the model to
// write its reply to the standard `content` field instead.
if (!ctx.model?.reasoning) {
return null;
}
const underlying = ctx.streamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
(payload as Record<string, unknown>).enable_thinking = false;
}
return originalOnPayload?.(payload, model);
},
});
};
},
},
});
89 changes: 88 additions & 1 deletion extensions/xiaomi/onboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createLegacyProviderConfig } from "../../test/helpers/plugins/onboard-config.js";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import plugin from "./index.js";
import { applyXiaomiConfig, applyXiaomiProviderConfig } from "./onboard.js";

describe("xiaomi onboard", () => {
Expand Down Expand Up @@ -39,3 +41,88 @@ describe("xiaomi onboard", () => {
]);
});
});

describe("xiaomi wrapStreamFn", () => {
function buildBaseStreamFn() {
let capturedPayload: Record<string, unknown> | undefined;
const baseStreamFn = vi.fn((_model: unknown, _context: unknown, options: unknown) => {
const opts = options as { onPayload?: (p: unknown, m: unknown) => unknown } | undefined;
const payload: Record<string, unknown> = { messages: [], stream: true };
opts?.onPayload?.(payload, _model);
capturedPayload = payload;
return {} as never;
});
return { baseStreamFn, getPayload: () => capturedPayload };
}

function getWrappedProvider() {
return registerSingleProviderPlugin({ register: (api) => plugin.register(api) });
}

it("adds enable_thinking: false for reasoning models (mimo-v2-pro)", () => {
const provider = getWrappedProvider();
const { baseStreamFn, getPayload } = buildBaseStreamFn();

const wrapped = provider.wrapStreamFn?.({
provider: "xiaomi",
modelId: "mimo-v2-pro",
model: {
api: "openai-completions",
provider: "xiaomi",
id: "mimo-v2-pro",
reasoning: true,
baseUrl: "https://api.xiaomimimo.com/v1",
} as never,
streamFn: baseStreamFn,
} as never);

expect(typeof wrapped).toBe("function");
void wrapped?.({} as never, {} as never, {});
expect(baseStreamFn).toHaveBeenCalledTimes(1);
expect(getPayload()?.enable_thinking).toBe(false);
});

it("adds enable_thinking: false for reasoning models (mimo-v2-omni)", () => {
const provider = getWrappedProvider();
const { baseStreamFn, getPayload } = buildBaseStreamFn();

const wrapped = provider.wrapStreamFn?.({
provider: "xiaomi",
modelId: "mimo-v2-omni",
model: {
api: "openai-completions",
provider: "xiaomi",
id: "mimo-v2-omni",
reasoning: true,
baseUrl: "https://api.xiaomimimo.com/v1",
} as never,
streamFn: baseStreamFn,
} as never);

expect(typeof wrapped).toBe("function");
void wrapped?.({} as never, {} as never, {});
expect(getPayload()?.enable_thinking).toBe(false);
});

it("does not add enable_thinking for non-reasoning models (mimo-v2-flash)", () => {
const provider = getWrappedProvider();
const { baseStreamFn, getPayload } = buildBaseStreamFn();

const wrapped = provider.wrapStreamFn?.({
provider: "xiaomi",
modelId: "mimo-v2-flash",
model: {
api: "openai-completions",
provider: "xiaomi",
id: "mimo-v2-flash",
reasoning: false,
baseUrl: "https://api.xiaomimimo.com/v1",
} as never,
streamFn: baseStreamFn,
} as never);

// Non-reasoning models return null (no wrapping needed)
expect(wrapped).toBeNull();
expect(getPayload()).toBeUndefined();
});
});
10 changes: 9 additions & 1 deletion src/agents/command/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,15 @@ export async function updateSessionStoreAfterAgentRun(params: {
next.compactionCount = (entry.compactionCount ?? 0) + compactionsThisRun;
}
const persisted = await updateSessionStore(storePath, (store) => {
const merged = mergeSessionEntry(store[sessionKey], next);
// Omit status from next so that the terminal status already written to disk
// by persistGatewaySessionLifecycleEvent is not clobbered by a stale
// in-memory "running" value. The in-memory sessionStore is loaded during
// session initialisation (before the run) and never updated by the lifecycle
// handler, so it can still carry status: "running" by the time this
// post-run write executes — even though the lifecycle end event has already
// persisted a terminal status (#60250).
const { status: _status, ...patch } = next;
const merged = mergeSessionEntry(store[sessionKey], patch);
store[sessionKey] = merged;
return merged;
});
Expand Down
6 changes: 4 additions & 2 deletions src/agents/tool-loop-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ describe("tool-loop-detection", () => {
}
});

it("keeps generic loops warn-only below global breaker threshold", () => {
it("escalates generic repeat loops to critical at criticalThreshold", () => {
const fixture = createReadNoProgressFixture();
const loopResult = detectLoopAfterRepeatedCalls({
toolName: fixture.toolName,
Expand All @@ -309,7 +309,9 @@ describe("tool-loop-detection", () => {
});
expect(loopResult.stuck).toBe(true);
if (loopResult.stuck) {
expect(loopResult.level).toBe("warning");
expect(loopResult.level).toBe("critical");
expect(loopResult.detector).toBe("generic_repeat");
expect(loopResult.message).toContain("CRITICAL");
}
});

Expand Down
20 changes: 19 additions & 1 deletion src/agents/tool-loop-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,11 +470,29 @@ export function detectToolCallLoop(
};
}

// Generic detector: warn-only for repeated identical calls.
// Generic detector: warn then block for repeated identical calls.
const recentCount = history.filter(
(h) => h.toolName === toolName && h.argsHash === currentHash,
).length;

if (
!knownPollTool &&
resolvedConfig.detectors.genericRepeat &&
recentCount >= resolvedConfig.criticalThreshold
) {
log.error(
`Critical loop detected: ${toolName} called ${recentCount} times with identical arguments`,
);
return {
stuck: true,
level: "critical",
detector: "generic_repeat",
count: recentCount,
message: `CRITICAL: You have called ${toolName} ${recentCount} times with identical arguments and are making no progress. Session execution blocked to prevent runaway loops.`,
warningKey: `generic:${toolName}:${currentHash}`,
};
}

if (
!knownPollTool &&
resolvedConfig.detectors.genericRepeat &&
Expand Down
64 changes: 64 additions & 0 deletions src/commands/agent/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,70 @@ describe("updateSessionStoreAfterAgentRun", () => {
expect(staleInMemory[sessionKey]?.acp).toBeDefined();
});

it("does not clobber terminal status already written to disk by lifecycle handler", async () => {
// Regression test for #60250.
// persistGatewaySessionLifecycleEvent (fire-and-forget) writes the terminal
// status to disk before updateSessionStoreAfterAgentRun runs. The in-memory
// sessionStore carries a stale status: "running" because it was loaded before
// the lifecycle end event fired. This test asserts that the stale in-memory
// status does not overwrite the terminal status on disk.
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-"));
const storePath = path.join(dir, "sessions.json");
const sessionKey = `agent:default:lifecycle:${randomUUID()}`;
const sessionId = randomUUID();
const endedAt = Date.now();

// Disk already has the terminal state written by the lifecycle end handler.
const diskState: SessionEntry = {
sessionId,
updatedAt: endedAt,
status: "done",
endedAt,
runtimeMs: 1234,
};
await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: diskState }, null, 2), "utf8");

// In-memory sessionStore has a stale status: "running" (loaded before the run ended).
const staleInMemory: Record<string, SessionEntry> = {
[sessionKey]: {
sessionId,
updatedAt: endedAt - 2000,
status: "running",
},
};

await updateSessionStoreAfterAgentRun({
cfg: {} as never,
sessionId,
sessionKey,
storePath,
sessionStore: staleInMemory,
defaultProvider: "openai-codex",
defaultModel: "gpt-5.4",
result: {
payloads: [],
meta: {
aborted: false,
agentMeta: {
provider: "openai-codex",
model: "gpt-5.4",
usage: { input: 100, output: 50 },
},
},
} as never,
});

const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey];
// Terminal status must be preserved, not overwritten with stale "running".
expect(persisted?.status).toBe("done");
// endedAt and runtimeMs written by the lifecycle handler must be preserved.
expect(persisted?.endedAt).toBe(endedAt);
expect(persisted?.runtimeMs).toBe(1234);
// Token fields from this run should have been merged in.
expect(persisted?.inputTokens).toBe(100);
expect(persisted?.outputTokens).toBe(50);
});

it("persists latest systemPromptReport for downstream warning dedupe", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-"));
const storePath = path.join(dir, "sessions.json");
Expand Down
13 changes: 13 additions & 0 deletions src/infra/provider-usage.fetch.minimax.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ describe("fetchMinimaxUsage", () => {
windows: [{ label: "5h", usedPercent: 75, resetAt: 1_700_000_000_000 }],
},
},
{
name: "inverts usage_percent when no count fields are present (remaining→used)",
payload: {
data: {
usage_percent: 98,
plan_name: "Coding Plan",
},
},
expected: {
plan: "Coding Plan",
windows: [{ label: "5h", usedPercent: 2, resetAt: undefined }],
},
},
{
name: "falls back to payload-level reset and plan when nested usage records omit them",
payload: {
Expand Down
Loading
Loading