Skip to content

Commit 6310f24

Browse files
authored
fix: set meaningful tool titles via tool.execute.after hook (#17)
1 parent f0ce81d commit 6310f24

4 files changed

Lines changed: 209 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,8 @@ jobs:
2020
- name: Install dependencies
2121
run: bun install
2222

23+
- name: Run E2E regression test
24+
run: bun test test/tool-titles-e2e.test.ts
25+
2326
- name: Run tests
2427
run: bun test

src/index.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,67 @@ function extractRecentTools(
123123
return tools
124124
}
125125

126+
// Tracks how many memory entries a memory_list call saw so tool.execute.after
127+
// can render a meaningful title without re-reading the filesystem. Keyed by
128+
// callID, which uniquely identifies a single tool invocation.
129+
const memoryListCountByCallID = new Map<string, number>()
130+
const memorySearchCountByCallID = new Map<string, number>()
131+
132+
function buildMemoryToolTitle(
133+
toolID: string,
134+
args: Record<string, unknown> | undefined,
135+
callID: string | undefined,
136+
): string | undefined {
137+
switch (toolID) {
138+
case "memory_save": {
139+
const type = typeof args?.type === "string" ? args.type : ""
140+
const name = typeof args?.name === "string" ? args.name : ""
141+
if (type && name) return `${type}: ${name}`
142+
if (name) return name
143+
return undefined
144+
}
145+
case "memory_delete":
146+
case "memory_read": {
147+
const fileName = typeof args?.file_name === "string" ? args.file_name : ""
148+
return fileName || undefined
149+
}
150+
case "memory_list": {
151+
const count = callID ? memoryListCountByCallID.get(callID) : undefined
152+
if (callID) memoryListCountByCallID.delete(callID)
153+
if (count === undefined) return "list memories"
154+
return `${count} ${count === 1 ? "memory" : "memories"}`
155+
}
156+
case "memory_search": {
157+
const query = typeof args?.query === "string" ? args.query : ""
158+
const count = callID ? memorySearchCountByCallID.get(callID) : undefined
159+
if (callID) memorySearchCountByCallID.delete(callID)
160+
if (query && count !== undefined) {
161+
return `"${query}" · ${count} ${count === 1 ? "match" : "matches"}`
162+
}
163+
if (query) return `"${query}"`
164+
return undefined
165+
}
166+
default:
167+
return undefined
168+
}
169+
}
170+
171+
function getCallID(ctx: unknown): string | undefined {
172+
if (!ctx || typeof ctx !== "object") return undefined
173+
const v = (ctx as { callID?: unknown }).callID
174+
return typeof v === "string" ? v : undefined
175+
}
176+
126177
export const MemoryPlugin: Plugin = async ({ worktree }) => {
127178
getMemoryDir(worktree)
128179

129180
return {
181+
"tool.execute.after": async (input, output) => {
182+
if (!input.tool.startsWith("memory_")) return
183+
const title = buildMemoryToolTitle(input.tool, input.args, input.callID)
184+
if (title) output.title = title
185+
},
186+
130187
"experimental.chat.messages.transform": async (_input, output) => {
131188
const { query, sessionID } = getLastUserQuery(output.messages)
132189

@@ -219,7 +276,7 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
219276
"Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines",
220277
),
221278
},
222-
async execute(args) {
279+
async execute(args, _ctx) {
223280
const filePath = saveMemory(worktree, args.file_name, args.name, args.description, args.type, args.content)
224281
return `Memory saved to ${filePath}`
225282
},
@@ -230,7 +287,7 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
230287
args: {
231288
file_name: tool.schema.string().describe("File name of the memory to delete (with or without .md extension)"),
232289
},
233-
async execute(args) {
290+
async execute(args, _ctx) {
234291
const deleted = deleteMemory(worktree, args.file_name)
235292
return deleted ? `Memory "${args.file_name}" deleted.` : `Memory "${args.file_name}" not found.`
236293
},
@@ -242,8 +299,10 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
242299
"Use this to check what memories exist before saving a new one (to avoid duplicates) " +
243300
"or when you need to recall what's been stored.",
244301
args: {},
245-
async execute() {
302+
async execute(_args, ctx) {
246303
const entries = listMemories(worktree)
304+
const callID = getCallID(ctx)
305+
if (callID) memoryListCountByCallID.set(callID, entries.length)
247306
if (entries.length === 0) {
248307
return "No memories saved yet."
249308
}
@@ -261,8 +320,10 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
261320
args: {
262321
query: tool.schema.string().describe("Search query — searches across name, description, and content"),
263322
},
264-
async execute(args) {
323+
async execute(args, ctx) {
265324
const results = searchMemories(worktree, args.query)
325+
const callID = getCallID(ctx)
326+
if (callID) memorySearchCountByCallID.set(callID, results.length)
266327
if (results.length === 0) {
267328
return `No memories matching "${args.query}".`
268329
}
@@ -278,7 +339,7 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
278339
args: {
279340
file_name: tool.schema.string().describe("File name of the memory to read (with or without .md extension)"),
280341
},
281-
async execute(args) {
342+
async execute(args, _ctx) {
282343
const entry = readMemory(worktree, args.file_name)
283344
if (!entry) {
284345
return `Memory "${args.file_name}" not found.`

test/github-actions-ci.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { join } from "path"
55
const workflowPath = join(process.cwd(), ".github", "workflows", "ci.yml")
66

77
describe("GitHub Actions CI workflow", () => {
8-
test("defines pull request validation that installs dependencies and runs bun test", () => {
8+
test("defines pull request validation that runs the dedicated e2e regression test before the full suite", () => {
99
expect(existsSync(workflowPath)).toBe(true)
1010

1111
const workflow = readFileSync(workflowPath, "utf-8")
@@ -16,6 +16,8 @@ describe("GitHub Actions CI workflow", () => {
1616
expect(workflow).toContain("branches: [main]")
1717
expect(workflow).toContain("oven-sh/setup-bun")
1818
expect(workflow).toContain("bun install")
19+
expect(workflow).toContain("Run E2E regression test")
20+
expect(workflow).toContain("bun test test/tool-titles-e2e.test.ts")
1921
expect(workflow).toContain("bun test")
2022
})
2123
})

test/tool-titles-e2e.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { mkdtempSync, mkdirSync, rmSync } from "fs"
3+
import { tmpdir } from "os"
4+
import { join } from "path"
5+
import { MemoryPlugin } from "../src/index.js"
6+
7+
const tempDirs: string[] = []
8+
9+
function makeTempGitRepo(): string {
10+
const root = mkdtempSync(join(tmpdir(), "tool-title-e2e-"))
11+
mkdirSync(join(root, ".git"), { recursive: true })
12+
tempDirs.push(root)
13+
return root
14+
}
15+
16+
afterEach(() => {
17+
while (tempDirs.length > 0) {
18+
const dir = tempDirs.pop()
19+
if (dir) rmSync(dir, { recursive: true, force: true })
20+
}
21+
})
22+
23+
type ToolCallContext = { callID?: string }
24+
25+
type ToolExecute<TArgs extends object> = (args: TArgs, ctx: ToolCallContext) => Promise<string>
26+
27+
type MemoryTools = {
28+
memory_save: {
29+
execute: ToolExecute<{
30+
file_name: string
31+
name: string
32+
description: string
33+
type: "user" | "feedback" | "project" | "reference"
34+
content: string
35+
}>
36+
}
37+
memory_list: {
38+
execute: ToolExecute<Record<string, never>>
39+
}
40+
memory_search: {
41+
execute: ToolExecute<{ query: string }>
42+
}
43+
memory_read: {
44+
execute: ToolExecute<{ file_name: string }>
45+
}
46+
memory_delete: {
47+
execute: ToolExecute<{ file_name: string }>
48+
}
49+
}
50+
51+
type ToolExecuteAfter = (
52+
input: { tool: string; args?: Record<string, unknown>; callID?: string },
53+
output: { title?: string },
54+
) => Promise<void>
55+
56+
async function runToolWithAfter<TArgs extends object>(
57+
afterHook: ToolExecuteAfter,
58+
toolName: keyof MemoryTools,
59+
execute: ToolExecute<TArgs>,
60+
args: TArgs,
61+
callID: string,
62+
): Promise<{ result: string; title?: string }> {
63+
const result = await execute(args, { callID })
64+
const output: { title?: string } = {}
65+
await afterHook({ tool: toolName, args: args as Record<string, unknown>, callID }, output)
66+
return { result, title: output.title }
67+
}
68+
69+
describe("memory tool titles end-to-end", () => {
70+
test("persists human-readable titles across the full plugin tool lifecycle", async () => {
71+
const repo = makeTempGitRepo()
72+
const plugin = await MemoryPlugin({ worktree: repo } as never)
73+
const tools = plugin.tool as unknown as MemoryTools
74+
const afterHook = plugin["tool.execute.after"] as unknown as ToolExecuteAfter
75+
76+
const save = await runToolWithAfter(
77+
afterHook,
78+
"memory_save",
79+
tools.memory_save.execute,
80+
{
81+
file_name: "title_verification",
82+
name: "Title Verification Test",
83+
description: "Verifies final tool titles are persisted",
84+
type: "reference",
85+
content: "Used to validate the completed tool title in end-to-end flow.",
86+
},
87+
"call-save",
88+
)
89+
90+
expect(save.result).toContain("Memory saved to")
91+
expect(save.title).toBe("reference: Title Verification Test")
92+
93+
const list = await runToolWithAfter(afterHook, "memory_list", tools.memory_list.execute, {}, "call-list")
94+
expect(list.result).toContain("Title Verification Test")
95+
expect(list.title).toBe("1 memory")
96+
97+
const search = await runToolWithAfter(
98+
afterHook,
99+
"memory_search",
100+
tools.memory_search.execute,
101+
{ query: "verification" },
102+
"call-search",
103+
)
104+
expect(search.result).toContain("Title Verification Test")
105+
expect(search.title).toBe('"verification" · 1 match')
106+
107+
const read = await runToolWithAfter(
108+
afterHook,
109+
"memory_read",
110+
tools.memory_read.execute,
111+
{ file_name: "title_verification.md" },
112+
"call-read",
113+
)
114+
expect(read.result).toContain("# Title Verification Test")
115+
expect(read.title).toBe("title_verification.md")
116+
117+
const remove = await runToolWithAfter(
118+
afterHook,
119+
"memory_delete",
120+
tools.memory_delete.execute,
121+
{ file_name: "title_verification.md" },
122+
"call-delete",
123+
)
124+
expect(remove.result).toContain('Memory "title_verification.md" deleted.')
125+
expect(remove.title).toBe("title_verification.md")
126+
127+
const emptyList = await runToolWithAfter(
128+
afterHook,
129+
"memory_list",
130+
tools.memory_list.execute,
131+
{},
132+
"call-empty-list",
133+
)
134+
expect(emptyList.result).toBe("No memories saved yet.")
135+
expect(emptyList.title).toBe("0 memories")
136+
})
137+
})

0 commit comments

Comments
 (0)