mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix: route ClickClack replies through runtime policy
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user