Files
openclaw/extensions/clickclack/src/activity.test.ts
ragesaq 67044ce7c7 feat(clickclack): normalize reasoning progress as commentary
ClickClack durable activity now treats preamble, commentary, analysis, thinking, and reasoning as streaming commentary segments. Reasoning-family lanes get a normalized Thinking label, while lifecycle frames stay hidden.

Why: native ClickClack needs Clickglass parity for commentary progress, tool calls, and final assistant delivery before the sidecar can be retired.

Validation:

pnpm tsgo:extensions

pnpm tsgo:extensions:test

pnpm exec oxlint extensions/clickclack/src/activity.ts extensions/clickclack/src/activity.test.ts

pnpm exec vitest run extensions/clickclack/src/activity.test.ts

HyperReview-Reflex: pass-with-conditions
HyperReview-Tier: light
HyperReview-Scope: staged-tree:ed2adf325ad5beffcb55d7c6b929b0be7a752b61
HyperReview-Paths-SHA256: e5499bb489781e9c4dd31828a71220bbe89c4e646011a7f31f5ce3b8db8d54c6
HyperReview-Reflex-Version: 0.3.3
HyperReview-Routed-To: none
HyperReview-Receipt-SHA256: 4d3480047c0b66556236cecde7dd3e199b57a027b8dd894acab47056d9be5b4c
2026-07-05 09:32:37 -07:00

289 lines
10 KiB
TypeScript

