vis-agent 0.2.38.dev7570__tar.gz → 0.2.39__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 (108) hide show
  1. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/PKG-INFO +1 -1
  2. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/gateway.json +4 -0
  3. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/toggle.json +3 -0
  4. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/pyproject.toml +1 -1
  5. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/_contracts.py +21 -0
  6. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/_outside.py +122 -4
  7. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/engine/_agent.py +15 -1
  8. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/extension.py +227 -15
  9. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_agent.py +15 -0
  10. vis_agent-0.2.39/tests/test_attachments.py +109 -0
  11. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_live_primitives.py +25 -0
  12. vis_agent-0.2.39/tests/test_read_session.py +55 -0
  13. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_scoped_settings.py +17 -0
  14. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/.gitignore +0 -0
  15. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/LICENSE +0 -0
  16. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/README.md +0 -0
  17. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity-arguments.json +0 -0
  18. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity-cases.json +0 -0
  19. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity-copy.json +0 -0
  20. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity-execution.json +0 -0
  21. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity-groups.json +0 -0
  22. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity-read-session.json +0 -0
  23. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity-reads.json +0 -0
  24. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity-results.json +0 -0
  25. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity-tables.json +0 -0
  26. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/activity.json +0 -0
  27. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/diff.json +0 -0
  28. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/live-primitives.json +0 -0
  29. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/session-health.json +0 -0
  30. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/fixtures/view.json +0 -0
  31. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/activity.json +0 -0
  32. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/agents.json +0 -0
  33. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/automations.json +0 -0
  34. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/common.json +0 -0
  35. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/config.json +0 -0
  36. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/content.json +0 -0
  37. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/council.json +0 -0
  38. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/diff.json +0 -0
  39. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/improve.json +0 -0
  40. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/provider.json +0 -0
  41. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/rooms.json +0 -0
  42. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/symbol.json +0 -0
  43. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/contracts/schema/view.json +0 -0
  44. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/examples/greeter/tests/test_greeter.py +0 -0
  45. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/hatch_build.py +0 -0
  46. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/__init__.py +0 -0
  47. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/_wire.py +0 -0
  48. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/activity.py +0 -0
  49. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/engine/__init__.py +0 -0
  50. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/engine/_agents.py +0 -0
  51. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/engine/_client.py +0 -0
  52. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/engine/_council.py +0 -0
  53. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/engine/_extensions.py +0 -0
  54. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/engine/_local.py +0 -0
  55. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/evals/__init__.py +0 -0
  56. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/evals/harbor.py +0 -0
  57. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/evals/stats.py +0 -0
  58. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/evals/trajectory.py +0 -0
  59. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/extension_package.py +0 -0
  60. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/py.typed +0 -0
  61. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/rooms.py +0 -0
  62. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/src/blockether/vis/views.py +0 -0
  63. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/conftest.py +0 -0
  64. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_activity.py +0 -0
  65. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_agent_extension_lifecycle.py +0 -0
  66. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_agent_extensions.py +0 -0
  67. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_agents.py +0 -0
  68. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_api_guides.py +0 -0
  69. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_authoring.py +0 -0
  70. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_catalog.py +0 -0
  71. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_catalog_recipe.py +0 -0
  72. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_ci_report_recipe.py +0 -0
  73. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_client.py +0 -0
  74. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_client_extensions_engine.py +0 -0
  75. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_code_quality_recipe.py +0 -0
  76. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_contracts.py +0 -0
  77. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_council.py +0 -0
  78. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_declarations.py +0 -0
  79. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_engine.py +0 -0
  80. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_engine_docstrings.py +0 -0
  81. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_evals_harbor.py +0 -0
  82. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_evals_stats.py +0 -0
  83. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_evals_trajectory.py +0 -0
  84. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_extension_package.py +0 -0
  85. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_extension_reference.py +0 -0
  86. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_gateway_performance.py +0 -0
  87. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_goals.py +0 -0
  88. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_live_wait.py +0 -0
  89. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_local.py +0 -0
  90. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_method_typing.py +0 -0
  91. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_monitor_recipe.py +0 -0
  92. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_namespace.py +0 -0
  93. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_outside.py +0 -0
  94. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_providers.py +0 -0
  95. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_record_arguments.py +0 -0
  96. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_registration.py +0 -0
  97. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_repair_hooks.py +0 -0
  98. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_result_sequence.py +0 -0
  99. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_rooms.py +0 -0
  100. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_sdk_bootstrap.py +0 -0
  101. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_sdk_guide.py +0 -0
  102. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_sdk_typing.py +0 -0
  103. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_structured_live.py +0 -0
  104. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_symbol_contract.py +0 -0
  105. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_testing.py +0 -0
  106. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_text_list_arguments.py +0 -0
  107. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_tools.py +0 -0
  108. {vis_agent-0.2.38.dev7570 → vis_agent-0.2.39}/tests/test_views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vis-agent
3
- Version: 0.2.38.dev7570
3
+ Version: 0.2.39
4
4
  Summary: The Vis Python SDK — author extensions and control local or remote engines.
5
5
  Project-URL: Homepage, https://vis.blockether.com
6
6
  Project-URL: Documentation, https://vis.blockether.com
@@ -2732,6 +2732,10 @@
2732
2732
  "type": "string"
2733
2733
  }
2734
2734
  },
2735
+ "change_error": {
2736
+ "type": "string",
2737
+ "description": "Present after a write when the owner's change hook failed. The value stays saved."
2738
+ },
2735
2739
  "parent": {
2736
2740
  "type": "string",
2737
2741
  "description": "Id of the setting that this row belongs under."
@@ -100,6 +100,9 @@
100
100
  "minItems": 1
101
101
  },
102
102
  "visible_fn": {},
103
+ "on_change_fn": {
104
+ "description": "Local callback that receives the id, old value, new value and scope once after a write changes the effective value."
105
+ },
103
106
  "scopes": {
104
107
  "type": "array",
105
108
  "items": {
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
8
8
 
9
9
  [project]
10
10
  name = "vis-agent"
11
- version = "0.2.38.dev7570"
11
+ version = "0.2.39"
12
12
  description = "The Vis Python SDK — author extensions and control local or remote engines."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -41,6 +41,27 @@ def definition(name: str, key: str) -> dict[str, Any]:
41
41
  return schema(name)["$defs"][key]
42
42
 
43
43
 
44
+ # An appended live item replaces the item with its id. The host fills these keys
45
+ # when the item leaves them out; every other required key must come with it.
46
+ _LIVE_ITEM_DEFAULTS = {"step": {"tone": "idle"}, "link": {"target_kind": "url"}}
47
+ _LIVE_ITEMS = {"stats": "stat", "steps": "step", "rows": "row", "links": "link"}
48
+
49
+
50
+ def live_items(key: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
51
+ """Return appended live items as the host keeps them; refuse a missing key."""
52
+ item = _LIVE_ITEMS[key]
53
+ required = definition("view", item)["required"]
54
+ result = []
55
+ for one in items:
56
+ one = {**_LIVE_ITEM_DEFAULTS.get(item, {}), **one}
57
+ if missing := [name for name in required if name not in one]:
58
+ raise ValueError(
59
+ f"{item} {one.get('id')}: {', '.join(missing)} is missing (required)"
60
+ )
61
+ result.append(one)
62
+ return result
63
+
64
+
44
65
  @lru_cache(maxsize=64)
45
66
  def _validator(name: str, definition: str):
46
67
  from jsonschema import Draft202012Validator, FormatChecker
@@ -8,6 +8,7 @@ primes prompt values. With `VIS_OUTSIDE_NONINTERACTIVE=1`, prompts return
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import base64
11
12
  import json
12
13
  import os
13
14
  import secrets
@@ -81,6 +82,22 @@ def call_tool(tool, args, kwargs):
81
82
  )
82
83
 
83
84
 
85
+ def read_session(target):
86
+ """Refuse a session read without a bound Vis session and its database."""
87
+ raise Refused(
88
+ "vis.read_session needs a bound Vis session. Outside an extension, read the "
89
+ "session with the SDK client's get_session_transcript(session_id)."
90
+ )
91
+
92
+
93
+ def list_sessions(search):
94
+ """Refuse a session search without a bound Vis session and its database."""
95
+ raise Refused(
96
+ "vis.list_sessions needs a bound Vis session. Outside an extension, list the "
97
+ "sessions with the SDK client's list_sessions()."
98
+ )
99
+
100
+
84
101
  # -- Where the outside host keeps things --------------------------------------
85
102
 
86
103
 
@@ -212,6 +229,93 @@ def forget_secret(handle):
212
229
  return _VAULT.pop(str(handle), None) is not None
213
230
 
214
231
 
232
+ # -- Attachments ----------------------------------------------------------------
233
+ # Outside Vis, no session stores artifacts. They stay in this process memory, so
234
+ # an extension test can attach an artifact and read it back.
235
+
236
+ _ARTIFACTS = []
237
+ _ARTIFACT_BYTES = {}
238
+
239
+
240
+ def attach(options):
241
+ """Store one artifact in memory and return its descriptor as JSON."""
242
+ data = base64.b64decode(options.get("base64") or "")
243
+ if not data:
244
+ raise ValueError("vis.attach: empty payload.")
245
+ name = options.get("filename") or "artifact"
246
+ media_type = (
247
+ options.get("media_type")
248
+ or options.get("media_type_hint")
249
+ or "application/octet-stream"
250
+ )
251
+ with _LOCK:
252
+ row = {
253
+ "id": uuid.uuid4().hex,
254
+ "source": "tool",
255
+ "filename": name,
256
+ "version": 1 + sum(1 for r in _ARTIFACTS if r["filename"] == name),
257
+ "media_type": media_type,
258
+ "kind": options.get("kind")
259
+ or ("image" if media_type.startswith("image/") else "file"),
260
+ "size": len(data),
261
+ "is_pending": True,
262
+ "audience": options.get("audience") or "both",
263
+ "commentable": options.get("commentable") is True,
264
+ }
265
+ _ARTIFACTS.append(row)
266
+ _ARTIFACT_BYTES[row["id"]] = data
267
+ return json.dumps(row)
268
+
269
+
270
+ def list_attachments():
271
+ """List the in-memory artifact descriptors as JSON, oldest first."""
272
+ with _LOCK:
273
+ return json.dumps(_ARTIFACTS)
274
+
275
+
276
+ def _artifact(caller, target, version):
277
+ # The same addressing as the engine: an id, or a filename with an optional
278
+ # version, where a negative version counts back from the latest one.
279
+ with _LOCK:
280
+ if version is None:
281
+ for row in _ARTIFACTS:
282
+ if row["id"] == target:
283
+ return row
284
+ versions = sorted(
285
+ (r for r in _ARTIFACTS if r["filename"] == target),
286
+ key=lambda r: r["version"],
287
+ )
288
+ if versions:
289
+ if version is None:
290
+ return versions[-1]
291
+ if -len(versions) <= version <= -1:
292
+ return versions[version]
293
+ for row in versions:
294
+ if row["version"] == version:
295
+ return row
296
+ raise LookupError(f"{caller}: {target!r} has no version {version}.")
297
+ raise LookupError(f"{caller}: unknown id or filename {target!r}.")
298
+
299
+
300
+ def get_attachment(target, version):
301
+ """Return one in-memory artifact descriptor as JSON."""
302
+ return json.dumps(_artifact("get_attachment", target, version))
303
+
304
+
305
+ def read_attachment(target, version):
306
+ """Return the bytes of one in-memory artifact as base64 text."""
307
+ row = _artifact("read_attachment", target, version)
308
+ return base64.b64encode(_ARTIFACT_BYTES[row["id"]]).decode("ascii")
309
+
310
+
311
+ def show_attachment(target, version):
312
+ """Accept an image. Outside Vis, no model request follows to show it to."""
313
+ row = _artifact("show_attachment", target, version)
314
+ if not row["media_type"].startswith("image/"):
315
+ raise ValueError(f"show_attachment: {row['filename']!r} is not an image.")
316
+ return [row["id"], row["filename"], row["media_type"], row["size"]]
317
+
318
+
215
319
  # -- Environment --------------------------------------------------------------
216
320
 
217
321
 
@@ -1037,9 +1141,10 @@ def _live_find(nodes, node_id):
1037
1141
  return None
1038
1142
 
1039
1143
 
1040
- def _live_upsert(held, incoming):
1144
+ def _live_upsert(held, incoming, *, is_merged=False):
1041
1145
  # An item keeps the position it was first given: a row that updates in place
1042
- # is what makes a table readable while it fills.
1146
+ # is what makes a table readable while it fills. The host replaces an item;
1147
+ # only a group declaration merges onto the one before it.
1043
1148
  merged = list(held or [])
1044
1149
  for item in incoming or []:
1045
1150
  item_id = item.get("id") if isinstance(item, dict) else None
@@ -1051,7 +1156,7 @@ def _live_upsert(held, incoming):
1051
1156
  if at is None:
1052
1157
  merged.append(item)
1053
1158
  else:
1054
- merged[at] = {**merged[at], **item}
1159
+ merged[at] = {**merged[at], **item} if is_merged else item
1055
1160
  return merged
1056
1161
 
1057
1162
 
@@ -1144,12 +1249,18 @@ def _live_apply(view, op):
1144
1249
  )
1145
1250
  node[key] = list(node.get(key) or []) + payload
1146
1251
  else:
1252
+ try:
1253
+ payload = _contracts.live_items(key, payload)
1254
+ except ValueError as error:
1255
+ raise Refused(str(error)) from None
1147
1256
  node[key] = _live_upsert(node.get(key), payload)
1148
1257
  if "groups" in op:
1149
1258
  # A table upserts its group DECLARATIONS in the same append that
1150
1259
  # carries the rows hanging under them, and a second declaration
1151
1260
  # merges onto the first: re-toning a head keeps its label.
1152
- node["groups"] = _live_upsert(node.get("groups"), op["groups"])
1261
+ node["groups"] = _live_upsert(
1262
+ node.get("groups"), op["groups"], is_merged=True
1263
+ )
1153
1264
  elif name == "clear":
1154
1265
  node[_live_items_key(node, "clear")] = []
1155
1266
  if node.get("type") == "log":
@@ -1332,12 +1443,19 @@ _IMPLEMENTATIONS = {
1332
1443
  "jailed_shell": jailed_shell,
1333
1444
  "council_wake": council_wake,
1334
1445
  "call_tool": call_tool,
1446
+ "read_session": read_session,
1447
+ "list_sessions": list_sessions,
1335
1448
  "request_input": request_input,
1336
1449
  "live": live,
1337
1450
  "activity": lambda blocks: False,
1338
1451
  "reveal_secret": reveal_secret,
1339
1452
  "forget_secret": forget_secret,
1340
1453
  "declare_env": declare_env,
1454
+ "attach": attach,
1455
+ "list_attachments": list_attachments,
1456
+ "get_attachment": get_attachment,
1457
+ "read_attachment": read_attachment,
1458
+ "show_attachment": show_attachment,
1341
1459
  }
1342
1460
 
1343
1461
 
@@ -377,6 +377,10 @@ class Agent:
377
377
  extensions: Iterable of `blockether.vis.extension.Extension` declarations.
378
378
  Their Python callables stay in your application and execute on the
379
379
  calling thread while you read, wait or iterate events.
380
+ tools: Optional iterable of Vis extension tool names for the conversation.
381
+ Names such as `"py.lint_code"` or `"py.repl_*"` keep only those tools of
382
+ the extensions they name. Names such as `"-py.repl_stop"` turn tools off.
383
+ Do not mix the two forms. The gateway refuses an unknown name.
380
384
 
381
385
  Construction does not start a process or connect. Session access, context
382
386
  entry, `send` or `run` starts the conversation. Closing the default agent stops
@@ -386,7 +390,8 @@ class Agent:
386
390
 
387
391
  Raises:
388
392
  TypeError: The supplied layer does not implement
389
- `blockether.vis.engine.ExecutionLayer`, or an extension is unsupported.
393
+ `blockether.vis.engine.ExecutionLayer`, an extension is unsupported, or
394
+ `tools` is not an iterable of strings.
390
395
  ValueError: A project or extension declaration is invalid.
391
396
  FileNotFoundError: The local project does not exist.
392
397
  NotADirectoryError: The local project is not a directory.
@@ -398,10 +403,17 @@ class Agent:
398
403
  *,
399
404
  execution_layer: ExecutionLayer | None = None,
400
405
  extensions=(),
406
+ tools=None,
401
407
  ):
402
408
  from ._extensions import ClientExtensions
403
409
 
404
410
  self._extensions = ClientExtensions(extensions)
411
+ if tools is not None:
412
+ if isinstance(tools, str):
413
+ raise TypeError("tools must be an iterable of tool names, not a string")
414
+ tools = list(tools)
415
+ if not all(isinstance(name, str) for name in tools):
416
+ raise TypeError("tools must be an iterable of tool names")
405
417
  if execution_layer is not None and not isinstance(
406
418
  execution_layer, ExecutionLayer
407
419
  ):
@@ -411,6 +423,8 @@ class Agent:
411
423
  LocalEngine(root=project) if execution_layer is None else execution_layer
412
424
  )
413
425
  self._session_options = self.execution_layer.session_options(project)
426
+ if tools is not None:
427
+ self._session_options["tools"] = tools
414
428
  self.project = self._session_options["root"]
415
429
  self._session = None
416
430
  self._closed = False
@@ -9,10 +9,14 @@ from __future__ import annotations
9
9
  import __future__
10
10
 
11
11
  import ast
12
+ import base64
12
13
  import builtins
13
14
  import inspect
15
+ import io
14
16
  import json
15
17
  import math
18
+ import mimetypes
19
+ import os
16
20
  import sys
17
21
  import time
18
22
  from collections.abc import Callable, Mapping, Sequence
@@ -138,6 +142,34 @@ class Host(Protocol):
138
142
  """Run one active session tool by the name `python_execution` calls."""
139
143
  ...
140
144
 
145
+ def read_session(self, target: str | None) -> str:
146
+ """Read one whole session as the sandbox `read_session` does. Return JSON."""
147
+ ...
148
+
149
+ def list_sessions(self, search: str | None) -> str:
150
+ """List sessions as the sandbox `list_sessions` does. Return JSON."""
151
+ ...
152
+
153
+ def attach(self, options: Mapping[str, Any]) -> str:
154
+ """Store one artifact of the running block. Return its descriptor as JSON."""
155
+ ...
156
+
157
+ def list_attachments(self) -> str:
158
+ """List the session's artifact descriptors as JSON, pending ones included."""
159
+ ...
160
+
161
+ def get_attachment(self, target: str, version: int | None) -> str:
162
+ """Return the descriptor of one artifact as JSON."""
163
+ ...
164
+
165
+ def read_attachment(self, target: str, version: int | None) -> str:
166
+ """Return the bytes of one artifact as base64 text."""
167
+ ...
168
+
169
+ def show_attachment(self, target: str, version: int | None) -> Sequence[Any]:
170
+ """Queue a stored image for the next model request: id, filename, type, size."""
171
+ ...
172
+
141
173
 
142
174
  # The engine seeds `_host` into the module dict before exec.
143
175
  _host: Host
@@ -1007,6 +1039,9 @@ class Setting(Generic[_SettingValue]):
1007
1039
  for a choice setting. Outside Vis, it returns the declared default.
1008
1040
  `parent` names another setting of the same section. Settings shows this setting
1009
1041
  under that setting. Settings can nest at any depth.
