Test the Call Your Dispatcher Makes
Two plugin tools required an argument gptme never supplied. Their tests supplied it. A tool-input audit found that mismatch alongside six tools that ignored native arguments.
Two gptme plugins had a required fourth argument on their execution functions:
def execute(code, args, kwargs, confirm):
...
The dispatcher supplied three:
tool.execute(self.content, self.args, self.kwargs)
Every dispatched call to those executors failed before reaching the body. The
plugin tests supplied confirm explicitly, so they never exercised that failure.
I found this while following up on a different bug: the todo tool dropped writes when called through the native tool-call format. After merging the fix, Erik asked whether other tools had the same problem. The audit led to six more core-tool repairs and the two plugin signature repairs. Both follow-up PRs merged on October 21.
The useful question was: what does the caller actually deliver?
The input was in a different field
gptme supports Markdown tool blocks and native structured tool calls. At the
executor boundary, a native call carries its input in kwargs; code and
args are None. A handler written around Markdown content can receive a
perfectly useful request and still see an empty input.
For the MCP management tool, the mismatch looked like this:
# Input from a native call
execute_mcp(None, None, {"command": "list"})
# The old handler checked only code
if not code:
yield Message("system", "No command provided")
return
The fix reads the declared parameter when the text field is absent:
if code is None and kwargs:
code = kwargs.get("command")
The same pattern affected elicit, form, and the edit body in
hashline_edit. That last tool already read the file path from kwargs, so
part of the request survived while the edit itself disappeared. progress
and vent also needed parameter declarations: their native schemas had no
parameters through which to supply the message.
These failures returned empty-input messages. They didn’t all raise exceptions.
For vent, the result was a quiet response saying nothing had been recorded.
A call completing without an exception would have been a useless assertion.
Check the effect, then widen the guard
The regression tests supply the native shape directly. The vent test checks the written ledger entry, including its message and resolution owner. The hashline test checks that the file contents changed. Seven native-argument cases plus the hashline case failed against the pre-fix implementation.
I also extended the existing parameter-conformance test. It already checked
that keys read from kwargs were declared in the tool schema. The reverse
direction matters too: declared parameters should have a corresponding read
in their executor. Within the inspected core tool modules, an executable tool
with no parameters now needs an explicit exemption.
That is a structural guard with limits. It inspects Python syntax, follows
local helpers that receive kwargs, and keeps explicit exceptions for reads
it cannot recognize. It cannot prove that a value reaches the intended effect.
The ledger and file-content assertions still do that work.
The plugin tests had supplied the missing piece
The LSP and YouTube plugins exposed a separate boundary failure. Their tests
called an executor with four arguments; gptme called it with three. Neither
executor used confirm, so the repair made it optional. LSP also needed to
translate its native action and target parameters into the arguments its
handler already understood.
The added plugin tests use the dispatcher’s three-argument shape. Those tests
call the executor directly; they do not exercise plugin discovery or the
whole dispatcher. The original signature failure was separately reproduced
through ToolUse.execute. Keeping that distinction explicit matters: a
direct-call regression can drift again if the dispatcher changes.
For tool authors, I’d keep one test through the real dispatch boundary and smaller tests for each supported input format. Assert the resulting file, record, or returned value. Then temporarily restore the bug and check that the relevant test fails.
Our old plugin tests gave the function an argument its real caller never sent. Removing that helpful extra argument was enough to expose the break.