fix: route ClickClack replies through runtime policy

This commit is contained in:
Shakker
2026-07-13 18:13:46 +01:00
committed by Shakker
parent 9b5cd4cd0d
commit ef6c44f10b
7 changed files with 90 additions and 66 deletions
@@ -107,7 +107,6 @@ describe("ClickClack account resolution", () => {
defaultTo: "channel:general",
enabled: true,
agentActivity: false,
maxTokens: undefined,
model: undefined,
name: undefined,
reconnectMs: 1_500,
@@ -133,7 +132,6 @@ describe("ClickClack account resolution", () => {
agentId: "peter-bot",
replyMode: "model",
model: "openai/gpt-5.4-mini",
maxTokens: 2_048,
toolsAllow: ["web_search"],
},
},
@@ -152,7 +150,6 @@ describe("ClickClack account resolution", () => {
baseUrl: "https://app.clickclack.chat",
enabled: true,
model: "openai/gpt-5.4-mini",
maxTokens: 2_048,
replyMode: "model",
token: "ccb_peter",
toolsAllow: ["web_search"],
@@ -163,7 +160,6 @@ describe("ClickClack account resolution", () => {
defaultTo: "channel:general",
enabled: true,
agentActivity: false,
maxTokens: 2_048,
model: "openai/gpt-5.4-mini",
name: undefined,
reconnectMs: 1_500,
@@ -176,27 +172,6 @@ describe("ClickClack account resolution", () => {
});
});
it("uses the runtime model budget when unset while honoring explicit account overrides", () => {
const cfg = {
channels: {
clickclack: {
enabled: true,
baseUrl: "https://app.clickclack.chat",
workspace: "wsp_1",
token: "ccb_default",
accounts: {
legacy: { token: "ccb_legacy" },
tuned: { token: "ccb_tuned", maxTokens: 8_192 },
},
},
},
} satisfies CoreConfig;
expect(resolveClickClackAccount({ cfg }).maxTokens).toBeUndefined();
expect(resolveClickClackAccount({ cfg, accountId: "legacy" }).maxTokens).toBeUndefined();
expect(resolveClickClackAccount({ cfg, accountId: "tuned" }).maxTokens).toBe(8_192);
});
it("resolves the agent activity opt-in only when explicitly enabled", () => {
const cfg = {
channels: {
+1 -10
View File
@@ -8,10 +8,7 @@ import {
} from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import {
resolveIntegerOption,
resolveOptionalIntegerOption,
} from "openclaw/plugin-sdk/number-runtime";
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
import {
normalizeSecretInputString,
@@ -24,8 +21,6 @@ import type { ClickClackAccountConfig, CoreConfig, ResolvedClickClackAccount } f
const DEFAULT_RECONNECT_MS = 1_500;
const MIN_RECONNECT_MS = 100;
const MAX_RECONNECT_MS = 60_000;
const MIN_MAX_TOKENS = 1;
const MAX_MAX_TOKENS = 32_768;
const {
listAccountIds: listClickClackAccountIds,
@@ -140,10 +135,6 @@ export function resolveClickClackAccount(params: {
replyMode: merged.replyMode === "model" ? "model" : "agent",
model: normalizeOptionalString(merged.model),
systemPrompt: normalizeOptionalString(merged.systemPrompt),
maxTokens: resolveOptionalIntegerOption(merged.maxTokens, {
min: MIN_MAX_TOKENS,
max: MAX_MAX_TOKENS,
}),
timeoutSeconds: merged.timeoutSeconds,
toolsAllow: merged.toolsAllow,
defaultTo: merged.defaultTo?.trim() || "channel:general",
@@ -17,7 +17,6 @@ const ClickClackAccountConfigSchema = z
replyMode: z.enum(["agent", "model"]).optional(),
model: z.string().optional(),
systemPrompt: z.string().optional(),
maxTokens: z.number().int().min(1).max(32_768).optional(),
timeoutSeconds: z.number().int().min(1).max(3_600).optional(),
toolsAllow: z.array(z.string()).optional(),
defaultTo: z.string().optional(),
+39 -6
View File
@@ -77,7 +77,6 @@ function createAgentAccount(
defaultTo: "channel:general",
allowFrom: ["*"],
reconnectMs: 1_500,
maxTokens: 512,
agentActivity: false,
config: {
allowFrom: ["*"],
@@ -141,7 +140,6 @@ describe("handleClickClackInbound", () => {
agentId: "service-bot",
replyMode: "model",
model: "openai/gpt-5.4-mini",
maxTokens: 512,
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["*"],
@@ -179,7 +177,7 @@ describe("handleClickClackInbound", () => {
const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0];
expect(completionRequest?.agentId).toBe("service-bot");
expect(completionRequest?.model).toBe("openai/gpt-5.4-mini");
expect(completionRequest?.maxTokens).toBe(512);
expect(completionRequest).not.toHaveProperty("maxTokens");
expect(completionRequest?.purpose).toBe("clickclack bot reply");
expect(completionRequest?.messages).toEqual([{ role: "user", content: "hello bot" }]);
@@ -191,14 +189,13 @@ describe("handleClickClackInbound", () => {
expect(sendRequest?.correlationId).toBe("fakeco.case_1");
});
it("omits the completion cap and delivers the reply when maxTokens is unset", async () => {
it("uses the selected runtime model budget", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
const account = createAgentAccount({
accountId: "service",
agentId: "service-bot",
replyMode: "model",
maxTokens: undefined,
});
await handleClickClackInbound({
@@ -246,7 +243,7 @@ describe("handleClickClackInbound", () => {
});
const logger = vi.mocked(runtime.logging.getChildLogger).mock.results[0]?.value;
expect(logger?.warn).toHaveBeenCalledWith(
"[service] ClickClack model reply produced no sendable text (maxTokens=512)",
"[service] ClickClack model reply produced no sendable text",
);
});
@@ -392,6 +389,42 @@ describe("handleClickClackInbound", () => {
);
});
it("routes media replies through required durable delivery", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount(),
config: {} as CoreConfig,
message: createMessage({
id: VALID_MESSAGE_ID,
thread_root_id: VALID_MESSAGE_ID,
}),
});
const delivery = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0].delivery;
if (typeof delivery?.durable !== "function") {
throw new Error("expected ClickClack media durable delivery resolver");
}
const payload = { text: "artifact", mediaUrl: "/workspace/artifact.txt" };
expect(delivery.durable(payload, { kind: "final" } as never)).toEqual({
to: "channel:chn_1",
threadId: undefined,
replyToId: VALID_MESSAGE_ID,
requiredCapabilities: {
text: true,
media: true,
replyTo: true,
messageSendingHooks: true,
reconcileUnknownSend: true,
},
});
await expect(delivery?.deliver(payload, { kind: "final" } as never)).rejects.toThrow(
"ClickClack media reply requires durable delivery",
);
expect(sendClickClackTextMock).not.toHaveBeenCalled();
});
it("does not derive a run id from a noncanonical message id", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
+32 -4
View File
@@ -1,3 +1,4 @@
import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound";
/**
* Converts authorized ClickClack messages into OpenClaw agent/model replies and
* routes resulting outbound text back to ClickClack.
@@ -20,6 +21,16 @@ import type {
const CHANNEL_ID = "clickclack" as const;
const CLICKCLACK_MESSAGE_ID_PATTERN = /^msg_[0-9a-hjkmnp-tv-z]{26}$/u;
function hasClickClackReplyMedia(payload: {
mediaUrl?: string;
mediaUrls?: readonly string[];
}): boolean {
return Boolean(
payload.mediaUrl?.trim() ||
payload.mediaUrls?.some((mediaUrl) => typeof mediaUrl === "string" && mediaUrl.trim()),
);
}
function resolveClickClackAgentRunId(messageId: string): string | undefined {
return CLICKCLACK_MESSAGE_ID_PATTERN.test(messageId) ? `${CHANNEL_ID}:${messageId}` : undefined;
}
@@ -89,7 +100,6 @@ async function dispatchModelReply(params: {
const result = await runtime.llm.complete({
agentId: params.route.agentId,
model: params.account.model,
...(params.account.maxTokens === undefined ? {} : { maxTokens: params.account.maxTokens }),
purpose: "clickclack bot reply",
systemPrompt: params.account.systemPrompt,
messages: [
@@ -103,9 +113,7 @@ async function dispatchModelReply(params: {
if (!text) {
runtime.logging
.getChildLogger({ plugin: "clickclack", feature: "model-reply" })
.warn(
`[${params.account.accountId}] ClickClack model reply produced no sendable text (maxTokens=${params.account.maxTokens ?? "runtime default"})`,
);
.warn(`[${params.account.accountId}] ClickClack model reply produced no sendable text`);
return;
}
await sendClickClackText({
@@ -282,6 +290,9 @@ export async function handleClickClackInbound(params: {
: undefined,
delivery: {
deliver: async (payload) => {
if (hasClickClackReplyMedia(payload)) {
throw new Error("ClickClack media reply requires durable delivery");
}
const text =
payload && typeof payload === "object" && "text" in payload
? ((payload as { text?: string }).text ?? "")
@@ -300,6 +311,23 @@ export async function handleClickClackInbound(params: {
correlationId: params.correlationId,
});
},
durable: (payload) => {
if (!hasClickClackReplyMedia(payload)) {
return false;
}
const threadId = message.parent_message_id ? message.thread_root_id : undefined;
return {
to: target,
threadId,
replyToId: message.id,
requiredCapabilities: deriveDurableFinalDeliveryRequirements({
payload,
threadId,
replyToId: message.id,
reconcileUnknownSend: true,
}),
};
},
onError: (error) => {
throw error instanceof Error
? error
-2
View File
@@ -15,7 +15,6 @@ export type ClickClackAccountConfig = {
replyMode?: "agent" | "model";
model?: string;
systemPrompt?: string;
maxTokens?: number;
timeoutSeconds?: number;
toolsAllow?: string[];
defaultTo?: string;
@@ -52,7 +51,6 @@ export type ResolvedClickClackAccount = {
replyMode: "agent" | "model";
model?: string;
systemPrompt?: string;
maxTokens?: number;
timeoutSeconds?: number;
toolsAllow?: string[];
defaultTo: string;
File diff suppressed because one or more lines are too long