// Tests for the durable ClickClack agent-activity publisher (coalescing rules).
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createClickClackActivityPublisher, type ClickClackActivityClient } from "./activity.js";
import type { ClickClackMessage } from "./types.js";
function createClientMock(): {
client: ClickClackActivityClient;
createActivityMessage: ReturnType<typeof vi.fn>;
updateMessageBody: ReturnType<typeof vi.fn>;
} {
let counter = 0;
const createActivityMessage = vi.fn(async () => {
counter += 1;
return { id: `msg_${counter}` } as ClickClackMessage;
});
const updateMessageBody = vi.fn(async () => ({}) as ClickClackMessage);
return {
client: { createActivityMessage, updateMessageBody } as ClickClackActivityClient,
createActivityMessage,
updateMessageBody,
};
}
describe("createClickClackActivityPublisher", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("coalesces cumulative commentary snapshots into one POST per segment", async () => {
const { client, createActivityMessage, updateMessageBody } = createClientMock();
const publisher = createClickClackActivityPublisher({
client,
target: { channelId: "chn_1" },
turnId: "msg_turn",
});
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "Looking at" });
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "Looking at the repo" });
await publisher.finalize();
expect(createActivityMessage).toHaveBeenCalledTimes(1);
expect(createActivityMessage).toHaveBeenCalledWith({
channelId: "chn_1",
conversationId: undefined,
body: "Looking at the repo",
kind: "agent_commentary",
turnId: "msg_turn",
});
expect(updateMessageBody).not.toHaveBeenCalled();
});
it("PATCHes the commentary row when the snapshot grows after a debounce flush", async () => {
const { client, createActivityMessage, updateMessageBody } = createClientMock();
const publisher = createClickClackActivityPublisher({
client,
target: { channelId: "chn_1" },
turnId: "msg_turn",
flushMs: 10,
});
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "First" });
await vi.advanceTimersByTimeAsync(20);
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "First and second" });
await publisher.finalize();
expect(createActivityMessage).toHaveBeenCalledTimes(1);
expect(updateMessageBody).toHaveBeenCalledTimes(1);
expect(updateMessageBody).toHaveBeenCalledWith("msg_1", "First and second");
});
it("skips redundant PATCHes for identical or stale-shorter commentary snapshots", async () => {
const { client, createActivityMessage, updateMessageBody } = createClientMock();
const publisher = createClickClackActivityPublisher({
client,
target: { channelId: "chn_1" },
turnId: "msg_turn",
flushMs: 10,
});
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "First and second" });
await vi.advanceTimersByTimeAsync(20);
// Identical snapshot and a stale shorter frame must not queue new flushes.
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "First and second" });
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "First" });
await publisher.finalize();
expect(createActivityMessage).toHaveBeenCalledTimes(1);
expect(updateMessageBody).not.toHaveBeenCalled();
});
it("opens a new durable row for each commentary segment (item id)", async () => {
const { client, createActivityMessage } = createClientMock();
const publisher = createClickClackActivityPublisher({
client,
target: { conversationId: "dcn_1" },
turnId: "msg_turn",
});
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "before tool" });
publisher.onItemEvent({ itemId: "c2", kind: "preamble", progressText: "after tool" });
await publisher.finalize();
expect(createActivityMessage).toHaveBeenCalledTimes(2);
const bodies = createActivityMessage.mock.calls.map(
(call) => (call[0] as { body: string }).body,
);
expect(bodies).toEqual(["before tool", "after tool"]);
});
it("dedupes lane-prefixed tool frames into one row and upgrades on longer bodies", async () => {
const { client, createActivityMessage, updateMessageBody } = createClientMock();
const publisher = createClickClackActivityPublisher({
client,
target: { channelId: "chn_1" },
turnId: "msg_turn",
});
// The runtime emits one opaque toolCallId across all frames of a call;
// the lane prefix (tool:/command:) lives on itemId only.
publisher.onItemEvent({
itemId: "tool:toolu_1",
toolCallId: "toolu_1",
kind: "tool",
name: "exec",
});
await publisher.finalize();
publisher.onItemEvent({
itemId: "command:toolu_1",
toolCallId: "toolu_1",
kind: "command",
name: "exec",
progressText: "ls -la",
});
// A shorter late echo must never clobber the richer body.
publisher.onItemEvent({ toolCallId: "toolu_1", kind: "tool", name: "exec" });
await publisher.finalize();
expect(createActivityMessage).toHaveBeenCalledTimes(1);
expect(createActivityMessage.mock.calls[0]?.[0]).toMatchObject({
kind: "agent_tool",
body: "🛠️ Exec",
});
expect(updateMessageBody).toHaveBeenCalledTimes(1);
expect(updateMessageBody).toHaveBeenCalledWith("msg_1", "🛠️ ls -la");
});
it("posts the upgraded body directly when frames land before the first POST runs", async () => {
const { client, createActivityMessage, updateMessageBody } = createClientMock();
const publisher = createClickClackActivityPublisher({
client,
target: { channelId: "chn_1" },
turnId: "msg_turn",
});
publisher.onItemEvent({ toolCallId: "toolu_1", kind: "tool", name: "exec" });
publisher.onItemEvent({
toolCallId: "toolu_1",
kind: "tool",
name: "exec",
progressText: "ls -la",
});
await publisher.finalize();
expect(createActivityMessage).toHaveBeenCalledTimes(1);
expect(createActivityMessage.mock.calls[0]?.[0]).toMatchObject({
kind: "agent_tool",
body: "🛠️ ls -la",
});
expect(updateMessageBody).not.toHaveBeenCalled();
});
it("renders non-tool item kinds as commentary rows and skips lifecycle lanes", async () => {
const { client, createActivityMessage } = createClientMock();
const publisher = createClickClackActivityPublisher({
client,
target: { channelId: "chn_1" },
turnId: "msg_turn",
});
publisher.onItemEvent({ itemId: "p1", kind: "plan", title: "Plan", summary: "step one" });
publisher.onItemEvent({ itemId: "life1", kind: "lifecycle", progressText: "internal state" });
await publisher.finalize();
expect(createActivityMessage).toHaveBeenCalledTimes(1);
expect(createActivityMessage.mock.calls[0]?.[0]).toMatchObject({
kind: "agent_commentary",
body: "step one",
});
});
it("normalizes reasoning-style progress lanes into durable commentary rows", async () => {
const { client, createActivityMessage, updateMessageBody } = createClientMock();
const publisher = createClickClackActivityPublisher({
client,
target: { channelId: "chn_1" },
turnId: "msg_turn",
flushMs: 10,
});
publisher.onItemEvent({ itemId: "empty1", kind: "thinking", progressText: " " });
publisher.onItemEvent({
itemId: "think1",
kind: "thinking",
progressText: "Checking the runtime",
});
await vi.advanceTimersByTimeAsync(20);
publisher.onItemEvent({
itemId: "think1",
kind: "thinking",
progressText: "Checking the runtime and recent rows",
});
publisher.onItemEvent({
itemId: "reason1",
kind: "reasoning",
progressText: "Comparing provider lanes",
});
publisher.onItemEvent({
itemId: "analysis1",
kind: "analysis",
summary: "Mapping this to ClickClack",
});
await publisher.finalize();
expect(createActivityMessage).toHaveBeenCalledTimes(3);
expect(createActivityMessage.mock.calls.map((call) => call[0])).toEqual([
expect.objectContaining({
body: "**Thinking**\n\nChecking the runtime",
kind: "agent_commentary",
}),
expect.objectContaining({
body: "**Thinking**\n\nComparing provider lanes",
kind: "agent_commentary",
}),
expect.objectContaining({
body: "**Thinking**\n\nMapping this to ClickClack",
kind: "agent_commentary",
}),
]);
expect(updateMessageBody).toHaveBeenCalledTimes(1);
expect(updateMessageBody).toHaveBeenCalledWith(
"msg_1",
"**Thinking**\n\nChecking the runtime and recent rows",
);
});
it("reports transport failures through onError without rejecting finalize", async () => {
const onError = vi.fn();
const createActivityMessage = vi.fn(async () => {
throw new Error("boom");
});
const updateMessageBody = vi.fn(async () => ({}) as ClickClackMessage);
const publisher = createClickClackActivityPublisher({
client: { createActivityMessage, updateMessageBody } as ClickClackActivityClient,
target: { channelId: "chn_1" },
turnId: "msg_turn",
onError,
});
publisher.onItemEvent({ itemId: "c1", kind: "preamble", progressText: "streaming" });
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" },
}),
);
});
});