1042
+ `on_change(old, new, scope)` runs once after a person changes the setting in the
1043
+ app or TUI and its effective value changes. `scope` is `global`, `project`, `group`
1044
+ or `session`. Vis keeps the saved value if the hook raises, and shows the error.
1010
1045
  """
1011
1046
 
1012
1047
  id: str
@@ -1017,6 +1052,7 @@ class Setting(Generic[_SettingValue]):
1017
1052
  scopes: Sequence[str] | None = None
1018
1053
  description: str | None = None
1019
1054
  parent: str | None = None
1055
+ on_change: Callable[[Any, Any, str], Any] | None = None
1020
1056
 
1021
1057
  def __post_init__(self):
1022
1058
  for name, value in (("scopes", self.scopes), ("choices", self.choices)):
@@ -1025,7 +1061,11 @@ class Setting(Generic[_SettingValue]):
1025
1061
  if not isinstance(value, (list, tuple)):
1026
1062
  raise TypeError(f"Setting {name} must be a list or tuple")
1027
1063
  object.__setattr__(self, name, tuple(value))
1028
- spec = _host.setting_declaration(self._spec())
1064
+ if self.on_change is not None and not callable(self.on_change):
1065
+ raise TypeError("Setting on_change must be callable")
1066
+ declaration = self._spec()
1067
+ declaration.pop("on_change", None)
1068
+ spec = _host.setting_declaration(declaration)
1029
1069
  object.__setattr__(self, "scopes", tuple(spec["scopes"]))
1030
1070
  if self.type == "boolean" and type(self.default) is not bool:
1031
1071
  raise ValueError("A boolean setting requires a boolean default")
@@ -1054,6 +1094,8 @@ class Setting(Generic[_SettingValue]):
1054
1094
  spec["description"] = self.description
1055
1095
  if self.parent is not None:
1056
1096
  spec["parent"] = self.parent
1097
+ if self.on_change is not None:
1098
+ spec["on_change"] = self.on_change
1057
1099
  return spec
1058
1100
 
1059
1101
  def value(self) -> _SettingValue:
@@ -3796,6 +3838,176 @@ def forget(handle):
3796
3838
  return bool(_host.forget_secret(str(handle)))
3797
3839
 
3798
3840
 
3841
+ # -- Attachments ----------------------------------------------------------------
3842
+ # An extension tool stores and reads artifacts like the sandbox `attach` verbs.
3843
+ # The engine binds each call to the python_execution block that runs the tool.
3844
+
3845
+ _ATTACH_AUDIENCES = ("both", "user", "model")
3846
+
3847
+ # Pillow encoders by filename suffix, as in the sandbox `attach` verb.
3848
+ _PIL_FORMATS = {
3849
+ ".png": "PNG",
3850
+ ".jpg": "JPEG",
3851
+ ".jpeg": "JPEG",
3852
+ ".gif": "GIF",
3853
+ ".bmp": "BMP",
3854
+ ".webp": "WEBP",
3855
+ ".tif": "TIFF",
3856
+ ".tiff": "TIFF",
3857
+ }
3858
+
3859
+
3860
+ def _attachment_row(row):
3861
+ # The engine sends kebab-case keys; the SDK returns snake_case keys.
3862
+ return {str(k).replace("-", "_"): v for k, v in dict(row).items()}
3863
+
3864
+
3865
+ def _attachment_source(source, filename):
3866
+ """Return `(bytes, filename)` for a path, bytes, a figure or a Pillow image."""
3867
+ if isinstance(source, (bytes, bytearray, memoryview)):
3868
+ return bytes(source), str(filename) if filename else "artifact"
3869
+ if hasattr(source, "savefig"):
3870
+ buffer = io.BytesIO()
3871
+ source.savefig(buffer, format="png")
3872
+ return buffer.getvalue(), str(filename) if filename else "figure.png"
3873
+ if hasattr(source, "save") and hasattr(source, "mode") and hasattr(source, "size"):
3874
+ name = str(filename) if filename else "image.png"
3875
+ buffer = io.BytesIO()
3876
+ source.save(buffer, format=_PIL_FORMATS.get(Path(name).suffix.lower(), "PNG"))
3877
+ return buffer.getvalue(), name
3878
+ if not isinstance(source, (str, PathLike)):
3879
+ raise TypeError(
3880
+ "vis.attach: source must be a path, bytes, an image or a figure object, got "
3881
+ + type(source).__name__
3882
+ )
3883
+ path = Path(os.path.expandvars(os.path.expanduser(os.fspath(source))))
3884
+ if path.is_dir():
3885
+ raise IsADirectoryError(f"vis.attach: {path} is a directory - attach one file")
3886
+ try:
3887
+ data = path.read_bytes()
3888
+ except FileNotFoundError:
3889
+ raise FileNotFoundError(f"vis.attach: no such file: {path}") from None
3890
+ return data, str(filename) if filename else (path.name or "artifact")
3891
+
3892
+
3893
+ def _attachment_target(target, version):
3894
+ if isinstance(target, Mapping):
3895
+ target = target.get("id") or target.get("filename") or ""
3896
+ if version is not None and (type(version) is not int):
3897
+ raise TypeError("attachment version must be an integer")
3898
+ return str(target), version
3899
+
3900
+
3901
+ def attach(
3902
+ source,
3903
+ filename=None,
3904
+ *,
3905
+ kind=None,
3906
+ media_type=None,
3907
+ label=None,
3908
+ audience="both",
3909
+ commentable=False,
3910
+ ):
3911
+ """Store an artifact as a durable attachment of the running block.
3912
+
3913
+ `source` is a path, bytes, a Matplotlib figure or a Pillow image. The engine
3914
+ stores the artifact with the `python_execution` block that called this tool,
3915
+ exactly like the sandbox `attach` verb. A second attachment under the same
3916
+ `filename` is the next version of that artifact. `audience` is `both`, `user`
3917
+ or `model`; a PDF or HTML document is for the user only. Set
3918
+ `commentable=True` to let a person comment on this version.
3919
+
3920
+ Returns the descriptor dict: id, filename, version, media_type, kind, size,
3921
+ audience and commentable. Every read function takes it as its target.
3922
+ """
3923
+ data, name = _attachment_source(source, filename)
3924
+ if audience not in _ATTACH_AUDIENCES:
3925
+ raise ValueError(
3926
+ f"vis.attach: audience must be 'both', 'user' or 'model', got {audience!r}"
3927
+ )
3928
+ if not isinstance(commentable, bool):
3929
+ raise TypeError("vis.attach: commentable must be a boolean")
3930
+ if not data:
3931
+ raise ValueError("vis.attach: empty payload.")
3932
+ options = {
3933
+ "base64": base64.b64encode(data).decode("ascii"),
3934
+ "filename": name,
3935
+ "media_type": media_type,
3936
+ "media_type_hint": mimetypes.guess_type(name)[0],
3937
+ "kind": kind,
3938
+ "audience": audience,
3939
+ "label": label,
3940
+ "commentable": commentable,
3941
+ }
3942
+ payload = _host.attach({k: v for k, v in options.items() if v is not None})
3943
+ return _attachment_row(json.loads(payload))
3944
+
3945
+
3946
+ def list_attachments(name=None):
3947
+ """List the artifact descriptors of this session, oldest first.
3948
+
3949
+ The list includes the artifacts that the running block attached. With `name`,
3950
+ it lists the versions of that one artifact.
3951
+ """
3952
+ rows = [_attachment_row(row) for row in json.loads(_host.list_attachments())]
3953
+ if name is None:
3954
+ return rows
3955
+ versions = [row for row in rows if row.get("filename") == str(name)]
3956
+ return sorted(versions, key=lambda row: int(row.get("version") or 1))
3957
+
3958
+
3959
+ def get_attachment(target, version=None):
3960
+ """Return the descriptor of one artifact, without its bytes.
3961
+
3962
+ `target` is a filename, an id or a descriptor. A filename means the latest
3963
+ version, unless you give `version`. A negative version counts back.
3964
+ """
3965
+ payload = _host.get_attachment(*_attachment_target(target, version))
3966
+ return _attachment_row(json.loads(payload))
3967
+
3968
+
3969
+ def read_attachment(target, version=None):
3970
+ """Return the bytes of one artifact. `target` works as in `get_attachment`."""
3971
+ encoded = _host.read_attachment(*_attachment_target(target, version))
3972
+ return base64.b64decode(encoded) if encoded else b""
3973
+
3974
+
3975
+ def show_attachment(target, version=None):
3976
+ """Show a stored image to the model for the next request only.
3977
+
3978
+ `target` works as in `get_attachment`. Returns the id, filename, media_type
3979
+ and size of the queued image.
3980
+ """
3981
+ shown = list(_host.show_attachment(*_attachment_target(target, version)))
3982
+ return dict(zip(("id", "filename", "media_type", "size"), shown, strict=True))
3983
+
3984
+
3985
+ def read_session(target=None):
3986
+ """Read one whole session, exactly as the sandbox `read_session` does.
3987
+
3988
+ Without `target`, it reads the session that runs the extension. `target` takes a
3989
+ bare id, an unambiguous prefix or the copied `vis_session_id#<uuid>` marker. The
3990
+ `introspection` toggle does not apply, because an extension is trusted code.
3991
+
3992
+ Returns the same string-keyed dict: session, current_turn, failures, diagnosis,
3993
+ session_forks, turn_retries, usage and transcript.
3994
+ """
3995
+ return json.loads(_host.read_session(None if target is None else str(target)))
3996
+
3997
+
3998
+ def list_sessions(search=None):
3999
+ """List sessions newest first, exactly as the sandbox `list_sessions` does.
4000
+
4001
+ With `search`, it runs the session search of the TUI and the Companion app. The
4002
+ rows come in rank order: title, request, reply, then thinking, newest first in
4003
+ each rank. Keep that order. The `introspection` toggle does not apply.
4004
+
4005
+ Returns string-keyed rows: id, channel, title, goal, turn_count, created_at and
4006
+ modified_at. A match adds rank, the is_in_* flags and optional snippets.
4007
+ """
4008
+ return json.loads(_host.list_sessions(None if search is None else str(search)))
4009
+
4010
+
3799
4011
  # -- Live views ---------------------------------------------------------------
3800
4012
  # A form PAUSES a run to collect values; a live view REPORTS on work already
3801
4013
  # running. Nothing here blocks: a push crosses to the engine, every mounted
@@ -3908,8 +4120,9 @@ class Progress(_Node):
3908
4120
 
3909
4121
 
3910
4122
  class Stat(_Node):
3911
- def set(self, stat_id, value_text, label=None, tone=None):
3912
- # One counter of the strip, upserted by id.
4123
+ def set(self, stat_id, value_text, label, tone=None):
4124
+ # One counter of the strip, upserted by id. The View schema requires
4125
+ # the label in each update.
3913
4126
  return self._op(
3914
4127
  "append",
3915
4128
  stats=[
@@ -3928,9 +4141,10 @@ class Stat(_Node):
3928
4141
 
3929
4142
 
3930
4143
  class Steps(_Node):
3931
- def set(self, step_id, tone=None, label=None, detail=None, value=None):
4144
+ def set(self, step_id, label, tone=None, detail=None, value=None):
3932
4145
  # One step of the checklist, upserted by id: the same call marks it
3933
- # running and later done.
4146
+ # running and later done. The View schema requires the label in each
4147
+ # update.
3934
4148
  return self._op(
3935
4149
  "append",
3936
4150
  steps=[
@@ -4368,14 +4582,12 @@ class LiveView:
4368
4582
  value=value, done=done, total=total
4369
4583
  )
4370
4584
 
4371
- def stat(self, stat_id, value_text, label=None, tone=None):
4372
- return self._only("stat", "stat").set(
4373
- stat_id, value_text, label=label, tone=tone
4374
- )
4585
+ def stat(self, stat_id, value_text, label, tone=None):
4586
+ return self._only("stat", "stat").set(stat_id, value_text, label, tone=tone)
4375
4587
 
4376
- def step(self, step_id, tone=None, label=None, detail=None, value=None):
4588
+ def step(self, step_id, label, tone=None, detail=None, value=None):
4377
4589
  return self._only("steps", "step").set(
4378
- step_id, tone=tone, label=label, detail=detail, value=value
4590
+ step_id, label, tone=tone, detail=detail, value=value
4379
4591
  )
4380
4592
 
4381
4593
  def write(self, *lines: str | Sequence[str], tone: LogTone | None = None):
@@ -4597,7 +4809,7 @@ class _LiveRecorder:
4597
4809
  item = _LiveRecorder._copy(item)
4598
4810
  if item["id"] in positions:
4599
4811
  at = positions[item["id"]]
4600
- result[at] = {**result[at], **item}
4812
+ result[at] = item
4601
4813
  else:
4602
4814
  positions[item["id"]] = len(result)
4603
4815
  result.append(item)
@@ -4697,9 +4909,9 @@ class _LiveRecorder:
4697
4909
  elif action == "append":
4698
4910
  key = self._COLLECTION[node["type"]]
4699
4911
  incoming = self._copy(op.get(key) or [])
4700
- if node["type"] == "link":
4701
- for item in incoming:
4702
- item.setdefault("target_kind", "url")
4912
+ from blockether.vis import _contracts
4913
+
4914
+ incoming = _contracts.live_items(key, incoming)
4703
4915
  node[key] = self._upsert(node[key], incoming)
4704
4916
  if "groups" in op:
4705
4917
  # `live/apply-append`: a group already declared KEEPS the keys the
@@ -276,6 +276,21 @@ def test_invalid_execution_layer_is_rejected_before_default_engine(local):
276
276
  local.assert_not_called()
277
277
 
278
278
 
279
+ def test_tools_reach_the_new_conversation(local, tmp_path):
280
+ with engine.Agent(tmp_path, tools=("-py.lint_code", "py.repl_*")) as agent:
281
+ assert agent.session is agent.execution_layer.create_session.return_value
282
+ agent.execution_layer.create_session.assert_called_once_with(
283
+ root=str(tmp_path.resolve()), tools=["-py.lint_code", "py.repl_*"]
284
+ )
285
+
286
+
287
+ @pytest.mark.parametrize("tools", ["py.lint_code", [1]])
288
+ def test_tools_must_be_names(local, tmp_path, tools):
289
+ with pytest.raises(TypeError, match="tool names"):
290
+ engine.Agent(tmp_path, tools=tools)
291
+ local.assert_not_called()
292
+
293
+
279
294
  class Delivery(BaseModel):
280
295
  model_config = ConfigDict(extra="forbid")
281
296
 
@@ -0,0 +1,109 @@
1
+ """`vis.attach` and the attachment readers, against the outside host."""
2
+
3
+ import base64
4
+ import json
5
+
6
+ import blockether.vis.extension as vis
7
+ import pytest
8
+ from blockether.vis import _outside
9
+
10
+ PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 16
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def _empty_store(monkeypatch):
15
+ monkeypatch.setattr(_outside, "_ARTIFACTS", [])
16
+ monkeypatch.setattr(_outside, "_ARTIFACT_BYTES", {})
17
+ monkeypatch.setattr(vis, "_host", _outside.host)
18
+
19
+
20
+ def test_attach_stores_a_path_and_returns_its_descriptor(tmp_path):
21
+ source = tmp_path / "report.csv"
22
+ source.write_text("a,b\n1,2\n", encoding="utf-8")
23
+
24
+ row = vis.attach(source, label="Totals", commentable=True)
25
+
26
+ assert row["filename"] == "report.csv"
27
+ assert row["version"] == 1
28
+ assert row["media_type"] == "text/csv"
29
+ assert row["kind"] == "file"
30
+ assert row["size"] == 8
31
+ assert row["commentable"] is True
32
+ assert vis.read_attachment(row) == b"a,b\n1,2\n"
33
+
34
+
35
+ def test_same_filename_adds_a_version_and_targets_resolve_it():
36
+ first = vis.attach(PNG, "chart.png")
37
+ second = vis.attach(PNG + b"x", "chart.png", audience="user")
38
+
39
+ assert second["version"] == 2
40
+ assert second["kind"] == "image"
41
+ assert [r["version"] for r in vis.list_attachments("chart.png")] == [1, 2]
42
+ assert vis.get_attachment("chart.png")["id"] == second["id"]
43
+ assert vis.get_attachment("chart.png", version=-2)["id"] == first["id"]
44
+ assert vis.get_attachment(first["id"])["version"] == 1
45
+ assert vis.read_attachment("chart.png", 1) == PNG
46
+ assert vis.show_attachment(second) == {
47
+ "id": second["id"],
48
+ "filename": "chart.png",
49
+ "media_type": "image/png",
50
+ "size": len(PNG) + 1,
51
+ }
52
+
53
+
54
+ def test_attach_sends_one_options_map_to_the_host(monkeypatch):
55
+ sent = []
56
+
57
+ class Host:
58
+ def attach(self, options):
59
+ sent.append(options)
60
+ return json.dumps(
61
+ {"id": "a1", "filename": options["filename"], "media-type": "x/y"}
62
+ )
63
+
64
+ monkeypatch.setattr(vis, "_host", Host())
65
+
66
+ # `.txt` maps to text/plain on every Python version; `.md` does not before 3.13.
67
+ assert vis.attach(b"data", "notes.txt", media_type="text/markdown") == {
68
+ "id": "a1",
69
+ "filename": "notes.txt",
70
+ "media_type": "x/y",
71
+ }
72
+ assert sent == [
73
+ {
74
+ "base64": base64.b64encode(b"data").decode("ascii"),
75
+ "filename": "notes.txt",
76
+ "media_type": "text/markdown",
77
+ "media_type_hint": "text/plain",
78
+ "audience": "both",
79
+ "commentable": False,
80
+ }
81
+ ]
82
+
83
+
84
+ def test_attach_refuses_wrong_sources_and_options(tmp_path):
85
+ with pytest.raises(TypeError, match="source must be a path"):
86
+ vis.attach(object())
87
+ with pytest.raises(IsADirectoryError):
88
+ vis.attach(tmp_path)
89
+ with pytest.raises(FileNotFoundError, match="no such file"):
90
+ vis.attach(tmp_path / "missing.png")
91
+ with pytest.raises(ValueError, match="audience"):
92
+ vis.attach(PNG, "a.png", audience="everyone")
93
+ with pytest.raises(TypeError, match="commentable"):
94
+ vis.attach(PNG, "a.png", commentable="yes")
95
+ with pytest.raises(ValueError, match="empty payload"):
96
+ vis.attach(b"", "a.png")
97
+
98
+
99
+ def test_readers_name_an_unknown_target_and_a_non_image():
100
+ vis.attach(b"text", "notes.txt")
101
+
102
+ with pytest.raises(LookupError, match="unknown id or filename"):
103
+ vis.get_attachment("other.txt")
104
+ with pytest.raises(LookupError, match="has no version 3"):
105
+ vis.read_attachment("notes.txt", 3)
106
+ with pytest.raises(ValueError, match="not an image"):
107
+ vis.show_attachment("notes.txt")
108
+ with pytest.raises(TypeError, match="version must be an integer"):
109
+ vis.get_attachment("notes.txt", "1")
@@ -358,3 +358,28 @@ def test_a_group_arranging_nothing_is_refused_where_it_was_written():
358
358
  ):
359
359
  with pytest.raises(ValueError, match="at least one node"):
360
360
  build()
361
+
362
+
363
+ @pytest.mark.parametrize("host_kind", ["outside", "recorder"])
364
+ def test_stat_and_step_updates_carry_the_label_the_schema_requires(
365
+ monkeypatch, host_kind
366
+ ):
367
+ # The evals watch view sent a counter update without its label, and the host
368
+ # refused it. The recorder now refuses the same patch, as the View schema says.
369
+ host = (
370
+ _outside.host if host_kind == "outside" else vis.testing.LiveRecorder(vis._host)
371
+ )
372
+ monkeypatch.setattr(vis, "_host", host)
373
+ nodes = [
374
+ vis.stat("counts", stats=[{"id": "ok", "label": "Passed", "value_text": "0"}]),
375
+ vis.steps("plan", steps=[{"id": "build", "label": "Build"}]),
376
+ ]
377
+ with vis.live("Counts", nodes, flush_ms=0) as view:
378
+ with pytest.raises((ValueError, _outside.Refused), match="label"):
379
+ view["counts"]._op("append", stats=[{"id": "ok", "value_text": "1"}])
380
+ view["counts"].set("ok", "2", "Passed")
381
+ view["plan"].set("build", "Build", tone="ok")
382
+ counts, plan = view.state()["nodes"]
383
+ stats, steps = counts["stats"], plan["steps"]
384
+ assert stats == [{"id": "ok", "label": "Passed", "value_text": "2"}]
385
+ assert steps[0]["label"] == "Build" and steps[0]["tone"] == "ok"
@@ -0,0 +1,55 @@
1
+ """`vis.read_session` and `vis.list_sessions` use the canonical host session reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import blockether.vis.extension as vis
8
+ import pytest
9
+ from blockether.vis import _outside
10
+
11
+
12
+ def test_answers_the_host_read_as_one_string_keyed_dict(monkeypatch):
13
+ targets = []
14
+ data = {
15
+ "session": {"id": "6fccb4a2-bf20-448c-ac4f-9d170983c0f3"},
16
+ "transcript": {"turns": [{"position": 1, "user_request": "Fix the typo."}]},
17
+ }
18
+
19
+ def read_session(target):
20
+ targets.append(target)
21
+ return json.dumps(data)
22
+
23
+ monkeypatch.setattr(vis._host, "read_session", read_session)
24
+ assert vis.read_session() == data
25
+ assert vis.read_session("vis_session_id#6fccb4a2") == data
26
+ assert targets == [None, "vis_session_id#6fccb4a2"]
27
+
28
+
29
+ def test_answers_the_host_search_rows_in_their_rank_order(monkeypatch):
30
+ searches = []
31
+ rows = [
32
+ {"id": "b", "title": "Fix the typo", "rank": 0, "is_in_title": True},
33
+ {"id": "a", "title": "Release", "rank": 1, "is_in_request": True},
34
+ ]
35
+
36
+ def list_sessions(search):
37
+ searches.append(search)
38
+ return json.dumps(rows)
39
+
40
+ monkeypatch.setattr(vis._host, "list_sessions", list_sessions)
41
+ assert vis.list_sessions() == rows
42
+ assert vis.list_sessions(search="typo") == rows
43
+ assert searches == [None, "typo"]
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ "call, message",
48
+ [
49
+ (vis.read_session, "vis.read_session needs a bound Vis session"),
50
+ (vis.list_sessions, "vis.list_sessions needs a bound Vis session"),
51
+ ],
52
+ )
53
+ def test_refuses_outside_a_vis_session(call, message):
54
+ with pytest.raises(_outside.Refused, match=message):
55
+ call()
@@ -68,6 +68,23 @@ def test_parent_nests_a_setting():
68
68
  vis.Setting(id="test_detail", label="Test detail", default=False, parent=" ")
69
69
 
70
70
 
71
+ # Regression for #369: a setting declares a change hook that the host calls.
72
+ def test_on_change_declares_a_callable_hook():
73
+ def record(old, new, scope):
74
+ return None
75
+
76
+ plain = vis.Setting(id="test_feature", label="Test feature", default=True)
77
+ hooked = vis.Setting(
78
+ id="test_feature", label="Test feature", default=True, on_change=record
79
+ )
80
+ assert "on_change" not in plain._spec()
81
+ assert hooked._spec()["on_change"] is record
82
+ with pytest.raises(TypeError):
83
+ vis.Setting(
84
+ id="test_feature", label="Test feature", default=True, on_change="record"
85
+ )
86
+
87
+
71
88
  # Regression for #322 and #323: a 300-character description is valid, and a
72
89
  # longer one names the setting, the field, the rule and the limit.
73
90
  def test_description_limit_and_actionable_error():
File without changes
File without changes