fix(ci): restore main lint/typecheck after direct merges

This commit is contained in:
Peter Steinberger
2026-02-16 23:26:02 +00:00
parent 076df941a3
commit eaa2f7a7bf
29 changed files with 3025 additions and 2575 deletions

View File

@@ -3,23 +3,25 @@
在你的项目中导入:
```javascript
const translations = require('./translations/zh-CN.json');
console.log(translations['Save']); // 输出:保存
const translations = require("./translations/zh-CN.json");
console.log(translations["Save"]); // 输出:保存
```
## 继续翻译工作
1. **提取 OpenClaw 界面字符串**
1. **提取 OpenClaw 界面字符串**
```bash
node scripts/extract-strings.js
```
2. **过滤真正的界面文本**
2. **过滤真正的界面文本**
```bash
node scripts/filter-real-ui.js
```
3. **翻译剩余的字符串**
3. **翻译剩余的字符串**
- 编辑 `translations/ui-only.json`
## 🛠️ 工具说明
@@ -64,10 +66,12 @@ extensions/openclaw-zh-cn-ui/
## 📈 路线图
### 短期目标
- 完成剩余翻译
- 提交 Pull Request
### 长期目标
- 支持更多语言
- 创建翻译平台

View File

@@ -2,7 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { whatsappPlugin } from "./channel.js";
// Mock runtime
const mockSendMessageWhatsApp = vi.fn().mockResolvedValue({ messageId: "123", toJid: "123@s.whatsapp.net" });
const mockSendMessageWhatsApp = vi
.fn()
.mockResolvedValue({ messageId: "123", toJid: "123@s.whatsapp.net" });
vi.mock("./runtime.js", () => ({
getWhatsAppRuntime: () => ({
@@ -35,7 +37,7 @@ describe("whatsappPlugin.outbound.sendText", () => {
"http://example.com",
expect.objectContaining({
linkPreview: false,
})
}),
);
});
@@ -50,7 +52,7 @@ describe("whatsappPlugin.outbound.sendText", () => {
"hello",
expect.objectContaining({
linkPreview: undefined,
})
}),
);
});
});

View File

