From d69c84dae17d9b14debdbd504a19b11bc28c3a0c Mon Sep 17 00:00:00 2001 From: Thomas Mustier <6326440+tmustier@users.noreply.github.com> Date: Sun, 24 May 2026 21:58:54 +0100 Subject: [PATCH 1/3] feat(slack-bridge): collapse pinet delivery cards --- slack-bridge/index.ts | 10 +- slack-bridge/pinet-delivery-card.test.ts | 91 +++++++++ slack-bridge/pinet-delivery-card.ts | 177 ++++++++++++++++++ .../slack-tool-policy-runtime.test.ts | 27 +++ slack-bridge/slack-tool-policy-runtime.ts | 31 +-- types/pi-coding-agent.d.ts | 14 +- 6 files changed, 336 insertions(+), 14 deletions(-) create mode 100644 slack-bridge/pinet-delivery-card.test.ts create mode 100644 slack-bridge/pinet-delivery-card.ts diff --git a/slack-bridge/index.ts b/slack-bridge/index.ts index 28b9cb89..f1e28191 100644 --- a/slack-bridge/index.ts +++ b/slack-bridge/index.ts @@ -68,6 +68,10 @@ import { createSlackRequestRuntime } from "./slack-request-runtime.js"; import { createPinetRegistrationGate } from "./pinet-registration-gate.js"; import { createBrokerRuntimeAccess } from "./broker-runtime-access.js"; import { createInboxDrainRuntime } from "./inbox-drain-runtime.js"; +import { + registerPinetDeliveryMessageRenderer, + sendPinetDeliveryMessage, +} from "./pinet-delivery-card.js"; import { createAgentCompletionRuntime } from "./agent-completion-runtime.js"; import { sendBrokerMessage } from "./broker/message-send.js"; import { @@ -84,6 +88,8 @@ import { // Settings and helpers imported from ./helpers.js export default function (pi: ExtensionAPI) { + registerPinetDeliveryMessageRenderer(pi); + let settings = loadSettingsFromFile(); let botToken = settings.botToken ?? process.env.SLACK_BOT_TOKEN; @@ -213,7 +219,7 @@ export default function (pi: ExtensionAPI) { }) => boolean = () => false; const inboxDrainRuntime = createInboxDrainRuntime({ sendUserMessage: (body) => { - pi.sendUserMessage(body); + sendPinetDeliveryMessage(pi, body); }, isIdle: () => sessionUiRuntime.getExtensionContext()?.isIdle?.() ?? true, takeInboxMessages: (maxMessages) => inbox.splice(0, maxMessages ?? inbox.length), @@ -503,7 +509,7 @@ export default function (pi: ExtensionAPI) { getActiveBrokerSelfId, isIdle: () => sessionUiRuntime.getExtensionContext()?.isIdle?.() ?? true, sendUserMessage: (body) => { - pi.sendUserMessage(body); + sendPinetDeliveryMessage(pi, body); }, }); const { sendBrokerMaintenanceMessage, trySendBrokerFollowUp } = pinetMaintenanceDelivery; diff --git a/slack-bridge/pinet-delivery-card.test.ts b/slack-bridge/pinet-delivery-card.test.ts new file mode 100644 index 00000000..61c84d2e --- /dev/null +++ b/slack-bridge/pinet-delivery-card.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildPinetDeliveryCardDetails, + PINET_DELIVERY_CUSTOM_TYPE, + registerPinetDeliveryMessageRenderer, + renderPinetDeliveryMessage, + sendPinetDeliveryMessage, + type PinetDeliveryApi, +} from "./pinet-delivery-card.js"; + +const body = [ + "New Pinet messages:", + "[thread a2a:broker:worker] [steering] Broker: inbox_id=42 pointer=pinet action=read args.thread_id=a2a:broker:worker args.unread_only=true", + "", + "Read pointer(s) before acting; reply via pinet action=send.", +].join("\n"); + +function renderText(component: { render(width: number): string[] }): string { + return component.render(300).join("\n"); +} + +describe("Pinet delivery cards", () => { + it("builds compact delivery card details from the full prompt body", () => { + expect(buildPinetDeliveryCardDetails(body)).toEqual({ + title: "New Pinet messages:", + summary: + "[thread a2a:broker:worker] [steering] Broker: inbox_id=42 pointer=pinet action=read args.thread_id=a2a:broker:worker args.unread_only=true", + lineCount: 4, + characterCount: body.length, + }); + }); + + it("renders collapsed cards by default with an expand hint and without the full body", () => { + const rendered = renderText( + renderPinetDeliveryMessage( + { content: body, details: buildPinetDeliveryCardDetails(body) }, + { expanded: false }, + ), + ); + + expect(rendered).toContain("[Slack Bridge] New Pinet messages:"); + expect(rendered).toContain("Ctrl+O to expand full delivery prompt"); + expect(rendered).toContain("[thread a2a:broker:worker] [steering] Broker:"); + expect(rendered).not.toContain("Read pointer(s) before acting"); + }); + + it("renders the exact full prompt body when expanded", () => { + const rendered = renderText(renderPinetDeliveryMessage({ content: body }, { expanded: true })); + + expect(rendered).toContain(body); + }); + + it("registers the custom renderer under the Slack Bridge Pinet delivery type", () => { + const registerMessageRenderer = vi.fn(); + + registerPinetDeliveryMessageRenderer({ registerMessageRenderer }); + + expect(registerMessageRenderer).toHaveBeenCalledWith( + PINET_DELIVERY_CUSTOM_TYPE, + renderPinetDeliveryMessage, + ); + }); + + it("delivers full model-visible content through a displayed custom message", () => { + const sendMessage = vi.fn(); + const sendUserMessage = vi.fn(); + const pi: PinetDeliveryApi = { sendMessage, sendUserMessage }; + + sendPinetDeliveryMessage(pi, body); + + expect(sendMessage).toHaveBeenCalledWith( + { + customType: PINET_DELIVERY_CUSTOM_TYPE, + content: body, + display: true, + details: buildPinetDeliveryCardDetails(body), + }, + { triggerTurn: true }, + ); + expect(sendUserMessage).not.toHaveBeenCalled(); + }); + + it("falls back to user-message delivery when custom messages are unavailable", () => { + const sendUserMessage = vi.fn(); + const pi: PinetDeliveryApi = { sendUserMessage }; + + sendPinetDeliveryMessage(pi, body); + + expect(sendUserMessage).toHaveBeenCalledWith(body); + }); +}); diff --git a/slack-bridge/pinet-delivery-card.ts b/slack-bridge/pinet-delivery-card.ts new file mode 100644 index 00000000..bd086691 --- /dev/null +++ b/slack-bridge/pinet-delivery-card.ts @@ -0,0 +1,177 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export const PINET_DELIVERY_CUSTOM_TYPE = "slack-bridge:pinet-delivery"; + +const DEFAULT_SUMMARY = "Slack Bridge delivery"; +const COLLAPSED_PREVIEW_MAX_LENGTH = 180; + +export interface PinetDeliveryCardDetails { + title: string; + summary: string; + lineCount: number; + characterCount: number; +} + +interface PinetDeliveryMessageContentBlock { + type: string; + text?: string; +} + +interface PinetDeliveryCustomMessage { + content: string | PinetDeliveryMessageContentBlock[]; + details?: unknown; +} + +interface PinetDeliveryRenderOptions { + expanded?: boolean; +} + +interface PinetDeliveryComponent { + render(width: number): string[]; + invalidate(): void; +} + +export interface PinetDeliveryApi { + sendMessage?: ( + message: { + customType: string; + content: string; + display: boolean; + details: PinetDeliveryCardDetails; + }, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ) => void; + sendUserMessage: ExtensionAPI["sendUserMessage"]; +} + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncateText(value: string, maxLength = COLLAPSED_PREVIEW_MAX_LENGTH): string { + const collapsed = collapseWhitespace(value); + if (collapsed.length <= maxLength) return collapsed; + return `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`; +} + +function getDeliveryTitle(body: string): string { + const firstLine = body + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + return firstLine ?? DEFAULT_SUMMARY; +} + +function getDeliverySummary(body: string): string { + const lines = body + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const firstMessageLine = + lines.find((line) => line.startsWith("[thread ")) ?? lines[1] ?? lines[0]; + return truncateText(firstMessageLine ?? DEFAULT_SUMMARY); +} + +export function buildPinetDeliveryCardDetails(body: string): PinetDeliveryCardDetails { + return { + title: getDeliveryTitle(body), + summary: getDeliverySummary(body), + lineCount: body.length === 0 ? 0 : body.split("\n").length, + characterCount: body.length, + }; +} + +function readTextContent(message: PinetDeliveryCustomMessage): string { + if (typeof message.content === "string") { + return message.content; + } + + return message.content + .filter((block) => block.type === "text" && typeof block.text === "string") + .map((block) => block.text ?? "") + .join("\n"); +} + +function readDetails(message: PinetDeliveryCustomMessage): PinetDeliveryCardDetails | null { + if (typeof message.details !== "object" || message.details === null) return null; + const details = message.details; + if (!("title" in details) || !("summary" in details)) return null; + const title = details.title; + const summary = details.summary; + if (typeof title !== "string" || typeof summary !== "string") return null; + return { + title, + summary, + lineCount: + "lineCount" in details && typeof details.lineCount === "number" ? details.lineCount : 0, + characterCount: + "characterCount" in details && typeof details.characterCount === "number" + ? details.characterCount + : 0, + }; +} + +function truncateLineToWidth(value: string, width: number): string { + if (width <= 0) return ""; + if (value.length <= width) return value; + if (width === 1) return "…"; + return `${value.slice(0, width - 1)}…`; +} + +class PinetDeliveryCardComponent implements PinetDeliveryComponent { + constructor(private readonly text: string) {} + + render(width: number): string[] { + return this.text.split("\n").map((line) => truncateLineToWidth(line, width)); + } + + invalidate(): void { + // Stateless component. + } +} + +export function renderPinetDeliveryMessage( + message: PinetDeliveryCustomMessage, + options: PinetDeliveryRenderOptions, +): PinetDeliveryComponent { + const body = readTextContent(message); + const details = readDetails(message) ?? buildPinetDeliveryCardDetails(body); + const heading = `[Slack Bridge] ${details.title}`; + + if (options.expanded) { + return new PinetDeliveryCardComponent(`${heading}\n\n${body}`); + } + + const stats = + details.lineCount > 1 || details.characterCount > COLLAPSED_PREVIEW_MAX_LENGTH + ? ` (${details.lineCount} lines, ${details.characterCount} chars)` + : ""; + const summary = details.summary; + return new PinetDeliveryCardComponent( + `${heading}${stats}\n${summary}\nCtrl+O to expand full delivery prompt`, + ); +} + +export function registerPinetDeliveryMessageRenderer( + pi: Pick, +): void { + if (typeof pi.registerMessageRenderer !== "function") return; + pi.registerMessageRenderer(PINET_DELIVERY_CUSTOM_TYPE, renderPinetDeliveryMessage); +} + +export function sendPinetDeliveryMessage(pi: PinetDeliveryApi, body: string): void { + if (typeof pi.sendMessage === "function") { + pi.sendMessage( + { + customType: PINET_DELIVERY_CUSTOM_TYPE, + content: body, + display: true, + details: buildPinetDeliveryCardDetails(body), + }, + { triggerTurn: true }, + ); + return; + } + + pi.sendUserMessage(body); +} diff --git a/slack-bridge/slack-tool-policy-runtime.test.ts b/slack-bridge/slack-tool-policy-runtime.test.ts index 3d082643..ef7dcfb7 100644 --- a/slack-bridge/slack-tool-policy-runtime.test.ts +++ b/slack-bridge/slack-tool-policy-runtime.test.ts @@ -65,6 +65,33 @@ describe("createSlackToolPolicyRuntime", () => { expect(requireToolPolicy).toHaveBeenCalledTimes(1); }); + it("tracks a delivered Slack follow-up turn even when custom-message delivery emits no input event", async () => { + const { deps, requireToolPolicy } = createDeps({ + getGuardrails: () => ({ requireConfirmation: ["read"] }), + }); + const runtime = createSlackToolPolicyRuntime(deps); + + expect( + runtime.deliverTrackedSlackFollowUpMessage({ + prompt: "custom slack prompt", + messages: [{ threadTs: "100.1" }], + }), + ).toBe(true); + await runtime.onTurnStart(); + + await expect( + runtime.onToolCall({ + toolName: "read", + input: { path: "plans/440.md" }, + }), + ).resolves.toBeUndefined(); + expect(requireToolPolicy).toHaveBeenCalledWith( + "read", + "100.1", + "path=plans/440.md | offset= | limit=", + ); + }); + it("ignores non-extension input and rolls back undelivered turns", async () => { const { deps, requireToolPolicy } = createDeps({ getGuardrails: () => ({ requireConfirmation: ["read"] }), diff --git a/slack-bridge/slack-tool-policy-runtime.ts b/slack-bridge/slack-tool-policy-runtime.ts index 16f12fb1..65a614cc 100644 --- a/slack-bridge/slack-tool-policy-runtime.ts +++ b/slack-bridge/slack-tool-policy-runtime.ts @@ -4,7 +4,8 @@ import { evaluateSlackOriginRepoToolPolicy } from "./repo-tool-guardrails.js"; import { isBrokerForbiddenTool, type SecurityGuardrails } from "./guardrails.js"; import { consumePendingSlackToolPolicyTurn, - deliverTrackedSlackFollowUpMessage as trackAndDeliverSlackFollowUpMessage, + enqueuePendingSlackToolPolicyTurn, + removePendingSlackToolPolicyTurn, type PendingSlackToolPolicyTurn, } from "./slack-turn-guardrails.js"; @@ -43,12 +44,20 @@ export function createSlackToolPolicyRuntime( prompt: string; messages: Pick[]; }): boolean { - return trackAndDeliverSlackFollowUpMessage({ - queue: pendingSlackToolPolicyTurns, - prompt: options.prompt, - messages: options.messages, - deliver: deps.deliverFollowUpMessage, - }); + const entry = enqueuePendingSlackToolPolicyTurn( + pendingSlackToolPolicyTurns, + options.prompt, + options.messages, + ); + + if (deps.deliverFollowUpMessage(options.prompt)) { + removePendingSlackToolPolicyTurn(pendingSlackToolPolicyTurns, entry); + nextSlackToolPolicyTurn = entry; + return true; + } + + removePendingSlackToolPolicyTurn(pendingSlackToolPolicyTurns, entry); + return false; } async function onInput(event: { source?: string; text: string }): Promise { @@ -56,10 +65,10 @@ export function createSlackToolPolicyRuntime( return; } - nextSlackToolPolicyTurn = consumePendingSlackToolPolicyTurn( - pendingSlackToolPolicyTurns, - event.text, - ); + const consumedTurn = consumePendingSlackToolPolicyTurn(pendingSlackToolPolicyTurns, event.text); + if (consumedTurn) { + nextSlackToolPolicyTurn = consumedTurn; + } } async function onTurnStart(): Promise { diff --git a/types/pi-coding-agent.d.ts b/types/pi-coding-agent.d.ts index f45b4765..23084d81 100644 --- a/types/pi-coding-agent.d.ts +++ b/types/pi-coding-agent.d.ts @@ -46,6 +46,15 @@ declare module "@mariozechner/pi-coding-agent" { sessionManager: SessionManager; } + export interface CustomMessage { + role: "custom"; + customType: string; + content: string | Array>; + display: boolean; + details?: T; + timestamp?: number; + } + export interface ToolUpdate { content?: Array<{ type: string; text?: string }>; details?: any; @@ -86,7 +95,10 @@ declare module "@mariozechner/pi-coding-agent" { content: string | Array>, options?: { deliverAs?: string }, ): void; - sendMessage(message: any): void; + sendMessage( + message: Pick, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ): void; appendEntry(customType: string, data?: unknown): void; } } From 66ff8f29ef5d7d0de24c417b46067ac146ab2b3e Mon Sep 17 00:00:00 2001 From: Thomas Mustier <6326440+tmustier@users.noreply.github.com> Date: Sun, 24 May 2026 22:58:48 +0100 Subject: [PATCH 2/3] fix(slack-bridge): collapse pinet tool results --- slack-bridge/pinet-tools.test.ts | 28 ++++++++ slack-bridge/pinet-tools.ts | 112 +++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/slack-bridge/pinet-tools.test.ts b/slack-bridge/pinet-tools.test.ts index 43adf822..62cf0d15 100644 --- a/slack-bridge/pinet-tools.test.ts +++ b/slack-bridge/pinet-tools.test.ts @@ -6,11 +6,19 @@ import { type RegisterPinetToolsDeps, } from "./pinet-tools.js"; +type RenderedComponent = { + render(width: number): string[]; +}; + type ToolDefinition = { name: string; promptSnippet?: string; parameters?: unknown; execute: (id: string, params: Record) => Promise; + renderResult?: ( + result: unknown, + options: { expanded?: boolean; isPartial?: boolean }, + ) => RenderedComponent; }; function makeAgent(overrides: Partial = {}): PinetToolsAgentRecord { @@ -590,4 +598,24 @@ describe("registerPinetTools", () => { expect(result.details.data.details.hint.repo).toBe("extensions"); expect(result.details.data.compact_details).toBeUndefined(); }); + + it("renders direct pinet tool results collapsed by default and expandable", async () => { + const tools = registerWithDeps(createDeps()); + const pinet = tools.get("pinet"); + + const result = await pinet?.execute("tool-call-json-result", { + action: "agents", + args: { format: "json", full: true }, + }); + + const collapsed = pinet?.renderResult?.(result, { expanded: false }).render(300).join("\n"); + const expanded = pinet?.renderResult?.(result, { expanded: true }).render(300).join("\n"); + + expect(collapsed).toContain("[Pinet] ✓ agents"); + expect(collapsed).toContain("Ctrl+O to expand full Pinet tool result"); + expect(collapsed).toContain("Golden Chalk Rabbit"); + expect(collapsed).not.toContain('"lastHeartbeat"'); + expect(expanded).toContain('"lastHeartbeat"'); + expect(expanded).toContain('"status": "succeeded"'); + }); }); diff --git a/slack-bridge/pinet-tools.ts b/slack-bridge/pinet-tools.ts index 52da24a2..3338a479 100644 --- a/slack-bridge/pinet-tools.ts +++ b/slack-bridge/pinet-tools.ts @@ -139,6 +139,30 @@ const PINET_OUTPUT_OPTION_PARAMETERS = { ), }; +const PINET_TOOL_RESULT_PREVIEW_MAX_LENGTH = 240; + +interface PinetToolRenderOptions { + expanded?: boolean; + isPartial?: boolean; +} + +interface PinetToolResultComponent { + render(width: number): string[]; + invalidate(): void; +} + +class PinetToolResultCardComponent implements PinetToolResultComponent { + constructor(private readonly text: string) {} + + render(width: number): string[] { + return this.text.split("\n").map((line) => truncateLineToWidth(line, width)); + } + + invalidate(): void { + // Stateless component. + } +} + function getRecordString( record: Record | null | undefined, key: string, @@ -184,6 +208,91 @@ function getErrorMessage(error: unknown): string { } } +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncateText(value: string, maxLength: number): string { + const collapsed = collapseWhitespace(value); + if (collapsed.length <= maxLength) return collapsed; + return `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`; +} + +function truncateLineToWidth(value: string, width: number): string { + if (width <= 0) return ""; + if (value.length <= width) return value; + if (width === 1) return "…"; + return `${value.slice(0, width - 1)}…`; +} + +function getTextContent(result: unknown): string { + if (!isRecord(result) || !Array.isArray(result.content)) return ""; + return result.content + .filter((block): block is { type: string; text: string } => { + if (!isRecord(block)) return false; + return block.type === "text" && typeof block.text === "string"; + }) + .map((block) => block.text) + .join("\n"); +} + +function readDispatcherEnvelope(value: unknown): PinetDispatcherEnvelope | null { + if (!isRecord(value)) return null; + const { status, data, errors, warnings } = value; + if (status !== "succeeded" && status !== "failed") return null; + if (!Array.isArray(errors) || !Array.isArray(warnings)) return null; + return { status, data, errors: [], warnings: [] }; +} + +function getEnvelopeDataText(envelope: PinetDispatcherEnvelope): string | null { + if (!isRecord(envelope.data)) return null; + return typeof envelope.data.text === "string" && envelope.data.text.trim() + ? envelope.data.text + : null; +} + +function getEnvelopeAction(envelope: PinetDispatcherEnvelope): string | null { + if (!isRecord(envelope.data)) return null; + return typeof envelope.data.action === "string" && envelope.data.action.trim() + ? envelope.data.action + : null; +} + +function getPinetToolResultTitle(envelope: PinetDispatcherEnvelope | null): string { + if (!envelope) return "[Pinet] Tool result"; + const status = envelope.status === "succeeded" ? "✓" : "✗"; + const action = getEnvelopeAction(envelope); + return action ? `[Pinet] ${status} ${action}` : `[Pinet] ${status} result`; +} + +function renderPinetToolResult( + result: unknown, + options: PinetToolRenderOptions, +): PinetToolResultComponent { + const envelope = isRecord(result) ? readDispatcherEnvelope(result.details) : null; + const fullText = getTextContent(result) || (envelope ? JSON.stringify(envelope, null, 2) : ""); + const title = getPinetToolResultTitle(envelope); + + if (options.expanded) { + return new PinetToolResultCardComponent(`${title}\n\n${fullText}`.trimEnd()); + } + + const previewSource = envelope ? (getEnvelopeDataText(envelope) ?? fullText) : fullText; + const preview = truncateText( + previewSource || "(no output)", + PINET_TOOL_RESULT_PREVIEW_MAX_LENGTH, + ); + const lineCount = fullText.length === 0 ? 0 : fullText.split("\n").length; + const stats = + lineCount > 1 || fullText.length > PINET_TOOL_RESULT_PREVIEW_MAX_LENGTH + ? ` (${lineCount} lines, ${fullText.length} chars)` + : ""; + const partial = options.isPartial ? "Running…\n" : ""; + return new PinetToolResultCardComponent( + `${partial}${title}${stats}\n${preview}\nCtrl+O to expand full Pinet tool result`, + ); +} + function classifyPinetError(message: string): PinetDispatcherError { if (message.includes("requires confirmation for action")) { return { @@ -771,6 +880,9 @@ export function registerPinetTools(pi: ExtensionAPI, deps: RegisterPinetToolsDep }), ), }), + renderResult(result: unknown, options: PinetToolRenderOptions) { + return renderPinetToolResult(result, options); + }, async execute(toolCallId, params) { let normalizedAction: PinetDispatcherAction; try { From f7acddf675423e07013abd2d3944a0d3514cdb4f Mon Sep 17 00:00:00 2001 From: Thomas Mustier <6326440+tmustier@users.noreply.github.com> Date: Sun, 24 May 2026 23:28:28 +0100 Subject: [PATCH 3/3] fix(slack-bridge): compact direct pinet result text --- slack-bridge/pinet-tools.test.ts | 35 +++++++++++------- slack-bridge/pinet-tools.ts | 63 +++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/slack-bridge/pinet-tools.test.ts b/slack-bridge/pinet-tools.test.ts index 62cf0d15..48b56710 100644 --- a/slack-bridge/pinet-tools.test.ts +++ b/slack-bridge/pinet-tools.test.ts @@ -281,12 +281,14 @@ describe("registerPinetTools", () => { const result = (await tools.get("pinet")?.execute("tool-call-read-json-details", { action: "read", args: { thread_id: "a2a:broker:worker", f: "json" }, - })) as { content: Array<{ text: string }> }; - const envelope = JSON.parse(result.content[0]?.text ?? "{}") as { - data: { details: { messages: Array<{ message: { body: string } }> } }; + })) as { + content: Array<{ text: string }>; + details: { data: { details: { messages: Array<{ message: { body: string } }> } } }; }; - expect(envelope.data.details.messages[0]?.message.body).toBe(body); + expect(result.content[0]?.text).toContain("Ctrl+O to expand full Pinet tool result"); + expect(result.content[0]?.text).not.toContain(body); + expect(result.details.data.details.messages[0]?.message.body).toBe(body); }); it("shows exact Pinet read bodies only with explicit full output", async () => { @@ -330,7 +332,8 @@ describe("registerPinetTools", () => { }; }; - expect(result.content[0]?.text).toContain(body); + expect(result.content[0]?.text).toContain("Ctrl+O to expand full Pinet tool result"); + expect(result.content[0]?.text).not.toContain(body); expect(result.details.data.details.messages[0]?.message.body).toBe(body); expect(result.details.data.full_details).toBeUndefined(); }); @@ -366,16 +369,20 @@ describe("registerPinetTools", () => { const result = (await tools.get("pinet")?.execute("tool-call-read-json-full-details", { action: "read", args: { thread_id: "a2a:broker:worker", format: "json", full: true }, - })) as { content: Array<{ text: string }> }; - const envelope = JSON.parse(result.content[0]?.text ?? "{}") as { - data: { - details: { messages: Array<{ message: { body: string } }> }; - full_details?: unknown; + })) as { + content: Array<{ text: string }>; + details: { + data: { + details: { messages: Array<{ message: { body: string } }> }; + full_details?: unknown; + }; }; }; - expect(envelope.data.details.messages[0]?.message.body).toBe(body); - expect(envelope.data.full_details).toBeUndefined(); + expect(result.content[0]?.text).toContain("Ctrl+O to expand full Pinet tool result"); + expect(result.content[0]?.text).not.toContain(body); + expect(result.details.data.details.messages[0]?.message.body).toBe(body); + expect(result.details.data.full_details).toBeUndefined(); }); it("routes action-dispatched help through the dispatcher", async () => { @@ -442,7 +449,9 @@ describe("registerPinetTools", () => { expect(result.details.status).toBe("succeeded"); expect(result.details.data.action).toBe("send"); expect(result.details.data.text).toBe("Pinet message sent to alpha."); - expect(result.content[0]?.text).toContain('"status": "succeeded"'); + expect(result.content[0]?.text).toContain("[Pinet] ✓ send"); + expect(result.content[0]?.text).toContain("Ctrl+O to expand full Pinet tool result"); + expect(result.content[0]?.text).not.toContain('"status": "succeeded"'); }); it("honors explicit full output for pinet send", async () => { diff --git a/slack-bridge/pinet-tools.ts b/slack-bridge/pinet-tools.ts index 3338a479..5fddfd97 100644 --- a/slack-bridge/pinet-tools.ts +++ b/slack-bridge/pinet-tools.ts @@ -265,12 +265,31 @@ function getPinetToolResultTitle(envelope: PinetDispatcherEnvelope | null): stri return action ? `[Pinet] ${status} ${action}` : `[Pinet] ${status} result`; } +function isCollapsedPinetToolText(value: string): boolean { + return value.includes("Ctrl+O to expand full Pinet tool result"); +} + +function getExpandedPinetToolText( + contentText: string, + envelope: PinetDispatcherEnvelope | null, +): string { + if (!isCollapsedPinetToolText(contentText)) { + return contentText; + } + + return envelope ? JSON.stringify(envelope, null, 2) : contentText; +} + function renderPinetToolResult( result: unknown, options: PinetToolRenderOptions, ): PinetToolResultComponent { const envelope = isRecord(result) ? readDispatcherEnvelope(result.details) : null; - const fullText = getTextContent(result) || (envelope ? JSON.stringify(envelope, null, 2) : ""); + const contentText = getTextContent(result); + const fullText = getExpandedPinetToolText( + contentText || (envelope ? JSON.stringify(envelope, null, 2) : ""), + envelope, + ); const title = getPinetToolResultTitle(envelope); if (options.expanded) { @@ -355,6 +374,40 @@ function getPinetEnvelopeCliText(envelope: PinetDispatcherEnvelope): string { return JSON.stringify(envelope, null, 2); } +function getPinetEnvelopePreviewText(envelope: PinetDispatcherEnvelope): string { + const text = getEnvelopeDataText(envelope) ?? getPinetEnvelopeCliText(envelope); + const firstLine = text + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + return firstLine ?? (envelope.status === "succeeded" ? "(no output)" : "Pinet action failed"); +} + +function getPinetEnvelopeCollapsedText(envelope: PinetDispatcherEnvelope): string { + const title = getPinetToolResultTitle(envelope); + const preview = truncateText( + getPinetEnvelopePreviewText(envelope), + PINET_TOOL_RESULT_PREVIEW_MAX_LENGTH, + ); + const fullText = JSON.stringify(envelope, null, 2); + const stats = + fullText.length > PINET_TOOL_RESULT_PREVIEW_MAX_LENGTH || fullText.includes("\n") + ? ` (${fullText.split("\n").length} lines, ${fullText.length} chars)` + : ""; + return `${title}${stats}\n${preview}\nCtrl+O to expand full Pinet tool result`; +} + +function shouldCollapsePinetEnvelopeContent( + envelope: PinetDispatcherEnvelope, + output: PinetOutputOptions, +): boolean { + return ( + output.format === "json" || + output.full || + getPinetEnvelopeCliText(envelope).length > PINET_TOOL_RESULT_PREVIEW_MAX_LENGTH + ); +} + function wrapDispatcherEnvelope( envelope: PinetDispatcherEnvelope, output: PinetOutputOptions = { format: "json", full: true }, @@ -362,14 +415,14 @@ function wrapDispatcherEnvelope( content: Array<{ type: "text"; text: string }>; details: PinetDispatcherEnvelope; } { + const text = shouldCollapsePinetEnvelopeContent(envelope, output) + ? getPinetEnvelopeCollapsedText(envelope) + : getPinetEnvelopeCliText(envelope); return { content: [ { type: "text", - text: - output.format === "json" - ? JSON.stringify(envelope, null, 2) - : getPinetEnvelopeCliText(envelope), + text, }, ], details: envelope,