ctxprotocol 0.20.0__tar.gz → 0.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/CHANGELOG.md +16 -0
  2. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/PKG-INFO +5 -5
  3. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/README.md +4 -4
  4. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/__init__.py +1 -1
  5. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/client/resources/query.py +26 -26
  6. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/client/types.py +3 -0
  7. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/pyproject.toml +1 -1
  8. ctxprotocol-0.22.0/scripts/hermes-parity-sanity-check.py +153 -0
  9. ctxprotocol-0.22.0/scripts/hermes-parity-stream-check.py +96 -0
  10. ctxprotocol-0.22.0/scripts/medium-query.txt +1 -0
  11. ctxprotocol-0.22.0/scripts/saudi-oil-query.txt +16 -0
  12. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/tests/test_query.py +141 -137
  13. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/.codexignore +0 -0
  14. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/.gitignore +0 -0
  15. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/auth/__init__.py +0 -0
  16. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/client/__init__.py +0 -0
  17. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/client/client.py +0 -0
  18. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/client/resources/__init__.py +0 -0
  19. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/client/resources/developer.py +0 -0
  20. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/client/resources/discovery.py +0 -0
  21. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/client/resources/tools.py +0 -0
  22. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/context/__init__.py +0 -0
  23. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/context/hyperliquid.py +0 -0
  24. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/context/polymarket.py +0 -0
  25. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/context/wallet.py +0 -0
  26. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/contrib/__init__.py +0 -0
  27. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/contrib/search/__init__.py +0 -0
  28. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/contrib/search/core.py +0 -0
  29. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/contrib/search/trace.py +0 -0
  30. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/contrib/search/types.py +0 -0
  31. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/contrib/search/validation.py +0 -0
  32. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/handshake/__init__.py +0 -0
  33. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/ctxprotocol/py.typed +0 -0
  34. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/docs/README.md +0 -0
  35. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/execute_client.py +0 -0
  36. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/polymarket_query_trace_validation.py +0 -0
  37. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/query_client.py +0 -0
  38. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/test_favorites_only.py +0 -0
  39. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/test_get_tool.py +0 -0
  40. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/two_surfaces_client.py +0 -0
  41. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/validation/generate_contributor_search_validation_artifacts.py +0 -0
  42. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/validation/named-regression-iran-boots-on-ground.json +0 -0
  43. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/validation/named-regression-supreme-court-tariffs.json +0 -0
  44. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/validation/shared-capability-miss-unsupported-venue.json +0 -0
  45. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/validation/shared-generic-overlap-best-match.json +0 -0
  46. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/client/validation/shared-still-ambiguous-shortlist.json +0 -0
  47. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/server/hummingbot-contributor/README.md +0 -0
  48. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/server/hummingbot-contributor/env.example +0 -0
  49. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/server/hummingbot-contributor/requirements.txt +0 -0
  50. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/examples/server/hummingbot-contributor/server.py +0 -0
  51. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/polymarket-query-trace-results-py.json +0 -0
  52. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/tests/__init__.py +0 -0
  53. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/tests/contrib_search_validation_cases.py +0 -0
  54. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/tests/test_client.py +0 -0
  55. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/tests/test_contrib_search.py +0 -0
  56. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/tests/test_discovery.py +0 -0
  57. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/tests/test_tools.py +0 -0
  58. {ctxprotocol-0.20.0 → ctxprotocol-0.22.0}/uv.lock +0 -0
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0
4
+
5
+ - Added `bounded_explicit_empty_result_guardrail` to the query controller stop-reason literal type so strict consumers no longer fail validation when the platform returns that live stop reason.
6
+ - Platform note (no type change): `computed_artifacts` is now capped at 4 per response by the shared artifact emission policy, so SDK/MCP consumers receive exactly the artifact list the web app displays for the identical run.
7
+
8
+ ## 0.21.0
9
+
10
+ - `client.query.run()` is now backed by the durable job path (`start()` + `poll()`) instead of a held-open SSE connection. One call now reliably covers the full 1800s hosted compute ceiling and survives transient connection drops — the "sometimes works, sometimes times out on hard queries" failure mode is gone. `run()` accepts optional `interval_ms` / `timeout_ms` keyword arguments.
11
+ - `run_or_poll()` is kept as an explicit alias of the same path, and now also synthesizes a fallback developer trace when `include_developer_trace` is set but the backend omits the trace (matching prior `run()` behavior).
12
+ - `client.query.stream()` is unchanged and remains the real-time SSE surface.
13
+
14
+ ## 0.20.0
15
+
16
+ - Poll defaults aligned with the hosted 1800s compute ceiling: `poll()`/`run_or_poll()` check status every 5 seconds over plain HTTP and wait up to 31 minutes by default.
17
+ - Documented that HTTP polling costs no model tokens; model turns do.
18
+
3
19
  ## 0.19.1
4
20
 
5
21
  - Fixed `client.query.start()` returning `400 "Invalid query job request"` when called without explicit `tools`. The request body builder now omits optional fields (notably `tools`) when unset instead of serializing them as JSON `null`; the durable jobs endpoint's Zod schema rejects `null` for optional fields. `client.query.run()` and `client.query.stream()` were not failing (the `/api/v1/query` endpoint tolerates `null`), but are now consistent with the TS SDK and MCP behavior.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctxprotocol
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: Official Python SDK for the Context Protocol - Discover and execute AI tools programmatically
5
5
  Project-URL: Homepage, https://ctxprotocol.com
6
6
  Project-URL: Documentation, https://docs.ctxprotocol.com
