From c4dbf8afc049a7e90433a4e92b2f14c9994d8355 Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Mon, 3 Aug 2026 14:09:26 -0700 Subject: [PATCH 1/2] fix: register chart as channel component --- app/channel.test.ts | 57 ++++++++++++++- app/channel.tsx | 10 ++- app/tools/__tests__/render-tools.test.ts | 89 +++++++++--------------- app/tools/index.ts | 2 - app/tools/render-chart.tsx | 30 ++++---- 5 files changed, 110 insertions(+), 78 deletions(-) diff --git a/app/channel.test.ts b/app/channel.test.ts index fbb88f7b..0ed69102 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -19,6 +19,7 @@ import { } from "@copilotkit/channels/slack"; import { appContext } from "./context/app-context.js"; import { appTools } from "./tools/index.js"; +import { RenderChart } from "./tools/render-chart.js"; import { createOpenTagChannel } from "./channel.js"; class CapturingAgent extends FakeAgent { @@ -215,16 +216,62 @@ describe("createOpenTagChannel", () => { "issue_list", "page_list", "read_thread", - "render_chart", "render_diagram", "render_table", "show_incident", "show_links", "show_status", ]); + expect(RenderChart.name).toBe("render_chart"); expect(appTools.map(({ name }) => name)).not.toContain("confirm_write"); }); + it("renders render_chart through the registered Channel component", async () => { + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onToolCallEndEvent?.({ + event: { toolCallId: "chart-1" }, + toolCallName: RenderChart.name, + toolCallArgs: { + title: "Incidents", + chart: { + type: "pie", + segments: [ + { label: "SEV1", value: 2 }, + { label: "SEV2", value: 5 }, + ], + }, + }, + } as never); + }, + () => undefined, + ]); + const { adapter, channel } = makeChannel({ agent }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "chart-thread", + replyTarget: {}, + userText: "chart incidents by severity", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }); + + expect(adapter.posted).toHaveLength(1); + const { blocks } = renderSlackMessage(adapter.posted[0]!); + expect(blocks[0]).toMatchObject({ + type: "data_visualization", + title: "Incidents", + chart: { + type: "pie", + segments: [ + { label: "SEV1", value: 2 }, + { label: "SEV2", value: 5 }, + ], + }, + }); + }); + it("injects Slack defaults per managed Slack run", async () => { const { adapter, agent, channel } = makeChannel(); @@ -239,7 +286,11 @@ describe("createOpenTagChannel", () => { const call = (agent as CapturingAgent).calls[0]; expect(call?.tools?.map(({ name }) => name).sort()).toEqual( - [...appTools, ...defaultSlackTools].map(({ name }) => name).sort(), + [ + ...appTools.map(({ name }) => name), + RenderChart.name, + ...defaultSlackTools.map(({ name }) => name), + ].sort(), ); expect(call?.context).toEqual([ ...appContext, @@ -265,7 +316,7 @@ describe("createOpenTagChannel", () => { const call = (agent as CapturingAgent).calls[0]; expect(call?.tools?.map(({ name }) => name).sort()).toEqual( - appTools.map(({ name }) => name).sort(), + [...appTools.map(({ name }) => name), RenderChart.name].sort(), ); expect(call?.context).toEqual([ ...appContext, diff --git a/app/channel.tsx b/app/channel.tsx index 0d41e161..501d387e 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -14,6 +14,7 @@ import { ConfirmWrite } from "./human-in-the-loop/index.js"; import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { IncidentCard } from "./tools/showcase-tools.js"; +import { RenderChart } from "./tools/render-chart.js"; import { appTools } from "./tools/index.js"; type ChannelAgent = NonNullable; @@ -30,7 +31,14 @@ export function createOpenTagChannel( tools: appTools, context: [...appContext], commands: appCommands, - components: [IssueCard, IssueList, PageList, IncidentCard, ConfirmWrite], + components: [ + IssueCard, + IssueList, + PageList, + IncidentCard, + ConfirmWrite, + RenderChart, + ], }); const runAgentSafely: Parameters[0] = async ({ diff --git a/app/tools/__tests__/render-tools.test.ts b/app/tools/__tests__/render-tools.test.ts index c5278293..9f9d09bd 100644 --- a/app/tools/__tests__/render-tools.test.ts +++ b/app/tools/__tests__/render-tools.test.ts @@ -1,7 +1,7 @@ /** - * Covers the `render_chart` and `render_diagram` tools (render-chart.tsx / - * render-diagram.tsx) — the agent-facing tools that render a native Slack - * data visualization or a Mermaid PNG. The + * Covers the `render_chart` component and `render_diagram` tool + * (render-chart.tsx / render-diagram.tsx) — the agent-facing definitions that + * render a native Slack data visualization or a Mermaid PNG. The * `issue_card` / `issue_list` / `page_list` render-tool wrappers are covered * separately in render-tools.test.tsx. */ @@ -15,43 +15,40 @@ const DIAGRAM_PNG = Buffer.from("DIAGRAMPNG"); const renderDiagram = vi.fn(async () => DIAGRAM_PNG); vi.mock("../../render/diagram.js", () => ({ renderDiagram })); -const { renderChartTool } = await import("../render-chart.js"); +const { RenderChart } = await import("../render-chart.js"); const { renderDiagramTool } = await import("../render-diagram.js"); /** The ctx a ChannelTool handler receives. */ -type HandlerCtx = Parameters[1]; +type HandlerCtx = Parameters[1]; function makeCtx(opts?: { postFileResult?: { ok: boolean; fileId?: string; error?: string }; - postError?: Error; - platform?: string; }) { - const posts: unknown[] = []; const postFileResult = opts?.postFileResult ?? { ok: true, fileId: "F1" }; const postFile = vi.fn(async () => postFileResult); const thread = { - post: vi.fn(async (ui: unknown) => { - if (opts?.postError) throw opts.postError; - posts.push(ui); - return { id: "m1" }; - }), + post: vi.fn(async () => ({ id: "m1" })), postFile, }; - const ctx = { - thread, - platform: opts?.platform ?? "slack", - } as unknown as HandlerCtx; - return { ctx, posts, postFile, thread }; + const ctx = { thread, platform: "slack" } as unknown as HandlerCtx; + return { ctx, postFile, thread }; } beforeEach(() => { renderDiagram.mockClear(); }); -describe("render_chart tool", () => { - it("posts a native Slack series chart", async () => { - const { ctx, posts, postFile } = makeCtx(); - const out = (await renderChartTool.handler( +describe("render_chart component", () => { + it("is defined as an agent-rendered Channel component", () => { + expect(RenderChart).toMatchObject({ + name: "render_chart", + parameters: expect.any(Object), + render: expect.any(Function), + }); + }); + + it("renders a native Slack series chart", async () => { + const ui = await RenderChart.render( { title: "Revenue Q2", chart: { @@ -71,10 +68,9 @@ describe("render_chart tool", () => { }, }, }, - ctx, - )) as string; - expect(posts).toHaveLength(1); - const { blocks } = renderSlackMessage(renderToIR(posts[0] as never)); + { platform: "slack", signal: new AbortController().signal }, + ); + const { blocks } = renderSlackMessage(renderToIR(ui as never)); expect(blocks[0]).toMatchObject({ type: "data_visualization", title: "Revenue Q2", @@ -95,13 +91,10 @@ describe("render_chart tool", () => { }, }, }); - expect(postFile).not.toHaveBeenCalled(); - expect(out).toBe("Rendered and posted the native Slack chart to the thread."); }); - it("posts a native Slack pie chart", async () => { - const { ctx, posts } = makeCtx(); - await renderChartTool.handler( + it("renders a native Slack pie chart", async () => { + const ui = await RenderChart.render( { title: "Incidents by severity", chart: { @@ -112,10 +105,10 @@ describe("render_chart tool", () => { ], }, }, - ctx, + { platform: "slack", signal: new AbortController().signal }, ); - const { blocks } = renderSlackMessage(renderToIR(posts[0] as never)); + const { blocks } = renderSlackMessage(renderToIR(ui as never)); expect(blocks[0]).toMatchObject({ type: "data_visualization", title: "Incidents by severity", @@ -129,9 +122,8 @@ describe("render_chart tool", () => { }); }); - it("surfaces native chart post failures", async () => { - const { ctx, posts } = makeCtx({ postError: new Error("post rejected") }); - const out = (await renderChartTool.handler( + it("renders an explicit portable fallback outside Slack", async () => { + const ui = await RenderChart.render( { title: "Incidents", chart: { @@ -139,27 +131,14 @@ describe("render_chart tool", () => { segments: [{ label: "SEV1", value: 2 }], }, }, - ctx, - )) as string; - expect(out).toBe("Chart render failed: post rejected"); - expect(posts).toHaveLength(0); - }); + { platform: "teams", signal: new AbortController().signal }, + ); - it("does not offer Slack-native charts on other platforms", async () => { - const { ctx, posts, thread } = makeCtx({ platform: "teams" }); - const out = await renderChartTool.handler( - { - title: "Incidents", - chart: { - type: "pie", - segments: [{ label: "SEV1", value: 2 }], - }, - }, - ctx, + const ir = renderToIR(ui as never); + expect(ir[0]?.type).toBe("section"); + expect(JSON.stringify(ir)).toContain( + "Native data visualizations are currently only available in Slack.", ); - expect(out).toContain("only available in Slack"); - expect(posts).toHaveLength(0); - expect(thread.post).not.toHaveBeenCalled(); }); }); diff --git a/app/tools/index.ts b/app/tools/index.ts index c6b91d46..9a7b5a90 100644 --- a/app/tools/index.ts +++ b/app/tools/index.ts @@ -6,7 +6,6 @@ * `createChannel({ tools })`. */ import { readThreadTool } from "./read-thread.js"; -import { renderChartTool } from "./render-chart.js"; import { renderDiagramTool } from "./render-diagram.js"; import { renderTableTool } from "./render-table.js"; import { issueCardTool, issueListTool, pageListTool } from "./render-tools.js"; @@ -27,7 +26,6 @@ import type { ChannelTool } from "@copilotkit/channels"; */ export const appTools: ChannelTool[] = [ readThreadTool, - renderChartTool, renderDiagramTool, renderTableTool, issueCardTool, diff --git a/app/tools/render-chart.tsx b/app/tools/render-chart.tsx index dfdc18cd..d43252dd 100644 --- a/app/tools/render-chart.tsx +++ b/app/tools/render-chart.tsx @@ -1,10 +1,10 @@ /** - * `render_chart` — render Slack's native data visualization block directly - * in the conversation. This is the "upload a CSV → get a chart" payoff: the - * agent parses the data, then calls this with Slack's documented chart shape. + * `render_chart` — an agent-rendered Channel component backed by Slack's native + * data visualization block. This is the "upload a CSV → get a chart" payoff: + * the agent parses the data, then renders this with Slack's documented shape. */ import { z } from "zod"; -import { defineChannelTool } from "@copilotkit/channels"; +import { Section, defineChannelComponent } from "@copilotkit/channels"; import { Slack } from "@copilotkit/channels/slack"; const shortLabel = z.string().min(1).max(20); @@ -97,7 +97,7 @@ const schema = z.object({ } }); -export const renderChartTool = defineChannelTool({ +export const RenderChart = defineChannelComponent({ name: "render_chart", description: "Render a native Slack data visualization in the conversation. Use this " + @@ -105,19 +105,15 @@ export const renderChartTool = defineChannelTool({ "and line charts. Series data labels must exactly match the ordered axis " + "categories.", parameters: schema, - async handler({ title, chart }, ctx) { - if (ctx.platform !== "slack") { - return "Chart render failed: native data visualizations are only available in Slack."; - } - - try { - await ctx.thread.post( - , + render({ title, chart }, { platform }) { + if (platform !== "slack") { + return ( +
+ Native data visualizations are currently only available in Slack. +
); - return "Rendered and posted the native Slack chart to the thread."; - } catch (e) { - console.error("[render-chart] native Slack render failed", e); - return `Chart render failed: ${(e as Error).message}`; } + + return ; }, }); From d67f23a73038b50f9743ef5e3f1852fb8db15e49 Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Mon, 3 Aug 2026 14:16:58 -0700 Subject: [PATCH 2/2] fix: only respond without mentions if subscribed to the thread Signed-off-by: Tyler Slaton --- app/channel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/channel.tsx b/app/channel.tsx index 501d387e..bb11fae3 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -74,7 +74,8 @@ export function createOpenTagChannel( }); channel.onMessage(async ({ thread, message }) => { - await runAgentSafely({ thread, message }); + if(await thread.isSubscribed()) + await runAgentSafely({ thread, message }); }); channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit);