From 47a686e972c915823340b41231c86f90b1fdfad7 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 4 Mar 2026 15:55:01 +0000 Subject: [PATCH 1/3] fix: return actionable error message when attemptCompletion is blocked by incomplete todos Previously, attemptCompletion returned a success result with raw JSON data when incomplete todos existed, giving the AI no guidance on what to do next. This caused a loop where the AI repeatedly called attemptCompletion without resolving the pending todos. Now returns isError: true with a clear message listing remaining todos and explicit instructions to use the todo tool to mark them as completed. Co-Authored-By: Claude Opus 4.6 --- .../fix-attempt-completion-error-message.md | 5 ++++ .../base/src/tools/attempt-completion.test.ts | 27 +++++++++-------- apps/base/src/tools/attempt-completion.ts | 29 ++++++++++++++++--- 3 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-attempt-completion-error-message.md diff --git a/.changeset/fix-attempt-completion-error-message.md b/.changeset/fix-attempt-completion-error-message.md new file mode 100644 index 00000000..a217c33c --- /dev/null +++ b/.changeset/fix-attempt-completion-error-message.md @@ -0,0 +1,5 @@ +--- +"@perstack/base": patch +--- + +fix: return actionable error message when attemptCompletion is blocked by incomplete todos diff --git a/apps/base/src/tools/attempt-completion.test.ts b/apps/base/src/tools/attempt-completion.test.ts index 7eca2ae6..ea75a207 100644 --- a/apps/base/src/tools/attempt-completion.test.ts +++ b/apps/base/src/tools/attempt-completion.test.ts @@ -7,28 +7,31 @@ describe("attemptCompletion tool", () => { await clearTodo() }) - it("returns empty object when no todos exist", async () => { + it("returns success when no todos exist", async () => { const result = await attemptCompletion() - expect(result).toStrictEqual({}) + expect(result).toStrictEqual({ + content: [{ type: "text", text: JSON.stringify({}) }], + }) }) - it("returns empty object when all todos are completed", async () => { + it("returns success when all todos are completed", async () => { await todo({ newTodos: ["Task 1", "Task 2"] }) await todo({ completedTodos: [0, 1] }) const result = await attemptCompletion() - expect(result).toStrictEqual({}) + expect(result).toStrictEqual({ + content: [{ type: "text", text: JSON.stringify({}) }], + }) }) - it("returns remaining todos when incomplete todos exist", async () => { + it("returns error with instructions when incomplete todos exist", async () => { await todo({ newTodos: ["Task 1", "Task 2", "Task 3"] }) await todo({ completedTodos: [1] }) const result = await attemptCompletion() - expect(result).toStrictEqual({ - remainingTodos: [ - { id: 0, title: "Task 1", completed: false }, - { id: 2, title: "Task 3", completed: false }, - ], - }) + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain("Cannot complete: there are incomplete todos remaining.") + expect(result.content[0].text).toContain("[id=0] Task 1") + expect(result.content[0].text).toContain("[id=2] Task 3") + expect(result.content[0].text).toContain("todo tool") }) it("registers tool with correct metadata", () => { @@ -41,7 +44,7 @@ describe("attemptCompletion tool", () => { ) }) - it("returns correct MCP response format", async () => { + it("returns correct MCP response format when no todos", async () => { const mockServer = { registerTool: mock() } registerAttemptCompletion(mockServer as never) const handler = mockServer.registerTool.mock.calls[0][2] diff --git a/apps/base/src/tools/attempt-completion.ts b/apps/base/src/tools/attempt-completion.ts index dc693b41..b193dcc9 100644 --- a/apps/base/src/tools/attempt-completion.ts +++ b/apps/base/src/tools/attempt-completion.ts @@ -1,4 +1,5 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { errorToolResult, successToolResult } from "../lib/tool-result.js" import { getRemainingTodos } from "./todo.js" @@ -7,12 +8,32 @@ export type AttemptCompletionResult = | { remainingTodos: { id: number; title: string; completed: boolean }[] } | Record -export async function attemptCompletion(): Promise { +export async function attemptCompletion(): Promise { const remainingTodos = getRemainingTodos() if (remainingTodos.length > 0) { - return { remainingTodos } + const todoList = remainingTodos + .map((t) => ` - [id=${t.id}] ${t.title}`) + .join("\n") + return { + isError: true, + content: [ + { + type: "text", + text: [ + "Cannot complete: there are incomplete todos remaining.", + "", + "Remaining todos:", + todoList, + "", + "You must either:", + '1. Complete the remaining work, then mark each todo as done using the todo tool (e.g., todo({ completedTodos: [id1, id2] }))', + "2. If the work for a todo is already done, mark it as completed using the todo tool before calling attemptCompletion again", + ].join("\n"), + }, + ], + } } - return {} + return successToolResult({}) } export function registerAttemptCompletion(server: McpServer) { @@ -26,7 +47,7 @@ export function registerAttemptCompletion(server: McpServer) { }, async () => { try { - return successToolResult(await attemptCompletion()) + return await attemptCompletion() } catch (e) { if (e instanceof Error) return errorToolResult(e) throw e From 225ec1314443dc6edfc92cd64b573dea02fcee96 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 4 Mar 2026 15:55:37 +0000 Subject: [PATCH 2/3] chore: add perstack patch bump to changeset Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-attempt-completion-error-message.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/fix-attempt-completion-error-message.md b/.changeset/fix-attempt-completion-error-message.md index a217c33c..fa9defc0 100644 --- a/.changeset/fix-attempt-completion-error-message.md +++ b/.changeset/fix-attempt-completion-error-message.md @@ -1,5 +1,6 @@ --- "@perstack/base": patch +"perstack": patch --- fix: return actionable error message when attemptCompletion is blocked by incomplete todos From d918672fb2e44ac83ed1f944ad719b7cfc9f21fc Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 4 Mar 2026 15:57:58 +0000 Subject: [PATCH 3/3] chore: fix formatting Co-Authored-By: Claude Opus 4.6 --- apps/base/src/tools/attempt-completion.test.ts | 4 +++- apps/base/src/tools/attempt-completion.ts | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/base/src/tools/attempt-completion.test.ts b/apps/base/src/tools/attempt-completion.test.ts index ea75a207..a6d42af7 100644 --- a/apps/base/src/tools/attempt-completion.test.ts +++ b/apps/base/src/tools/attempt-completion.test.ts @@ -28,7 +28,9 @@ describe("attemptCompletion tool", () => { await todo({ completedTodos: [1] }) const result = await attemptCompletion() expect(result.isError).toBe(true) - expect(result.content[0].text).toContain("Cannot complete: there are incomplete todos remaining.") + expect(result.content[0].text).toContain( + "Cannot complete: there are incomplete todos remaining.", + ) expect(result.content[0].text).toContain("[id=0] Task 1") expect(result.content[0].text).toContain("[id=2] Task 3") expect(result.content[0].text).toContain("todo tool") diff --git a/apps/base/src/tools/attempt-completion.ts b/apps/base/src/tools/attempt-completion.ts index b193dcc9..38791388 100644 --- a/apps/base/src/tools/attempt-completion.ts +++ b/apps/base/src/tools/attempt-completion.ts @@ -11,9 +11,7 @@ export type AttemptCompletionResult = export async function attemptCompletion(): Promise { const remainingTodos = getRemainingTodos() if (remainingTodos.length > 0) { - const todoList = remainingTodos - .map((t) => ` - [id=${t.id}] ${t.title}`) - .join("\n") + const todoList = remainingTodos.map((t) => ` - [id=${t.id}] ${t.title}`).join("\n") return { isError: true, content: [ @@ -26,7 +24,7 @@ export async function attemptCompletion(): Promise { todoList, "", "You must either:", - '1. Complete the remaining work, then mark each todo as done using the todo tool (e.g., todo({ completedTodos: [id1, id2] }))', + "1. Complete the remaining work, then mark each todo as done using the todo tool (e.g., todo({ completedTodos: [id1, id2] }))", "2. If the work for a todo is already done, mark it as completed using the todo tool before calling attemptCompletion again", ].join("\n"), },