@@ -149,7 +149,7 @@ print(
149
149
  print(answer.orchestration_metrics) # Optional first-pass / rediscovery metrics
150
150
  ```
151
151
 
152
- For long-running questions from LLM agents, use `run_or_poll()` so the entire wait happens inside one SDK call (one model turn):
152
+ Since 0.21.0 `run()` is backed by the durable job path (`start()` + `poll()`): one call works the same for a 5-second lookup and a 30-minute chart build, with no held-open SSE connection for proxies or client timeouts to kill. `run_or_poll()` remains as an explicit alias:
153
153
 
154
154
  ```python
155
155
  answer = await client.query.run_or_poll(
@@ -249,7 +249,7 @@ See a full dual-surface client script in [`examples/two-surfaces-client.py`](./e
249
249
  | `api_key` | `str` | Yes | — | Your Context Protocol API key |
250
250
  | `base_url` | `str` | No | `https://www.ctxprotocol.com` | API base URL (for development) |
251
251
  | `request_timeout_seconds` | `float` | No | `300.0` | Timeout for non-streaming JSON API calls |
252
- | `stream_timeout_seconds` | `float` | No | `600.0` | Timeout for streaming API calls; also used by `client.query.run()` |
252
+ | `stream_timeout_seconds` | `float` | No | `600.0` | Timeout for streaming API calls (`client.query.stream()`) |
253
253
 
254
254
  ```python
255
255
  # Production
@@ -342,7 +342,7 @@ closed = await client.tools.close_session("sess_123")
342
342
 
343
343
  Run an agentic query. The server applies the live librarian pipeline (`discover -> select -> iterative execute -> synthesize -> settle`) with up to 100 MCP calls per response turn, then returns the selected Query response contract (`answer_with_evidence` or `evidence_only`, default `answer_with_evidence`).
344
344
 
345
- `client.query.run()` buffers the same SSE transport used by `client.query.stream()` and returns the final `done` result. This keeps Python aligned with the TypeScript SDK and the live query runtime.
345
+ Since 0.21.0, `client.query.run()` is backed by the durable job path (`start()` + `poll()`), so a single call reliably covers the full 1800s hosted compute ceiling and survives transient connection drops. This matches the TypeScript SDK. Use `client.query.stream()` when you want real-time SSE events.
346
346
 
347
347
  The query runtime now exposes a single managed executor surface.
348
348
  The server decides internal budgets, ambiguity handling, and exploration policy
@@ -383,7 +383,7 @@ When retrieval-first synthesis rollout is enabled server-side, full-data or trun
383
383
 
384
384
  #### `client.query.stream(query, tools?, agent_model_id?, include_data?, include_data_url?, include_developer_trace?, idempotency_key?)`
385
385
 
386
- Same as `run()` but streams events in real-time via SSE.
386
+ Runs the same query pipeline as `run()` but over a live SSE connection, yielding events in real-time.
387
387
 
388
388
  Event types:
389
389
  - `tool-status`
@@ -111,7 +111,7 @@ print(
111
111
  print(answer.orchestration_metrics) # Optional first-pass / rediscovery metrics
112
112
  ```
113
113
 
114
- For long-running questions from LLM agents, use `run_or_poll()` so the entire wait happens inside one SDK call (one model turn):
114
+ Since 0.21.0 `run()` is backed by the durable job path (`start()` + `poll()`): one call works the same for a 5-second lookup and a 30-minute chart build, with no held-open SSE connection for proxies or client timeouts to kill. `run_or_poll()` remains as an explicit alias:
115
115
 
116
116
  ```python
117
117
  answer = await client.query.run_or_poll(
@@ -211,7 +211,7 @@ See a full dual-surface client script in [`examples/two-surfaces-client.py`](./e
211
211
  | `api_key` | `str` | Yes | — | Your Context Protocol API key |
212
212
  | `base_url` | `str` | No | `https://www.ctxprotocol.com` | API base URL (for development) |
213
213
  | `request_timeout_seconds` | `float` | No | `300.0` | Timeout for non-streaming JSON API calls |
214
- | `stream_timeout_seconds` | `float` | No | `600.0` | Timeout for streaming API calls; also used by `client.query.run()` |
214
+ | `stream_timeout_seconds` | `float` | No | `600.0` | Timeout for streaming API calls (`client.query.stream()`) |
215
215
 
216
216
  ```python
217
217
  # Production
@@ -304,7 +304,7 @@ closed = await client.tools.close_session("sess_123")
304
304
 
305
305
  Run an agentic query. The server applies the live librarian pipeline (`discover -> select -> iterative execute -> synthesize -> settle`) with up to 100 MCP calls per response turn, then returns the selected Query response contract (`answer_with_evidence` or `evidence_only`, default `answer_with_evidence`).
306
306
 
307
- `client.query.run()` buffers the same SSE transport used by `client.query.stream()` and returns the final `done` result. This keeps Python aligned with the TypeScript SDK and the live query runtime.
307
+ Since 0.21.0, `client.query.run()` is backed by the durable job path (`start()` + `poll()`), so a single call reliably covers the full 1800s hosted compute ceiling and survives transient connection drops. This matches the TypeScript SDK. Use `client.query.stream()` when you want real-time SSE events.
308
308
 
309
309
  The query runtime now exposes a single managed executor surface.
310
310
  The server decides internal budgets, ambiguity handling, and exploration policy
@@ -345,7 +345,7 @@ When retrieval-first synthesis rollout is enabled server-side, full-data or trun
345
345
 
346
346
  #### `client.query.stream(query, tools?, agent_model_id?, include_data?, include_data_url?, include_developer_trace?, idempotency_key?)`
347
347
 
348
- Same as `run()` but streams events in real-time via SSE.
348
+ Runs the same query pipeline as `run()` but over a live SSE connection, yielding events in real-time.
349
349
 
350
350
  Event types:
351
351
  - `tool-status`
@@ -31,7 +31,7 @@ Example:
31
31
  For more information, visit: https://ctxprotocol.com
32
32
  """
33
33
 
34
- __version__ = "0.19.1"
34
+ __version__ = "0.22.0"
35
35
 
36
36
  # Re-export everything from client module
37
37
  from ctxprotocol.client import (
@@ -265,6 +265,8 @@ class Query:
265
265
  resume_from: QueryAttemptReference | dict[str, Any] | None = None,
266
266
  fork_from: QueryForkReference | dict[str, Any] | None = None,
267
267
  idempotency_key: str | None = None,
268
+ interval_ms: int = DEFAULT_QUERY_POLL_INTERVAL_MS,
269
+ timeout_ms: int = DEFAULT_QUERY_POLL_TIMEOUT_MS,
268
270
  ) -> QueryResult:
269
271
  """Run an agentic query and wait for the full response.
270
272
 
@@ -272,8 +274,12 @@ class Query:
272
274
  executes the discovery-first pipeline (up to 100 MCP calls per tool),
273
275
  and returns an AI-synthesized answer. Payment is settled after
274
276
  successful execution via deferred settlement.
275
- Internally this follows the same SSE `done` path as `query.stream()`
276
- so Python and TypeScript observe the same query runtime behavior.
277
+
278
+ Since 0.21.0 this is backed by the durable job path (``start()`` +
279
+ ``poll()``), so one call reliably survives the full 1800s hosted
280
+ compute ceiling and transient connection drops — no held-open SSE
281
+ connection to be killed by proxies or client timeouts. Use
282
+ ``stream()`` when you want real-time SSE events instead.
277
283
 
278
284
  Args:
279
285
  query: The natural-language question to answer
@@ -288,6 +294,8 @@ class Query:
288
294
  resume_from: Resume a prior durable query attempt
289
295
  fork_from: Fork a new durable query attempt from a previous attempt
290
296
  idempotency_key: Optional idempotency key (UUID recommended) for safe retries
297
+ interval_ms: Internal HTTP status-check cadence (costs no LLM tokens)
298
+ timeout_ms: Max client-side wait for the job's terminal state
291
299
 
292
300
  Returns:
293
301
  The complete query result with response text, tools used, and cost
@@ -311,9 +319,7 @@ class Query:
311
319
  ... tools=["tool-uuid-1", "tool-uuid-2"],
312
320
  ... )
313
321
  """
314
- terminal_error: QueryStreamErrorEvent | None = None
315
-
316
- async for event in self.stream(
322
+ return await self.run_or_poll(
317
323
  query=query,
318
324
  tools=tools,
319
325
  favorites_only=favorites_only,
@@ -325,21 +331,9 @@ class Query:
325
331
  resume_from=resume_from,
326
332
  fork_from=fork_from,
327
333
  idempotency_key=idempotency_key,
328
- ):
329
- if event.type == "error":
330
- terminal_error = event
331
- continue
332
-
333
- if event.type == "done":
334
- return event.result
335
-
336
- if terminal_error is not None:
337
- raise ContextError(
338
- message=terminal_error.error,
339
- code=terminal_error.code,
340
- )
341
-
342
- raise ContextError("Streaming query ended before done event")
334
+ interval_ms=interval_ms,
335
+ timeout_ms=timeout_ms,
336
+ )
343
337
 
344
338
  async def start(
345
339
  self,
@@ -435,11 +429,11 @@ class Query:
435
429
  ) -> QueryResult:
436
430
  """Run a durable query job and wait internally for completion.
437
431
 
438
- This is the recommended one-call helper for LLM agents: the entire
439
- wait happens inside this single call (one model turn), instead of one
440
- turn per ``get_status()`` check. It also avoids starting a duplicate
441
- paid query after a client-side streaming timeout. The job is bounded
442
- by the 1800s hosted compute ceiling.
432
+ ``run()`` delegates here since 0.21.0, so the two are equivalent;
433
+ ``run_or_poll()`` is kept as an explicit alias. The entire wait
434
+ happens inside this single call (one model turn for LLM agents),
435
+ instead of one turn per ``get_status()`` check, and the job is
436
+ bounded by the 1800s hosted compute ceiling.
443
437
  """
444
438
  job = await self.start(
445
439
  query=query,
@@ -460,7 +454,13 @@ class Query:
460
454
  timeout_ms=timeout_ms,
461
455
  )
462
456
  if completed.status == "completed" and completed.result is not None:
463
- return completed.result
457
+ result = completed.result
458
+ if include_developer_trace and result.developer_trace is None:
459
+ result.developer_trace = self._build_synthetic_trace_from_run_result(
460
+ result.tools_used,
461
+ result.duration_ms,
462
+ )
463
+ return result
464
464
  raise ContextError(completed.error or "Context query job completed without a result")
465
465
 
466
466
  async def stream(
@@ -47,6 +47,7 @@ DEFAULT_AGENT_MODEL_ID = "kimi-k2.6-model"
47
47
  AGENT_MODEL_IDS = (
48
48
  "kimi-k2.6-model",
49
49
  "glm-5.2-model",
50
+ "grok-4.5-model",
50
51
  "deepseek-v4-pro-model",
51
52
  "deepseek-v4-flash-model",
52
53
  "qwen-3.7-plus-model",
@@ -57,6 +58,7 @@ AGENT_MODEL_IDS = (
57
58
  AgentModelId = Literal[
58
59
  "kimi-k2.6-model",
59
60
  "glm-5.2-model",
61
+ "grok-4.5-model",
60
62
  "deepseek-v4-pro-model",
61
63
  "deepseek-v4-flash-model",
62
64
  "qwen-3.7-plus-model",
@@ -1461,6 +1463,7 @@ QueryControllerStopReason = Literal[
1461
1463
  "bounded_runtime_budget",
1462
1464
  "bounded_same_endpoint_guardrail",
1463
1465
  "bounded_upstream_abort_guardrail",
1466
+ "bounded_explicit_empty_result_guardrail",
1464
1467
  "capability_miss",
1465
1468
  ]
1466
1469
  QueryControllerIssueClass = Literal[
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ctxprotocol"
7
- version = "0.20.0"
7
+ version = "0.22.0"
8
8
  description = "Official Python SDK for the Context Protocol - Discover and execute AI tools programmatically"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env python3
2
+ """Throwaway Hermes-parity sanity check for ctxprotocol 0.21.0.
3
+
4
+ Mirrors ~/.hermes/workspace/ctx-gtm/scripts/ctxprotocol-query.py:
5
+ - same ContextClient timeouts
6
+ - same query.run() kwargs (no agent_model_id)
7
+ - same output contract (JSON + CHART_URL + COST_USD)
8
+
9
+ Reads CONTEXT_API_KEY from context-sdk/.env.local (or env).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+ SDK_QUERY_TIMEOUT = 1860 # Hermes: 1800s ceiling + 60s grace
21
+ FAVORITES_ONLY = os.environ.get("CTX_FAVORITES_ONLY", "true").strip().lower() in (
22
+ "true",
23
+ "1",
24
+ "yes",
25
+ )
26
+
27
+ DEFAULT_QUERY = (
28
+ "What is the current price of Bitcoin? One sentence answer, no charts needed."
29
+ )
30
+
31
+
32
+ def load_api_key() -> str:
33
+ candidates = [
34
+ os.environ.get("CONTEXT_API_KEY"),
35
+ _read_env_file(
36
+ os.path.join(
37
+ os.path.dirname(__file__),
38
+ "..",
39
+ "..",
40
+ "context-sdk",
41
+ ".env.local",
42
+ )
43
+ ),
44
+ _read_env_file(
45
+ os.path.expanduser("~/.hermes/.env"),
46
+ ),
47
+ ]
48
+ for value in candidates:
49
+ if value:
50
+ return value
51
+ print(
52
+ "ERROR: CONTEXT_API_KEY not found in env, context-sdk/.env.local, or ~/.hermes/.env",
53
+ file=sys.stderr,
54
+ )
55
+ sys.exit(1)
56
+
57
+
58
+ def _read_env_file(path: str) -> str | None:
59
+ try:
60
+ with open(os.path.abspath(path)) as handle:
61
+ for line in handle:
62
+ line = line.strip()
63
+ if line.startswith("CONTEXT_API_KEY="):
64
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
65
+ except OSError:
66
+ return None
67
+ return None
68
+
69
+
70
+ def _chart_urls_from_result(ans) -> list[str]:
71
+ urls: list[str] = []
72
+ for art in getattr(ans, "computed_artifacts", None) or []:
73
+ if getattr(art, "kind", None) == "image":
74
+ url = getattr(art, "url", None)
75
+ if url:
76
+ urls.append(url)
77
+ if not urls:
78
+ for text in (getattr(ans, "response", None), getattr(ans, "summary", None)):
79
+ if not text:
80
+ continue
81
+ for match in re.finditer(r"!\[.*?\]\((https?://[^)\s]+)\)", text):
82
+ urls.append(match.group(1))
83
+ seen: set[str] = set()
84
+ deduped: list[str] = []
85
+ for url in urls:
86
+ if url not in seen:
87
+ seen.add(url)
88
+ deduped.append(url)
89
+ return deduped
90
+
91
+
92
+ def emit(ans) -> None:
93
+ cost = getattr(ans, "cost", None)
94
+ total = getattr(cost, "total_cost_usd", None) if cost else None
95
+ payload = {
96
+ "response": getattr(ans, "response", None),
97
+ "summary": getattr(ans, "summary", None),
98
+ "cost_usd": total,
99
+ "data_url": getattr(ans, "data_url", None),
100
+ "tools_used": [
101
+ {"name": getattr(tool, "name", None), "id": getattr(tool, "id", None)}
102
+ for tool in (getattr(ans, "tools_used", None) or [])
103
+ ],
104
+ "outcome_type": getattr(ans, "outcome_type", None),
105
+ "stop_reason": getattr(ans, "stop_reason", None),
106
+ "duration_ms": getattr(ans, "duration_ms", None),
107
+ }
108
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
109
+
110
+ chart_urls = _chart_urls_from_result(ans)
111
+ if chart_urls:
112
+ for index, url in enumerate(chart_urls, start=1):
113
+ print(f"CHART_URL_{index}: {url}")
114
+ else:
115
+ print("CHART_URL_1: none")
116
+ print(f"COST_USD: {total if total is not None else 'unknown'}")
117
+
118
+
119
+ async def run_hermes_style_query(question: str) -> None:
120
+ from ctxprotocol import ContextClient, __version__
121
+
122
+ print(f"ctxprotocol version: {__version__}", file=sys.stderr)
123
+ print(f"favorites_only: {FAVORITES_ONLY}", file=sys.stderr)
124
+ print(f"query: {question[:120]}{'...' if len(question) > 120 else ''}", file=sys.stderr)
125
+
126
+ async with ContextClient(
127
+ api_key=load_api_key(),
128
+ request_timeout_seconds=float(SDK_QUERY_TIMEOUT),
129
+ stream_timeout_seconds=float(SDK_QUERY_TIMEOUT),
130
+ ) as client:
131
+ answer = await client.query.run(
132
+ query=question,
133
+ response_shape="answer_with_evidence",
134
+ include_data=False,
135
+ include_data_url=True,
136
+ favorites_only=FAVORITES_ONLY,
137
+ )
138
+ emit(answer)
139
+
140
+
141
+ def main() -> None:
142
+ question = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_QUERY
143
+ try:
144
+ asyncio.run(run_hermes_style_query(question))
145
+ except Exception as exc: # noqa: BLE001
146
+ print(json.dumps({"error": f"{type(exc).__name__}: {exc}"}, indent=2))
147
+ print("CHART_URL_1: none")
148
+ print("COST_USD: unknown")
149
+ sys.exit(1)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """Throwaway: same Hermes kwargs but via stream() (direct /api/v1/query SSE).
3
+
4
+ Use to compare job-backed run() vs held-open sync path on long queries.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+
15
+ SDK_QUERY_TIMEOUT = 1860
16
+ FAVORITES_ONLY = True
17
+
18
+
19
+ def load_api_key() -> str:
20
+ path = os.path.abspath(
21
+ os.path.join(os.path.dirname(__file__), "..", "..", "context-sdk", ".env.local")
22
+ )
23
+ with open(path) as handle:
24
+ for line in handle:
25
+ if line.startswith("CONTEXT_API_KEY="):
26
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
27
+ raise SystemExit("CONTEXT_API_KEY missing")
28
+
29
+
30
+ async def main() -> None:
31
+ query_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
32
+ os.path.dirname(__file__), "saudi-oil-query.txt"
33
+ )
34
+ with open(query_path) as handle:
35
+ query = handle.read().strip()
36
+
37
+ from ctxprotocol import ContextClient, __version__
38
+
39
+ print(f"stream path | ctxprotocol {__version__}", file=sys.stderr)
40
+ started = time.time()
41
+ async with ContextClient(
42
+ api_key=load_api_key(),
43
+ request_timeout_seconds=float(SDK_QUERY_TIMEOUT),
44
+ stream_timeout_seconds=float(SDK_QUERY_TIMEOUT),
45
+ ) as client:
46
+ result = None
47
+ async for event in client.query.stream(
48
+ query=query,
49
+ response_shape="answer_with_evidence",
50
+ include_data=False,
51
+ include_data_url=True,
52
+ favorites_only=FAVORITES_ONLY,
53
+ ):
54
+ if event.type == "done":
55
+ result = event.result
56
+ elif event.type == "error":
57
+ raise RuntimeError(getattr(event, "message", str(event)))
58
+
59
+ elapsed = time.time() - started
60
+ cost = getattr(getattr(result, "cost", None), "total_cost_usd", None)
61
+ charts = [
62
+ getattr(art, "url", None)
63
+ for art in (getattr(result, "computed_artifacts", None) or [])
64
+ if getattr(art, "kind", None) == "image" and getattr(art, "url", None)
65
+ ]
66
+ print(
67
+ json.dumps(
68
+ {
69
+ "elapsed_s": round(elapsed, 1),
70
+ "stop_reason": getattr(result, "stop_reason", None),
71
+ "cost_usd": cost,
72
+ "chart_count": len(charts),
73
+ "tools_used": [
74
+ getattr(t, "name", None)
75
+ for t in (getattr(result, "tools_used", None) or [])
76
+ ],
77
+ "response_preview": (getattr(result, "response", None) or "")[:800],
78
+ },
79
+ indent=2,
80
+ )
81
+ )
82
+ for index, url in enumerate(charts, start=1):
83
+ print(f"CHART_URL_{index}: {url}")
84
+ if not charts:
85
+ print("CHART_URL_1: none")
86
+ print(f"COST_USD: {cost}")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ try:
91
+ asyncio.run(main())
92
+ except Exception as exc: # noqa: BLE001
93
+ print(json.dumps({"error": f"{type(exc).__name__}: {exc}"}, indent=2))
94
+ print("CHART_URL_1: none")
95
+ print("COST_USD: unknown")
96
+ sys.exit(1)
@@ -0,0 +1 @@
1
+ Compare the current price and 7-day trend of Bitcoin and Ethereum, and find one prediction market related to Bitcoin's price this year with its current odds. Provide a short verdict on whether crypto positioning looks bullish or bearish. Text only, no charts needed.
@@ -0,0 +1,16 @@
1
+ A news outlet reported: Saudi Arabia slashed the August price of its flagship Arab Light crude for Asia by $11 a barrel — the biggest reduction in at least 26 years. The move reflects weaker oil markets, rising global supply, and intensifying competition among Gulf producers.
2
+
3
+ I'm crafting a single tweet for a data-driven Twitter account (@ctxprotocol). The tweet needs one compelling data point and a chart that makes someone stop scrolling.
4
+
5
+ Search your available tools for data relevant to this story. Use whatever tools fit best — prediction markets, equities intelligence, crypto data, crypto positioning, trading intelligence, or any combination. You don't need to use every tool; pick the ones that produce the most insight for THIS specific story.
6
+
7
+ For each relevant market or asset you find, I need:
8
+ - Current key metrics (oil price, energy stock price, odds, spread, flow — whatever the tool provides)
9
+ - How has the key metric moved over the market's lifetime or let the backend determine the meaningful time range
10
+ - Positioning/flow: who is on which side? Is smart money aligned with or against the narrative?
11
+ - Catalyst date: when does this market resolve, or when is the next event that could move it? Include for every market/asset found.
12
+ - Follow-up assessment: is this worth revisiting in a future post? What event would trigger a followup?
13
+
14
+ Deliver a verdict: Was the market surprised by this news, or was it already priced in? What is the single most surprising data point? If you synthesized across multiple tools, what does the combined picture reveal that no single tool shows alone?
15
+
16
+ Analysis and Chart: You are a master quantitative analyst. Based on all the data you've gathered, what is the most meaningful analysis? Create the most compelling possible chart(s). When the data spans multiple distinct dimensions, splitting into focused charts often produces clearer visuals. Each chart should capture one dimension of the story clearly. Charts must have clean labels, readable legends, and professional styling. Include a labeled chart summary for EACH chart produced, describing what data it shows and the key visual takeaway.
@@ -397,57 +397,89 @@ def _make_done_stream_response(result: dict[str, Any]) -> _FakeStreamResponse:
397
397
  )
398
398
 
399
399
 
400
+ MOCK_RUN_JOB_ID = "11111111-1111-4111-8111-111111111111"
401
+
402
+
403
+ def _job_start_payload() -> dict[str, Any]:
404
+ """202 payload returned when the durable job behind run() is started."""
405
+ return {
406
+ "status": "running",
407
+ "jobId": MOCK_RUN_JOB_ID,
408
+ "pollingTool": "context_query_poll",
409
+ "message": "running",
410
+ "progress": None,
411
+ "querySession": None,
412
+ "createdAt": "2026-06-14T00:00:00.000Z",
413
+ "updatedAt": "2026-06-14T00:00:00.000Z",
414
+ }
415
+
416
+
417
+ def _job_completed_payload(result: dict[str, Any]) -> dict[str, Any]:
418
+ """Completed job-status payload carrying the final query result."""
419
+ return {
420
+ "status": "completed",
421
+ "jobId": MOCK_RUN_JOB_ID,
422
+ "progress": None,
423
+ "querySession": None,
424
+ "result": result,
425
+ "error": None,
426
+ "createdAt": "2026-06-14T00:00:00.000Z",
427
+ "updatedAt": "2026-06-14T00:01:00.000Z",
428
+ "completedAt": "2026-06-14T00:01:00.000Z",
429
+ }
430
+
431
+
432
+ def _job_run_side_effect(result: dict[str, Any]) -> list[dict[str, Any]]:
433
+ """Mock the job-start + completed-status pair backing run() since 0.21.0."""
434
+ return [_job_start_payload(), _job_completed_payload(result)]
435
+
436
+
400
437
  # ============================================================================
401
438
  # Tests: query.run()
402
439
  # ============================================================================
403
440
 
404
441
 
405
442
  class TestQueryRun:
406
- """Tests for client.query.run() — consumes SSE and returns the final result."""
443
+ """Tests for client.query.run() — durable job path (start + poll) since 0.21.0."""
407
444
 
408
445
  async def test_sends_correct_request_body_string(self) -> None:
409
- """String shorthand sends query with stream: true."""
446
+ """String shorthand starts a durable job, then polls its status."""
410
447
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
411
448
 
412
- with patch.object(
413
- client, "fetch_stream", new_callable=AsyncMock
414
- ) as mock_stream:
415
- mock_stream.return_value = _make_done_stream_response(MOCK_SUCCESS_RESPONSE)
449
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
450
+ mock_fetch.side_effect = _job_run_side_effect(MOCK_SUCCESS_RESPONSE)
416
451
  await client.query.run("What are the top whale movements?")
417
452
 
418
- mock_stream.assert_called_once_with(
419
- "/api/v1/query",
420
- method="POST",
421
- json_body={
422
- "query": "What are the top whale movements?",
423
- "stream": True,
424
- },
425
- extra_headers=None,
426
- )
453
+ assert mock_fetch.await_count == 2
454
+ start_call = mock_fetch.await_args_list[0]
455
+ assert start_call.args[0] == "/api/v1/query/jobs"
456
+ assert start_call.kwargs["method"] == "POST"
457
+ assert start_call.kwargs["json_body"] == {
458
+ "query": "What are the top whale movements?",
459
+ "stream": False,
460
+ }
461
+ assert start_call.kwargs["extra_headers"] is None
462
+ status_call = mock_fetch.await_args_list[1]
463
+ assert status_call.args[0] == f"/api/v1/query/jobs/{MOCK_RUN_JOB_ID}"
427
464
 
428
465
  async def test_sends_correct_request_body_with_tools(self) -> None:
429
- """Options dict with tools sends tool IDs."""
466
+ """Options dict with tools sends tool IDs to the job start endpoint."""
430
467
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
431
468
 
432
- with patch.object(
433
- client, "fetch_stream", new_callable=AsyncMock
434
- ) as mock_stream:
435
- mock_stream.return_value = _make_done_stream_response(MOCK_SUCCESS_RESPONSE)
469
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
470
+ mock_fetch.side_effect = _job_run_side_effect(MOCK_SUCCESS_RESPONSE)
436
471
  await client.query.run(
437
472
  query="Analyze whale activity",
438
473
  tools=["tool-uuid-1", "tool-uuid-2"],
439
474
  )
440
475
 
441
- mock_stream.assert_called_once_with(
442
- "/api/v1/query",
443
- method="POST",
444
- json_body={
445
- "query": "Analyze whale activity",
446
- "tools": ["tool-uuid-1", "tool-uuid-2"],
447
- "stream": True,
448
- },
449
- extra_headers=None,
450
- )
476
+ start_call = mock_fetch.await_args_list[0]
477
+ assert start_call.args[0] == "/api/v1/query/jobs"
478
+ assert start_call.kwargs["json_body"] == {
479
+ "query": "Analyze whale activity",
480
+ "tools": ["tool-uuid-1", "tool-uuid-2"],
481
+ "stream": False,
482
+ }
451
483
 
452
484
  async def test_forwards_model_and_data_options(self) -> None:
453
485
  """Optional model and data controls are forwarded in request body."""
@@ -461,10 +493,8 @@ class TestQueryRun:
461
493
  "orchestrationMetrics": MOCK_ORCHESTRATION_METRICS,
462
494
  }
463
495
 
464
- with patch.object(
465
- client, "fetch_stream", new_callable=AsyncMock
466
- ) as mock_stream:
467
- mock_stream.return_value = _make_done_stream_response(success_with_data)
496
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
497
+ mock_fetch.side_effect = _job_run_side_effect(success_with_data)
468
498
  result = await client.query.run(
469
499
  query="Analyze whale activity",
470
500
  agent_model_id="kimi-k2.6-model",
@@ -474,20 +504,17 @@ class TestQueryRun:
474
504
  include_developer_trace=True,
475
505
  )
476
506
 
477
- mock_stream.assert_called_once_with(
478
- "/api/v1/query",
479
- method="POST",
480
- json_body={
481
- "query": "Analyze whale activity",
482
- "stream": True,
483
- "agentModelId": "kimi-k2.6-model",
484
- "responseShape": "answer_with_evidence",
485
- "includeData": True,
486
- "includeDataUrl": True,
487
- "includeDeveloperTrace": True,
488
- },
489
- extra_headers=None,
490
- )
507
+ start_call = mock_fetch.await_args_list[0]
508
+ assert start_call.args[0] == "/api/v1/query/jobs"
509
+ assert start_call.kwargs["json_body"] == {
510
+ "query": "Analyze whale activity",
511
+ "stream": False,
512
+ "agentModelId": "kimi-k2.6-model",
513
+ "responseShape": "answer_with_evidence",
514
+ "includeData": True,
515
+ "includeDataUrl": True,
516
+ "includeDeveloperTrace": True,
517
+ }
491
518
  assert result.data == {"summary": "tool output"}
492
519
  assert (
493
520
  result.data_url
@@ -503,10 +530,8 @@ class TestQueryRun:
503
530
  """developerTrace payload is deserialized into QueryResult."""
504
531
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
505
532
 
506
- with patch.object(
507
- client, "fetch_stream", new_callable=AsyncMock
508
- ) as mock_stream:
509
- mock_stream.return_value = _make_done_stream_response(
533
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
534
+ mock_fetch.side_effect = _job_run_side_effect(
510
535
  {
511
536
  **MOCK_SUCCESS_RESPONSE,
512
537
  "developerTrace": MOCK_DEVELOPER_TRACE,
@@ -538,10 +563,8 @@ class TestQueryRun:
538
563
  """Query result keeps developer_trace unset when API omits it."""
539
564
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
540
565
 
541
- with patch.object(
542
- client, "fetch_stream", new_callable=AsyncMock
543
- ) as mock_stream:
544
- mock_stream.return_value = _make_done_stream_response(MOCK_SUCCESS_RESPONSE)
566
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
567
+ mock_fetch.side_effect = _job_run_side_effect(MOCK_SUCCESS_RESPONSE)
545
568
  result = await client.query.run("test query")
546
569
 
547
570
  assert result.developer_trace is None
@@ -550,10 +573,8 @@ class TestQueryRun:
550
573
  """When include_developer_trace is set, SDK synthesizes fallback trace if API omits it."""
551
574
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
552
575
 
553
- with patch.object(
554
- client, "fetch_stream", new_callable=AsyncMock
555
- ) as mock_stream:
556
- mock_stream.return_value = _make_done_stream_response(MOCK_SUCCESS_RESPONSE)
576
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
577
+ mock_fetch.side_effect = _job_run_side_effect(MOCK_SUCCESS_RESPONSE)
557
578
  result = await client.query.run(
558
579
  query="test query",
559
580
  include_developer_trace=True,
@@ -618,10 +639,8 @@ class TestQueryRun:
618
639
  """resume_from is forwarded to the query endpoint."""
619
640
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
620
641
 
621
- with patch.object(
622
- client, "fetch_stream", new_callable=AsyncMock
623
- ) as mock_stream:
624
- mock_stream.return_value = _make_done_stream_response(MOCK_SUCCESS_RESPONSE)
642
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
643
+ mock_fetch.side_effect = _job_run_side_effect(MOCK_SUCCESS_RESPONSE)
625
644
  await client.query.run(
626
645
  query="test query",
627
646
  resume_from={
@@ -630,7 +649,7 @@ class TestQueryRun:
630
649
  },
631
650
  )
632
651
 
633
- body = mock_stream.call_args.kwargs["json_body"]
652
+ body = mock_fetch.await_args_list[0].kwargs["json_body"]
634
653
  assert body["resumeFrom"] == {
635
654
  "sessionId": "11111111-1111-4111-8111-111111111111",
636
655
  "attemptId": "22222222-2222-4222-8222-222222222222",
@@ -658,10 +677,8 @@ class TestQueryRun:
658
677
  },
659
678
  }
660
679
 
661
- with patch.object(
662
- client, "fetch_stream", new_callable=AsyncMock
663
- ) as mock_stream:
664
- mock_stream.return_value = _make_done_stream_response(response)
680
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
681
+ mock_fetch.side_effect = _job_run_side_effect(response)
665
682
  result = await client.query.run("test query")
666
683
 
667
684
  assert result.query_session is not None
@@ -672,35 +689,31 @@ class TestQueryRun:
672
689
  """Explicit idempotency key is forwarded as request header."""
673
690
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
674
691
 
675
- with patch.object(
676
- client, "fetch_stream", new_callable=AsyncMock
677
- ) as mock_stream:
678
- mock_stream.return_value = _make_done_stream_response(MOCK_SUCCESS_RESPONSE)
692
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
693
+ mock_fetch.side_effect = _job_run_side_effect(MOCK_SUCCESS_RESPONSE)
679
694
  await client.query.run(
680
695
  query="Analyze whale activity",
681
696
  tools=["tool-uuid-1"],
682
697
  idempotency_key="7b31f437-61ed-4f76-8a6e-ed2f0766ffb8",
683
698
  )
684
699
 
685
- mock_stream.assert_called_once_with(
686
- "/api/v1/query",
687
- method="POST",
688
- json_body={
689
- "query": "Analyze whale activity",
690
- "tools": ["tool-uuid-1"],
691
- "stream": True,
692
- },
693
- extra_headers={"Idempotency-Key": "7b31f437-61ed-4f76-8a6e-ed2f0766ffb8"},
694
- )
700
+ start_call = mock_fetch.await_args_list[0]
701
+ assert start_call.args[0] == "/api/v1/query/jobs"
702
+ assert start_call.kwargs["json_body"] == {
703
+ "query": "Analyze whale activity",
704
+ "tools": ["tool-uuid-1"],
705
+ "stream": False,
706
+ }
707
+ assert start_call.kwargs["extra_headers"] == {
708
+ "Idempotency-Key": "7b31f437-61ed-4f76-8a6e-ed2f0766ffb8"
709
+ }
695
710
 
696
711
  async def test_parses_success_response_into_query_result(self) -> None:
697
712
  """Successful API response is deserialized into QueryResult."""
698
713
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
699
714
 
700
- with patch.object(
701
- client, "fetch_stream", new_callable=AsyncMock
702
- ) as mock_stream:
703
- mock_stream.return_value = _make_done_stream_response(MOCK_SUCCESS_RESPONSE)
715
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
716
+ mock_fetch.side_effect = _job_run_side_effect(MOCK_SUCCESS_RESPONSE)
704
717
  result = await client.query.run("test query")
705
718
 
706
719
  assert isinstance(result, QueryResult)
@@ -723,10 +736,8 @@ class TestQueryRun:
723
736
  """Structured evidence fields are exposed on QueryResult."""
724
737
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
725
738
 
726
- with patch.object(
727
- client, "fetch_stream", new_callable=AsyncMock
728
- ) as mock_stream:
729
- mock_stream.return_value = _make_done_stream_response(MOCK_EVIDENCE_RESPONSE)
739
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
740
+ mock_fetch.side_effect = _job_run_side_effect(MOCK_EVIDENCE_RESPONSE)
730
741
  result = await client.query.run(
731
742
  query="Where is BTC flowing today?",
732
743
  response_shape="evidence_only",
@@ -746,10 +757,8 @@ class TestQueryRun:
746
757
  """Shared SDK fixture preserves computed artifacts and grounding."""
747
758
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
748
759
 
749
- with patch.object(
750
- client, "fetch_stream", new_callable=AsyncMock
751
- ) as mock_stream:
752
- mock_stream.return_value = _make_done_stream_response(
760
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
761
+ mock_fetch.side_effect = _job_run_side_effect(
753
762
  SHARED_QUERY_FIXTURE["groundedAnswer"]
754
763
  )
755
764
  result = await client.query.run(
@@ -864,10 +873,8 @@ class TestQueryRun:
864
873
  ],
865
874
  }
866
875
 
867
- with patch.object(
868
- client, "fetch_stream", new_callable=AsyncMock
869
- ) as mock_stream:
870
- mock_stream.return_value = _make_done_stream_response(response)
876
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
877
+ mock_fetch.side_effect = _job_run_side_effect(response)
871
878
  result = await client.query.run("Render richer chart artifacts")
872
879
 
873
880
  assert result.computed_artifacts is not None
@@ -913,10 +920,8 @@ class TestQueryRun:
913
920
  },
914
921
  ],
915
922
  }
916
- with patch.object(
917
- client, "fetch_stream", new_callable=AsyncMock
918
- ) as mock_stream:
919
- mock_stream.return_value = _make_done_stream_response(response)
923
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
924
+ mock_fetch.side_effect = _job_run_side_effect(response)
920
925
  result = await client.query.run("Render a chart image")
921
926
 
922
927
  assert result.computed_artifacts is not None
@@ -934,10 +939,8 @@ class TestQueryRun:
934
939
  """Ungrounded runtime outcomes deserialize as capability_miss."""
935
940
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
936
941
 
937
- with patch.object(
938
- client, "fetch_stream", new_callable=AsyncMock
939
- ) as mock_stream:
940
- mock_stream.return_value = _make_done_stream_response(
942
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
943
+ mock_fetch.side_effect = _job_run_side_effect(
941
944
  SHARED_QUERY_FIXTURE["ungroundedCapabilityMiss"]
942
945
  )
943
946
  result = await client.query.run(
@@ -957,10 +960,8 @@ class TestQueryRun:
957
960
  """Structured capability miss results deserialize into QueryResult."""
958
961
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
959
962
 
960
- with patch.object(
961
- client, "fetch_stream", new_callable=AsyncMock
962
- ) as mock_stream:
963
- mock_stream.return_value = _make_done_stream_response(
963
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
964
+ mock_fetch.side_effect = _job_run_side_effect(
964
965
  MOCK_CAPABILITY_MISS_RESULT
965
966
  )
966
967
  result = await client.query.run(
@@ -977,43 +978,50 @@ class TestQueryRun:
977
978
  ]
978
979
  assert len(result.capability_miss.suggested_rewrites) == 3
979
980
 
980
- async def test_raises_if_run_stream_ends_before_done_event(self) -> None:
981
- """Run raises when the stream ends without a done event."""
981
+ async def test_raises_if_job_completes_without_result(self) -> None:
982
+ """Run raises when the job completes but carries no result payload."""
982
983
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
983
984
 
984
- with patch.object(
985
- client, "fetch_stream", new_callable=AsyncMock
986
- ) as mock_stream:
987
- mock_stream.return_value = _FakeStreamResponse(["data: [DONE]"])
985
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
986
+ mock_fetch.side_effect = [
987
+ _job_start_payload(),
988
+ {
989
+ **_job_completed_payload(MOCK_SUCCESS_RESPONSE),
990
+ "result": None,
991
+ },
992
+ ]
988
993
 
989
994
  with pytest.raises(ContextError) as exc_info:
990
995
  await client.query.run("test query")
991
996
 
992
- assert "Streaming query ended before done event" in str(exc_info.value)
997
+ assert "completed without a result" in str(exc_info.value)
993
998
 
994
- async def test_raises_context_error_on_stream_error_event(self) -> None:
995
- """Run raises the terminal SSE error when no done event arrives."""
999
+ async def test_raises_context_error_when_job_fails(self) -> None:
1000
+ """Run raises the failed job's error message."""
996
1001
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
997
1002
 
998
- with patch.object(
999
- client, "fetch_stream", new_callable=AsyncMock
1000
- ) as mock_stream:
1001
- mock_stream.return_value = _FakeStreamResponse(MOCK_SSE_ERROR_LINES)
1003
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
1004
+ mock_fetch.side_effect = [
1005
+ _job_start_payload(),
1006
+ {
1007
+ **_job_completed_payload(MOCK_SUCCESS_RESPONSE),
1008
+ "status": "failed",
1009
+ "result": None,
1010
+ "error": "Query failed before completion (query_failed)",
1011
+ },
1012
+ ]
1002
1013
 
1003
1014
  with pytest.raises(ContextError) as exc_info:
1004
1015
  await client.query.run("test query")
1005
1016
 
1006
1017
  assert "Query failed before completion" in str(exc_info.value)
1007
- assert exc_info.value.code == "query_failed"
1008
1018
 
1009
1019
  async def test_raises_context_error_on_no_wallet(self) -> None:
1010
1020
  """no_wallet error is propagated correctly."""
1011
1021
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
1012
1022
 
1013
- with patch.object(
1014
- client, "fetch_stream", new_callable=AsyncMock
1015
- ) as mock_stream:
1016
- mock_stream.side_effect = ContextError(
1023
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
1024
+ mock_fetch.side_effect = ContextError(
1017
1025
  message="Account not fully set up.",
1018
1026
  code="no_wallet",
1019
1027
  status_code=400,
@@ -1028,10 +1036,8 @@ class TestQueryRun:
1028
1036
  """query_failed error is propagated correctly."""
1029
1037
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
1030
1038
 
1031
- with patch.object(
1032
- client, "fetch_stream", new_callable=AsyncMock
1033
- ) as mock_stream:
1034
- mock_stream.side_effect = ContextError(
1039
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
1040
+ mock_fetch.side_effect = ContextError(
1035
1041
  message="Query failed: Tool execution timed out",
1036
1042
  code="query_failed",
1037
1043
  status_code=422,
@@ -1046,10 +1052,8 @@ class TestQueryRun:
1046
1052
  """HTTP errors before stream setup still propagate correctly."""
1047
1053
  client = ContextClient(api_key="ctx_test_key_1234567890abcdef12345678")
1048
1054
 
1049
- with patch.object(
1050
- client, "fetch_stream", new_callable=AsyncMock
1051
- ) as mock_stream:
1052
- mock_stream.side_effect = ContextError(
1055
+ with patch.object(client, "fetch", new_callable=AsyncMock) as mock_fetch:
1056
+ mock_fetch.side_effect = ContextError(
1053
1057
  message="Insufficient funds. Set a spending cap in the dashboard.",
1054
1058
  code="insufficient_allowance",
1055
1059
  status_code=402,
File without changes
File without changes
File without changes