Merge branch 'main' into vincentkoc-code/slack-block-kit-interactions

This commit is contained in:
Vincent Koc
2026-03-13 14:07:31 -04:00
committed by GitHub
283 changed files with 9560 additions and 5349 deletions

View File

@@ -315,6 +315,7 @@ describe("model compat config schema", () => {
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: false,
requiresMistralToolIds: false,
requiresOpenAiAnthropicToolPayload: true,
},
},
],
@@ -360,6 +361,33 @@ describe("config strict validation", () => {
expect(res.ok).toBe(false);
});
it("accepts documented agents.list[].params overrides", () => {
const res = validateConfigObject({
agents: {
list: [
{
id: "main",
model: "anthropic/claude-opus-4-6",
params: {
cacheRetention: "none",
temperature: 0.4,
maxTokens: 8192,
},
},
],
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.list?.[0]?.params).toEqual({
cacheRetention: "none",
temperature: 0.4,
maxTokens: 8192,
});
}
});
it("flags legacy config entries without auto-migrating", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {

View File

@@ -211,4 +211,17 @@ describe("config schema regressions", () => {
expect(res.ok).toBe(true);
});
it("accepts discovery.wideArea.domain for unicast DNS-SD", () => {
const res = validateConfigObject({
discovery: {
wideArea: {
enabled: true,
domain: "openclaw.internal",
},
},
});
expect(res.ok).toBe(true);
});
});

View File

@@ -296,6 +296,7 @@ const TARGET_KEYS = [
"web.reconnect.jitter",
"web.reconnect.maxAttempts",
"discovery",
"discovery.wideArea.domain",
"discovery.wideArea.enabled",
"discovery.mdns",
"discovery.mdns.mode",

View File

@@ -292,6 +292,8 @@ export const FIELD_HELP: Record<string, string> = {
"Wide-area discovery configuration group for exposing discovery signals beyond local-link scopes. Enable only in deployments that intentionally aggregate gateway presence across sites.",
"discovery.wideArea.enabled":
"Enables wide-area discovery signaling when your environment needs non-local gateway discovery. Keep disabled unless cross-network discovery is operationally required.",
"discovery.wideArea.domain":
"Optional unicast DNS-SD domain for wide-area discovery, such as openclaw.internal. Use this when you intentionally publish gateway discovery beyond local mDNS scopes.",
"discovery.mdns":
"mDNS discovery configuration group for local network advertisement and discovery behavior tuning. Keep minimal mode for routine LAN discovery unless extra metadata is required.",
tools:
@@ -1483,7 +1485,7 @@ export const FIELD_HELP: Record<string, string> = {
"messages.statusReactions.enabled":
"Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.",
"messages.statusReactions.emojis":
"Override default status reaction emojis. Keys: thinking, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.",
"Override default status reaction emojis. Keys: thinking, compacting, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.",
"messages.statusReactions.timing":
"Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).",
"messages.inbound.debounceMs":

View File

@@ -654,6 +654,7 @@ export const FIELD_LABELS: Record<string, string> = {
discovery: "Discovery",
"discovery.wideArea": "Wide-area Discovery",
"discovery.wideArea.enabled": "Wide-area Discovery Enabled",
"discovery.wideArea.domain": "Wide-area Discovery Domain",
"discovery.mdns": "mDNS Discovery",
canvasHost: "Canvas Host",
"canvasHost.enabled": "Canvas Host Enabled",

View File

@@ -1,6 +1,7 @@
import { beforeAll, describe, expect, it } from "vitest";
import { buildConfigSchema, lookupConfigSchema } from "./schema.js";
import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
describe("config schema", () => {
type SchemaInput = NonNullable<Parameters<typeof buildConfigSchema>[0]>;
@@ -200,6 +201,51 @@ describe("config schema", () => {
expect(tags).toContain("performance");
});
it("accepts web fetch readability and firecrawl config in the runtime zod schema", () => {
const parsed = ToolsSchema.parse({
web: {
fetch: {
readability: true,
firecrawl: {
enabled: true,
apiKey: "firecrawl-test-key",
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: true,
maxAgeMs: 60_000,
timeoutSeconds: 15,
},
},
},
});
expect(parsed?.web?.fetch?.readability).toBe(true);
expect(parsed?.web?.fetch).toMatchObject({
firecrawl: {
enabled: true,
apiKey: "firecrawl-test-key",
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: true,
maxAgeMs: 60_000,
timeoutSeconds: 15,
},
});
});
it("rejects unknown keys inside web fetch firecrawl config", () => {
expect(() =>
ToolsSchema.parse({
web: {
fetch: {
firecrawl: {
enabled: true,
nope: true,
},
},
},
}),
).toThrow();
});
it("keeps tags in the allowed taxonomy", () => {
const withTags = applyDerivedTags({
"gateway.auth.token": {},

View File

@@ -324,6 +324,88 @@ describe("appendAssistantMessageToSessionTranscript", () => {
expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!");
}
});
it("does not append a duplicate delivery mirror for the same idempotency key", async () => {
const sessionId = "test-session-id";
const sessionKey = "test-session";
const store = {
[sessionKey]: {
sessionId,
chatType: "direct",
channel: "discord",
},
};
fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8");
await appendAssistantMessageToSessionTranscript({
sessionKey,
text: "Hello from delivery mirror!",
idempotencyKey: "mirror:test-source-message",
storePath: fixture.storePath(),
});
await appendAssistantMessageToSessionTranscript({
sessionKey,
text: "Hello from delivery mirror!",
idempotencyKey: "mirror:test-source-message",
storePath: fixture.storePath(),
});
const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir());
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
expect(lines.length).toBe(2);
const messageLine = JSON.parse(lines[1]);
expect(messageLine.message.idempotencyKey).toBe("mirror:test-source-message");
expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!");
});
it("ignores malformed transcript lines when checking mirror idempotency", async () => {
const sessionId = "test-session-id";
const sessionKey = "test-session";
const store = {
[sessionKey]: {
sessionId,
chatType: "direct",
channel: "discord",
},
};
fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8");
const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir());
fs.writeFileSync(
sessionFile,
[
JSON.stringify({
type: "session",
version: 1,
id: sessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
}),
"{not-json",
JSON.stringify({
type: "message",
message: {
role: "assistant",
idempotencyKey: "mirror:test-source-message",
content: [{ type: "text", text: "Hello from delivery mirror!" }],
},
}),
].join("\n") + "\n",
"utf-8",
);
const result = await appendAssistantMessageToSessionTranscript({
sessionKey,
text: "Hello from delivery mirror!",
idempotencyKey: "mirror:test-source-message",
storePath: fixture.storePath(),
});
expect(result.ok).toBe(true);
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
expect(lines.length).toBe(3);
});
});
describe("resolveAndPersistSessionFile", () => {

View File

@@ -15,6 +15,58 @@ async function resolveRealStorePath(sessionsDir: string): Promise<string> {
return fsSync.realpathSync.native(path.join(sessionsDir, "sessions.json"));
}
async function createAgentSessionStores(
root: string,
agentIds: string[],
): Promise<Record<string, string>> {
const storePaths: Record<string, string> = {};
for (const agentId of agentIds) {
const sessionsDir = path.join(root, "agents", agentId, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(path.join(sessionsDir, "sessions.json"), "{}", "utf8");
storePaths[agentId] = await resolveRealStorePath(sessionsDir);
}
return storePaths;
}
function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenClawConfig {
return {
session: {
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
},
agents: {
list: [{ id: defaultAgentId, default: true }],
},
};
}
function expectTargetsToContainStores(
targets: Array<{ agentId: string; storePath: string }>,
stores: Record<string, string>,
): void {
expect(targets).toEqual(
expect.arrayContaining(
Object.entries(stores).map(([agentId, storePath]) => ({
agentId,
storePath,
})),
),
);
}
const discoveryResolvers = [
{
label: "async",
resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) =>
await resolveAllAgentSessionStoreTargets(cfg, { env }),
},
{
label: "sync",
resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) =>
resolveAllAgentSessionStoreTargetsSync(cfg, { env }),
},
] as const;
describe("resolveSessionStoreTargets", () => {
it("resolves all configured agent stores", () => {
const cfg: OpenClawConfig = {
@@ -83,97 +135,39 @@ describe("resolveAllAgentSessionStoreTargets", () => {
it("includes discovered on-disk agent stores alongside configured targets", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const opsSessionsDir = path.join(stateDir, "agents", "ops", "sessions");
const retiredSessionsDir = path.join(stateDir, "agents", "retired", "sessions");
await fs.mkdir(opsSessionsDir, { recursive: true });
await fs.mkdir(retiredSessionsDir, { recursive: true });
await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8");
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
const storePaths = await createAgentSessionStores(stateDir, ["ops", "retired"]);
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "ops", default: true }],
},
};
const opsStorePath = await resolveRealStorePath(opsSessionsDir);
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
expect(targets).toEqual(
expect.arrayContaining([
{
agentId: "ops",
storePath: opsStorePath,
},
{
agentId: "retired",
storePath: retiredStorePath,
},
]),
);
expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1);
expectTargetsToContainStores(targets, storePaths);
expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1);
});
});
it("discovers retired agent stores under a configured custom session root", async () => {
await withTempHome(async (home) => {
const customRoot = path.join(home, "custom-state");
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions");
await fs.mkdir(opsSessionsDir, { recursive: true });
await fs.mkdir(retiredSessionsDir, { recursive: true });
await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8");
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
const cfg: OpenClawConfig = {
session: {
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
},
agents: {
list: [{ id: "ops", default: true }],
},
};
const opsStorePath = await resolveRealStorePath(opsSessionsDir);
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
const storePaths = await createAgentSessionStores(customRoot, ["ops", "retired"]);
const cfg = createCustomRootCfg(customRoot);
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
expect(targets).toEqual(
expect.arrayContaining([
{
agentId: "ops",
storePath: opsStorePath,
},
{
agentId: "retired",
storePath: retiredStorePath,
},
]),
);
expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1);
expectTargetsToContainStores(targets, storePaths);
expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1);
});
});
it("keeps the actual on-disk store path for discovered retired agents", async () => {
await withTempHome(async (home) => {
const customRoot = path.join(home, "custom-state");
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
const retiredSessionsDir = path.join(customRoot, "agents", "Retired Agent", "sessions");
await fs.mkdir(opsSessionsDir, { recursive: true });
await fs.mkdir(retiredSessionsDir, { recursive: true });
await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8");
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
const cfg: OpenClawConfig = {
session: {
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
},
agents: {
list: [{ id: "ops", default: true }],
},
};
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
const storePaths = await createAgentSessionStores(customRoot, ["ops", "Retired Agent"]);
const cfg = createCustomRootCfg(customRoot);
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
@@ -181,7 +175,7 @@ describe("resolveAllAgentSessionStoreTargets", () => {
expect.arrayContaining([
expect.objectContaining({
agentId: "retired-agent",
storePath: retiredStorePath,
storePath: storePaths["Retired Agent"],
}),
]),
);
@@ -223,73 +217,52 @@ describe("resolveAllAgentSessionStoreTargets", () => {
});
});
it("skips unreadable or invalid discovery roots when other roots are still readable", async () => {
await withTempHome(async (home) => {
const customRoot = path.join(home, "custom-state");
await fs.mkdir(customRoot, { recursive: true });
await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8");
for (const resolver of discoveryResolvers) {
it(`skips unreadable or invalid discovery roots when other roots are still readable (${resolver.label})`, async () => {
await withTempHome(async (home) => {
const customRoot = path.join(home, "custom-state");
await fs.mkdir(customRoot, { recursive: true });
await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8");
const envStateDir = path.join(home, "env-state");
const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions");
const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions");
await fs.mkdir(mainSessionsDir, { recursive: true });
await fs.mkdir(retiredSessionsDir, { recursive: true });
await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8");
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
const envStateDir = path.join(home, "env-state");
const storePaths = await createAgentSessionStores(envStateDir, ["main", "retired"]);
const cfg = createCustomRootCfg(customRoot, "main");
const env = {
...process.env,
OPENCLAW_STATE_DIR: envStateDir,
};
const cfg: OpenClawConfig = {
session: {
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
},
agents: {
list: [{ id: "main", default: true }],
},
};
const env = {
...process.env,
OPENCLAW_STATE_DIR: envStateDir,
};
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
await expect(resolveAllAgentSessionStoreTargets(cfg, { env })).resolves.toEqual(
expect.arrayContaining([
{
agentId: "retired",
storePath: retiredStorePath,
},
]),
);
});
});
it("skips symlinked discovered stores under templated agents roots", async () => {
await withTempHome(async (home) => {
if (process.platform === "win32") {
return;
}
const customRoot = path.join(home, "custom-state");
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
const leakedFile = path.join(home, "outside.json");
await fs.mkdir(opsSessionsDir, { recursive: true });
await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8");
await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json"));
const cfg: OpenClawConfig = {
session: {
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
},
agents: {
list: [{ id: "ops", default: true }],
},
};
const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env });
expect(targets).not.toContainEqual({
agentId: "ops",
storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")),
await expect(resolver.resolve(cfg, env)).resolves.toEqual(
expect.arrayContaining([
{
agentId: "retired",
storePath: storePaths.retired,
},
]),
);
});
});
});
it(`skips symlinked discovered stores under templated agents roots (${resolver.label})`, async () => {
await withTempHome(async (home) => {
if (process.platform === "win32") {
return;
}
const customRoot = path.join(home, "custom-state");
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
const leakedFile = path.join(home, "outside.json");
await fs.mkdir(opsSessionsDir, { recursive: true });
await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8");
await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json"));
const targets = await resolver.resolve(createCustomRootCfg(customRoot), process.env);
expect(targets).not.toContainEqual({
agentId: "ops",
storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")),
});
});
});
}
it("skips discovered directories that only normalize into the default main agent", async () => {
await withTempHome(async (home) => {
@@ -315,73 +288,3 @@ describe("resolveAllAgentSessionStoreTargets", () => {
});
});
});
describe("resolveAllAgentSessionStoreTargetsSync", () => {
it("skips unreadable or invalid discovery roots when other roots are still readable", async () => {
await withTempHome(async (home) => {
const customRoot = path.join(home, "custom-state");
await fs.mkdir(customRoot, { recursive: true });
await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8");
const envStateDir = path.join(home, "env-state");
const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions");
const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions");
await fs.mkdir(mainSessionsDir, { recursive: true });
await fs.mkdir(retiredSessionsDir, { recursive: true });
await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8");
await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8");
const cfg: OpenClawConfig = {
session: {
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
},
agents: {
list: [{ id: "main", default: true }],
},
};
const env = {
...process.env,
OPENCLAW_STATE_DIR: envStateDir,
};
const retiredStorePath = await resolveRealStorePath(retiredSessionsDir);
expect(resolveAllAgentSessionStoreTargetsSync(cfg, { env })).toEqual(
expect.arrayContaining([
{
agentId: "retired",
storePath: retiredStorePath,
},
]),
);
});
});
it("skips symlinked discovered stores under templated agents roots", async () => {
await withTempHome(async (home) => {
if (process.platform === "win32") {
return;
}
const customRoot = path.join(home, "custom-state");
const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions");
const leakedFile = path.join(home, "outside.json");
await fs.mkdir(opsSessionsDir, { recursive: true });
await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8");
await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json"));
const cfg: OpenClawConfig = {
session: {
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
},
agents: {
list: [{ id: "ops", default: true }],
},
};
const targets = resolveAllAgentSessionStoreTargetsSync(cfg, { env: process.env });
expect(targets).not.toContainEqual({
agentId: "ops",
storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")),
});
});
});
});

View File

@@ -135,6 +135,7 @@ export async function appendAssistantMessageToSessionTranscript(params: {
sessionKey: string;
text?: string;
mediaUrls?: string[];
idempotencyKey?: string;
/** Optional override for store path (mostly for tests). */
storePath?: string;
}): Promise<{ ok: true; sessionFile: string } | { ok: false; reason: string }> {
@@ -179,6 +180,13 @@ export async function appendAssistantMessageToSessionTranscript(params: {
await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });
if (
params.idempotencyKey &&
(await transcriptHasIdempotencyKey(sessionFile, params.idempotencyKey))
) {
return { ok: true, sessionFile };
}
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "assistant",
@@ -202,8 +210,34 @@ export async function appendAssistantMessageToSessionTranscript(params: {
},
stopReason: "stop",
timestamp: Date.now(),
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
});
emitSessionTranscriptUpdate(sessionFile);
return { ok: true, sessionFile };
}
async function transcriptHasIdempotencyKey(
transcriptPath: string,
idempotencyKey: string,
): Promise<boolean> {
try {
const raw = await fs.promises.readFile(transcriptPath, "utf-8");
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
if (parsed.message?.idempotencyKey === idempotencyKey) {
return true;
}
} catch {
continue;
}
}
} catch {
return false;
}
return false;
}

View File

@@ -307,6 +307,8 @@ export type AgentCompactionConfig = {
reserveTokensFloor?: number;
/** Max share of context window for history during safeguard pruning (0.10.9, default 0.5). */
maxHistoryShare?: number;
/** Additional compaction-summary instructions that can preserve language or persona continuity. */
customInstructions?: string;
/** Preserve this many most-recent user/assistant turns verbatim in compaction summary context. */
recentTurnsPreserve?: number;
/** Identifier-preservation instruction policy for compaction summaries. */

View File

@@ -58,6 +58,7 @@ export type StatusReactionsEmojiConfig = {
error?: string;
stallSoft?: string;
stallHard?: string;
compacting?: string;
};
export type StatusReactionsTimingConfig = {

View File

@@ -1,8 +1,15 @@
import type { CommonChannelMessagingConfig } from "./types.channel-messaging-common.js";
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
export type SignalGroupConfig = {
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
};
export type SignalAccountConfig = CommonChannelMessagingConfig & {
/** Optional explicit E.164 account for signal-cli. */
account?: string;
@@ -24,6 +31,8 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & {
ignoreAttachments?: boolean;
ignoreStories?: boolean;
sendReadReceipts?: boolean;
/** Per-group overrides keyed by Signal group id (or "*"). */
groups?: Record<string, SignalGroupConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */

View File

@@ -91,6 +91,7 @@ export const AgentDefaultsSchema = z
keepRecentTokens: z.number().int().positive().optional(),
reserveTokensFloor: z.number().int().nonnegative().optional(),
maxHistoryShare: z.number().min(0.1).max(0.9).optional(),
customInstructions: z.string().optional(),
identifierPolicy: z
.union([z.literal("strict"), z.literal("off"), z.literal("custom")])
.optional(),

View File

@@ -327,6 +327,18 @@ export const ToolsWebFetchSchema = z
cacheTtlMinutes: z.number().nonnegative().optional(),
maxRedirects: z.number().int().nonnegative().optional(),
userAgent: z.string().optional(),
readability: z.boolean().optional(),
firecrawl: z
.object({
enabled: z.boolean().optional(),
apiKey: SecretInputSchema.optional().register(sensitive),
baseUrl: z.string().optional(),
onlyMainContent: z.boolean().optional(),
maxAgeMs: z.number().int().nonnegative().optional(),
timeoutSeconds: z.number().int().positive().optional(),
})
.strict()
.optional(),
})
.strict()
.optional();
@@ -757,6 +769,7 @@ export const AgentEntrySchema = z
.strict()
.optional(),
sandbox: AgentSandboxSchema,
params: z.record(z.string(), z.unknown()).optional(),
tools: AgentToolsSchema,
runtime: AgentRuntimeSchema,
})

View File

@@ -979,6 +979,16 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({
validateSlackSigningSecretRequirements(value, ctx);
});
const SignalGroupEntrySchema = z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
})
.strict();
const SignalGroupsSchema = z.record(z.string(), SignalGroupEntrySchema.optional()).optional();
export const SignalAccountSchemaBase = z
.object({
name: z.string().optional(),
@@ -1003,6 +1013,7 @@ export const SignalAccountSchemaBase = z
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groups: SignalGroupsSchema,
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),

View File

@@ -169,6 +169,7 @@ export const MessagesSchema = z
error: z.string().optional(),
stallSoft: z.string().optional(),
stallHard: z.string().optional(),
compacting: z.string().optional(),
})
.strict()
.optional(),

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("signal groups schema", () => {
it("accepts top-level Signal groups overrides", () => {
const res = validateConfigObject({
channels: {
signal: {
groups: {
"*": {
requireMention: false,
},
"+1234567890": {
requireMention: true,
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts per-account Signal groups overrides", () => {
const res = validateConfigObject({
channels: {
signal: {
accounts: {
primary: {
groups: {
"*": {
requireMention: false,
},
},
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects unknown keys in Signal groups entries", () => {
const res = validateConfigObject({
channels: {
signal: {
groups: {
"*": {
requireMention: false,
nope: true,
},
},
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((issue) => issue.path.startsWith("channels.signal.groups"))).toBe(
true,
);
}
});
});

View File

@@ -596,6 +596,7 @@ export const OpenClawSchema = z
wideArea: z
.object({
enabled: z.boolean().optional(),
domain: z.string().optional(),
})
.strict()
.optional(),