feat(clickclack): stamp model/thinking attribution onto agent posts

Wires replyOptions.onModelSelected so the resolved provider/model and
thinking level for each turn (including after fallback) are sent as
author_model / author_thinking fields on activity rows and the final
reply. Servers without these columns ignore the unknown JSON fields, so
the wire shape is backward compatible; servers that persist them get
per-message attribution.
This commit is contained in:
ragesaq
2026-07-05 05:50:10 +00:00
committed by Ayaan Zaidi
parent 9827fcc5c1
commit a08ca5fc5d
8 changed files with 132 additions and 22 deletions
+1
View File
@@ -153,6 +153,7 @@ Requirements and behavior:
- **Requires the `agent_activity:write` token scope.** This scope is separate from `bot:write` and is not inherited by it; create the bot token with `--scopes bot:write,agent_activity:write` (or grant the scope to an existing token) before enabling the option.
- **Best-effort degradation.** If the token lacks `agent_activity:write` or the server rejects activity writes, failures are logged and the final reply still delivers normally; no activity rows appear.
- Rows are grouped per turn (`turn_id`), coalesced so one logical step is one row, and tool rows use the same progress formatting as Discord/Slack/Telegram (tool name plus command detail).
- **Attribution metadata.** Agent-authored posts (activity rows and the final reply) carry `author_model` and `author_thinking` fields resolved from the actual model used for the turn (including after fallback). Servers that do not define these columns ignore the unknown JSON fields; servers that persist them can answer "which model said this line, at which thinking level" per message.
## Targets
@@ -208,4 +208,26 @@ describe("createClickClackActivityPublisher", () => {
await expect(publisher.finalize()).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledTimes(1);
});
it("stamps resolved provenance onto rows posted after setProvenance", async () => {
const { client, createActivityMessage } = createClientMock();
const publisher = createClickClackActivityPublisher({
client,
target: { channelId: "chn_1" },
turnId: "msg_turn",
});
publisher.setProvenance({ model: "anthropic/claude-opus-4-8", thinking: "low" });
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "working on it" });
await publisher.finalize();
expect(createActivityMessage).toHaveBeenCalledTimes(1);
expect(createActivityMessage).toHaveBeenCalledWith(
expect.objectContaining({
body: "working on it",
kind: "agent_commentary",
provenance: { model: "anthropic/claude-opus-4-8", thinking: "low" },
}),
);
});
});
+12 -1
View File
@@ -16,7 +16,7 @@
* frame, and PATCH it when a later frame carries a strictly longer body.
*/
import { buildChannelProgressDraftLine } from "openclaw/plugin-sdk/channel-outbound";
import type { ClickClackMessage } from "./types.js";
import type { ClickClackMessage, ClickClackMessageProvenance } from "./types.js";
/** Debounce window for PATCHing streaming commentary snapshots. */
export const CLICKCLACK_COMMENTARY_FLUSH_MS = 700;
@@ -49,6 +49,7 @@ export type ClickClackActivityClient = {
body: string;
kind: "agent_commentary" | "agent_tool";
turnId?: string;
provenance?: ClickClackMessageProvenance;
}): Promise<ClickClackMessage>;
updateMessageBody(messageId: string, body: string): Promise<ClickClackMessage>;
};
@@ -110,6 +111,11 @@ type ToolRow = {
/** Publisher wired into one agent turn via `replyOptions.onItemEvent`. */
export type ClickClackActivityPublisher = {
onItemEvent: (payload: ClickClackItemEventPayload) => void;
/**
* Records the resolved model/thinking for this turn (from
* `replyOptions.onModelSelected`); stamped onto subsequent activity rows.
*/
setProvenance: (provenance: ClickClackMessageProvenance) => void;
/** Flushes pending commentary and awaits all outstanding POST/PATCH work. */
finalize: () => Promise<void>;
};
@@ -128,6 +134,7 @@ export function createClickClackActivityPublisher(params: {
const flushMs = params.flushMs ?? CLICKCLACK_COMMENTARY_FLUSH_MS;
const commentaryByItem = new Map<string, CommentarySegment>();
const toolRows = new Map<string, ToolRow>();
let provenance: ClickClackMessageProvenance | undefined;
// Single promise chain so POST/PATCH ordering matches frame arrival order.
let chain: Promise<void> = Promise.resolve();
@@ -145,6 +152,7 @@ export function createClickClackActivityPublisher(params: {
body,
kind,
turnId: params.turnId,
provenance,
});
const flushCommentary = (segmentKey: string): Promise<void> => {
@@ -269,6 +277,9 @@ export function createClickClackActivityPublisher(params: {
}
handleDiscreteItem(payload);
},
setProvenance: (next) => {
provenance = next;
},
finalize: async () => {
await flushAllCommentary();
await chain;
+39 -5
View File
@@ -11,10 +11,30 @@ import type {
ClickClackChannel,
ClickClackEvent,
ClickClackMessage,
ClickClackMessageProvenance,
ClickClackUser,
ClickClackWorkspace,
} from "./types.js";
/**
* Serializes optional provenance into the wire fields. Unknown JSON fields
* are ignored by servers without the provenance columns, so these are safe
* to send unconditionally when present.
*/
function provenanceFields(provenance?: ClickClackMessageProvenance): Record<string, string> {
const fields: Record<string, string> = {};
if (provenance?.model?.trim()) {
fields.author_model = provenance.model.trim();
}
if (provenance?.thinking?.trim()) {
fields.author_thinking = provenance.thinking.trim();
}
if (provenance?.runtime?.trim()) {
fields.author_runtime = provenance.runtime.trim();
}
return fields;
}
type ClientOptions = {
baseUrl: string;
token: string;
@@ -91,17 +111,25 @@ export function createClickClackClient(options: ClientOptions) {
await request<{ root: ClickClackMessage; replies: ClickClackMessage[] }>(
`/api/messages/${encodeURIComponent(messageId)}/thread`,
),
createChannelMessage: async (channelId: string, body: string): Promise<ClickClackMessage> => {
createChannelMessage: async (
channelId: string,
body: string,
opts?: { provenance?: ClickClackMessageProvenance },
): Promise<ClickClackMessage> => {
const data = await request<{ message: ClickClackMessage }>(
`/api/channels/${encodeURIComponent(channelId)}/messages`,
{ method: "POST", body: JSON.stringify({ body }) },
{ method: "POST", body: JSON.stringify({ body, ...provenanceFields(opts?.provenance) }) },
);
return data.message;
},
createThreadReply: async (messageId: string, body: string): Promise<ClickClackMessage> => {
createThreadReply: async (
messageId: string,
body: string,
opts?: { provenance?: ClickClackMessageProvenance },
): Promise<ClickClackMessage> => {
const data = await request<{ message: ClickClackMessage }>(
`/api/messages/${encodeURIComponent(messageId)}/thread/replies`,
{ method: "POST", body: JSON.stringify({ body }) },
{ method: "POST", body: JSON.stringify({ body, ...provenanceFields(opts?.provenance) }) },
);
return data.message;
},
@@ -126,6 +154,7 @@ export function createClickClackClient(options: ClientOptions) {
body: string;
kind: "agent_commentary" | "agent_tool";
turnId?: string;
provenance?: ClickClackMessageProvenance;
}): Promise<ClickClackMessage> => {
if (!params.channelId && !params.conversationId) {
throw new Error("createActivityMessage requires a channelId or conversationId");
@@ -135,7 +164,12 @@ export function createClickClackClient(options: ClientOptions) {
: `/api/dms/${encodeURIComponent(params.conversationId ?? "")}/messages`;
const data = await request<{ message: ClickClackMessage }>(path, {
method: "POST",
body: JSON.stringify({ body: params.body, kind: params.kind, turn_id: params.turnId }),
body: JSON.stringify({
body: params.body,
kind: params.kind,
turn_id: params.turnId,
...provenanceFields(params.provenance),
}),
});
return data.message;
},
+8 -2
View File
@@ -277,15 +277,21 @@ describe("handleClickClackInbound", () => {
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply).toHaveBeenCalledTimes(2);
const withoutOptIn = dispatchReply.mock.calls[0]?.[0] as { replyOptions?: unknown };
const withoutOptIn = dispatchReply.mock.calls[0]?.[0] as {
replyOptions?: { onItemEvent?: unknown; onModelSelected?: unknown };
};
const withOptIn = dispatchReply.mock.calls[1]?.[0] as {
replyOptions?: {
onItemEvent?: unknown;
onModelSelected?: unknown;
commentaryProgressEnabled?: unknown;
suppressDefaultToolProgressMessages?: unknown;
};
};
expect(withoutOptIn.replyOptions).toBeUndefined();
// Model provenance capture applies to every account (it stamps the final
// reply), but durable activity item events wire up only on opt-in.
expect(typeof withoutOptIn.replyOptions?.onModelSelected).toBe("function");
expect(withoutOptIn.replyOptions?.onItemEvent).toBeUndefined();
expect(withOptIn.replyOptions?.commentaryProgressEnabled).toBe(true);
// Channel-owned progress rendering: item events must flow even when
// session verbose mode is off.
+29 -11
View File
@@ -9,7 +9,12 @@ import { createClickClackClient } from "./http-client.js";
import { sendClickClackText } from "./outbound.js";
import { getClickClackRuntime } from "./runtime.js";
import { buildClickClackTarget } from "./target.js";
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
import type {
ClickClackMessage,
ClickClackMessageProvenance,
CoreConfig,
ResolvedClickClackAccount,
} from "./types.js";
const CHANNEL_ID = "clickclack" as const;
@@ -131,6 +136,9 @@ export async function handleClickClackInbound(params: {
// per-account opt-in: they need a ClickClack bot token carrying the
// agent_activity:write scope. Publishing is best-effort and must never
// break final text delivery.
// Resolved model/thinking for this turn (from onModelSelected); stamped as
// attribution metadata onto activity rows and the final reply message.
let turnProvenance: ClickClackMessageProvenance | undefined;
let activity: ClickClackActivityPublisher | undefined;
if (params.account.agentActivity && (message.channel_id || message.direct_conversation_id)) {
activity = createClickClackActivityPublisher({
@@ -209,16 +217,25 @@ export async function handleClickClackInbound(params: {
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
toolsAllow: params.account.toolsAllow,
replyOptions: activity
? {
onItemEvent: activity.onItemEvent,
commentaryProgressEnabled: true,
// The durable activity rows are ClickClack's own progress
// rendering, so item events must flow even when session verbose
// mode is off and the default tool-progress texts stay suppressed.
suppressDefaultToolProgressMessages: true,
}
: undefined,
replyOptions: {
onModelSelected: (ctx: { provider: string; model: string; thinkLevel?: string }) => {
turnProvenance = {
model: ctx.provider && ctx.model ? `${ctx.provider}/${ctx.model}` : ctx.model,
thinking: ctx.thinkLevel,
};
activity?.setProvenance(turnProvenance);
},
...(activity
? {
onItemEvent: activity.onItemEvent,
commentaryProgressEnabled: true,
// The durable activity rows are ClickClack's own progress
// rendering, so item events must flow even when session verbose
// mode is off and the default tool-progress texts stay suppressed.
suppressDefaultToolProgressMessages: true,
}
: {}),
},
delivery: {
deliver: async (payload) => {
const text =
@@ -235,6 +252,7 @@ export async function handleClickClackInbound(params: {
text,
threadId: message.parent_message_id ? message.thread_root_id : undefined,
replyToId: message.id,
provenance: turnProvenance,
});
},
onError: (error) => {
+9 -3
View File
@@ -6,7 +6,7 @@ import { resolveClickClackAccount } from "./accounts.js";
import { createClickClackClient } from "./http-client.js";
import { resolveChannelId, resolveWorkspaceId } from "./resolve.js";
import { parseClickClackTarget } from "./target.js";
import type { CoreConfig } from "./types.js";
import type { ClickClackMessageProvenance, CoreConfig } from "./types.js";
/**
* Sends text to a normalized ClickClack target and returns the created message
@@ -19,6 +19,8 @@ export async function sendClickClackText(params: {
text: string;
threadId?: string | number | null;
replyToId?: string | number | null;
/** Optional model/thinking attribution stamped onto the created message. */
provenance?: ClickClackMessageProvenance;
}) {
const account = resolveClickClackAccount({ cfg: params.cfg, accountId: params.accountId });
const client = createClickClackClient({ baseUrl: account.baseUrl, token: account.token });
@@ -30,7 +32,9 @@ export async function sendClickClackText(params: {
// Explicit thread/reply context wins over the target kind so OpenClaw reply
// hooks keep conversations attached to the original ClickClack root.
const rootId = explicitThreadId || replyToId || parsed.id;
const message = await client.createThreadReply(rootId, params.text);
const message = await client.createThreadReply(rootId, params.text, {
provenance: params.provenance,
});
return { to: params.to, messageId: message.id };
}
if (parsed.kind === "dm") {
@@ -39,6 +43,8 @@ export async function sendClickClackText(params: {
return { to: params.to, messageId: message.id };
}
const channelId = await resolveChannelId(client, workspaceId, parsed.id);
const message = await client.createChannelMessage(channelId, params.text);
const message = await client.createChannelMessage(channelId, params.text, {
provenance: params.provenance,
});
return { to: params.to, messageId: message.id };
}
+12
View File
@@ -117,6 +117,18 @@ export type ClickClackEvent = {
payload: Record<string, unknown>;
};
/**
* Optional attribution metadata stamped onto agent-authored posts
* (author_model / author_thinking / author_runtime). Servers that do not
* define these columns ignore the unknown JSON fields, so sending them is
* always safe; servers that do define them persist per-message provenance.
*/
export type ClickClackMessageProvenance = {
model?: string;
thinking?: string;
runtime?: string;
};
/** Parsed outbound destination for ClickClack delivery. */
export type ClickClackTarget =
| { chatType: "group"; kind: "channel"; id: string }