fix(sessions): unify dual suspension systems, wire approve to agentic_resume #18
Reference in New Issue
Block a user
Delete Branch "fix/agentic-tool-approval-unification"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Unifies the two separate suspension systems so tool approval works for all modern agentic_loop sessions.
Review: fix(sessions): unify dual suspension systems, wire approve to agentic_resume
The refactor's goal is correct and the overall structure is cleaner. Removing the inline agentic loop from
handle_session_approveand routing throughagentic_resumeis the right call. The legacy-path bridge-blob synthesis is a reasonable migration strategy. However there are two blockers and a few warnings.Blocker 1 — Modern path dispatches tool without validating
tool_nameapprove_tool_namecomes fromjson_get(body, "tool_name"), which returns""if the client omits the field. The code reachesdispatch_tool(approve_tool_name, eff_input)without checking.dispatch_tool("")returns"unknown tool: "silently — no error, just a garbage string injected as the tool result into the resumed conversation.Add a guard before the dispatch block:
Blocker 2 — Modern path calls
dispatch_toolfor MCP tools that the client should executeagentic_loopsuspends specifically when!is_builtin_tool(tool_name)— i.e. formcp__*tools — so the client can execute them. When the client posts to/approve, it should be providing the already-executed result via acontentfield (same contract as/tool_result). Instead, the new code callsdispatch_tool(approve_tool_name, eff_input)server-side, re-running the tool and ignoring whatever the client did. This makes the approval flow semantically identical to/tool_resultexcept that it executes the tool twice (or not at all, if the client already ran it). The fix is to acceptjson_get(body, "content")as the tool result for MCP tools, identical tohandle_tool_result.Warning —
alwaysaction is silently brokenstate_set("always_allow_" + session_id, ...)is written correctly, but nothing inagentic_loopreads it. The old inline loop that checked this list was the code deleted in this PR.tool_auto_approved()inchat.elis defined but never called. A user choosing "Always allow" gets this call approved but every subsequent call to the same tool will still suspend for approval.Either wire
agentic_loopto checkalways_allow_<session_id>in its bridge-decision logic, or document "always" as not yet implemented and return a clear error.Warning — Legacy path hardcodes
agentic_tools_literal()The original session may have started with
agentic_tools_with_web()oragentic_tools_all(). Resuming withagentic_tools_literal()means the LLM sees a different tool schema than when it made the tool call — Anthropic's API requirestool_useIDs to match tools actually present in thetoolsarray. Consider storing atools_variantfield in thepending_tool_*blob, or storing the fulltools_jsonliteral (already done in the bridge blob format).Nit — Double truncation
sessions.eltruncatesdispatch_tooloutput to 6000 chars before passing toagentic_resume, which truncates again. Pass the raw result toagentic_resumeand let its truncation path handle it once.The happy path for the modern path (builtin tool, allow, tool_name present) should work. The blocker issues surface when clients omit
tool_nameor when the suspended tool is an MCP connector (which is the most common case for the approval flow).BLOCKER 1 (sessions.el, modern path): Add guard that rejects allow action when tool_name is missing from the body. Previously, omitting tool_name caused dispatch_tool("", ...) to return "unknown tool: " and silently inject a corrupted tool_result into the conversation. BLOCKER 2 (sessions.el, modern path): Stop re-executing client-side tools server-side. When the client provides body["content"], use it directly as the tool result (matching the handle_tool_result contract). Only fall back to dispatch_tool for builtin tools when no content is present. Non-builtin tools with no client content now return a clear error instead of a broken dispatch attempt. WARNING 1 (chat.el, agentic_loop): Wire always_allow_<session_id> state into the bridge-suspension decision. When a tool is in the session's always-allow list, treat it as locally dispatchable (like a builtin) and skip the bridge pause, so the approval UI is never shown again for that tool in that session. WARNING 2 (sessions.el, legacy path): Read a "tools_variant" field from the legacy pending blob when present, and call the corresponding agentic_tools_*() variant on resume. Falls back to agentic_tools_literal() for blobs written before this field existed. tests/test_sessions_approve.el: Add 10-case test suite covering: - empty session_id / missing call_id / missing action guards - no pending tool returns correct error - missing tool_name on allow returns error (BLOCKER 1) - deny action does not require tool_name - legacy call_id mismatch returns mismatch error - always action records tool_name in always_allow state - allow with client content skips re-execution (BLOCKER 2)