@@ -73,10 +73,18 @@ async function listJsonlFiles(dir: string): Promise<string[]> {
function safeParseLine(line: string): CronRunLogEntry | null {
try {
const obj = JSON.parse(line) as Partial<CronRunLogEntry> | null;
if (!obj || typeof obj !== "object") return null;
if (obj.action !== "finished") return null;
if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) return null;
if (typeof obj.jobId !== "string" || !obj.jobId.trim()) return null;
if (!obj || typeof obj !== "object") {
return null;
}
if (obj.action !== "finished") {
return null;
}
if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) {
return null;
}
if (typeof obj.jobId !== "string" || !obj.jobId.trim()) {
return null;
}
return obj as CronRunLogEntry;
} catch {
return null;
@@ -91,7 +99,8 @@ export async function main() {
const args = parseArgs(process.argv);
const store = typeof args.store === "string" ? args.store : undefined;
const runsDirArg = typeof args.runsDir === "string" ? args.runsDir : undefined;
const runsDir = runsDirArg ?? (store ? path.join(path.dirname(path.resolve(store)), "runs") : null);
const runsDir =
runsDirArg ?? (store ? path.join(path.dirname(path.resolve(store)), "runs") : null);
if (!runsDir) {
usageAndExit(2);
}
@@ -138,19 +147,31 @@ export async function main() {
for (const file of files) {
const raw = await fs.readFile(file, "utf-8").catch(() => "");
if (!raw.trim()) continue;
if (!raw.trim()) {
continue;
}
const lines = raw.split("\n");
for (const line of lines) {
const entry = safeParseLine(line.trim());
if (!entry) continue;
if (entry.ts < fromMs || entry.ts > toMs) continue;
if (filterJobId && entry.jobId !== filterJobId) continue;
if (!entry) {
continue;
}
if (entry.ts < fromMs || entry.ts > toMs) {
continue;
}
if (filterJobId && entry.jobId !== filterJobId) {
continue;
}
const model = (entry.model ?? "<unknown>").trim() || "<unknown>";
if (filterModel && model !== filterModel) continue;
if (filterModel && model !== filterModel) {
continue;
}
const jobId = entry.jobId;
const usage = entry.usage;
const hasUsage = Boolean(usage && (usage.total_tokens ?? usage.input_tokens ?? usage.output_tokens) !== undefined);
const hasUsage = Boolean(
usage && (usage.total_tokens ?? usage.input_tokens ?? usage.output_tokens) !== undefined,
);
const jobAgg = (totalsByJob[jobId] ??= {
jobId,
@@ -219,8 +240,12 @@ export async function main() {
console.log(`Cron usage report`);
console.log(` runsDir: ${runsDir}`);
console.log(` window: ${new Date(fromMs).toISOString()}${new Date(toMs).toISOString()}`);
if (filterJobId) console.log(` filter jobId: ${filterJobId}`);
if (filterModel) console.log(` filter model: ${filterModel}`);
if (filterJobId) {
console.log(` filter jobId: ${filterJobId}`);
}
if (filterModel) {
console.log(` filter model: ${filterModel}`);
}
console.log("");
if (rows.length === 0) {

View File

@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { applyExtraParamsToAgent } from "./extra-params.js";
// Mock streamSimple for testing
@@ -13,7 +12,6 @@ vi.mock("@mariozechner/pi-ai", () => ({
describe("extra-params: Z.AI tool_stream support", () => {
it("should inject tool_stream=true for zai provider by default", () => {
const capturedPayloads: unknown[] = [];
const mockStreamFn: StreamFn = vi.fn((model, context, options) => {
// Capture the payload that would be sent
options?.onPayload?.({ model: model.id, messages: [] });
@@ -24,7 +22,7 @@ describe("extra-params: Z.AI tool_stream support", () => {
content: [{ type: "text", text: "ok" }],
stopReason: "stop",
}),
} as any;
} as unknown as ReturnType<StreamFn>;
});
const agent = { streamFn: mockStreamFn };
@@ -34,7 +32,12 @@ describe("extra-params: Z.AI tool_stream support", () => {
},
};
applyExtraParamsToAgent(agent, cfg as any, "zai", "glm-5");
applyExtraParamsToAgent(
agent,
cfg as unknown as Parameters<typeof applyExtraParamsToAgent>[1],
"zai",
"glm-5",
);
// The streamFn should be wrapped
expect(agent.streamFn).toBeDefined();
@@ -42,33 +45,44 @@ describe("extra-params: Z.AI tool_stream support", () => {
});
it("should not inject tool_stream for non-zai providers", () => {
const mockStreamFn: StreamFn = vi.fn(() => ({
push: vi.fn(),
result: vi.fn().mockResolvedValue({
role: "assistant",
content: [{ type: "text", text: "ok" }],
stopReason: "stop",
}),
} as any));
const mockStreamFn: StreamFn = vi.fn(
() =>
({
push: vi.fn(),
result: vi.fn().mockResolvedValue({
role: "assistant",
content: [{ type: "text", text: "ok" }],
stopReason: "stop",
}),
}) as unknown as ReturnType<StreamFn>,
);
const agent = { streamFn: mockStreamFn };
const cfg = {};
applyExtraParamsToAgent(agent, cfg as any, "anthropic", "claude-opus-4-6");
applyExtraParamsToAgent(
agent,
cfg as unknown as Parameters<typeof applyExtraParamsToAgent>[1],
"anthropic",
"claude-opus-4-6",
);
// Should remain unchanged (except for OpenAI wrapper)
expect(agent.streamFn).toBeDefined();
});
it("should allow disabling tool_stream via params", () => {
const mockStreamFn: StreamFn = vi.fn(() => ({
push: vi.fn(),
result: vi.fn().mockResolvedValue({
role: "assistant",
content: [{ type: "text", text: "ok" }],
stopReason: "stop",
}),
} as any));
const mockStreamFn: StreamFn = vi.fn(
() =>
({
push: vi.fn(),
result: vi.fn().mockResolvedValue({
role: "assistant",
content: [{ type: "text", text: "ok" }],
stopReason: "stop",
}),
}) as unknown as ReturnType<StreamFn>,
);
const agent = { streamFn: mockStreamFn };
const cfg = {
@@ -85,7 +99,12 @@ describe("extra-params: Z.AI tool_stream support", () => {
},
};
applyExtraParamsToAgent(agent, cfg as any, "zai", "glm-5");
applyExtraParamsToAgent(
agent,
cfg as unknown as Parameters<typeof applyExtraParamsToAgent>[1],
"zai",
"glm-5",
);
// The tool_stream wrapper should be applied but with enabled=false
// In this case, it should just return the underlying streamFn

View File

@@ -1,10 +1,10 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { TextContent } from "@mariozechner/pi-ai";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type {
PluginHookBeforeMessageWriteEvent,
PluginHookBeforeMessageWriteResult,
} from "../plugins/types.js";
import type { TextContent } from "@mariozechner/pi-ai";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { HARD_MAX_TOOL_RESULT_CHARS } from "./pi-embedded-runner/tool-result-truncation.js";
import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js";
@@ -132,10 +132,16 @@ export function installSessionToolResultGuard(
* or null if the message should be blocked.
*/
const applyBeforeWriteHook = (msg: AgentMessage): AgentMessage | null => {
if (!beforeWrite) return msg;
if (!beforeWrite) {
return msg;
}
const result = beforeWrite({ message: msg });
if (result?.block) return null;
if (result?.message) return result.message;
if (result?.block) {
return null;
}
if (result?.message) {
return result.message;
}
return msg;
};
@@ -192,7 +198,9 @@ export function installSessionToolResultGuard(
isSynthetic: false,
}),
);
if (!persisted) return undefined;
if (!persisted) {
return undefined;
}
return originalAppend(persisted as never);
}
@@ -213,7 +221,9 @@ export function installSessionToolResultGuard(
}
const finalMessage = applyBeforeWriteHook(persistMessage(nextMessage));
if (!finalMessage) return undefined;
if (!finalMessage) {
return undefined;
}
const result = originalAppend(finalMessage as never);
const sessionFile = (

View File

@@ -97,10 +97,10 @@ function generateHtml(sessionData: SessionData): string {
// Build CSS with theme variables
const css = templateCss
.replace("{{THEME_VARS}}", themeVars)
.replace("{{BODY_BG}}", bodyBg)
.replace("{{CONTAINER_BG}}", containerBg)
.replace("{{INFO_BG}}", infoBg);
.replace("/* {{THEME_VARS}} */", themeVars.trim())
.replace("/* {{BODY_BG_DECL}} */", `--body-bg: ${bodyBg};`)
.replace("/* {{CONTAINER_BG_DECL}} */", `--container-bg: ${containerBg};`)
.replace("/* {{INFO_BG_DECL}} */", `--info-bg: ${infoBg};`);
return template
.replace("{{CSS}}", css)
@@ -234,7 +234,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro
const args = parseExportArgs(params.command.commandBodyNormalized);
// 1. Resolve session file
const sessionEntry = params.sessionEntry as SessionEntry | undefined;
const sessionEntry = params.sessionEntry;
if (!sessionEntry?.sessionId) {
return { text: "❌ No active session found." };
}

View File

@@ -31,7 +31,7 @@ function trimMeshPlanCache() {
return;
}
const oldest = [...meshPlanCache.entries()]
.sort((a, b) => a[1].createdAt - b[1].createdAt)
.toSorted((a, b) => a[1].createdAt - b[1].createdAt)
.slice(0, meshPlanCache.size - MAX_CACHED_MESH_PLANS);
for (const [key] of oldest) {
meshPlanCache.delete(key);
@@ -110,7 +110,10 @@ function putCachedPlan(params: Parameters<CommandHandler>[0], plan: MeshPlanShap
trimMeshPlanCache();
}
function getCachedPlan(params: Parameters<CommandHandler>[0], planId: string): MeshPlanShape | null {
function getCachedPlan(
params: Parameters<CommandHandler>[0],
planId: string,
): MeshPlanShape | null {
return meshPlanCache.get(cacheKeyForPlan(params, planId))?.plan ?? null;
}
@@ -190,7 +193,9 @@ export const handleMeshCommand: CommandHandler = async (params, allowTextCommand
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(`Ignoring /mesh from unauthorized sender: ${params.command.senderId || "<unknown>"}`);
logVerbose(
`Ignoring /mesh from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (!parsed.ok) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +1,88 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Export</title>
<style>
{{CSS}}
</style>
</head>
<body>
<button id="hamburger" title="Open sidebar"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><rect x="5" y="6" width="2" height="12"/><path d="M6 12h10c1 0 2 0 2-2V8"/></svg></button>
<div id="sidebar-overlay"></div>
<div id="app">
<aside id="sidebar">
<div class="sidebar-header">
<div class="sidebar-controls">
<input type="text" class="sidebar-search" id="tree-search" placeholder="Search...">
</div>
<div class="sidebar-filters">
<button class="filter-btn active" data-filter="default" title="Hide settings entries">Default</button>
<button class="filter-btn" data-filter="no-tools" title="Default minus tool results">No-tools</button>
<button class="filter-btn" data-filter="user-only" title="Only user messages">User</button>
<button class="filter-btn" data-filter="labeled-only" title="Only labeled entries">Labeled</button>
<button class="filter-btn" data-filter="all" title="Show everything">All</button>
<button class="sidebar-close" id="sidebar-close" title="Close"></button>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Session Export</title>
<style>
{{CSS}}
</style>
</head>
<body>
<button id="hamburger" title="Open sidebar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<circle cx="6" cy="6" r="2.5" />
<circle cx="6" cy="18" r="2.5" />
<circle cx="18" cy="12" r="2.5" />
<rect x="5" y="6" width="2" height="12" />
<path d="M6 12h10c1 0 2 0 2-2V8" />
</svg>
</button>
<div id="sidebar-overlay"></div>
<div id="app">
<aside id="sidebar">
<div class="sidebar-header">
<div class="sidebar-controls">
<input type="text" class="sidebar-search" id="tree-search" placeholder="Search..." />
</div>
<div class="sidebar-filters">
<button class="filter-btn active" data-filter="default" title="Hide settings entries">
Default
</button>
<button class="filter-btn" data-filter="no-tools" title="Default minus tool results">
No-tools
</button>
<button class="filter-btn" data-filter="user-only" title="Only user messages">
User
</button>
<button class="filter-btn" data-filter="labeled-only" title="Only labeled entries">
Labeled
</button>
<button class="filter-btn" data-filter="all" title="Show everything">All</button>
<button class="sidebar-close" id="sidebar-close" title="Close"></button>
</div>
</div>
<div class="tree-container" id="tree-container"></div>
<div class="tree-status" id="tree-status"></div>
</aside>
<main id="content">
<div id="header-container"></div>
<div id="messages"></div>
</main>
<div id="image-modal" class="image-modal">
<img id="modal-image" src="" alt="" />
</div>
<div class="tree-container" id="tree-container"></div>
<div class="tree-status" id="tree-status"></div>
</aside>
<main id="content">
<div id="header-container"></div>
<div id="messages"></div>
</main>
<div id="image-modal" class="image-modal">
<img id="modal-image" src="" alt="">
</div>
</div>
<script id="session-data" type="application/json">{{SESSION_DATA}}</script>
<script id="session-data" type="application/json">
{{SESSION_DATA}}
</script>
<!-- Vendored libraries -->
<script>{{MARKED_JS}}</script>
<!-- Vendored libraries -->
<script>
{
{
MARKED_JS;
}
}
</script>
<!-- highlight.js -->
<script>{{HIGHLIGHT_JS}}</script>
<!-- highlight.js -->
<script>
{
{
HIGHLIGHT_JS;
}
}
</script>
<!-- Main application code -->
<script>
{{JS}}
</script>
</body>
<!-- Main application code -->
<script>
{
{
JS;
}
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -130,7 +130,9 @@ export async function initSessionState(params: {
// Stale cache (especially with multiple gateway processes or on Windows where
// mtime granularity may miss rapid writes) can cause incorrect sessionId
// generation, leading to orphaned transcript files. See #17971.
const sessionStore: Record<string, SessionEntry> = loadSessionStore(storePath, { skipCache: true });
const sessionStore: Record<string, SessionEntry> = loadSessionStore(storePath, {
skipCache: true,
});
let sessionKey: string | undefined;
let sessionEntry: SessionEntry;

View File

@@ -235,31 +235,6 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question =
readStringParam(params, "pollQuestion") ??
readStringParam(params, "question", { required: true });
const options =
readStringArrayParam(params, "pollOption") ?? readStringArrayParam(params, "options");
const threadId = readStringParam(params, "threadId");
const replyTo = readStringParam(params, "replyTo");
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
return await handleTelegramAction(
{
action: "sendPoll",
to,
question,
options,
replyTo: replyTo != null ? Number(replyTo) : undefined,
threadId: threadId != null ? Number(threadId) : undefined,
silent,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};

View File

@@ -36,6 +36,7 @@ function createActionIO(params: { action: DaemonAction; json: boolean }) {
message?: string;
error?: string;
hints?: string[];
warnings?: string[];
service?: {
label: string;
loaded: boolean;

View File

@@ -171,10 +171,7 @@ export function loadSessionStore(
let store: Record<string, SessionEntry> = {};
let mtimeMs = getFileMtimeMs(storePath);
const maxReadAttempts = process.platform === "win32" ? 3 : 1;
const retryBuf =
maxReadAttempts > 1
? new Int32Array(new SharedArrayBuffer(4))
: undefined;
const retryBuf = maxReadAttempts > 1 ? new Int32Array(new SharedArrayBuffer(4)) : undefined;
for (let attempt = 0; attempt < maxReadAttempts; attempt++) {
try {
const raw = fs.readFileSync(storePath, "utf-8");
@@ -587,9 +584,7 @@ async function saveSessionStoreUnlocked(
// Final attempt failed — skip this save. The write lock ensures
// the next save will retry with fresh data. Log for diagnostics.
if (i === 4) {
console.warn(
`[session-store] rename failed after 5 attempts: ${storePath}`,
);
console.warn(`[session-store] rename failed after 5 attempts: ${storePath}`);
}
}
}

View File

@@ -641,7 +641,13 @@ export async function runCronIsolatedAgentTurn(params: {
}
} catch (err) {
if (!deliveryBestEffort) {
return withRunSession({ status: "error", summary, outputText, error: String(err), ...telemetry });
return withRunSession({
status: "error",
summary,
outputText,
error: String(err),
...telemetry,
});
}
}
} else if (synthesizedText) {
@@ -739,7 +745,13 @@ export async function runCronIsolatedAgentTurn(params: {
}
} catch (err) {
if (!deliveryBestEffort) {
return withRunSession({ status: "error", summary, outputText, error: String(err), ...telemetry });
return withRunSession({
status: "error",
summary,
outputText,
error: String(err),
...telemetry,
});
}
logWarn(`[cron:${params.job.id}] ${String(err)}`);
}

View File

@@ -8,10 +8,7 @@ vi.mock("../../config/sessions.js", () => ({
resolveSessionResetPolicy: vi.fn().mockReturnValue({ mode: "idle", idleMinutes: 60 }),
}));
import {
loadSessionStore,
evaluateSessionFreshness,
} from "../../config/sessions.js";
import { loadSessionStore, evaluateSessionFreshness } from "../../config/sessions.js";
import { resolveCronSession } from "./session.js";
describe("resolveCronSession", () => {
@@ -153,7 +150,11 @@ describe("resolveCronSession", () => {
"webhook:stable-key": {
updatedAt: Date.now() - 1000,
modelOverride: "some-model",
} as any,
} as unknown as {
sessionId: string;
updatedAt: number;
modelOverride?: string;
},
});
vi.mocked(evaluateSessionFreshness).mockReturnValue({ fresh: true });

View File

@@ -106,6 +106,10 @@ export async function readCronRunLogEntries(
if (jobId && obj.jobId !== jobId) {
continue;
}
const usage =
obj.usage && typeof obj.usage === "object"
? (obj.usage as Record<string, unknown>)
: undefined;
const entry: CronRunLogEntry = {
ts: obj.ts,
jobId: obj.jobId,
@@ -117,26 +121,20 @@ export async function readCronRunLogEntries(
durationMs: obj.durationMs,
nextRunAtMs: obj.nextRunAtMs,
model: typeof obj.model === "string" && obj.model.trim() ? obj.model : undefined,
provider: typeof obj.provider === "string" && obj.provider.trim() ? obj.provider : undefined,
usage:
obj.usage && typeof obj.usage === "object"
? {
input_tokens:
typeof (obj.usage as any).input_tokens === "number" ? (obj.usage as any).input_tokens : undefined,
output_tokens:
typeof (obj.usage as any).output_tokens === "number" ? (obj.usage as any).output_tokens : undefined,
total_tokens:
typeof (obj.usage as any).total_tokens === "number" ? (obj.usage as any).total_tokens : undefined,
cache_read_tokens:
typeof (obj.usage as any).cache_read_tokens === "number"
? (obj.usage as any).cache_read_tokens
: undefined,
cache_write_tokens:
typeof (obj.usage as any).cache_write_tokens === "number"
? (obj.usage as any).cache_write_tokens
: undefined,
}
: undefined,
provider:
typeof obj.provider === "string" && obj.provider.trim() ? obj.provider : undefined,
usage: usage
? {
input_tokens: typeof usage.input_tokens === "number" ? usage.input_tokens : undefined,
output_tokens:
typeof usage.output_tokens === "number" ? usage.output_tokens : undefined,
total_tokens: typeof usage.total_tokens === "number" ? usage.total_tokens : undefined,
cache_read_tokens:
typeof usage.cache_read_tokens === "number" ? usage.cache_read_tokens : undefined,
cache_write_tokens:
typeof usage.cache_write_tokens === "number" ? usage.cache_write_tokens : undefined,
}
: undefined,
};
if (typeof obj.sessionId === "string" && obj.sessionId.trim().length > 0) {
entry.sessionId = obj.sessionId;

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { auditGatewayServiceConfig, checkTokenDrift, SERVICE_AUDIT_CODES } from "./service-audit.js";
import {
auditGatewayServiceConfig,
checkTokenDrift,
SERVICE_AUDIT_CODES,
} from "./service-audit.js";
import { buildMinimalServicePath } from "./service-env.js";
describe("auditGatewayServiceConfig", () => {

View File

@@ -1,10 +1,17 @@
import { describe, it, expect, vi } from "vitest";
import { ChannelType } from "@buape/carbon";
import { describe, it, expect, vi } from "vitest";
import { maybeCreateDiscordAutoThread } from "./threading.js";
describe("maybeCreateDiscordAutoThread", () => {
const mockClient = { rest: { post: vi.fn(), get: vi.fn() } } as any;
const mockMessage = { id: "msg1", timestamp: "123" } as any;
const postMock = vi.fn();
const getMock = vi.fn();
const mockClient = {
rest: { post: postMock, get: getMock },
} as unknown as Parameters<typeof maybeCreateDiscordAutoThread>[0]["client"];
const mockMessage = {
id: "msg1",
timestamp: "123",
} as unknown as Parameters<typeof maybeCreateDiscordAutoThread>[0]["message"];
it("skips auto-thread if channelType is GuildForum", async () => {
const result = await maybeCreateDiscordAutoThread({
@@ -18,7 +25,7 @@ describe("maybeCreateDiscordAutoThread", () => {
combinedBody: "test",
});
expect(result).toBeUndefined();
expect(mockClient.rest.post).not.toHaveBeenCalled();
expect(postMock).not.toHaveBeenCalled();
});
it("skips auto-thread if channelType is GuildMedia", async () => {
@@ -33,11 +40,11 @@ describe("maybeCreateDiscordAutoThread", () => {
combinedBody: "test",
});
expect(result).toBeUndefined();
expect(mockClient.rest.post).not.toHaveBeenCalled();
expect(postMock).not.toHaveBeenCalled();
});
it("creates auto-thread if channelType is GuildText", async () => {
mockClient.rest.post.mockResolvedValueOnce({ id: "thread1" });
postMock.mockResolvedValueOnce({ id: "thread1" });
const result = await maybeCreateDiscordAutoThread({
client: mockClient,
message: mockMessage,
@@ -49,6 +56,6 @@ describe("maybeCreateDiscordAutoThread", () => {
combinedBody: "test",
});
expect(result).toBe("thread1");
expect(mockClient.rest.post).toHaveBeenCalled();
expect(postMock).toHaveBeenCalled();
});
});

View File

@@ -73,7 +73,10 @@ describe("mesh handlers", () => {
it("runs steps in DAG order and supports retrying failed steps", async () => {
const runState = new Map<string, "ok" | "error">();
mocks.agent.mockImplementation(
(opts: { params: { idempotencyKey: string }; respond: (ok: boolean, payload?: unknown) => void }) => {
(opts: {
params: { idempotencyKey: string };
respond: (ok: boolean, payload?: unknown) => void;
}) => {
const agentRunId = `agent-${opts.params.idempotencyKey}`;
runState.set(agentRunId, "ok");
if (opts.params.idempotencyKey.includes(":review:1")) {
@@ -120,7 +123,10 @@ describe("mesh handlers", () => {
// Make subsequent retries succeed
mocks.agent.mockImplementation(
(opts: { params: { idempotencyKey: string }; respond: (ok: boolean, payload?: unknown) => void }) => {
(opts: {
params: { idempotencyKey: string };
respond: (ok: boolean, payload?: unknown) => void;
}) => {
const agentRunId = `agent-${opts.params.idempotencyKey}`;
runState.set(agentRunId, "ok");
opts.respond(true, { runId: agentRunId, status: "accepted" });

View File

@@ -1,8 +1,8 @@
import { randomUUID } from "node:crypto";
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers, RespondFn } from "./types.js";
import { agentCommand } from "../../commands/agent.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers, RespondFn } from "./types.js";
import {
ErrorCodes,
errorShape,
@@ -12,8 +12,6 @@ import {
validateMeshRetryParams,
validateMeshRunParams,
validateMeshStatusParams,
type MeshPlanAutoParams,
type MeshRunParams,
type MeshWorkflowPlan,
} from "../protocol/index.js";
import { agentHandlers } from "./agent.js";
@@ -77,13 +75,27 @@ function trimMap() {
if (meshRuns.size <= MAX_KEEP_RUNS) {
return;
}
const sorted = [...meshRuns.values()].sort((a, b) => a.startedAt - b.startedAt);
const sorted = [...meshRuns.values()].toSorted((a, b) => a.startedAt - b.startedAt);
const overflow = meshRuns.size - MAX_KEEP_RUNS;
for (const stale of sorted.slice(0, overflow)) {
meshRuns.delete(stale.runId);
}
}
function stringifyUnknown(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value instanceof Error) {
return value.message;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function normalizeDependsOn(dependsOn: string[] | undefined): string[] {
if (!Array.isArray(dependsOn)) {
return [];
@@ -123,10 +135,7 @@ function normalizePlan(plan: MeshWorkflowPlan): MeshWorkflowPlan {
};
}
function createPlanFromParams(params: {
goal: string;
steps?: MeshAutoStep[];
}): MeshWorkflowPlan {
function createPlanFromParams(params: { goal: string; steps?: MeshAutoStep[] }): MeshWorkflowPlan {
const now = Date.now();
const goal = params.goal.trim();
const sourceSteps = params.steps?.length
@@ -164,7 +173,9 @@ function createPlanFromParams(params: {
};
}
function validatePlanGraph(plan: MeshWorkflowPlan): { ok: true; order: string[] } | { ok: false; error: string } {
function validatePlanGraph(
plan: MeshWorkflowPlan,
): { ok: true; order: string[] } | { ok: false; error: string } {
const ids = new Set<string>();
for (const step of plan.steps) {
if (ids.has(step.id)) {
@@ -231,7 +242,12 @@ async function callGatewayHandler(
): Promise<{ ok: boolean; payload?: unknown; error?: unknown; meta?: Record<string, unknown> }> {
return await new Promise((resolve) => {
let settled = false;
const settle = (result: { ok: boolean; payload?: unknown; error?: unknown; meta?: Record<string, unknown> }) => {
const settle = (result: {
ok: boolean;
payload?: unknown;
error?: unknown;
meta?: Record<string, unknown>;
}) => {
if (settled) {
return;
}
@@ -312,7 +328,7 @@ async function executeStep(params: {
if (!accepted.ok) {
step.status = "failed";
step.endedAt = Date.now();
step.error = String(accepted.error ?? "agent request failed");
step.error = stringifyUnknown(accepted.error ?? "agent request failed");
run.history.push({
ts: Date.now(),
type: "step.error",
@@ -369,7 +385,7 @@ async function executeStep(params: {
step.error =
typeof waitPayload?.error === "string"
? waitPayload.error
: String(waited.error ?? `agent.wait returned status ${waitStatus}`);
: stringifyUnknown(waited.error ?? `agent.wait returned status ${waitStatus}`);
run.history.push({
ts: Date.now(),
type: "step.error",
@@ -647,7 +663,8 @@ async function generateAutoPlan(params: {
const prompt = buildAutoPlannerPrompt({ goal: params.goal, maxSteps: params.maxSteps });
const timeoutSeconds = Math.ceil((params.timeoutMs ?? AUTO_PLAN_TIMEOUT_MS) / 1000);
const resolvedAgentId = normalizeAgentId(params.agentId ?? "main");
const plannerSessionKey = params.sessionKey?.trim() || `agent:${resolvedAgentId}:${PLANNER_MAIN_KEY}`;
const plannerSessionKey =
params.sessionKey?.trim() || `agent:${resolvedAgentId}:${PLANNER_MAIN_KEY}`;
try {
const runResult = await agentCommand(
@@ -732,7 +749,7 @@ export const meshHandlers: GatewayRequestHandlers = {
return;
}
const p = params as MeshPlanAutoParams;
const p = params;
const maxSteps =
typeof p.maxSteps === "number" && Number.isFinite(p.maxSteps)
? Math.max(1, Math.min(16, Math.floor(p.maxSteps)))
@@ -782,7 +799,7 @@ export const meshHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as MeshRunParams;
const p = params;
const plan = normalizePlan(p.plan);
const graph = validatePlanGraph(plan);
if (!graph.ok) {
@@ -853,7 +870,11 @@ export const meshHandlers: GatewayRequestHandlers = {
return;
}
if (run.status === "running") {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "mesh run is currently running"));
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "mesh run is currently running"),
);
return;
}
const stepIds = resolveStepIdsForRetry(run, params.stepIds);

View File

@@ -526,8 +526,9 @@ describe("gateway mesh.plan.auto scope handling", () => {
it("allows operator.write clients for mesh.plan.auto", async () => {
const { handleGatewayRequest } = await import("../server-methods.js");
const respond = vi.fn();
const handler = vi.fn(({ respond: send }: { respond: (ok: boolean, payload?: unknown) => void }) =>
send(true, { ok: true }),
const handler = vi.fn(
({ respond: send }: { respond: (ok: boolean, payload?: unknown) => void }) =>
send(true, { ok: true }),
);
await handleGatewayRequest({

View File

@@ -19,9 +19,7 @@ beforeEach(() => {
const runtime = createPluginRuntime();
setTelegramRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
]),
createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]),
);
});
@@ -99,7 +97,7 @@ describe("heartbeat transcript pruning", () => {
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
// Create a transcript with some existing content
const originalContent = await createTranscriptWithContent(transcriptPath, sessionId);
await createTranscriptWithContent(transcriptPath, sessionId);
const originalSize = (await fs.stat(transcriptPath)).size;
// Seed session store
@@ -147,7 +145,7 @@ describe("heartbeat transcript pruning", () => {
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
// Create a transcript with some existing content
const originalContent = await createTranscriptWithContent(transcriptPath, sessionId);
await createTranscriptWithContent(transcriptPath, sessionId);
const originalSize = (await fs.stat(transcriptPath)).size;
// Seed session store

View File

@@ -50,7 +50,11 @@ function resolvePrimaryIPv4(): string | undefined {
function initSelfPresence() {
const host = os.hostname();
const ip = resolvePrimaryIPv4() ?? undefined;
const version = process.env.OPENCLAW_VERSION ?? process.env.OPENCLAW_SERVICE_VERSION ?? process.env.npm_package_version ?? "unknown";
const version =
process.env.OPENCLAW_VERSION ??
process.env.OPENCLAW_SERVICE_VERSION ??
process.env.npm_package_version ??
"unknown";
const modelIdentifier = (() => {
const p = os.platform();
if (p === "darwin") {

View File

@@ -414,7 +414,6 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return { message: current };
}
// =========================================================================
// Message Write Hooks
// =========================================================================

View File

@@ -502,7 +502,7 @@ export type PluginHookBeforeMessageWriteEvent = {
};
export type PluginHookBeforeMessageWriteResult = {
block?: boolean; // If true, message is NOT written to JSONL
block?: boolean; // If true, message is NOT written to JSONL
message?: AgentMessage; // Optional: modified message to write instead
};

View File

@@ -63,7 +63,7 @@ describe("Discord Session Key Continuity", () => {
});
expect(missingIdKey).toContain("unknown");
// Should still be distinct from main
expect(missingIdKey).not.toBe("agent:main:main");
});

View File

@@ -1,4 +1,8 @@
import type { AnyMessageContent, MiscMessageGenerationOptions, WAPresence } from "@whiskeysockets/baileys";
import type {
AnyMessageContent,
MiscMessageGenerationOptions,
WAPresence,
} from "@whiskeysockets/baileys";
import type { ActiveWebSendOptions } from "../active-listener.js";
import { recordChannelActivity } from "../../infra/channel-activity.js";
import { toWhatsappJid } from "../../utils.js";
@@ -67,9 +71,11 @@ export function createWebSendApi(params: {
} else {
payload = { text };
}
const miscOptions: MiscMessageGenerationOptions = {
linkPreview: sendOptions?.linkPreview === false ? null : undefined,
};
const miscOptions: MiscMessageGenerationOptions | undefined =
sendOptions?.linkPreview === false
? // Baileys typing removed linkPreview from public options, but runtime still accepts it.
({ linkPreview: null } as unknown as MiscMessageGenerationOptions)
: undefined;
const result = await params.sock.sendMessage(jid, payload, miscOptions);
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
recordWhatsAppOutbound(accountId);

View File

@@ -83,9 +83,7 @@ export async function sendMessageWhatsApp(
? {
...(options.gifPlayback ? { gifPlayback: true } : {}),
...(documentFileName ? { fileName: documentFileName } : {}),
...(options.linkPreview !== undefined
? { linkPreview: options.linkPreview }
: {}),
...(options.linkPreview !== undefined ? { linkPreview: options.linkPreview } : {}),
accountId,
}
: undefined;