Fix durable screenshot artifacts and Xpra sizing
Materialize browser, desktop, computer-use, and vision-load screenshots into chat-scoped artifacts so historical image refs survive temporary screenshot pruning. Keep history serialization free of rescue assumptions, document durable screenshot behavior in tool prompts/skills, and size Xpra canvases from backend-normalized display dimensions to prevent stretched desktop views. Verified with focused pytest coverage plus live Docker checks for browser screenshot persistence and Xpra canvas dimensions.
Alessandro committed
May 30, 2026 at 17:45 UTC
edd58a42d2b27cc2e9e013609f64fb10652f168f
18 files changed
+688
-81
helpers/chat_media.py
new
+244
@@ -0,0 +1,244 @@
1
+from __future__ import annotations
2
+
3
+import time
4
+import uuid
5
+from dataclasses import dataclass
6
+from pathlib import Path
7
+from typing import Literal
8
+
9
+from helpers import files, media_artifacts
10
+
11
+
12
+DEFAULT_MAX_IMAGE_BYTES = media_artifacts.DEFAULT_MAX_ARTIFACT_SIZE_BYTES
13
+ImageCategory = Literal["images", "screenshots"]
14
+
15
+
16
+@dataclass(frozen=True)
17
+class ChatImage:
18
+ path: str
19
+ a0_path: str
20
+ mime: str
21
+ size: int
22
+
23
+
24
+def screenshot_dir(context_id: str, source: str) -> Path:
25
+ return artifact_dir(context_id, category="screenshots", source=source)
26
+
27
+
28
+def artifact_dir(
29
+ context_id: str,
30
+ *,
31
+ category: ImageCategory = "images",
32
+ source: str = "vision-load",
33
+) -> Path:
34
+ context_segment = files.safe_file_name(str(context_id or "default")).strip("._") or "default"
35
+ safe_category = files.safe_file_name(category).strip("._") or "images"
36
+ safe_source = files.safe_file_name(source).strip("._") or "vision-load"
37
+
38
+ return Path(files.get_abs_path("usr/chats", context_segment)) / safe_category / safe_source
39
+
40
+
41
+def save_image_bytes(
42
+ *,
43
+ context_id: str,
44
+ payload: bytes,
45
+ mime_type: str = "image/png",
46
+ category: ImageCategory = "images",
47
+ source: str = "vision-load",
48
+ preferred_name: str = "",
49
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
50
+) -> ChatImage:
51
+ data = bytes(payload or b"")
52
+ if not data:
53
+ raise media_artifacts.EmptyBase64Data("image payload is empty")
54
+ if max_bytes is not None and len(data) > max_bytes:
55
+ raise media_artifacts.ArtifactTooLarge(len(data), max_bytes)
56
+
57
+ safe_mime = media_artifacts.normalize_mime(
58
+ mime_type,
59
+ default="image/png",
60
+ required_prefix="image/",
61
+ )
62
+ default_extension = media_artifacts.guess_extension(safe_mime, ".png")
63
+ default_filename = f"{source or 'image'}{default_extension}"
64
+ filename = media_artifacts.safe_filename(
65
+ preferred_name,
66
+ default=default_filename,
67
+ default_extension=default_extension,
68
+ )
69
+ filename_path = Path(filename)
70
+ stem = filename_path.stem or Path(default_filename).stem or "image"
71
+ suffix = filename_path.suffix or default_extension
72
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
73
+ path = artifact_dir(context_id, category=category, source=source) / (
74
+ f"{stem}-{timestamp}-{uuid.uuid4().hex[:8]}{suffix}"
75
+ )
76
+ path.parent.mkdir(parents=True, exist_ok=True)
77
+ path.write_bytes(data)
78
+ return ChatImage(
79
+ path=str(path),
80
+ a0_path=files.normalize_a0_path(str(path)),
81
+ mime=safe_mime,
82
+ size=len(data),
83
+ )
84
+
85
+
86
+def save_image_base64(
87
+ *,
88
+ context_id: str,
89
+ data: str,
90
+ mime_type: str = "image/png",
91
+ category: ImageCategory = "images",
92
+ source: str = "vision-load",
93
+ preferred_name: str = "",
94
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
95
+) -> ChatImage:
96
+ payload = media_artifacts.decode_base64_payload(data, max_bytes=max_bytes)
97
+ return save_image_bytes(
98
+ context_id=context_id,
99
+ payload=payload.payload,
100
+ mime_type=mime_type,
101
+ category=category,
102
+ source=source,
103
+ preferred_name=preferred_name,
104
+ max_bytes=max_bytes,
105
+ )
106
+
107
+
108
+def save_image_file(
109
+ *,
110
+ context_id: str,
111
+ path: str | Path,
112
+ category: ImageCategory = "images",
113
+ source: str = "vision-load",
114
+ preferred_name: str = "",
115
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
116
+) -> ChatImage:
117
+ image_path = Path(path)
118
+ payload = image_path.read_bytes()
119
+ mime = media_artifacts.normalize_mime(
120
+ _guess_image_mime(image_path),
121
+ default="image/png",
122
+ required_prefix="image/",
123
+ )
124
+ return save_image_bytes(
125
+ context_id=context_id,
126
+ payload=payload,
127
+ mime_type=mime,
128
+ category=category,
129
+ source=source,
130
+ preferred_name=preferred_name or image_path.name,
131
+ max_bytes=max_bytes,
132
+ )
133
+
134
+
135
+def save_image_data_url(
136
+ *,
137
+ context_id: str,
138
+ data_url: str,
139
+ category: ImageCategory = "images",
140
+ source: str = "vision-load",
141
+ preferred_name: str = "",
142
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
143
+) -> ChatImage:
144
+ header, encoded = _split_image_data_url(data_url)
145
+ mime = header.removeprefix("data:").split(";", 1)[0] or "image/png"
146
+ return save_image_base64(
147
+ context_id=context_id,
148
+ data=encoded,
149
+ mime_type=mime,
150
+ category=category,
151
+ source=source,
152
+ preferred_name=preferred_name,
153
+ max_bytes=max_bytes,
154
+ )
155
+
156
+
157
+def materialize_image_ref(
158
+ *,
159
+ context_id: str,
160
+ url: str,
161
+ source: str = "",
162
+ preferred_name: str = "",
163
+ max_bytes: int | None = DEFAULT_MAX_IMAGE_BYTES,
164
+) -> str:
165
+ value = str(url or "").strip()
166
+ if not value or not str(context_id or "").strip():
167
+ return value
168
+
169
+ resolved_source = source or infer_source(value, preferred_name)
170
+ category = category_for_source(resolved_source)
171
+ if _is_data_image_url(value):
172
+ saved = save_image_data_url(
173
+ context_id=context_id,
174
+ data_url=value,
175
+ category=category,
176
+ source=resolved_source,
177
+ preferred_name=preferred_name,
178
+ max_bytes=max_bytes,
179
+ )
180
+ return saved.a0_path
181
+
182
+ from helpers import images
183
+
184
+ source_path = images.resolve_ref(value)
185
+ if is_chat_scoped_path(context_id=context_id, path=source_path):
186
+ return files.normalize_a0_path(str(source_path))
187
+ saved = save_image_file(
188
+ context_id=context_id,
189
+ path=source_path,
190
+ category=category,
191
+ source=resolved_source,
192
+ preferred_name=preferred_name or source_path.name,
193
+ max_bytes=max_bytes,
194
+ )
195
+ return saved.a0_path
196
+
197
+
198
+def is_chat_scoped_path(*, context_id: str, path: str | Path) -> bool:
199
+ if not str(context_id or "").strip():
200
+ return False
201
+ try:
202
+ target = Path(path).resolve(strict=False)
203
+ root = artifact_dir(context_id, category="images", source="vision-load").parents[1].resolve(strict=False)
204
+ return target == root or root in target.parents
205
+ except OSError:
206
+ return False
207
+
208
+
209
+def infer_source(value: str = "", preferred_name: str = "") -> str:
210
+ raw = f"{value or ''} {preferred_name or ''}".lower()
211
+ if "computer-use" in raw or "computer_use" in raw or "_a0_connector/computer_use" in raw:
212
+ return "computer-use"
213
+ if "/desktop/screenshots/" in raw or "\\desktop\\screenshots\\" in raw or "desktop-" in raw:
214
+ return "desktop"
215
+ if (
216
+ "/browser/screenshots/" in raw
217
+ or "\\browser\\screenshots\\" in raw
218
+ or "host-browser" in raw
219
+ or "browser-" in raw
220
+ ):
221
+ return "browser"
222
+ return "vision-load"
223
+
224
+
225
+def category_for_source(source: str) -> ImageCategory:
226
+ return "screenshots" if source in {"desktop", "browser", "computer-use"} else "images"
227
+
228
+
229
+def _guess_image_mime(path: Path) -> str:
230
+ import mimetypes
231
+
232
+ return mimetypes.guess_type(path.name)[0] or "image/png"
233
+
234
+
235
+def _is_data_image_url(value: str) -> bool:
236
+ normalized = str(value or "").strip().lower()
237
+ return normalized.startswith("data:image/") and ";base64," in normalized
238
+
239
+
240
+def _split_image_data_url(data_url: str) -> tuple[str, str]:
241
+ value = str(data_url or "").strip()
242
+ if not _is_data_image_url(value) or "," not in value:
243
+ raise ValueError("image data URL must be data:image/*;base64,...")
244
+ return value.split(",", 1)
plugins/_a0_connector/tools/computer_use_remote.py
+20
-3
@@ -6,7 +6,7 @@ from pathlib import Path
6
import uuid
7
from typing import Any
8
9
-from helpers import history, media_artifacts
9
+from helpers import chat_media, history, media_artifacts
10
from helpers.print_style import PrintStyle
11
from helpers.tool import Response, Tool
12
from helpers.ws import NAMESPACE
@@ -744,7 +744,15 @@ class ComputerUseRemote(Tool):
744
except FileNotFoundError as exc:
745
path_error = exc
746
else:
747
- return display_path, image_path.stem
747
+ saved = chat_media.save_image_file(
748
+ context_id=self.agent.context.id,
749
+ path=image_path,
750
+ category="screenshots",
751
+ source="computer-use",
752
+ preferred_name=Path(display_path).name or image_path.name,
753
+ max_bytes=MAX_CAPTURE_ARTIFACT_SIZE_BYTES,
754
+ )
755
+ return saved.a0_path, Path(saved.path).stem
756
757
artifact = data.get("artifact")
758
if isinstance(artifact, dict) and str(artifact.get("encoding", "")).strip().lower() == "base64":
@@ -764,7 +772,16 @@ class ComputerUseRemote(Tool):
772
default=f"computer-use-{uuid.uuid4().hex}.png",
773
default_extension=".png",
774
)
767
- return f"data:{mime};base64,{encoded}", Path(filename).stem
775
+ saved = chat_media.save_image_base64(
776
+ context_id=self.agent.context.id,
777
+ data=encoded,
778
+ mime_type=mime,
779
+ category="screenshots",
780
+ source="computer-use",
781
+ preferred_name=filename,
782
+ max_bytes=MAX_CAPTURE_ARTIFACT_SIZE_BYTES,
783
+ )
784
+ return saved.a0_path, Path(saved.path).stem
785
786
if path_error is not None:
787
raise path_error
plugins/_browser/helpers/connector_runtime.py
+14
-7
@@ -9,7 +9,7 @@ from pathlib import Path
9
from typing import Any
10
from urllib.parse import urlparse
11
12
-from helpers import ephemeral_images, media_artifacts
12
+from helpers import chat_media, media_artifacts
13
14
try:
15
from helpers.ws import NAMESPACE
@@ -451,12 +451,16 @@ class ConnectorBrowserRuntime:
451
default=f"host-browser-{uuid.uuid4().hex}.jpg",
452
default_extension=".jpg",
453
)
454
+ mime = str(artifact.get("mime") or result.get("mime") or "image/jpeg")
455
try:
455
- ref = ephemeral_images.put_image(
456
+ saved = chat_media.save_image_base64(
457
context_id=self.context_id,
457
- mime=str(artifact.get("mime") or result.get("mime") or "image/jpeg"),
458
data=data,
459
- name=filename,
459
+ mime_type=mime,
460
+ category="screenshots",
461
+ source="browser",
462
+ preferred_name=filename,
463
+ max_bytes=MAX_ARTIFACT_SIZE_BYTES,
464
)
465
except Exception as exc:
466
raise RuntimeError("Host browser artifact could not be decoded.") from exc
@@ -466,11 +470,14 @@ class ConnectorBrowserRuntime:
470
materialized.pop("a0_path", None)
471
materialized.pop("host_path", None)
472
materialized.setdefault("context_id", self.context_id)
469
- materialized["ephemeral"] = True
470
- materialized["ephemeral_ref"] = ref
473
+ materialized["path"] = saved.path
474
+ materialized["a0_path"] = saved.a0_path
475
+ materialized["mime"] = saved.mime
476
+ materialized["ephemeral"] = False
477
+ materialized["chat_scoped"] = True
478
materialized["vision_load"] = {
479
"tool_name": "vision_load",
473
- "tool_args": {"paths": [ref]},
480
+ "tool_args": {"paths": [saved.a0_path]},
481
}
482
return materialized
483
plugins/_browser/helpers/runtime.py
+11
-7
@@ -15,7 +15,7 @@ from dataclasses import dataclass
15
from pathlib import Path
16
from typing import Any
17
18
-from helpers import ephemeral_images, files
18
+from helpers import chat_media, files
19
from helpers.defer import DeferredTask
20
from helpers.errors import RepairableException
21
from helpers.print_style import PrintStyle
@@ -1558,23 +1558,27 @@ class _BrowserRuntimeCore:
1558
quality=max(20, min(95, int(quality))),
1559
full_page=bool(full_page),
1560
)
1561
- ref = ephemeral_images.put_image_bytes(
1561
+ saved = chat_media.save_image_bytes(
1562
context_id=self.context_id,
1563
- mime="image/jpeg",
1563
payload=image,
1565
- name=f"browser-{resolved_id}.jpg",
1564
+ mime_type="image/jpeg",
1565
+ category="screenshots",
1566
+ source="browser",
1567
+ preferred_name=f"browser-{resolved_id}.jpg",
1568
)
1569
return {
1570
"browser_id": resolved_id,
1571
"context_id": self.context_id,
1572
+ "path": saved.path,
1573
+ "a0_path": saved.a0_path,
1574
"mime": "image/jpeg",
1571
- "ephemeral": True,
1572
- "ephemeral_ref": ref,
1575
+ "ephemeral": False,
1576
+ "chat_scoped": True,
1577
"state": await self._state(resolved_id),
1578
"vision_load": {
1579
"tool_name": "vision_load",
1580
"tool_args": {
1577
- "paths": [ref],
1581
+ "paths": [saved.a0_path],
1582
},
1583
},
1584
}
plugins/_browser/prompts/agent.system.tool.browser.md
+1
-1
@@ -20,7 +20,7 @@ Workflow:
20
- For same-page controls that are easier to identify structurally, `click`, `type`, `submit`, `type_submit`, `scroll`, `select_option`, `set_checked`, and `upload_file` may use `selector` instead of `ref`; the tool resolves the selector through `content` first.
21
- `click` with `x`/`y` and no `ref` is treated as a coordinate mouse click. `type` with text and no `ref` types into the currently focused element. `key_chord` accepts either `["Control", "A"]` or `"CTRL+A"`.
22
- `navigate` reuses an existing `browser_id` and is preferred for serial browsing.
23
-- Screenshots are explicit only; the browser does not automatically load screenshots. Call `vision_load` with the returned `vision_load.tool_args.paths` value before reasoning visually. When no `path` is requested, browser screenshots are ephemeral refs rather than conserved files.
23
+- Screenshots are explicit only; the browser does not automatically load screenshots. Call `vision_load` with the returned `vision_load.tool_args.paths` value before reasoning visually. When no `path` is requested, browser screenshots are saved as chat-scoped artifacts; explicit `path` requests remain user-owned files.
24
- Keep the tab set small; close pages after extracting what you need.
25
26
`multi` is only a browser action: use `tool_name: "browser"` with `tool_args.action: "multi"`. Never use `tool_name: "multi"`.
plugins/_browser/skills/browser-automation/SKILL.md
+1
-1
@@ -32,7 +32,7 @@ Screenshots are explicit only; the browser does not automatically load images in
32
2. Call `vision_load` with the returned `vision_load.tool_args.paths` value.
33
3. Reason from the latest loaded screenshot.
34
35
-Screenshot args include `quality`, `full_page`, and optional `path`. Without `path`, the screenshot is an ephemeral ref consumed by `vision_load`; with `path`, PNG is used when `path` ends with `.png`, otherwise JPEG is used.
35
+Screenshot args include `quality`, `full_page`, and optional `path`. Without `path`, the screenshot is saved as a chat-scoped artifact and returned through `vision_load.tool_args.paths`; with `path`, PNG is used when `path` ends with `.png`, otherwise JPEG is used.
36
37
## Forms And Files
38
plugins/_browser/skills/browser-form-workflows/SKILL.md
+1
-1
@@ -11,7 +11,7 @@ Start with `browser:content` to capture current refs, then use `browser:detail`
11
12
Use `select_option`, `set_checked`, `upload_file`, `type`, `type_submit`, and `submit` for form interaction. Use coordinates only when no stable ref exists or the UI is intentionally canvas-like.
13
14
-Use `browser:screenshot` plus `vision_load` when layout, visual validation, captcha-like UI, canvas content, or hidden state matters. Browser screenshots are not automatically loaded into model-visible history; no-path screenshots return ephemeral refs for `vision_load`.
14
+Use `browser:screenshot` plus `vision_load` when layout, visual validation, captcha-like UI, canvas content, or hidden state matters. Browser screenshots are not automatically loaded into model-visible history; no-path screenshots return chat-scoped artifact paths for `vision_load`.
15
16
Verify after submission with `browser:content`, `browser:state`, or another explicit `browser:screenshot` plus `vision_load`.
17
plugins/_desktop/helpers/desktop_state.py
+70
-15
@@ -37,6 +37,19 @@ def context_screenshot_dir(context_id: str = "") -> Path:
37
return SCREENSHOT_DIR / _safe_context_id(context_id)
38
39
40
+def chat_screenshot_dir(context_id: str = "") -> Path:
41
+ return BASE_DIR / "usr" / "chats" / _safe_context_id(context_id) / "screenshots" / "desktop"
42
+
43
+
44
+def normalize_a0_path(path: str | Path) -> str:
45
+ candidate = Path(path)
46
+ try:
47
+ relative = candidate.resolve(strict=False).relative_to(BASE_DIR.resolve(strict=False))
48
+ except ValueError:
49
+ return str(candidate)
50
+ return "/a0/" + str(relative).replace(os.sep, "/")
51
+
52
+
53
def _safe_context_id(context_id: str = "") -> str:
54
raw = str(context_id or os.environ.get("A0_DESKTOP_CONTEXT_ID") or "default")
55
return _SAFE_CONTEXT_RE.sub("_", raw).strip("._") or "default"
@@ -118,9 +131,11 @@ def capture_screenshot(
131
return {"ok": False, "path": "", "format": "", "captured_at": "", "error": message}
132
133
explicit_path = path is not None and str(path).strip() != ""
121
- ephemeral_ref = not explicit_path and str(transport or "").strip().lower() != "path"
122
- screenshot_dir = context_screenshot_dir(context_id)
123
- if not explicit_path:
134
+ transport_mode = str(transport or "").strip().lower()
135
+ chat_scoped = bool(not explicit_path and transport_mode == "path" and str(context_id or "").strip())
136
+ ephemeral_ref = not explicit_path and transport_mode != "path"
137
+ screenshot_dir = chat_screenshot_dir(context_id) if chat_scoped else context_screenshot_dir(context_id)
138
+ if not explicit_path and not chat_scoped:
139
prune_context_screenshots(context_id=context_id)
140
screenshot_dir.mkdir(parents=True, exist_ok=True)
141
timestamp = time.strftime("%Y%m%d-%H%M%S")
@@ -138,15 +153,17 @@ def capture_screenshot(
153
return {"ok": False, "path": "", "format": "", "captured_at": "", "error": detail}
154
155
if target.suffix.lower() == ".xwd":
141
- if not explicit_path:
156
+ if not explicit_path and not chat_scoped:
157
prune_context_screenshots(context_id=context_id, keep_path=raw_path)
158
return {
159
"ok": True,
160
"path": str(raw_path),
161
+ "a0_path": normalize_a0_path(raw_path),
162
"format": "xwd",
163
"captured_at": iso_now(),
164
"recent": True,
149
- "ephemeral": not explicit_path,
165
+ "ephemeral": not explicit_path and not chat_scoped,
166
+ "chat_scoped": chat_scoped,
167
"context_id": safe_context,
168
"error": "",
169
}
@@ -167,17 +184,19 @@ def capture_screenshot(
184
width=width,
185
height=height,
186
)
170
- if not explicit_path:
187
+ if not explicit_path and not chat_scoped:
188
prune_context_screenshots(context_id=context_id, keep_path=target)
189
return {
190
"ok": True,
191
"path": str(target),
192
+ "a0_path": normalize_a0_path(target),
193
"format": target.suffix.lower().lstrip(".") or "png",
194
"width": width,
195
"height": height,
196
"captured_at": iso_now(),
197
"recent": True,
180
- "ephemeral": not explicit_path,
198
+ "ephemeral": not explicit_path and not chat_scoped,
199
+ "chat_scoped": chat_scoped,
200
"context_id": safe_context,
201
"error": "",
202
}
@@ -193,17 +212,19 @@ def capture_screenshot(
212
width=converted["width"],
213
height=converted["height"],
214
)
196
- if not explicit_path:
215
+ if not explicit_path and not chat_scoped:
216
prune_context_screenshots(context_id=context_id, keep_path=target)
217
return {
218
"ok": True,
219
"path": str(target),
220
+ "a0_path": normalize_a0_path(target),
221
"format": target.suffix.lower().lstrip(".") or "png",
222
"width": converted["width"],
223
"height": converted["height"],
224
"captured_at": iso_now(),
225
"recent": True,
206
- "ephemeral": not explicit_path,
226
+ "ephemeral": not explicit_path and not chat_scoped,
227
+ "chat_scoped": chat_scoped,
228
"context_id": safe_context,
229
"error": "",
230
}
@@ -226,10 +247,12 @@ def capture_screenshot(
247
return {
248
"ok": True,
249
"path": str(raw_path),
250
+ "a0_path": normalize_a0_path(raw_path),
251
"format": "xwd",
252
"captured_at": iso_now(),
253
"recent": True,
232
- "ephemeral": not explicit_path,
254
+ "ephemeral": not explicit_path and not chat_scoped,
255
+ "chat_scoped": chat_scoped,
256
"context_id": safe_context,
257
"error": message,
258
}
@@ -575,8 +598,36 @@ def parse_xprop(output: str) -> dict[str, str]:
598
599
600
def latest_screenshot(*, context_id: str = "") -> dict[str, Any]:
601
+ chat_dir = chat_screenshot_dir(context_id)
602
+ chat_latest = _latest_screenshot_from_dir(
603
+ chat_dir,
604
+ context_id=context_id,
605
+ ephemeral=False,
606
+ chat_scoped=True,
607
+ prune_older=False,
608
+ )
609
+ if chat_latest.get("ok"):
610
+ return chat_latest
611
+
612
prune_context_screenshots(context_id=context_id, max_age_seconds=RECENT_SCREENSHOT_SECONDS)
613
screenshot_dir = context_screenshot_dir(context_id)
614
+ return _latest_screenshot_from_dir(
615
+ screenshot_dir,
616
+ context_id=context_id,
617
+ ephemeral=True,
618
+ chat_scoped=False,
619
+ prune_older=True,
620
+ )
621
+
622
+
623
+def _latest_screenshot_from_dir(
624
+ screenshot_dir: Path,
625
+ *,
626
+ context_id: str = "",
627
+ ephemeral: bool,
628
+ chat_scoped: bool,
629
+ prune_older: bool,
630
+) -> dict[str, Any]:
631
if not screenshot_dir.exists():
632
return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
633
candidates = [
@@ -587,17 +638,20 @@ def latest_screenshot(*, context_id: str = "") -> dict[str, Any]:
638
if not candidates:
639
return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
640
latest = max(candidates, key=lambda item: item.stat().st_mtime)
590
- for candidate in candidates:
591
- if candidate != latest:
592
- candidate.unlink(missing_ok=True)
641
+ if prune_older:
642
+ for candidate in candidates:
643
+ if candidate != latest:
644
+ candidate.unlink(missing_ok=True)
645
age = max(0.0, time.time() - latest.stat().st_mtime)
646
return {
647
"ok": True,
648
"path": str(latest),
649
+ "a0_path": normalize_a0_path(latest),
650
"format": latest.suffix.lower().lstrip("."),
651
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(latest.stat().st_mtime)),
652
"recent": age <= RECENT_SCREENSHOT_SECONDS,
600
- "ephemeral": True,
653
+ "ephemeral": ephemeral,
654
+ "chat_scoped": chat_scoped,
655
"context_id": _safe_context_id(context_id),
656
}
657
@@ -660,7 +714,8 @@ def compact_prompt_context(state: dict[str, Any] | None = None) -> str:
714
screenshot = state.get("screenshot") or {}
715
if screenshot.get("recent") and screenshot.get("path"):
716
ephemeral = " ephemeral" if screenshot.get("ephemeral") else ""
663
- lines.append(f"- recent_screenshot={screenshot['path']}{ephemeral}")
717
+ screenshot_ref = screenshot.get("a0_path") or screenshot["path"]
718
+ lines.append(f"- recent_screenshot={screenshot_ref}{ephemeral}")
719
context_id = str(state.get("context_id") or "").strip()
720
if context_id:
721
lines.append(f"- screenshot_context={context_id}")
plugins/_desktop/skills/linux-desktop/SKILL.md
+2
-2
@@ -38,7 +38,7 @@ The Desktop is an observe-act-verify control surface. Use this decision hierarch
38
3. Prefer launcher commands, window focus, keyboard shortcuts, menus, paste, and save commands.
39
4. Use coordinate clicks only as a last resort, and only after a fresh Desktop observation.
40
5. After any GUI action, verify through Desktop state, active window titles, screenshots, saved file state, or exported output.
41
-6. For terminal or CLI-agent work, verify against a fresh final `observe --json --screenshot` captured after the command has finished or visibly returned to an input prompt. Agent-facing Desktop screenshots are ephemeral refs; `desktopctl` shell observations return temporary context paths. Do not report from an earlier screenshot path.
41
+6. For terminal or CLI-agent work, verify against a fresh final `observe --json --screenshot` captured after the command has finished or visibly returned to an input prompt. Agent-facing Desktop screenshots are ephemeral refs; `desktopctl` shell observations with `--context-id` return chat-scoped screenshot paths. Do not report from an earlier screenshot path.
42
43
Keep these standing rules:
44
@@ -68,7 +68,7 @@ $DESKTOP key ctrl+s
68
69
The script targets the persistent `agent-zero-desktop` X display, sets `DISPLAY`, `XAUTHORITY`, and `HOME` to the XFCE profile, then uses `xdotool` for input. Startup normally prepares this session. If `check` fails during explicit Desktop work, report that the Desktop runtime is not ready instead of installing packages ad hoc.
70
71
-If `observe --json --screenshot` shows a reachable display, visible Desktop/window entries, and a fresh screenshot, the Desktop is usable even when `active_window` is `null`; a bare XFCE desktop can have no active application window. Treat missing screenshots, missing display, or unavailable `xdotool`/`xwd` as blockers and stop with the specific readiness message instead of repeating clicks or inventing a fallback. Use any returned shell screenshot path promptly; only the latest temporary context screenshot is retained.
71
+If `observe --json --screenshot` shows a reachable display, visible Desktop/window entries, and a fresh screenshot, the Desktop is usable even when `active_window` is `null`; a bare XFCE desktop can have no active application window. Treat missing screenshots, missing display, or unavailable `xdotool`/`xwd` as blockers and stop with the specific readiness message instead of repeating clicks or inventing a fallback. Shell screenshots captured with `--context-id` live in the owning chat's screenshot folder; screenshots without a chat context remain temporary.
72
73
For direct app launches without coordinates:
74
plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
+1
-1
@@ -60,7 +60,7 @@ Commands:
60
observe --json [--screenshot] [--context-id ID]
61
Return structured state, optionally with a fresh screenshot.
62
screenshot [PATH] [--context-id ID]
63
- Capture the Desktop to PATH, or to the temporary context screenshot directory.
63
+ Capture the Desktop to PATH, or to the chat screenshot directory when context-id is set.
64
active-window Print the active window name.
65
geometry PATTERN Print the first matching visible window geometry.
66
wait-window PATTERN Wait for a visible matching window and print its id.
plugins/_desktop/webui/desktop-store.js
+48
-8
@@ -258,6 +258,7 @@ const model = {
258
_desktopFrameHost: null,
259
_desktopFrameLoadHandler: null,
260
_desktopKeepaliveHost: null,
261
+ _desktopDisplaySizes: {},
262
_desktopIntentionalShutdown: false,
263
264
async init(element = null) {
@@ -1499,7 +1500,7 @@ const model = {
1500
this.stopXpraDesktopPrime();
1501
this._desktopPrimeAttempts = 0;
1502
}
1502
- if (this.applyXpraDesktopFrameMode(options.frame || null)) return;
1503
+ if (this.applyXpraDesktopFrameMode(options.frame || null, options)) return;
1504
if (this._desktopPrimeAttempts >= XPRA_DESKTOP_PRIME_ATTEMPTS) return;
1505
this._desktopPrimeAttempts += 1;
1506
if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
@@ -1540,8 +1541,12 @@ const model = {
1541
const windows = Object.values(client.id_to_window || {});
1542
if (!client.connected || !windows.length) return false;
1543
1543
- const width = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
1544
- const height = Math.round(container.clientHeight || remoteWindow.innerHeight || 0);
1544
+ const token = options.token || this.session?.desktop?.token || "";
1545
+ const displaySize = options.displaySize || this.desktopDisplaySizeForToken(token);
1546
+ const viewportWidth = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
1547
+ const viewportHeight = Math.round(container.clientHeight || remoteWindow.innerHeight || 0);
1548
+ const width = Math.round(displaySize?.width || viewportWidth || 0);
1549
+ const height = Math.round(displaySize?.height || viewportHeight || 0);
1550
if (width > 0 && height > 0) {
1551
client.desktop_width = width;
1552
client.desktop_height = height;
@@ -1574,6 +1579,26 @@ const model = {
1579
}
1580
},
1581
1582
+ desktopDisplaySizeForToken(token = "") {
1583
+ const key = String(token || "").trim();
1584
+ const size = key ? this._desktopDisplaySizes?.[key] : null;
1585
+ const width = Math.round(Number(size?.width || 0));
1586
+ const height = Math.round(Number(size?.height || 0));
1587
+ return width > 0 && height > 0 ? { width, height } : null;
1588
+ },
1589
+
1590
+ rememberDesktopDisplaySize(token = "", width = 0, height = 0) {
1591
+ const key = String(token || "").trim();
1592
+ const normalizedWidth = Math.round(Number(width || 0));
1593
+ const normalizedHeight = Math.round(Number(height || 0));
1594
+ if (!key || normalizedWidth <= 0 || normalizedHeight <= 0) return null;
1595
+ this._desktopDisplaySizes = {
1596
+ ...(this._desktopDisplaySizes || {}),
1597
+ [key]: { width: normalizedWidth, height: normalizedHeight },
1598
+ };
1599
+ return this._desktopDisplaySizes[key];
1600
+ },
1601
+
1602
installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container) {
1603
if (!frame || !remoteWindow || !remoteDocument || !client) return null;
1604
const store = this;
@@ -1584,8 +1609,10 @@ const model = {
1609
const metrics = () => {
1610
const desktopWidth = Math.max(1, finite(client.desktop_width || container?.clientWidth || remoteWindow.innerWidth, 1));
1611
const desktopHeight = Math.max(1, finite(client.desktop_height || container?.clientHeight || remoteWindow.innerHeight, 1));
1587
- const clientWidth = Math.max(1, finite(container?.clientWidth || remoteWindow.innerWidth, desktopWidth));
1588
- const clientHeight = Math.max(1, finite(container?.clientHeight || remoteWindow.innerHeight, desktopHeight));
1612
+ const primaryWindow = Object.values(client.id_to_window || {})[0];
1613
+ const canvas = primaryWindow?.canvas;
1614
+ const clientWidth = Math.max(1, finite(canvas?.clientWidth || canvas?.width || container?.clientWidth || remoteWindow.innerWidth, desktopWidth));
1615
+ const clientHeight = Math.max(1, finite(canvas?.clientHeight || canvas?.height || container?.clientHeight || remoteWindow.innerHeight, desktopHeight));
1616
return {
1617
desktopWidth,
1618
desktopHeight,
@@ -1683,8 +1710,10 @@ const model = {
1710
},
1711
1712
fitXpraDesktopWindowElement(xpraWindow, width, height) {
1686
- const cssWidth = `${Math.max(1, Number(width || 0))}px`;
1687
- const cssHeight = `${Math.max(1, Number(height || 0))}px`;
1713
+ const normalizedWidth = Math.max(1, Math.round(Number(width || 0)));
1714
+ const normalizedHeight = Math.max(1, Math.round(Number(height || 0)));
1715
+ const cssWidth = `${normalizedWidth}px`;
1716
+ const cssHeight = `${normalizedHeight}px`;
1717
const windowElement = xpraWindow?.div;
1718
const canvas = xpraWindow?.canvas;
1719
windowElement?.style?.setProperty("left", "0px", "important");
@@ -1698,6 +1727,12 @@ const model = {
1727
canvas?.style?.setProperty("height", cssHeight, "important");
1728
canvas?.style?.setProperty("display", "block", "important");
1729
canvas?.style?.setProperty("margin", "0", "important");
1730
+ if (canvas) {
1731
+ if (canvas.width !== normalizedWidth) canvas.width = normalizedWidth;
1732
+ if (canvas.height !== normalizedHeight) canvas.height = normalizedHeight;
1733
+ canvas.setAttribute("width", String(normalizedWidth));
1734
+ canvas.setAttribute("height", String(normalizedHeight));
1735
+ }
1736
},
1737
1738
installXpraDesktopWheelBridge(remoteWindow, xpraWindow) {
@@ -2139,6 +2174,11 @@ const model = {
2174
const response = await fetch(`/desktop/resize?${params.toString()}`, { credentials: "same-origin" });
2175
if (response.ok) {
2176
const result = await response.json().catch(() => ({}));
2177
+ const displaySize = this.rememberDesktopDisplaySize(
2178
+ token,
2179
+ result?.width || width,
2180
+ result?.height || height,
2181
+ );
2182
this._desktopResizeKey = key;
2183
const activeFrame = this.desktopFrame(frame);
2184
const activeTarget = activeFrame?.parentElement || activeFrame;
@@ -2153,7 +2193,7 @@ const model = {
2193
}
2194
}
2195
if (result?.reload) this.reloadDesktopFrame(activeFrame || frame);
2156
- this.primeXpraDesktopFrame({ reset: true, frame: activeFrame || frame });
2196
+ this.primeXpraDesktopFrame({ reset: true, frame: activeFrame || frame, token, displaySize });
2197
}
2198
} catch (error) {
2199
console.warn("Desktop resize skipped", error);
tests/test_browser_agent_regressions.py
+31
-11
@@ -2477,7 +2477,7 @@ async def test_browser_runtime_remounts_initial_changed_viewport():
2477
2478
2479
@pytest.mark.anyio
2480
-async def test_browser_runtime_screenshot_file_defaults_to_ephemeral_ref(monkeypatch, tmp_path):
2480
+async def test_browser_runtime_screenshot_file_defaults_to_chat_scoped_artifact(monkeypatch, tmp_path):
2481
screenshot_calls = []
2482
2483
def fake_get_abs_path(*parts):
@@ -2512,15 +2512,15 @@ async def test_browser_runtime_screenshot_file_defaults_to_ephemeral_ref(monkeyp
2512
2513
result = await core.screenshot_file(5, quality=500)
2514
2515
- assert "path" not in result
2516
- assert "a0_path" not in result
2515
+ assert Path(result["path"]).read_bytes() == b"image-bytes"
2516
+ assert result["a0_path"].startswith("/a0/usr/chats/ctx_id/screenshots/browser/browser-5-")
2517
assert result["context_id"] == "ctx/id"
2518
assert result["mime"] == "image/jpeg"
2519
- assert result["ephemeral"] is True
2520
- assert result["ephemeral_ref"].startswith(ephemeral_images.REF_PREFIX)
2519
+ assert result["ephemeral"] is False
2520
+ assert result["chat_scoped"] is True
2521
assert result["vision_load"] == {
2522
"tool_name": "vision_load",
2523
- "tool_args": {"paths": [result["ephemeral_ref"]]},
2523
+ "tool_args": {"paths": [result["a0_path"]]},
2524
}
2525
assert "image" not in result
2526
assert not list((tmp_path / "tmp" / "browser" / "screenshots").rglob("*.jpg"))
@@ -2528,7 +2528,6 @@ async def test_browser_runtime_screenshot_file_defaults_to_ephemeral_ref(monkeyp
2528
assert screenshot_calls[-1]["quality"] == 95
2529
assert screenshot_calls[-1]["full_page"] is False
2530
assert "path" not in screenshot_calls[-1]
2531
- assert ephemeral_images.consume_image(result["ephemeral_ref"], context_id="ctx/id").data_url == "data:image/jpeg;base64,aW1hZ2UtYnl0ZXM="
2531
2532
png_path = tmp_path / "custom.png"
2533
png_result = await core.screenshot_file(5, quality=1, full_page=True, path=str(png_path))
@@ -2543,9 +2542,27 @@ async def test_browser_runtime_screenshot_file_defaults_to_ephemeral_ref(monkeyp
2542
2543
2544
@pytest.mark.anyio
2546
-async def test_vision_load_consumes_ephemeral_browser_refs(monkeypatch):
2545
+async def test_vision_load_materializes_ephemeral_browser_refs(monkeypatch, tmp_path):
2546
+ monkeypatch.setitem(sys.modules, "helpers.tool", SimpleNamespace(Response=_TestResponse, Tool=_TestTool))
2547
+ history_stub = ModuleType("helpers.history")
2548
+
2549
+ class _RawMessage(dict):
2550
+ def __init__(self, raw_content, preview):
2551
+ super().__init__(raw_content=raw_content, preview=preview)
2552
+
2553
+ history_stub.RawMessage = _RawMessage
2554
+ monkeypatch.setitem(sys.modules, "helpers.history", history_stub)
2555
+ monkeypatch.delitem(sys.modules, "tools.vision_load", raising=False)
2556
import tools.vision_load as vision_load_module
2557
2558
+ def fake_get_abs_path(*parts):
2559
+ return str(tmp_path.joinpath(*parts))
2560
+
2561
+ def fake_normalize_a0_path(path):
2562
+ return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
2563
+
2564
+ monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
2565
+ monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
2566
monkeypatch.setattr(
2567
vision_load_module.plugins,
2568
"get_plugin_config",
@@ -2561,7 +2578,7 @@ async def test_vision_load_consumes_ephemeral_browser_refs(monkeypatch):
2578
hist_add_tool_result=lambda *args, **kwargs: tool_results.append((args, kwargs)),
2579
hist_add_message=lambda *args, **kwargs: messages.append((args, kwargs)),
2580
)
2564
- ref = ephemeral_images.put_image(
2581
+ ref = vision_load_module.ephemeral_images.put_image(
2582
context_id="ctx-vision",
2583
mime="image/jpeg",
2584
data=SMALL_JPEG_10X10,
@@ -2580,10 +2597,13 @@ async def test_vision_load_consumes_ephemeral_browser_refs(monkeypatch):
2597
response = await tool.execute(paths=[ref])
2598
await tool.after_execution(response)
2599
2583
- assert ephemeral_images.get_image(ref, context_id="ctx-vision") is None
2600
+ assert vision_load_module.ephemeral_images.get_image(ref, context_id="ctx-vision") is None
2601
assert tool.loaded_paths == ["browser-shot.jpg"]
2602
raw_message = messages[0][1]["content"]
2586
- assert raw_message.raw_content[0]["image_url"]["url"] == f"data:image/jpeg;base64,{SMALL_JPEG_10X10}"
2603
+ stored_ref = raw_message["raw_content"][0]["image_url"]["url"]
2604
+ assert stored_ref.startswith("/a0/usr/chats/ctx-vision/screenshots/browser/browser-shot-")
2605
+ stored_path = tmp_path / stored_ref.removeprefix("/a0/")
2606
+ assert stored_path.read_bytes() == __import__("base64").b64decode(SMALL_JPEG_10X10)
2607
assert updates[-1]["result"] == "1 images loaded, 0 skipped"
2608
2609
tests/test_host_browser_connector.py
+15
-11
@@ -12,8 +12,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
12
if str(PROJECT_ROOT) not in sys.path:
13
sys.path.insert(0, str(PROJECT_ROOT))
14
15
-from helpers import ephemeral_images
15
from plugins._a0_connector.helpers import ws_runtime
16
+from plugins._browser.helpers import connector_runtime as connector_runtime_module
17
from plugins._browser.helpers.connector_runtime import (
18
ConnectorBrowserRuntime,
19
_agent_uses_local_chat_model,
@@ -330,7 +330,15 @@ def test_connector_runtime_adds_docker_recovery_to_host_errors():
330
assert "/browser container" in message
331
332
333
-def test_host_browser_artifacts_become_context_scoped_ephemeral_refs(tmp_path):
333
+def test_host_browser_artifacts_become_chat_scoped_files(monkeypatch, tmp_path):
334
+ def fake_get_abs_path(*parts):
335
+ return str(tmp_path.joinpath(*parts))
336
+
337
+ def fake_normalize_a0_path(path):
338
+ return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
339
+
340
+ monkeypatch.setattr(connector_runtime_module.chat_media.files, "get_abs_path", fake_get_abs_path)
341
+ monkeypatch.setattr(connector_runtime_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
342
runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
343
344
result = runtime._materialize_artifact(
@@ -352,19 +360,15 @@ def test_host_browser_artifacts_become_context_scoped_ephemeral_refs(tmp_path):
360
361
inner = result[0]["result"]
362
assert "artifact" not in inner
355
- assert "path" not in inner
356
- assert "a0_path" not in inner
363
+ assert Path(inner["path"]).read_bytes() == b"fake"
364
+ assert inner["a0_path"].startswith("/a0/usr/chats/ctx-host/screenshots/browser/shot-")
365
assert inner["context_id"] == "ctx-host"
358
- assert inner["ephemeral"] is True
359
- assert inner["ephemeral_ref"].startswith(ephemeral_images.REF_PREFIX)
360
- assert inner["vision_load"]["tool_args"]["paths"] == [inner["ephemeral_ref"]]
361
- assert ephemeral_images.consume_image(inner["ephemeral_ref"], context_id="ctx-host").data_url == "data:image/jpeg;base64,ZmFrZQ=="
362
- assert not list(tmp_path.rglob("shot.jpg"))
366
+ assert inner["ephemeral"] is False
367
+ assert inner["chat_scoped"] is True
368
+ assert inner["vision_load"]["tool_args"]["paths"] == [inner["a0_path"]]
369
370
371
def test_host_browser_artifact_materialization_rejects_oversized_payload(monkeypatch, tmp_path):
366
- import plugins._browser.helpers.connector_runtime as connector_runtime_module
367
-
372
monkeypatch.setattr(connector_runtime_module, "MAX_ARTIFACT_SIZE_BYTES", 2)
373
runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
374
tests/test_office_canvas_setup.py
+8
@@ -264,6 +264,14 @@ def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths():
264
assert "DESKTOP_RUNTIME_INSTALL_MESSAGE" in desktop_store
265
assert "openDesktopWhenRuntimeReady" in desktop_store
266
assert "isDesktopRuntimeInstalling" in desktop_store
267
+ assert "_desktopDisplaySizes: {}" in desktop_store
268
+ assert "desktopDisplaySizeForToken(token" in desktop_store
269
+ assert "rememberDesktopDisplaySize(token" in desktop_store
270
+ assert "options.displaySize || this.desktopDisplaySizeForToken(token)" in desktop_store
271
+ assert "result?.width || width" in desktop_store
272
+ assert "canvas.width = normalizedWidth" in desktop_store
273
+ assert "canvas.height = normalizedHeight" in desktop_store
274
+ assert "canvas?.clientWidth || canvas?.width" in desktop_store
275
assert "Installing Agent Zero Desktop runtime dependencies" in desktop_session
276
assert "__a0XpraOffsetWarnPatched" in desktop_store
277
assert "window does not fit in canvas, offsets" in desktop_store
tests/test_office_desktop_state.py
+8
-5
@@ -191,7 +191,8 @@ def test_desktop_state_screenshot_capture_uses_xwd_and_pillow_when_available(tmp
191
192
193
def test_desktop_state_shell_screenshot_path_is_context_scoped(tmp_path, monkeypatch):
194
- monkeypatch.setattr(desktop_state, "SCREENSHOT_DIR", tmp_path)
194
+ monkeypatch.setattr(desktop_state, "BASE_DIR", tmp_path)
195
+ monkeypatch.setattr(desktop_state, "SCREENSHOT_DIR", tmp_path / "tmp" / "desktop" / "screenshots")
196
capabilities = {"xwd": "/usr/bin/xwd"}
197
env = {"DISPLAY": ":120"}
198
@@ -222,7 +223,7 @@ def test_desktop_state_shell_screenshot_path_is_context_scoped(tmp_path, monkeyp
223
monkeypatch.setattr(desktop_state, "run", fake_run)
224
monkeypatch.setitem(sys.modules, "PIL", pil_module)
225
monkeypatch.setitem(sys.modules, "PIL.Image", image_module)
225
- stale_path = tmp_path / "ctx_id" / "stale.png"
226
+ stale_path = tmp_path / "tmp" / "desktop" / "screenshots" / "ctx_id" / "stale.png"
227
stale_path.parent.mkdir(parents=True)
228
stale_path.write_bytes(b"stale")
229
@@ -236,12 +237,14 @@ def test_desktop_state_shell_screenshot_path_is_context_scoped(tmp_path, monkeyp
237
238
path = Path(screenshot["path"])
239
assert screenshot["ok"] is True
239
- assert screenshot["ephemeral"] is True
240
+ assert screenshot["ephemeral"] is False
241
+ assert screenshot["chat_scoped"] is True
242
assert screenshot["context_id"] == "ctx_id"
241
- assert path.parent == tmp_path / "ctx_id"
243
+ assert screenshot["a0_path"].startswith("/a0/usr/chats/ctx_id/screenshots/desktop/desktop-")
244
+ assert path.parent == tmp_path / "usr" / "chats" / "ctx_id" / "screenshots" / "desktop"
245
assert path.name.startswith("desktop-")
246
assert desktop_state.latest_screenshot(context_id="ctx/id")["path"] == str(path)
244
- assert not stale_path.exists()
247
+ assert stale_path.exists()
248
249
250
def test_desktop_state_default_screenshot_returns_ephemeral_ref(tmp_path, monkeypatch):
tests/test_tool_action_contracts.py
+32
@@ -699,3 +699,35 @@ def test_computer_use_remote_start_session_reports_backend_features_and_windows_
699
assert "backend=windows/windows" in message
700
assert "features=uia-tree-snapshot, uia-structural-targeting" in message
701
assert "host-computer-use-windows" in message
702
+
703
+
704
+def test_computer_use_remote_capture_artifact_is_chat_scoped(monkeypatch, tmp_path: Path):
705
+ module = _load_computer_use_remote_tool(monkeypatch)
706
+
707
+ def fake_get_abs_path(*parts):
708
+ return str(tmp_path.joinpath(*parts))
709
+
710
+ def fake_normalize_a0_path(path):
711
+ return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
712
+
713
+ monkeypatch.setattr(module.chat_media.files, "get_abs_path", fake_get_abs_path)
714
+ monkeypatch.setattr(module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
715
+
716
+ tool = object.__new__(module.ComputerUseRemote)
717
+ tool.agent = types.SimpleNamespace(context=types.SimpleNamespace(id="ctx-computer"))
718
+
719
+ display_ref, capture_id = tool._resolve_capture_ref(
720
+ {
721
+ "artifact": {
722
+ "filename": "capture.png",
723
+ "mime": "image/png",
724
+ "encoding": "base64",
725
+ "data": "ZmFrZQ==",
726
+ },
727
+ }
728
+ )
729
+
730
+ assert display_ref.startswith("/a0/usr/chats/ctx-computer/screenshots/computer-use/capture-")
731
+ stored_path = tmp_path / display_ref.removeprefix("/a0/")
732
+ assert stored_path.read_bytes() == b"fake"
733
+ assert capture_id == stored_path.stem
tests/test_vision_load_image_refs.py
new
+123
@@ -0,0 +1,123 @@
1
+import types
2
+from types import SimpleNamespace
3
+import sys
4
+from pathlib import Path
5
+
6
+import pytest
7
+
8
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
9
+if str(PROJECT_ROOT) not in sys.path:
10
+ sys.path.insert(0, str(PROJECT_ROOT))
11
+
12
+from helpers import images
13
+
14
+
15
+class _TestResponse(SimpleNamespace):
16
+ def __init__(self, message="", break_loop=False, **kwargs):
17
+ super().__init__(message=message, break_loop=break_loop, **kwargs)
18
+
19
+
20
+class _TestTool:
21
+ def __init__(
22
+ self,
23
+ agent=None,
24
+ name="",
25
+ method=None,
26
+ args=None,
27
+ message="",
28
+ loop_data=None,
29
+ **kwargs,
30
+ ):
31
+ self.agent = agent
32
+ self.name = name
33
+ self.method = method
34
+ self.args = args or {}
35
+ self.message = message
36
+ self.loop_data = loop_data
37
+
38
+
39
+def _install_tool_stub(monkeypatch):
40
+ tool_stub = types.ModuleType("helpers.tool")
41
+ tool_stub.Response = _TestResponse
42
+ tool_stub.Tool = _TestTool
43
+ history_stub = types.ModuleType("helpers.history")
44
+
45
+ class _RawMessage(dict):
46
+ def __init__(self, raw_content, preview):
47
+ super().__init__(raw_content=raw_content, preview=preview)
48
+
49
+ history_stub.RawMessage = _RawMessage
50
+ monkeypatch.setitem(sys.modules, "helpers.tool", tool_stub)
51
+ monkeypatch.setitem(sys.modules, "helpers.history", history_stub)
52
+ monkeypatch.delitem(sys.modules, "tools.vision_load", raising=False)
53
+
54
+
55
+def test_prepare_content_keeps_missing_local_image_refs_strict():
56
+ missing_path = "/tmp/a0-missing-desktop-screenshot.png"
57
+
58
+ with pytest.raises(FileNotFoundError):
59
+ images.prepare_content(
60
+ [{"type": "image_url", "image_url": {"url": missing_path}}]
61
+ )
62
+
63
+
64
+@pytest.mark.anyio
65
+async def test_vision_load_materializes_local_image_to_chat_artifact(monkeypatch, tmp_path):
66
+ _install_tool_stub(monkeypatch)
67
+ import tools.vision_load as vision_load_module
68
+
69
+ def fake_get_abs_path(*parts):
70
+ return str(tmp_path.joinpath(*parts))
71
+
72
+ def fake_normalize_a0_path(path):
73
+ return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
74
+
75
+ monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
76
+ monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
77
+ monkeypatch.setattr(
78
+ vision_load_module.plugins,
79
+ "get_plugin_config",
80
+ lambda *args, **kwargs: {"chat_model": {"max_embeds": 10}},
81
+ )
82
+
83
+ async def direct_call(func, *args, **kwargs):
84
+ return func(*args, **kwargs)
85
+
86
+ monkeypatch.setattr(
87
+ vision_load_module.runtime,
88
+ "call_development_function",
89
+ direct_call,
90
+ )
91
+
92
+ image_path = tmp_path / "sample-image.png"
93
+ image_path.write_bytes(b"png-data")
94
+
95
+ tool_results = []
96
+ messages = []
97
+ updates = []
98
+ agent = SimpleNamespace(
99
+ context=SimpleNamespace(id="ctx-vision"),
100
+ agent_name="Agent 0",
101
+ hist_add_tool_result=lambda *args, **kwargs: tool_results.append((args, kwargs)),
102
+ hist_add_message=lambda *args, **kwargs: messages.append((args, kwargs)),
103
+ )
104
+ tool = vision_load_module.VisionLoad(
105
+ agent=agent,
106
+ name="vision_load",
107
+ method=None,
108
+ args={"paths": [str(image_path)]},
109
+ message="",
110
+ loop_data=None,
111
+ )
112
+ tool.log = SimpleNamespace(id="vision-log", update=lambda **kwargs: updates.append(kwargs))
113
+
114
+ response = await tool.execute(paths=[str(image_path)])
115
+ image_path.unlink()
116
+ await tool.after_execution(response)
117
+
118
+ raw_message = messages[0][1]["content"]
119
+ stored_ref = raw_message["raw_content"][0]["image_url"]["url"]
120
+ assert stored_ref.startswith("/a0/usr/chats/ctx-vision/images/vision-load/sample-image-")
121
+ stored_path = tmp_path / stored_ref.removeprefix("/a0/")
122
+ assert stored_path.read_bytes() == b"png-data"
123
+ assert updates[-1]["result"] == "1 images loaded, 0 skipped"
tools/vision_load.py
+58
-8
@@ -1,6 +1,6 @@
1
from helpers.print_style import PrintStyle
2
from helpers.tool import Tool, Response
3
-from helpers import runtime, files, plugins, ephemeral_images
3
+from helpers import runtime, files, plugins, ephemeral_images, images, chat_media
4
from mimetypes import guess_type
5
from helpers import history
6
@@ -27,7 +27,7 @@ class VisionLoad(Tool):
27
else []
28
)
29
30
- for path, display_path in limited_paths:
30
+ for idx, (path, display_path) in enumerate(limited_paths):
31
if not path:
32
continue
33
if ephemeral_images.is_ref(path):
@@ -38,12 +38,16 @@ class VisionLoad(Tool):
38
if image is None:
39
continue
40
display = image.display_name or display_path
41
- self.images_dict[display] = image.data_url
42
- self.loaded_paths.append(display)
41
+ stored_ref = self._store_ephemeral_image(image)
42
+ if stored_ref:
43
+ self.images_dict[display] = stored_ref
44
+ self.loaded_paths.append(display)
45
continue
46
if self._is_data_image_url(path):
45
- self.images_dict[display_path] = path
46
- self.loaded_paths.append(display_path)
47
+ stored_ref = self._store_data_url(path, preferred_name=f"vision-load-{idx + 1}.png")
48
+ if stored_ref:
49
+ self.images_dict[display_path] = stored_ref
50
+ self.loaded_paths.append(display_path)
51
continue
52
if not await runtime.call_development_function(files.exists, str(path)):
53
continue
@@ -51,8 +55,12 @@ class VisionLoad(Tool):
55
if path not in self.images_dict:
56
mime_type, _ = guess_type(str(path))
57
if mime_type and mime_type.startswith("image/"):
54
- self.images_dict[display_path] = str(path)
55
- self.loaded_paths.append(display_path)
58
+ try:
59
+ stored_ref = self._store_local_image(path, preferred_name=files.basename(path))
60
+ self.images_dict[display_path] = stored_ref
61
+ self.loaded_paths.append(display_path)
62
+ except (FileNotFoundError, OSError, ValueError):
63
+ continue
64
65
return Response(message="dummy", break_loop=False)
66
@@ -65,6 +73,48 @@ class VisionLoad(Tool):
73
def _context_id(self) -> str:
74
return str(getattr(getattr(self.agent, "context", None), "id", "") or "").strip()
75
76
+ def _store_ephemeral_image(self, image: ephemeral_images.EphemeralImage) -> str:
77
+ context_id = self._context_id()
78
+ if not context_id:
79
+ return image.data_url
80
+ source = chat_media.infer_source(image.ref, image.display_name)
81
+ category = chat_media.category_for_source(source)
82
+ saved = chat_media.save_image_base64(
83
+ context_id=context_id,
84
+ data=image.data,
85
+ mime_type=image.mime,
86
+ category=category,
87
+ source=source,
88
+ preferred_name=image.display_name,
89
+ )
90
+ return saved.a0_path
91
+
92
+ def _store_data_url(self, data_url: str, *, preferred_name: str = "") -> str:
93
+ context_id = self._context_id()
94
+ if not context_id:
95
+ return data_url
96
+ source = chat_media.infer_source(data_url, preferred_name)
97
+ category = chat_media.category_for_source(source)
98
+ saved = chat_media.save_image_data_url(
99
+ context_id=context_id,
100
+ data_url=data_url,
101
+ category=category,
102
+ source=source,
103
+ preferred_name=preferred_name,
104
+ )
105
+ return saved.a0_path
106
+
107
+ def _store_local_image(self, path: str, *, preferred_name: str = "") -> str:
108
+ context_id = self._context_id()
109
+ if not context_id:
110
+ return images.to_data_url(path)
111
+ return chat_media.materialize_image_ref(
112
+ context_id=context_id,
113
+ url=path,
114
+ source=chat_media.infer_source(path, preferred_name),
115
+ preferred_name=preferred_name,
116
+ )
117
+
118
@staticmethod
119
def _is_data_image_url(value: str) -> bool:
120
normalized = str(value or "").strip().lower()