mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-09 19:49:58 +00:00
feat(spa): redesign tool footer — show latest tool, stats line, expandable history (#2031)
Replace the continuously-appending tool list with a cleaner 3-part footer: 1. Latest tool call (swapped, not appended) — shows current tool + hint 2. Compact stats line — "1× Bash, 4× Read, 5× Grep, 8× Glob" 3. Expandable attachment — full ordered tool history (Slack auto-collapses) Also adds toolHint field to SlackSegment, extracts formatToolStats and formatToolHistory as tested helpers, and adds 19 new unit tests. Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
parent
a8b7bb7fb9
commit
6f6b2a7895
3 changed files with 270 additions and 28 deletions
|
|
@ -78,11 +78,19 @@ export interface SlackSegment {
|
|||
kind: "text" | "tool_use" | "tool_result";
|
||||
text: string;
|
||||
toolName?: string; // set for tool_use
|
||||
toolHint?: string; // set for tool_use — truncated command/pattern/path
|
||||
isError?: boolean; // set for tool_result
|
||||
}
|
||||
|
||||
/** Format a tool_use input block into a truncated backtick hint string. */
|
||||
function formatToolHint(block: Record<string, unknown>): string {
|
||||
/** Tracked tool call for history and stats. */
|
||||
export interface ToolCall {
|
||||
name: string;
|
||||
hint: string;
|
||||
errored?: boolean;
|
||||
}
|
||||
|
||||
/** Extract a truncated hint from a tool_use input block. */
|
||||
export function extractToolHint(block: Record<string, unknown>): string {
|
||||
const input = toRecord(block.input);
|
||||
if (!input) {
|
||||
return "";
|
||||
|
|
@ -94,8 +102,34 @@ function formatToolHint(block: Record<string, unknown>): string {
|
|||
if (!hint) {
|
||||
return "";
|
||||
}
|
||||
const short = hint.length > 80 ? `${hint.slice(0, 80)}...` : hint;
|
||||
return ` \`${short}\``;
|
||||
return hint.length > 80 ? `${hint.slice(0, 80)}...` : hint;
|
||||
}
|
||||
|
||||
/** Format a tool_use input block into a truncated backtick hint string. */
|
||||
function formatToolHint(block: Record<string, unknown>): string {
|
||||
const hint = extractToolHint(block);
|
||||
if (!hint) {
|
||||
return "";
|
||||
}
|
||||
return ` \`${hint}\``;
|
||||
}
|
||||
|
||||
/** Format tool counts into a compact stats string: "1× Bash, 4× Read, 5× Grep". */
|
||||
export function formatToolStats(counts: ReadonlyMap<string, number>): string {
|
||||
return Array.from(counts.entries())
|
||||
.map(([name, count]) => `${count}× ${name}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/** Format the full ordered tool history into a numbered list for the expandable attachment. */
|
||||
export function formatToolHistory(history: readonly ToolCall[]): string {
|
||||
return history
|
||||
.map((t, i) => {
|
||||
const icon = t.errored ? "✗" : "✓";
|
||||
const hint = t.hint ? ` — ${t.hint}` : "";
|
||||
return `${i + 1}. ${icon} ${t.name}${hint}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Parse an assistant-type event into a SlackSegment. */
|
||||
|
|
@ -109,6 +143,7 @@ function parseAssistantEvent(event: Record<string, unknown>): SlackSegment | nul
|
|||
const textParts: string[] = [];
|
||||
const toolParts: string[] = [];
|
||||
let firstToolName: string | undefined;
|
||||
let firstToolHint: string | undefined;
|
||||
|
||||
for (const rawBlock of content) {
|
||||
const block = toRecord(rawBlock);
|
||||
|
|
@ -123,6 +158,7 @@ function parseAssistantEvent(event: Record<string, unknown>): SlackSegment | nul
|
|||
if (block.type === "tool_use" && isString(block.name)) {
|
||||
if (!firstToolName) {
|
||||
firstToolName = block.name;
|
||||
firstToolHint = extractToolHint(block);
|
||||
}
|
||||
toolParts.push(`:hammer_and_wrench: *${block.name}*${formatToolHint(block)}`);
|
||||
}
|
||||
|
|
@ -134,6 +170,7 @@ function parseAssistantEvent(event: Record<string, unknown>): SlackSegment | nul
|
|||
kind: "tool_use",
|
||||
text: toolParts.join("\n"),
|
||||
toolName: firstToolName,
|
||||
toolHint: firstToolHint,
|
||||
};
|
||||
}
|
||||
if (textParts.length > 0) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { SectionBlock, ContextBlock, KnownBlock } from "@slack/bolt";
|
|||
import { App } from "@slack/bolt";
|
||||
import * as v from "valibot";
|
||||
import { toRecord, isString } from "@openrouter/spawn-shared";
|
||||
import type { State } from "./helpers";
|
||||
import type { State, ToolCall } from "./helpers";
|
||||
import {
|
||||
ResultSchema,
|
||||
loadState,
|
||||
|
|
@ -16,6 +16,8 @@ import {
|
|||
stripMention,
|
||||
downloadSlackFile,
|
||||
runCleanupIfDue,
|
||||
formatToolStats,
|
||||
formatToolHistory,
|
||||
} from "./helpers";
|
||||
|
||||
type SlackClient = InstanceType<typeof App>["client"];
|
||||
|
|
@ -99,8 +101,16 @@ When creating issues, include a footer: "_Filed from Slack by SPA_"
|
|||
|
||||
Below is the full Slack thread. The most recent message is the one you should respond to. Prior messages are context.`;
|
||||
|
||||
/** Slack attachment shape (secondary content below blocks). */
|
||||
interface SlackAttachment {
|
||||
color?: string;
|
||||
text: string;
|
||||
mrkdwn_in?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a new message or update an existing one. Returns the message timestamp.
|
||||
* Optional `attachments` adds expandable secondary content below blocks.
|
||||
*/
|
||||
async function postOrUpdate(
|
||||
client: SlackClient,
|
||||
|
|
@ -109,6 +119,7 @@ async function postOrUpdate(
|
|||
existingTs: string | undefined,
|
||||
fallback: string,
|
||||
blocks: KnownBlock[],
|
||||
attachments?: SlackAttachment[],
|
||||
): Promise<string | undefined> {
|
||||
if (!existingTs) {
|
||||
const msg = await client.chat
|
||||
|
|
@ -117,6 +128,7 @@ async function postOrUpdate(
|
|||
thread_ts: threadTs,
|
||||
text: fallback,
|
||||
blocks,
|
||||
attachments,
|
||||
})
|
||||
.catch(() => null);
|
||||
return msg?.ts;
|
||||
|
|
@ -127,6 +139,7 @@ async function postOrUpdate(
|
|||
ts: existingTs,
|
||||
text: fallback,
|
||||
blocks,
|
||||
attachments: attachments ?? [],
|
||||
})
|
||||
.catch(() => {});
|
||||
return existingTs;
|
||||
|
|
@ -209,17 +222,34 @@ async function buildThreadPrompt(client: SlackClient, channel: string, threadTs:
|
|||
|
||||
// ─── Block Kit message builder ─────────────────────────────────────────────
|
||||
|
||||
interface ToolEntry {
|
||||
name: string;
|
||||
errored?: boolean;
|
||||
}
|
||||
|
||||
const MAX_SECTION_LEN = 2900; // Slack section block text limit is 3000
|
||||
|
||||
/** Build Block Kit blocks: section(text) + context(tools + loading). */
|
||||
function buildBlocks(mainText: string, tools: ToolEntry[], loading: boolean): KnownBlock[] {
|
||||
const blocks: KnownBlock[] = [];
|
||||
interface BuildBlocksInput {
|
||||
mainText: string;
|
||||
currentTool: ToolCall | null;
|
||||
toolCounts: ReadonlyMap<string, number>;
|
||||
toolHistory: readonly ToolCall[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface BuildBlocksResult {
|
||||
blocks: KnownBlock[];
|
||||
attachments: SlackAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Block Kit blocks with redesigned tool footer:
|
||||
* 1. Section: main response text
|
||||
* 2. Context: latest tool call (swapped, not appended)
|
||||
* 3. Context: compact stats line (1× Bash, 4× Read, ...)
|
||||
* 4. Attachment: full ordered tool history (expandable in Slack)
|
||||
*/
|
||||
function buildBlocks(input: BuildBlocksInput): BuildBlocksResult {
|
||||
const { mainText, currentTool, toolCounts, toolHistory, loading } = input;
|
||||
const blocks: KnownBlock[] = [];
|
||||
const attachments: SlackAttachment[] = [];
|
||||
|
||||
// 1. Main text section
|
||||
if (mainText) {
|
||||
const display = mainText.length > MAX_SECTION_LEN ? `...${mainText.slice(-MAX_SECTION_LEN)}` : mainText;
|
||||
const section: SectionBlock = {
|
||||
|
|
@ -232,9 +262,13 @@ function buildBlocks(mainText: string, tools: ToolEntry[], loading: boolean): Kn
|
|||
blocks.push(section);
|
||||
}
|
||||
|
||||
if (tools.length > 0) {
|
||||
const parts = tools.map((t) => (t.errored ? `${t.name} :x:` : t.name));
|
||||
let toolLine = `:hammer_and_wrench: ${parts.join(" · ")}`;
|
||||
// 2. Current tool detail — shows only the LATEST tool (swapped each update)
|
||||
if (currentTool) {
|
||||
const icon = currentTool.errored ? ":x:" : ":hammer_and_wrench:";
|
||||
let toolLine = `${icon} *${currentTool.name}*`;
|
||||
if (currentTool.hint) {
|
||||
toolLine += ` \`${currentTool.hint}\``;
|
||||
}
|
||||
if (loading) {
|
||||
toolLine += " :openrouter-loading:";
|
||||
}
|
||||
|
|
@ -261,7 +295,32 @@ function buildBlocks(mainText: string, tools: ToolEntry[], loading: boolean): Kn
|
|||
blocks.push(ctx);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
// 3. Stats line — compact tool usage counts
|
||||
if (toolCounts.size > 0) {
|
||||
const stats = formatToolStats(toolCounts);
|
||||
const ctx: ContextBlock = {
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: stats,
|
||||
},
|
||||
],
|
||||
};
|
||||
blocks.push(ctx);
|
||||
}
|
||||
|
||||
// 4. Expandable tool history — Slack auto-collapses long attachment text
|
||||
if (!loading && toolHistory.length > 1) {
|
||||
const historyText = formatToolHistory(toolHistory);
|
||||
attachments.push({
|
||||
color: "#808080",
|
||||
text: historyText,
|
||||
mrkdwn_in: ["text"],
|
||||
});
|
||||
}
|
||||
|
||||
return { blocks, attachments };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -312,7 +371,9 @@ async function runClaudeAndStream(
|
|||
|
||||
// ─── Streaming state ─────────────────────────────────────────────────
|
||||
let mainText = "";
|
||||
const tools: ToolEntry[] = [];
|
||||
const toolHistory: ToolCall[] = [];
|
||||
const toolCounts = new Map<string, number>();
|
||||
let currentTool: ToolCall | null = null;
|
||||
let msgTs: string | undefined;
|
||||
let returnedSessionId: string | null = null;
|
||||
let hasOutput = false;
|
||||
|
|
@ -322,13 +383,28 @@ async function runClaudeAndStream(
|
|||
|
||||
/** Post or update the Slack message with current blocks. */
|
||||
async function updateMessage(loading: boolean): Promise<void> {
|
||||
const blocks = buildBlocks(mainText, tools, loading);
|
||||
const { blocks, attachments } = buildBlocks({
|
||||
mainText,
|
||||
currentTool,
|
||||
toolCounts,
|
||||
toolHistory,
|
||||
loading,
|
||||
});
|
||||
if (blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
const fallback = mainText || `Working... (${tools.length} tool${tools.length === 1 ? "" : "s"})`;
|
||||
const totalTools = toolHistory.length;
|
||||
const fallback = mainText || `Working... (${totalTools} tool${totalTools === 1 ? "" : "s"})`;
|
||||
hasOutput = true;
|
||||
msgTs = await postOrUpdate(client, channel, threadTs, msgTs, fallback, blocks);
|
||||
msgTs = await postOrUpdate(
|
||||
client,
|
||||
channel,
|
||||
threadTs,
|
||||
msgTs,
|
||||
fallback,
|
||||
blocks,
|
||||
attachments.length > 0 ? attachments : undefined,
|
||||
);
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
|
|
@ -383,12 +459,16 @@ async function runClaudeAndStream(
|
|||
mainText += segment.text;
|
||||
dirty = true;
|
||||
} else if (segment.kind === "tool_use" && segment.toolName) {
|
||||
tools.push({
|
||||
const tool: ToolCall = {
|
||||
name: segment.toolName,
|
||||
});
|
||||
hint: segment.toolHint ?? "",
|
||||
};
|
||||
toolHistory.push(tool);
|
||||
currentTool = tool;
|
||||
toolCounts.set(tool.name, (toolCounts.get(tool.name) ?? 0) + 1);
|
||||
dirty = true;
|
||||
} else if (segment.kind === "tool_result" && segment.isError && tools.length > 0) {
|
||||
tools[tools.length - 1].errored = true;
|
||||
} else if (segment.kind === "tool_result" && segment.isError && currentTool) {
|
||||
currentTool.errored = true;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import { describe, it, expect, mock, afterEach } from "bun:test";
|
||||
import { parseStreamEvent, stripMention, markdownToSlack, loadState, saveState, downloadSlackFile } from "./helpers";
|
||||
import {
|
||||
parseStreamEvent,
|
||||
stripMention,
|
||||
markdownToSlack,
|
||||
loadState,
|
||||
saveState,
|
||||
downloadSlackFile,
|
||||
extractToolHint,
|
||||
formatToolStats,
|
||||
formatToolHistory,
|
||||
} from "./helpers";
|
||||
import type { ToolCall } from "./helpers";
|
||||
import { toRecord } from "@openrouter/spawn-shared";
|
||||
import streamEvents from "../../../fixtures/claude-code/stream-events.json";
|
||||
|
||||
|
|
@ -20,11 +31,12 @@ describe("parseStreamEvent", () => {
|
|||
expect(result?.text).toContain("I'll look at the issue and check the repository structure.");
|
||||
});
|
||||
|
||||
it("parses assistant tool_use (Bash) from fixture with toolName", () => {
|
||||
it("parses assistant tool_use (Bash) from fixture with toolName and toolHint", () => {
|
||||
// fixture[1]: assistant with tool_use Bash
|
||||
const result = parseStreamEvent(fixture(1));
|
||||
expect(result?.kind).toBe("tool_use");
|
||||
expect(result?.toolName).toBe("Bash");
|
||||
expect(result?.toolHint).toContain("gh issue list");
|
||||
expect(result?.text).toContain(":hammer_and_wrench: *Bash*");
|
||||
expect(result?.text).toContain("gh issue list");
|
||||
});
|
||||
|
|
@ -38,11 +50,12 @@ describe("parseStreamEvent", () => {
|
|||
expect(result?.text).toContain("Fly.io deploy fails on arm64");
|
||||
});
|
||||
|
||||
it("parses assistant tool_use (Glob) from fixture with toolName", () => {
|
||||
it("parses assistant tool_use (Glob) from fixture with toolName and toolHint", () => {
|
||||
// fixture[3]: assistant with tool_use Glob
|
||||
const result = parseStreamEvent(fixture(3));
|
||||
expect(result?.kind).toBe("tool_use");
|
||||
expect(result?.toolName).toBe("Glob");
|
||||
expect(result?.toolHint).toBe("**/*.ts");
|
||||
expect(result?.text).toBe(":hammer_and_wrench: *Glob* `**/*.ts`");
|
||||
});
|
||||
|
||||
|
|
@ -99,6 +112,7 @@ describe("parseStreamEvent", () => {
|
|||
};
|
||||
const result = parseStreamEvent(event);
|
||||
expect(result?.text).toContain("...");
|
||||
expect(result?.toolHint).toContain("...");
|
||||
expect(result?.kind).toBe("tool_use");
|
||||
});
|
||||
|
||||
|
|
@ -158,6 +172,7 @@ describe("parseStreamEvent", () => {
|
|||
const result = parseStreamEvent(event);
|
||||
expect(result?.kind).toBe("tool_use");
|
||||
expect(result?.toolName).toBe("Bash");
|
||||
expect(result?.toolHint).toBe("");
|
||||
expect(result?.text).toBe(":hammer_and_wrench: *Bash*");
|
||||
});
|
||||
|
||||
|
|
@ -313,6 +328,116 @@ describe("saveState", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("extractToolHint", () => {
|
||||
it("extracts command from input", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
input: { command: "gh issue list --repo OpenRouterTeam/spawn" },
|
||||
};
|
||||
expect(extractToolHint(block)).toBe("gh issue list --repo OpenRouterTeam/spawn");
|
||||
});
|
||||
|
||||
it("extracts pattern from input", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
input: { pattern: "**/*.ts" },
|
||||
};
|
||||
expect(extractToolHint(block)).toBe("**/*.ts");
|
||||
});
|
||||
|
||||
it("extracts file_path from input", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
input: { file_path: "/home/user/spawn/index.ts" },
|
||||
};
|
||||
expect(extractToolHint(block)).toBe("/home/user/spawn/index.ts");
|
||||
});
|
||||
|
||||
it("prefers command over pattern and file_path", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
input: { command: "echo hi", pattern: "*.ts", file_path: "/foo" },
|
||||
};
|
||||
expect(extractToolHint(block)).toBe("echo hi");
|
||||
});
|
||||
|
||||
it("truncates hints longer than 80 chars", () => {
|
||||
const longCmd = "x".repeat(100);
|
||||
const block: Record<string, unknown> = {
|
||||
input: { command: longCmd },
|
||||
};
|
||||
const result = extractToolHint(block);
|
||||
expect(result).toHaveLength(83); // 80 + "..."
|
||||
expect(result).toEndWith("...");
|
||||
});
|
||||
|
||||
it("returns empty string for missing input", () => {
|
||||
expect(extractToolHint({})).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for input without recognized keys", () => {
|
||||
const block: Record<string, unknown> = {
|
||||
input: { query: "search term" },
|
||||
};
|
||||
expect(extractToolHint(block)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatToolStats", () => {
|
||||
it("formats a single tool count", () => {
|
||||
const counts = new Map([["Bash", 3]]);
|
||||
expect(formatToolStats(counts)).toBe("3× Bash");
|
||||
});
|
||||
|
||||
it("formats multiple tool counts", () => {
|
||||
const counts = new Map<string, number>([
|
||||
["Bash", 1],
|
||||
["Read", 4],
|
||||
["Grep", 5],
|
||||
["Glob", 8],
|
||||
]);
|
||||
expect(formatToolStats(counts)).toBe("1× Bash, 4× Read, 5× Grep, 8× Glob");
|
||||
});
|
||||
|
||||
it("returns empty string for empty map", () => {
|
||||
expect(formatToolStats(new Map())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatToolHistory", () => {
|
||||
it("formats a single tool call", () => {
|
||||
const history: ToolCall[] = [{ name: "Bash", hint: "echo hi" }];
|
||||
expect(formatToolHistory(history)).toBe("1. ✓ Bash — echo hi");
|
||||
});
|
||||
|
||||
it("formats multiple tool calls with numbering", () => {
|
||||
const history: ToolCall[] = [
|
||||
{ name: "Bash", hint: "gh issue list" },
|
||||
{ name: "Glob", hint: "**/*.ts" },
|
||||
{ name: "Read", hint: "/home/user/index.ts" },
|
||||
];
|
||||
const result = formatToolHistory(history);
|
||||
expect(result).toBe(
|
||||
"1. ✓ Bash — gh issue list\n2. ✓ Glob — **/*.ts\n3. ✓ Read — /home/user/index.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks errored tools with ✗", () => {
|
||||
const history: ToolCall[] = [
|
||||
{ name: "Bash", hint: "rm -rf /", errored: true },
|
||||
{ name: "Read", hint: "file.ts" },
|
||||
];
|
||||
const result = formatToolHistory(history);
|
||||
expect(result).toContain("1. ✗ Bash — rm -rf /");
|
||||
expect(result).toContain("2. ✓ Read — file.ts");
|
||||
});
|
||||
|
||||
it("handles tools without hints", () => {
|
||||
const history: ToolCall[] = [{ name: "Bash", hint: "" }];
|
||||
expect(formatToolHistory(history)).toBe("1. ✓ Bash");
|
||||
});
|
||||
|
||||
it("returns empty string for empty history", () => {
|
||||
expect(formatToolHistory([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadSlackFile", () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue