From 56f3263e0f4f490e9b811ceba298ecc6c4f64e6c Mon Sep 17 00:00:00 2001 From: "J.Jason" <130959319+JJasonSun@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:24:09 +0800 Subject: [PATCH] docs(examples): add resumable Calls V2 examples --- content/guides/authentication.mdx | 2 +- content/guides/calls.mdx | 5 + content/guides/goal-runs.mdx | 4 +- content/guides/quickstart.mdx | 23 +++++ content/guides/sdks.mdx | 9 +- examples/README.md | 9 ++ examples/v2/README.md | 64 +++++++++++++ examples/v2/calls.py | 104 +++++++++++++++++++++ examples/v2/calls.rb | 150 ++++++++++++++++++++++++++++++ examples/v2/read_call_result.py | 38 ++++++++ examples/v2/test_calls.rb | 73 +++++++++++++++ examples/v2/test_examples.py | 89 ++++++++++++++++++ tests/docs-site.spec.ts | 8 +- 13 files changed, 570 insertions(+), 8 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/v2/README.md create mode 100644 examples/v2/calls.py create mode 100644 examples/v2/calls.rb create mode 100644 examples/v2/read_call_result.py create mode 100644 examples/v2/test_calls.rb create mode 100644 examples/v2/test_examples.py diff --git a/content/guides/authentication.mdx b/content/guides/authentication.mdx index 4cca68b..8399d68 100644 --- a/content/guides/authentication.mdx +++ b/content/guides/authentication.mdx @@ -62,7 +62,7 @@ Use an explicit base URL in each environment so staging jobs do not accidentally export CALLE_BASE_URL="https://api.heycall-e.com" ``` -The [complete Python call example](https://github.com/CALLE-AI/calle-docs/blob/main/examples/calls.py) +The [complete Python call example](https://github.com/CALLE-AI/calle-docs/blob/main/examples/v2/calls.py) reads `CALLE_BASE_URL` for both `start` and `resume`. If it is unset or empty, the example keeps the SDK's default URL. Use the same API key and environment when resuming a saved call. Keep test and production API keys separate, and do diff --git a/content/guides/calls.mdx b/content/guides/calls.mdx index a58cc62..b70847d 100644 --- a/content/guides/calls.mdx +++ b/content/guides/calls.mdx @@ -23,6 +23,11 @@ Calls are available on the production API and in SDK 1.0. See the Calls and Goal Runs include `call_outcome`, `result_status` and `transcript`. Wait while `result_status` is `pending`, not until result or error becomes non-null. +Use the [complete Python and Ruby examples](/quickstart#run-a-complete-example) +to persist a request and resume it. The +[V2 result reader](https://github.com/CALLE-AI/calle-docs/blob/main/examples/v2/read_call_result.py) +reads their saved response, including unavailable results and the top-level transcript. + ## Call ID and Billing The response adds a fixed `call_id` field for matching a telephone call to the diff --git a/content/guides/goal-runs.mdx b/content/guides/goal-runs.mdx index 2e5d431..19c5323 100644 --- a/content/guides/goal-runs.mdx +++ b/content/guides/goal-runs.mdx @@ -8,7 +8,7 @@ Start outbound calls from a published Goal when your application repeats the sam **Server-side** ยท **Asynchronous** Examples target the [current Developer API contract](/openapi/calle.openapi.yaml) -and server SDKs `@call-e/calle@1.0.0` and `calle-ai==1.0.0`. +and server SDKs `@call-e/calle@1.0.1` and `calle-ai==1.0.1`. See [API versions and migration](/changelog#api-versions-and-migration) for request and response changes. @@ -356,7 +356,7 @@ See [Errors](/errors) for the HTTP error catalog. HTTP errors mean the API reque ## SDK examples -Use TypeScript and Python SDK `1.0.0` for these examples. SDK 0.7.x wait helpers +Use TypeScript and Python SDK `1.0.1` for these examples. SDK 0.7.x wait helpers wait for a non-null result or error and can time out on a final unavailable result. Upgrade or poll the REST response using `result_status`; do not redial on timeout. diff --git a/content/guides/quickstart.mdx b/content/guides/quickstart.mdx index c09d568..ca4106c 100644 --- a/content/guides/quickstart.mdx +++ b/content/guides/quickstart.mdx @@ -94,6 +94,29 @@ The [SDK guide](/sdks) includes installation and complete TypeScript and Python For tests, explicitly set `CALLE_BASE_URL=https://test-api.heycall-e.com` and use a test-compatible key. +## Run a complete example + +The runnable [Python SDK example](https://github.com/CALLE-AI/calle-docs/blob/main/examples/v2/calls.py) +and [Ruby HTTP example](https://github.com/CALLE-AI/calle-docs/blob/main/examples/v2/calls.rb) +save the original request, idempotency key and Call ID so the same operation +can resume after a timeout or lost response. Choose one runtime and an authorized +destination; each `start` creates a real call. + +```bash +export CALLE_TEST_PHONE="" +python -m pip install calle-ai==1.0.1 +python examples/v2/calls.py start ../calle-v2-run --phone "$CALLE_TEST_PHONE" +python examples/v2/calls.py resume ../calle-v2-run +``` + +For Ruby preview/start/resume commands, see the +[complete example instructions](https://github.com/CALLE-AI/calle-docs/tree/main/examples/v2). +Read either runtime's saved result without sending another API request: + +```bash +python examples/v2/read_call_result.py ../calle-v2-run/result.json +``` + ## Retry without calling twice If the create response is lost, retry the **same request with the same key**. diff --git a/content/guides/sdks.mdx b/content/guides/sdks.mdx index 30dc8e8..009cd68 100644 --- a/content/guides/sdks.mdx +++ b/content/guides/sdks.mdx @@ -4,14 +4,14 @@ description: Install SDK 1.0 for single-target Calls and published Goal Runs. --- The official server SDKs support single-target Calls and published Goal Runs. -Use SDK **1.0.0** with the production API. Legacy call-task integrations must +Use SDK **1.0.1** with the production API. Legacy call-task integrations must [migrate their inputs and result handling](/migration) before upgrading. ## Install ```bash -pnpm add @call-e/calle@1.0.0 -pip install calle-ai==1.0.0 +pnpm add @call-e/calle@1.0.1 +pip install calle-ai==1.0.1 ``` Python requires 3.11 or later. Keep API keys in trusted server environments. @@ -100,6 +100,9 @@ both result and error null. A timeout does not cancel or replace the Call. Python exposes the Billing identifier as `call_id`; TypeScript uses `callId`. Use the separate resource `id` for API requests. +For a runnable Python or Ruby example that saves the request and resumes the +same Call, see [Run a complete example](/quickstart#run-a-complete-example). + ## Retry safely Persist one key and the original request before calling `create`. Retrying the diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b9afc14 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,9 @@ +# CALL-E documentation examples + +Use [`v2/`](v2/) for new integrations with `/v2/calls` and SDK 1.0.1. +The files directly in this directory use the legacy `/v1/calls` contract and +SDK 0.7.0, including `recover_create.py` and the result/transcript readers. +Keep their request shapes, IDs and recovery rules separate from V2. + +See the [migration guide](https://docs.heycall-e.com/migration) before upgrading +an existing workflow. diff --git a/examples/v2/README.md b/examples/v2/README.md new file mode 100644 index 0000000..63b605b --- /dev/null +++ b/examples/v2/README.md @@ -0,0 +1,64 @@ +# Calls V2 examples + +These examples create one authorized English greeting call using `/v2/calls`. +Python requires 3.11+ and `calle-ai==1.0.1`; Ruby uses its standard library. +Check [supported regions and languages](https://docs.heycall-e.com/regions) +before choosing a number you own or are authorized to call. + +From the repository root, configure the same key and environment for start +and every resume. Use a new private directory for each intentionally new call. + +```bash +export CALLE_BASE_URL="https://api.heycall-e.com" +export CALLE_API_KEY="" +export CALLE_TEST_PHONE="" +``` + +Choose one runtime. Running both start commands places two calls. + +```bash +python -m pip install calle-ai==1.0.1 +python examples/v2/calls.py start ../calle-v2-run --phone "$CALLE_TEST_PHONE" +python examples/v2/calls.py resume ../calle-v2-run +``` + +```bash +# Preview without sending a request +ruby examples/v2/calls.rb start ../calle-v2-ruby-run +ruby examples/v2/calls.rb start ../calle-v2-ruby-run --execute --confirm-authorized-recipient +ruby examples/v2/calls.rb resume ../calle-v2-ruby-run +``` + +Both scripts save `request.json` with the original input and idempotency key +before submission, then `call-id.json` before waiting. `resume` reads a known +Call ID; when a create response was lost before saving the ID, it resubmits +the saved input and key unchanged. It never allocates a replacement key. +If a request is rejected, inspect the private error file and follow the +[V2 retry decisions](https://docs.heycall-e.com/calls#idempotency) before +correcting input. Keep any omitted region or locale omitted during recovery. + +Waiting stops when `result_status` is `available`, `unavailable` or +`not_applicable`. Exit 0 means retrieval finished, including a final null +result. It does not establish business success. + +Read either runtime's saved response without making another request: + +```bash +python examples/v2/read_call_result.py ../calle-v2-run/result.json +``` + +For Ruby, use `../calle-v2-ruby-run/result.json` instead. + +The reader preserves `result`, `error`, `call_outcome`, `result_status`, +the Billing `call_id` and the top-level transcript. Use the resource `id` +for API reads; a Billing ID can remain null. + +Supplemental checks make no real requests: + +```bash +python -m unittest discover -s examples/v2 -p test_examples.py +ruby examples/v2/test_calls.rb +``` + +Files in the parent `examples/` directory are legacy examples for SDK 0.7.0. +Use their original interface for historical call-task IDs. diff --git a/examples/v2/calls.py b/examples/v2/calls.py new file mode 100644 index 0000000..7e9d87e --- /dev/null +++ b/examples/v2/calls.py @@ -0,0 +1,104 @@ +"""Create or resume one Calls V2 operation. Python 3.11+, calle-ai==1.0.1.""" + +import argparse +import json +import os +from pathlib import Path +import re +import sys +import uuid + +from calle import CalleClient +from calle.errors import CalleAPIError, CalleConnectionError, CalleTimeoutError + + +def save(path, value): + with path.open("w", encoding="utf-8") as stream: + path.chmod(0o600) + json.dump(value, stream, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + + +def run(client, directory, phone=None): + if phone is not None: + if not re.fullmatch(r"\+[1-9][0-9]{7,14}", phone): + raise ValueError("Use an authorized E.164 phone number.") + directory.mkdir(mode=0o700) # Refuse to overwrite an existing operation. + save(directory / "request.json", { + "idempotency_key": str(uuid.uuid4()), + "phone": phone, + "task": ( + "Make one authorized developer integration test call in English. " + "Listen to the first complete greeting, then thank the other side " + "and end the call. Do not press menu keys or wait on hold. " + "Use unknown for heard_greeting and greeting_summary if no greeting is heard." + ), + "result_schema": { + "type": "object", + "properties": { + "heard_greeting": {"type": "string", "enum": ["yes", "no", "unknown"]}, + "greeting_summary": { + "type": "string", + "description": "Summarize only the greeting actually heard, or use unknown.", + }, + }, + "required": ["heard_greeting", "greeting_summary"], + "additionalProperties": False, + }, + }) + id_path = directory / "call-id.json" + if not id_path.is_file(): + # Lost create responses replay the exact saved request and key. + operation = json.loads((directory / "request.json").read_text(encoding="utf-8")) + created = client.calls.create(**operation) + save(id_path, created["id"]) + call_id = json.loads(id_path.read_text(encoding="utf-8")) + if not isinstance(call_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", call_id): + raise ValueError("Invalid saved Call ID; inspect call-id.json privately.") + print(f"Call ID: {call_id}", flush=True) + call = client.calls.wait_for_result(call_id, timeout_seconds=300) + if call["id"] != call_id: + raise ValueError("Response Call ID differs from the saved ID; stop and inspect the original call.") + save(directory / "result.json", call) + print(json.dumps({key: call[key] for key in ( + "status", "call_outcome", "result_status", "result", "error" + )}, indent=2)) + print("Retrieval finished. Read result.json and its transcript before deciding business success.") + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["start", "resume"]) + parser.add_argument("directory", type=Path, help="Private run directory; start requires a new path.") + parser.add_argument("--phone", help="Authorized E.164 test destination; start only.") + args = parser.parse_args() + if (args.action == "start") != bool(args.phone): + parser.error("start requires --phone; resume must omit it.") + if not os.environ.get("CALLE_API_KEY"): + parser.error("Set CALLE_API_KEY before running.") + options = {"api_key": os.environ["CALLE_API_KEY"]} + if base_url := os.environ.get("CALLE_BASE_URL"): + options["base_url"] = base_url + try: + with CalleClient(**options) as client: + return run(client, args.directory, args.phone) + except CalleAPIError as exc: + save(args.directory / "error.json", { + "status_code": exc.status_code, "code": exc.code, + "message": str(exc), "details": exc.details, + }) + print(f"HTTP {exc.status_code}: {exc.code}. Inspect error.json privately.", file=sys.stderr) + except (CalleConnectionError, CalleTimeoutError, json.JSONDecodeError, KeyError, TypeError): + print("Request or response unavailable. Keep the saved request and key.", file=sys.stderr) + except (OSError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 1 + print("Run resume with the same key and environment. No automatic retry was made.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/v2/calls.rb b/examples/v2/calls.rb new file mode 100644 index 0000000..efd85e2 --- /dev/null +++ b/examples/v2/calls.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true +# Calls V2, using Ruby standard library only. + +require "json" +require "net/http" +require "optparse" +require "securerandom" + +module CallsExample + module_function + + def save(directory, name, value) + File.open(File.join(directory, name), "w", 0o600) do |file| + file.write(JSON.pretty_generate(value) + "\n") + file.flush + file.fsync + end + end + + def request(http, key, directory, message) + message["Authorization"] = "Bearer #{key}" + response = http.request(message) + unless response.is_a?(Net::HTTPSuccess) + # Keep the response private: error details can contain recipient data. + save(directory, "error.json", { status: response.code.to_i, body: response.body }) + raise "HTTP #{response.code}; inspect error.json privately and https://docs.heycall-e.com/errors." + end + value = JSON.parse(response.body) + raise "Expected a JSON object; preserve this directory for recovery." unless value.is_a?(Hash) + + value + end + + def run(http, key, directory, phone = nil) + if phone + Dir.mkdir(directory, 0o700) # Refuse to overwrite an existing operation. + operation = { + "idempotency_key" => SecureRandom.uuid, + "task" => "Make one authorized developer integration test call. Listen to " \ + "the first complete greeting in English, then thank the other side and end " \ + "the call. Do not press menu keys or wait on hold. The purpose " \ + "is to verify that our application can read a real call result. " \ + "Use unknown for heard_greeting and greeting_summary if no greeting is heard.", + "phone" => phone, + "result_schema" => { + "type" => "object", + "properties" => { + "heard_greeting" => { "type" => "string", "enum" => %w[yes no unknown] }, + "greeting_summary" => { + "type" => "string", + "description" => "Summarize only the greeting actually heard; use unknown if none was heard." + } + }, + "required" => %w[heard_greeting greeting_summary], + "additionalProperties" => false + } + } + save(directory, "request.json", operation) + end + + id_path = File.join(directory, "call-id.json") + unless File.file?(id_path) + # Lost create responses replay the exact saved request and key. + operation = JSON.parse(File.read(File.join(directory, "request.json"))) + message = Net::HTTP::Post.new("/v2/calls") + message["Content-Type"] = "application/json" + message["Idempotency-Key"] = operation.fetch("idempotency_key") + message.body = JSON.generate(operation.reject { |name, _| name == "idempotency_key" }) + call = request(http, key, directory, message) + save(directory, "call-id.json", call.fetch("id")) + end + + call_id = JSON.parse(File.read(id_path)) + unless call_id.is_a?(String) && /\A[A-Za-z0-9_-]+\z/.match?(call_id) + raise "Invalid saved Call ID; inspect call-id.json privately." + end + + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 300 + loop do + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) + raise "Polling timed out; run resume to retrieve the same call." if remaining <= 0 + + http.read_timeout = [150, remaining].min + call = request(http, key, directory, Net::HTTP::Get.new("/v2/calls/#{call_id}")) + raise "Response Call ID differs from the saved ID; stop and inspect the original call." unless call["id"] == call_id + + status = call["status"] + result_status = call.fetch("result_status") + if %w[available unavailable not_applicable].include?(result_status) + save(directory, "result.json", call) + puts "Status: #{status}; result status: #{result_status}. Result saved to result.json." + puts "Exit 0 means retrieval finished. Review call_outcome, result, error and transcript before deciding business success." + return 0 + end + raise "Unrecognized result status; preserve the directory and inspect the saved call." unless result_status == "pending" + raise "Unrecognized call status; preserve the directory and inspect the saved call." unless %w[queued in_progress completed failed canceled].include?(status) + + puts "Call status: #{status}; waiting for the same call." + sleep [5, remaining].min + end + end + + def main(argv = ARGV) + options = {} + parser = OptionParser.new do |flags| + flags.banner = "Usage: ruby examples/v2/calls.rb start|resume DIRECTORY [--execute --confirm-authorized-recipient]" + flags.on("--execute") { options[:execute] = true } + flags.on("--confirm-authorized-recipient") { options[:authorized] = true } + end + args = parser.parse(argv) + action, directory = args + raise parser.to_s unless args.length == 2 && %w[start resume].include?(action) + raise "Resume only retrieves an existing call; omit start flags." if action == "resume" && !options.empty? + + phone = nil + if action == "start" + phone = ENV.fetch("CALLE_TEST_PHONE", "") + raise "Set CALLE_TEST_PHONE to an authorized E.164 destination." unless /\A\+[1-9][0-9]{7,14}\z/.match?(phone) + if !options[:execute] + puts "Preview: one English greeting call; will save its request, key and result in the new directory. No request sent." + puts "To place the call, add --execute --confirm-authorized-recipient." + return 0 + end + raise "Confirm permission to call with --confirm-authorized-recipient." unless options[:authorized] + end + key = ENV.fetch("CALLE_API_KEY", "") + raise "Set CALLE_API_KEY in the environment." if key.strip.empty? + + base = URI(ENV["CALLE_BASE_URL"].to_s.empty? ? "https://api.heycall-e.com" : ENV["CALLE_BASE_URL"]) + unless base.is_a?(URI::HTTPS) && base.host && ["", "/"].include?(base.path) && !base.userinfo && !base.query && !base.fragment + raise "CALLE_BASE_URL must be an HTTPS origin without a path or credentials." + end + http = Net::HTTP.new(base.host, base.port) + http.use_ssl = true + http.open_timeout = 30 + http.read_timeout = 150 + http.write_timeout = 30 + http.max_retries = 0 + run(http, key, directory, phone) + rescue OptionParser::ParseError, URI::InvalidURIError, RuntimeError => error + warn error.message + 1 + rescue JSON::ParserError, KeyError, IOError, SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse + warn "Request, response or local file error. Keep the directory and run resume with the same key and environment." + warn "See https://docs.heycall-e.com/calls#idempotency before changing the request." + 1 + end +end + +exit CallsExample.main if $PROGRAM_NAME == __FILE__ diff --git a/examples/v2/read_call_result.py b/examples/v2/read_call_result.py new file mode 100644 index 0000000..2dcc84f --- /dev/null +++ b/examples/v2/read_call_result.py @@ -0,0 +1,38 @@ +"""Read a saved Calls V2 result without making API requests.""" + +import argparse +import json +from pathlib import Path + + +def result_report(call): + if not isinstance(call, dict) or call.get("object") != "call": + raise ValueError("Expected a Calls V2 call object; use the legacy reader for call_task objects.") + report = {key: call[key] for key in ( + "id", "call_id", "status", "call_outcome", "result_status", "result", "error", "transcript", + )} + if not isinstance(call["transcript"], list): + raise ValueError("Expected a transcript array.") + report["transcript_lines"] = [] + for turn in call["transcript"]: + offset = turn["offset_seconds"] + timestamp = "time unavailable" if offset is None else f"{offset}s" + report["transcript_lines"].append(f"[{timestamp}] {turn['speaker']}: {turn['text']}") + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("result", type=Path, help="Private result.json saved by a Calls V2 example.") + args = parser.parse_args() + try: + report = result_report(json.loads(args.result.read_text(encoding="utf-8"))) + except OSError: + parser.error("Could not read the saved response file.") + except (ValueError, KeyError, TypeError): + parser.error("Expected a complete Calls V2 call JSON response.") + print(json.dumps(report, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/examples/v2/test_calls.rb b/examples/v2/test_calls.rb new file mode 100644 index 0000000..97d6441 --- /dev/null +++ b/examples/v2/test_calls.rb @@ -0,0 +1,73 @@ +require "tmpdir" +require "stringio" +require_relative "calls" + +def check(value) + raise "Check failed at #{caller.first}" unless value +end + +def response(value) + result = Net::HTTPOK.new("1.1", "200", "test") + result.body = JSON.generate({ id: "call_test", status: "completed", result: nil, error: nil, transcript: [] }.merge(value)) + result.instance_variable_set(:@read, true) + result +end + +FakeHTTP = Struct.new(:read_timeout, :messages, :respond) do + def request(message) + messages << message + respond.call(message) + end +end + +original_stdout = $stdout +$stdout = StringIO.new +CallsExample.define_singleton_method(:sleep) { |_| } +begin + Dir.mktmpdir do |root| + directory = File.join(root, "run") + http = FakeHTTP.new(nil, [], nil) + states = %w[pending unavailable] + http.respond = lambda do |message| + check(message["Authorization"] == "Bearer synthetic") + if message.method == "POST" + check(message.path == "/v2/calls") + saved = JSON.parse(File.read(File.join(directory, "request.json"))) + check(message["Idempotency-Key"] == saved.delete("idempotency_key")) + check(JSON.parse(message.body) == saved && saved.key?("phone") && !saved.key?("recipients")) + raise Net::ReadTimeout if http.messages.length == 1 + response(result_status: "pending") + else + check(message.path == "/v2/calls/call_test") + response(result_status: states.shift || "unavailable") + end + end + begin + CallsExample.run(http, "synthetic", directory, "+12025550123") + raise "Expected a lost create response" + rescue Net::ReadTimeout + end + original = File.read(File.join(directory, "request.json")) + check(CallsExample.run(http, "synthetic", directory) == 0) + check(states.empty?) + check(CallsExample.run(http, "synthetic", directory) == 0) + check(http.messages.map(&:method) == %w[POST POST GET GET GET]) + check(File.read(File.join(directory, "request.json")) == original) + check(JSON.parse(File.read(File.join(directory, "result.json")))["result"].nil?) + %w[available not_applicable].each do |state| + http.respond = ->(_) { response(result_status: state, result: state == "available" ? {} : nil) } + check(CallsExample.run(http, "synthetic", directory) == 0) + end + http.respond = ->(_) { response(result_status: "unexpected") } + begin + CallsExample.run(http, "synthetic", directory) + raise "Expected unknown result status rejection" + rescue RuntimeError => error + check(error.message.start_with?("Unrecognized result status")) + end + end +ensure + CallsExample.singleton_class.remove_method(:sleep) + $stdout = original_stdout +end +puts "Calls V2 Ruby persistence, lost-response replay, readiness and resume checks passed." diff --git a/examples/v2/test_examples.py b/examples/v2/test_examples.py new file mode 100644 index 0000000..a97a45f --- /dev/null +++ b/examples/v2/test_examples.py @@ -0,0 +1,89 @@ +"""Supplemental Calls V2 checks using the published SDK; no real calls.""" + +from copy import deepcopy +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import httpx +from calle import CalleClient +from calle.errors import CalleTimeoutError + +from calls import run +from read_call_result import result_report + + +def response(**changes): + return { + "object": "call", "id": "call_test", "call_id": None, + "status": "completed", "call_outcome": "busy", "result_status": "unavailable", + "result": None, "error": None, "transcript": [], **changes, + } + + +class ExamplesTest(unittest.TestCase): + def test_lost_create_replays_saved_input_then_resume_only_reads(self): + with tempfile.TemporaryDirectory() as root: + directory = Path(root) / "run" + requests = [] + + def respond(request): + requests.append(request.method) + if request.method == "POST": + self.assertEqual(request.url.path, "/v2/calls") + saved = json.loads((directory / "request.json").read_text()) + self.assertEqual(request.headers["Idempotency-Key"], saved.pop("idempotency_key")) + self.assertEqual(json.loads(request.content), saved) + self.assertNotIn("region", saved) + self.assertNotIn("locale", saved) + if len(requests) == 1: + raise httpx.ReadTimeout("lost after acceptance", request=request) + return httpx.Response(202, json=response(status="queued", result_status="pending")) + self.assertEqual(request.url.path, "/v2/calls/call_test") + return httpx.Response(200, json=response()) + + with httpx.Client(base_url="https://example.invalid", transport=httpx.MockTransport(respond)) as http: + with CalleClient(api_key="synthetic", http_client=http) as client: + with self.assertRaises(CalleTimeoutError): + run(client, directory, "+12025550123") + original = (directory / "request.json").read_bytes() + with self.assertRaises(FileExistsError): + run(client, directory, "+12025550123") + self.assertEqual(run(client, directory), 0) + self.assertEqual(run(client, directory), 0) + self.assertEqual((directory / "request.json").read_bytes(), original) + self.assertEqual(requests, ["POST", "POST", "GET", "GET"]) + self.assertIsNone(json.loads((directory / "result.json").read_text())["result"]) + + def test_completed_execution_keeps_waiting_for_result(self): + with tempfile.TemporaryDirectory() as root: + directory = Path(root) + (directory / "call-id.json").write_text('"call_test"') + states = [response(result_status="pending"), response(result_status="available", result={})] + def respond(request): + self.assertEqual(request.method, "GET") + return httpx.Response(200, json=states.pop(0)) + with httpx.Client(base_url="https://example.invalid", transport=httpx.MockTransport(respond)) as http: + with CalleClient(api_key="synthetic", http_client=http) as client, patch("calle.calls.time.sleep"): + self.assertEqual(run(client, directory), 0) + self.assertEqual(states, []) + self.assertEqual(json.loads((directory / "result.json").read_text())["result"], {}) + + def test_reader_keeps_unavailable_and_scalar_values_without_changing_input(self): + call = response(transcript=[{"offset_seconds": None, "speaker": "unknown", "text": "Hello."}]) + original = deepcopy(call) + report = result_report(call) + self.assertIsNone(report["result"]) + self.assertIsNone(report["error"]) + self.assertEqual(report["transcript_lines"], ["[time unavailable] unknown: Hello."]) + self.assertEqual(call, original) + for result in ({}, {"confirmed": False, "quantity": 0, "answer": "unknown"}): + self.assertEqual(result_report(response(result_status="available", result=result))["result"], result) + with self.assertRaises(ValueError): + result_report({"object": "call_task"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/docs-site.spec.ts b/tests/docs-site.spec.ts index ed34a51..ad680f9 100644 --- a/tests/docs-site.spec.ts +++ b/tests/docs-site.spec.ts @@ -1289,8 +1289,8 @@ test("keeps retry tables and API operations within the mobile viewport", async ( test("publishes production SDK installation and readiness guidance", async ({ page }) => { await page.goto("/sdks"); - await expect(page.locator("pre").filter({ hasText: "pnpm add @call-e/calle@1.0.0" })).toBeVisible(); - await expect(page.locator("pre").filter({ hasText: "pip install calle-ai==1.0.0" })).toBeVisible(); + await expect(page.locator("pre").filter({ hasText: "pnpm add @call-e/calle@1.0.1" })).toBeVisible(); + await expect(page.locator("pre").filter({ hasText: "pip install calle-ai==1.0.1" })).toBeVisible(); await expect(page.locator("main")).toContainText("resultStatus"); await expect(page.locator("main")).not.toContainText("These packages have not been published"); await page.goto("/migration"); @@ -1298,4 +1298,8 @@ test("publishes production SDK installation and readiness guidance", async ({ pa await page.goto("/calls"); await expect(page.locator("main")).toContainText("call_id"); await expect(page.locator("main")).not.toContainText("unreleased response additions"); + await page.goto("/quickstart#run-a-complete-example"); + await expect(page.locator("main a[href$='/examples/v2/calls.py']")).toBeVisible(); + await expect(page.locator("main a[href$='/examples/v2/calls.rb']")).toBeVisible(); + await expect(page.locator("main")).toContainText("python examples/v2/read_call_result.py"); });