From a08ca5fc5db7018658bdb72f27a32fe95cc51eb9 Mon Sep 17 00:00:00 2001 From: ragesaq <11304287+ragesaq@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:50:10 +0000 Subject: [PATCH] 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. --- docs/channels/clickclack.md | 1 + extensions/clickclack/src/activity.test.ts | 22 +++++++++++ extensions/clickclack/src/activity.ts | 13 ++++++- extensions/clickclack/src/http-client.ts | 44 +++++++++++++++++++--- extensions/clickclack/src/inbound.test.ts | 10 ++++- extensions/clickclack/src/inbound.ts | 40 ++++++++++++++------ extensions/clickclack/src/outbound.ts | 12 ++++-- extensions/clickclack/src/types.ts | 12 ++++++ 8 files changed, 132 insertions(+), 22 deletions(-) diff --git a/docs/channels/clickclack.md b/docs/channels/clickclack.md index 878db797f02b..9d76c0c8a738 100644 --- a/docs/channels/clickclack.md +++ b/docs/channels/clickclack.md @@ -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 diff --git a/extensions/clickclack/src/activity.test.ts b/extensions/clickclack/src/activity.test.ts index d5cb2345ae1c..e308c5e72ecd 100644 --- a/extensions/clickclack/src/activity.test.ts +++ b/extensions/clickclack/src/activity.test.ts @@ -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" }, + }), + ); + }); }); diff --git a/extensions/clickclack/src/activity.ts b/extensions/clickclack/src/activity.ts index 09b36d4dd1c4..20789751c0af 100644 --- a/extensions/clickclack/src/activity.ts +++ b/extensions/clickclack/src/activity.ts @@ -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; updateMessageBody(messageId: string, body: string): Promise; }; @@ -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; }; @@ -128,6 +134,7 @@ export function createClickClackActivityPublisher(params: { const flushMs = params.flushMs ?? CLICKCLACK_COMMENTARY_FLUSH_MS; const commentaryByItem = new Map(); const toolRows = new Map(); + let provenance: ClickClackMessageProvenance | undefined; // Single promise chain so POST/PATCH ordering matches frame arrival order. let chain: Promise = Promise.resolve(); @@ -145,6 +152,7 @@ export function createClickClackActivityPublisher(params: { body, kind, turnId: params.turnId, + provenance, }); const flushCommentary = (segmentKey: string): Promise => { @@ -269,6 +277,9 @@ export function createClickClackActivityPublisher(params: { } handleDiscreteItem(payload); }, + setProvenance: (next) => { + provenance = next; + }, finalize: async () => { await flushAllCommentary(); await chain; diff --git a/extensions/clickclack/src/http-client.ts b/extensions/clickclack/src/http-client.ts index 09fedeb26bd9..00a780de5c4a 100644 --- a/extensions/clickclack/src/http-client.ts +++ b/extensions/clickclack/src/http-client.ts @@ -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 { + const fields: Record = {}; + 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 => { + createChannelMessage: async ( + channelId: string, + body: string, + opts?: { provenance?: ClickClackMessageProvenance }, + ): Promise => { 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 => { + createThreadReply: async ( + messageId: string, + body: string, + opts?: { provenance?: ClickClackMessageProvenance }, + ): Promise => { 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 => { 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; }, diff --git a/extensions/clickclack/src/inbound.test.ts b/extensions/clickclack/src/inbound.test.ts index a2ca2ea38f36..49fb33fa0996 100644 --- a/extensions/clickclack/src/inbound.test.ts +++ b/extensions/clickclack/src/inbound.test.ts @@ -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. diff --git a/extensions/clickclack/src/inbound.ts b/extensions/clickclack/src/inbound.ts index 52437501785f..c3623bb0f7a4 100644 --- a/extensions/clickclack/src/inbound.ts +++ b/extensions/clickclack/src/inbound.ts @@ -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) => { diff --git a/extensions/clickclack/src/outbound.ts b/extensions/clickclack/src/outbound.ts index 1019a4ccbda0..65edf029a0fa 100644 --- a/extensions/clickclack/src/outbound.ts +++ b/extensions/clickclack/src/outbound.ts @@ -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 }; } diff --git a/extensions/clickclack/src/types.ts b/extensions/clickclack/src/types.ts index eb8cce27885a..eeb9910f041c 100644 --- a/extensions/clickclack/src/types.ts +++ b/extensions/clickclack/src/types.ts @@ -117,6 +117,18 @@ export type ClickClackEvent = { payload: Record; }; +/** + * 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 }