Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-attempt-completion-error-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@perstack/base": patch
"perstack": patch
---

fix: return actionable error message when attemptCompletion is blocked by incomplete todos
29 changes: 17 additions & 12 deletions apps/base/src/tools/attempt-completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,33 @@ 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", () => {
Expand All @@ -41,7 +46,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]
Expand Down
27 changes: 23 additions & 4 deletions apps/base/src/tools/attempt-completion.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -7,12 +8,30 @@ export type AttemptCompletionResult =
| { remainingTodos: { id: number; title: string; completed: boolean }[] }
| Record<string, never>

export async function attemptCompletion(): Promise<AttemptCompletionResult> {
export async function attemptCompletion(): Promise<CallToolResult> {
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) {
Expand All @@ -26,7 +45,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
Expand Down