Missing terminal tool_call when custom tool throws

Where does the bug appear (feature/product)?

Cursor SDK

Describe the Bug

When a custom MCP tool throws or otherwise never returns, the SDK does not emit a terminal tool_call event. The transcript only persists tool calls on completed or error, so a running call whose tool never finishes is never written. I lose failed delegations entirely from the durable record.

Steps to Reproduce

Run a conversation where custom tools (e.g. delegate) throw or hang without returning. Inspect the persisted transcript. In pr-delivery-loop I see 33 tool calls, all completed, with zero record of four delegations that failed. The only trace is model prose, not structured events.

Expected Behavior

Every started tool_call gets a terminal completed or error event in the transcript, even when the tool throws or the turn is cancelled.

Version Information

issue-tracker app embedding @cursor/sdk on linux 6.12.67-linuxkit, Node in Cursor agent session, model composer.

Hey, thanks for the detailed report. The breakdown is, as always, top-notch.

Confirmed, what you described is reproducible. In the local SDK runtime, the terminal tool_call event really doesn’t make it into the transcript when a custom/MCP tool throws an exception or hangs, and finished calls get stamped as completed with no way to get status: "error". So 33 tool calls, all completed with four failed delegations is exactly the behavior we’re seeing.

I’ve passed this to the team. I can’t share an ETA yet, but I’ll reply in this thread when there’s an update.

If you have a minimal repro script (a custom tool that throws + a snippet that reads run.messages()), please attach it. It’s not required to reproduce, but it’d still be helpful.

Hey @James_M,

Thanks again for the detailed report. There is already a way to save failed calls in the durable transcript: instead of letting a custom tool throw an exception, catch the error inside execute and return it as a result with isError: true, for example:

return { content: [{ type: "text", text: err.message }], isError: true };

That result goes through the normal completion path, so the call gets a terminal tool_call event with its args and error payload. The status will still be completed, but you can use result.isError to tell failed calls from successful ones.

For tools that may hang, wrap the work in your own timeout, for example Promise.race against a timer, and return an isError result when it fires. You’ll get the same kind of terminal record.

If calls still disappear from run.messages() on the latest SDK, write back here with details and we’ll take a closer look.