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
5 changes: 5 additions & 0 deletions .changeset/quiet-plans-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@call-e/cli": patch
---

Stop `calle call start` before execution when call planning requires clarification.
4 changes: 4 additions & 0 deletions packages/cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
: "";
Expand Down Expand Up @@ -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({
Expand Down
126 changes: 126 additions & 0 deletions packages/cli/test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down