diff --git a/.changeset/quiet-plans-wait.md b/.changeset/quiet-plans-wait.md new file mode 100644 index 0000000..83cfbed --- /dev/null +++ b/.changeset/quiet-plans-wait.md @@ -0,0 +1,5 @@ +--- +"@call-e/cli": patch +--- + +Stop `calle call start` before execution when call planning requires clarification. diff --git a/packages/cli/docs/cli-reference.md b/packages/cli/docs/cli-reference.md index ec28804..b425168 100644 --- a/packages/cli/docs/cli-reference.md +++ b/packages/cli/docs/cli-reference.md @@ -22,6 +22,10 @@ top-level or local failures may print plain stderr. | `calle call run` | Run a planned phone call, then fetch status once. | `--plan-id`, `--confirm-token` | | `calle call status` | Query a call run through `get_call_run`. | `--run-id` | +If `plan_call` returns `ready_to_run: false`, `calle call start` exits without +calling `run_call`. The JSON error uses code `plan_not_ready` and includes the +first clarification question when available. + ## Common Options These options are accepted by all commands because runtime configuration is diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 6285985..4ac6d09 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -699,6 +699,9 @@ function extractRequiredStructuredString(result, fieldName, context) { if (typeof value === "string" && value.trim()) { return value.trim(); } + if (Object.hasOwn(structured, fieldName)) { + throw new McpHttpError(`${context} did not return ${fieldName}`, { code: "mcp_error", payload: result }); + } const text = Array.isArray(result?.content) ? result.content.map((item) => (typeof item?.text === "string" ? item.text : "")).join("\n") : ""; @@ -830,6 +833,16 @@ async function handleCallCommand({ command, positional, options, config, deps, s requestMeta: buildPlanRequestMeta(options, deps.env || process.env), fetchImpl: deps.fetchImpl || globalThis.fetch, }); + const structuredPlan = structuredPayload(planResult); + if (structuredPlan.ready_to_run === false) { + const question = Array.isArray(structuredPlan.clarifying_questions) + ? structuredPlan.clarifying_questions.find((item) => typeof item === "string" && item.trim())?.trim() + : null; + throw new McpHttpError( + `Call plan needs more information before it can run${question ? `: ${question}` : "."}`, + { code: "plan_not_ready", payload: planResult } + ); + } const planId = extractRequiredStructuredString(planResult, "plan_id", "plan_call"); const confirmToken = extractRequiredStructuredString(planResult, "confirm_token", "plan_call"); const { runId, statusResult } = await runPlannedCallAndFetchStatus({ diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index df7136f..f270bd7 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -1207,6 +1207,132 @@ test("call start plans and runs without printing confirmation data", async () => assert.doesNotMatch(result.stdout, /confirm-1|plan-1|start-token/); }); +test("call start reports plan clarification and skips run_call when planning is not ready", async () => { + const cacheRoot = makeTempRoot("calle-cli-call-start-plan-not-ready"); + const serverUrl = "https://mcp.example/mcp/openagent_oauth"; + writeToken(cacheRoot, serverUrl); + const toolCalls = []; + const fetchImpl = async (_url, init) => { + const payload = JSON.parse(init.body); + if (payload.method === "initialize") { + return jsonRpcResponse({ jsonrpc: "2.0", id: payload.id, result: {} }); + } + if (payload.method === "notifications/initialized") { + return jsonRpcResponse({}); + } + if (payload.method === "tools/call") { + toolCalls.push(payload.params); + if (payload.params.name === "plan_call") { + return jsonRpcResponse({ + jsonrpc: "2.0", + id: payload.id, + result: { + content: [ + { + type: "text", + text: '{"plan_id":"plan-1","ready_to_run":false,"confirm_token":null}', + }, + ], + structuredContent: { + plan_id: "plan-1", + ready_to_run: false, + clarifying_questions: ["What should the agent ask or say on the call?"], + confirm_summary: "A call purpose is required.", + confirm_token: null, + }, + }, + }); + } + } + throw new Error(`unexpected method: ${payload.method}`); + }; + + const result = await run( + [ + "call", + "start", + "--to-phone", + "+15551234567", + "--goal", + "See what they say", + "--base-url", + "https://mcp.example", + "--cache-root", + cacheRoot, + ], + { fetchImpl } + ); + const payload = JSON.parse(result.stdout); + + assert.equal(result.code, 1); + assert.deepEqual(toolCalls.map((call) => call.name), ["plan_call"]); + assert.equal(payload.error.code, "plan_not_ready"); + assert.equal( + payload.error.message, + "Call plan needs more information before it can run: What should the agent ask or say on the call?" + ); +}); + +test("call start rejects a null structured confirm token without calling run_call", async () => { + const cacheRoot = makeTempRoot("calle-cli-call-start-null-confirm-token"); + const serverUrl = "https://mcp.example/mcp/openagent_oauth"; + writeToken(cacheRoot, serverUrl); + const toolCalls = []; + const fetchImpl = async (_url, init) => { + const payload = JSON.parse(init.body); + if (payload.method === "initialize") { + return jsonRpcResponse({ jsonrpc: "2.0", id: payload.id, result: {} }); + } + if (payload.method === "notifications/initialized") { + return jsonRpcResponse({}); + } + if (payload.method === "tools/call") { + toolCalls.push(payload.params); + if (payload.params.name === "plan_call") { + return jsonRpcResponse({ + jsonrpc: "2.0", + id: payload.id, + result: { + content: [ + { + type: "text", + text: '{"plan_id":"plan-1","confirm_token":null}', + }, + ], + structuredContent: { + plan_id: "plan-1", + confirm_token: null, + }, + }, + }); + } + } + throw new Error(`unexpected method: ${payload.method}`); + }; + + const result = await run( + [ + "call", + "start", + "--to-phone", + "+15551234567", + "--goal", + "Confirm appointment", + "--base-url", + "https://mcp.example", + "--cache-root", + cacheRoot, + ], + { fetchImpl } + ); + const payload = JSON.parse(result.stdout); + + assert.equal(result.code, 1); + assert.deepEqual(toolCalls.map((call) => call.name), ["plan_call"]); + assert.equal(payload.error.code, "mcp_error"); + assert.equal(payload.error.message, "plan_call did not return confirm_token"); +}); + test("call run invokes run_call then get_call_run once", async () => { const cacheRoot = makeTempRoot("calle-cli-call-run"); const serverUrl = "https://mcp.example/mcp/openagent_oauth";