Split text editor and Office artifact ownership
- rename document_artifact to office_artifact and remove retired shims/facades - make text_editor own Markdown saves, canvas-open intent, refresh, and stale-save protection - keep Office artifacts Desktop-only with Office formats and update skills/tests
Alessandro committed
May 22, 2026 at 11:21 UTC
8601f0d10cf240f24b6dadae81668094e366db11
38 files changed
+833
-941
plugins/_desktop/skills/linux-desktop/SKILL.md
+2
-2
@@ -13,7 +13,7 @@ triggers:
13
- "terminal app"
14
- "use the OS"
15
allowed_tools:
16
- - document_artifact
16
+ - office_artifact
17
- code_execution_tool
18
---
19
@@ -25,7 +25,7 @@ Use the Desktop as a full Linux GUI when the user explicitly needs a visual work
25
26
The Desktop is an observe-act-verify control surface. Use this decision hierarchy:
27
28
-1. Prefer structured tools such as `document_artifact` for deterministic file creation, reads, and edits.
28
+1. Prefer structured tools such as `office_artifact` for deterministic Office file creation, reads, and edits.
29
2. Prefer app-native helpers for visible live edits, such as `desktopctl.sh calc-set-cell` for Calc/UNO spreadsheet changes.
30
3. Prefer launcher commands, window focus, keyboard shortcuts, menus, paste, and save commands.
31
4. Use coordinate clicks only as a last resort, and only after a fresh Desktop observation.
plugins/_editor/extensions/python/text_editor_patch_after/_40_sync_open_sessions.py
new
+14
@@ -0,0 +1,14 @@
1
+from __future__ import annotations
2
+
3
+from typing import Any
4
+
5
+from helpers.extension import Extension
6
+from plugins._editor.helpers import markdown_sessions
7
+
8
+
9
+class SyncOpenEditorSessionsAfterTextEditorPatch(Extension):
10
+ async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
11
+ path = str((data or {}).get("path") or "").strip()
12
+ if not path:
13
+ return
14
+ markdown_sessions.get_manager().sync_external_file_mutations([path])
plugins/_editor/extensions/python/text_editor_write_after/_40_sync_open_sessions.py
new
+14
@@ -0,0 +1,14 @@
1
+from __future__ import annotations
2
+
3
+from typing import Any
4
+
5
+from helpers.extension import Extension
6
+from plugins._editor.helpers import markdown_sessions
7
+
8
+
9
+class SyncOpenEditorSessionsAfterTextEditorWrite(Extension):
10
+ async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
11
+ path = str((data or {}).get("path") or "").strip()
12
+ if not path:
13
+ return
14
+ markdown_sessions.get_manager().sync_external_file_mutations([path])
plugins/_editor/extensions/python/workdir_file_mutation_after/_40_sync_open_sessions.py
new
+17
@@ -0,0 +1,17 @@
1
+from __future__ import annotations
2
+
3
+from typing import Any
4
+
5
+from helpers.extension import Extension
6
+from plugins._editor.helpers import markdown_sessions
7
+
8
+
9
+class SyncOpenEditorSessionsAfterWorkdirMutation(Extension):
10
+ async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
11
+ payload = data or {}
12
+ paths = payload.get("paths")
13
+ if not isinstance(paths, list):
14
+ paths = [payload.get("path") or payload.get("current_path") or payload.get("parent_path")]
15
+ markdown_sessions.get_manager().sync_external_file_mutations(
16
+ [str(path) for path in paths if path],
17
+ )
plugins/_editor/extensions/webui/set_messages_after_loop/sync-text-editor-results.js
new
+213
@@ -0,0 +1,213 @@
1
+import { store as editorStore } from "/plugins/_editor/webui/editor-store.js";
2
+import { open as openSurface } from "/js/surfaces.js";
3
+
4
+const SYNC_WINDOW_MS = 10 * 60 * 1000;
5
+const syncedTextEditorResults = new Set();
6
+
7
+export default async function syncTextEditorResultsIntoOpenEditor(context) {
8
+ if (!context?.results?.length || context.historyEmpty) return;
9
+
10
+ for (const { args } of context.results) {
11
+ const payload = getTextEditorPayload(args);
12
+ if (toolName(payload) !== "text_editor") continue;
13
+ if (!shouldSyncTextEditorResult(args, payload)) continue;
14
+
15
+ const target = textEditorTarget(payload);
16
+ if (!target.path || target.extension !== "md") continue;
17
+
18
+ const key = [
19
+ args?.id || "",
20
+ payload.action || "",
21
+ target.path || "",
22
+ payload.version || "",
23
+ payload.last_modified || "",
24
+ ].join(":");
25
+ if (syncedTextEditorResults.has(key)) continue;
26
+ syncedTextEditorResults.add(key);
27
+
28
+ globalThis.setTimeout(() => {
29
+ if (shouldOpenEditorUiFromResult(payload, target)) {
30
+ void openSurface("editor", {
31
+ path: target.path || "",
32
+ file_id: target.file_id || "",
33
+ refresh: true,
34
+ source: "tool-result-open",
35
+ });
36
+ return;
37
+ }
38
+ void syncOpenEditorSurface(target);
39
+ }, 0);
40
+ }
41
+}
42
+
43
+function getTextEditorPayload(args = {}) {
44
+ const contentPayload = parseMaybeJson(args.content);
45
+ const kvpsPayload = args.kvps && typeof args.kvps === "object"
46
+ ? args.kvps
47
+ : parseMaybeJson(args.kvps);
48
+ return {
49
+ ...pickPayloadFields(args),
50
+ ...(contentPayload || {}),
51
+ ...(kvpsPayload || {}),
52
+ };
53
+}
54
+
55
+function pickPayloadFields(args = {}) {
56
+ const payload = {};
57
+ for (const key of [
58
+ "_tool_name",
59
+ "tool_name",
60
+ "action",
61
+ "extension",
62
+ "format",
63
+ "last_modified",
64
+ "open_canvas",
65
+ "open_document",
66
+ "open_in_canvas",
67
+ "path",
68
+ "version",
69
+ ]) {
70
+ if (args[key] != null && args[key] !== "") payload[key] = args[key];
71
+ }
72
+ return payload;
73
+}
74
+
75
+function toolName(payload = {}) {
76
+ return String(payload._tool_name || payload.tool_name || "").trim();
77
+}
78
+
79
+function shouldSyncTextEditorResult(args = {}, payload = {}) {
80
+ if (!isFresh(args.timestamp, payload.last_modified)) return false;
81
+ const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
82
+ return ["write", "patch"].includes(action);
83
+}
84
+
85
+function shouldOpenEditorUiFromResult(payload = {}, document = {}) {
86
+ return isExplicitEditorUiRequest(payload) && documentExtension(payload, document) === "md";
87
+}
88
+
89
+function isExplicitEditorUiRequest(payload = {}) {
90
+ const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
91
+ return action === "open"
92
+ || truthy(payload.open_in_canvas)
93
+ || truthy(payload.open_canvas)
94
+ || truthy(payload.open_document);
95
+}
96
+
97
+function textEditorTarget(payload = {}) {
98
+ const path = String(payload.path || "").trim();
99
+ const extension = documentExtension(payload, { path });
100
+ return {
101
+ path,
102
+ file_id: "",
103
+ extension,
104
+ format: extension,
105
+ version: payload.version || "",
106
+ last_modified: payload.last_modified || "",
107
+ };
108
+}
109
+
110
+function documentExtension(payload = {}, document = {}) {
111
+ return String(
112
+ payload.format
113
+ || payload.extension
114
+ || extensionFromPath(document.path)
115
+ || "",
116
+ ).toLowerCase().replace(/^\./, "");
117
+}
118
+
119
+function extensionFromPath(path = "") {
120
+ const clean = String(path || "").split("?")[0].split("#")[0];
121
+ const name = clean.split("/").filter(Boolean).pop() || "";
122
+ const index = name.lastIndexOf(".");
123
+ return index > 0 ? name.slice(index + 1).toLowerCase() : "";
124
+}
125
+
126
+function isEditorSurfaceOpen() {
127
+ return Boolean(
128
+ globalThis.document?.querySelector?.(
129
+ '[data-surface-id="editor"] .editor-panel, .modal-inner[data-surface-id="editor"] .editor-panel, .modal-inner[data-canvas-surface="editor"] .editor-panel',
130
+ ),
131
+ );
132
+}
133
+
134
+async function syncOpenEditorSurface(document = {}) {
135
+ const editor = editorStore;
136
+ if (!editor || !isEditorSurfaceOpen()) return false;
137
+ if (!hasSameDocument(editor, document)) return false;
138
+ if (isDirtySameDocument(editor, document)) return false;
139
+ await editor.openSession?.({
140
+ path: document.path || "",
141
+ file_id: document.file_id || "",
142
+ refresh: true,
143
+ source: "tool-result-sync",
144
+ });
145
+ return true;
146
+}
147
+
148
+function hasSameDocument(store, document = {}) {
149
+ return documentEntries(store).some((entry) => documentsMatch(entry, document));
150
+}
151
+
152
+function isDirtySameDocument(store, document = {}) {
153
+ return documentEntries(store).some((entry) => {
154
+ if (!documentsMatch(entry, document)) return false;
155
+ const isActive = entry === store?.session || (entry.tab_id && entry.tab_id === store?.activeTabId);
156
+ return Boolean(entry.dirty || (isActive && store?.dirty));
157
+ });
158
+}
159
+
160
+function documentEntries(store) {
161
+ const entries = [];
162
+ if (store?.session) entries.push(store.session);
163
+ if (Array.isArray(store?.tabs)) entries.push(...store.tabs);
164
+ return entries;
165
+}
166
+
167
+function documentsMatch(entry = {}, document = {}) {
168
+ const path = String(document.path || "").trim();
169
+ const fileId = String(document.file_id || "").trim();
170
+ const entryPath = String(entry.path || entry.document?.path || "").trim();
171
+ const entryFileId = String(entry.file_id || entry.document?.file_id || "").trim();
172
+ return Boolean(
173
+ (fileId && entryFileId === fileId)
174
+ || (path && entryPath === path),
175
+ );
176
+}
177
+
178
+function truthy(value) {
179
+ if (value === true) return true;
180
+ if (value === false || value == null) return false;
181
+ if (typeof value === "number") return value !== 0;
182
+ return ["1", "true", "yes", "y", "on"].includes(String(value).trim().toLowerCase());
183
+}
184
+
185
+function isFresh(...timestamps) {
186
+ const now = Date.now();
187
+ for (const value of timestamps) {
188
+ const time = parseTimestamp(value);
189
+ if (time && now - time < SYNC_WINDOW_MS) return true;
190
+ }
191
+ return false;
192
+}
193
+
194
+function parseTimestamp(value) {
195
+ if (!value) return 0;
196
+ if (typeof value === "number") return value > 1e12 ? value : value * 1000;
197
+ const parsed = Date.parse(String(value));
198
+ return Number.isFinite(parsed) ? parsed : 0;
199
+}
200
+
201
+function parseMaybeJson(value) {
202
+ if (!value) return null;
203
+ if (typeof value === "object") return value;
204
+ if (typeof value !== "string") return null;
205
+ const trimmed = value.trim();
206
+ if (!trimmed.startsWith("{")) return null;
207
+ try {
208
+ const parsed = JSON.parse(trimmed);
209
+ return parsed && typeof parsed === "object" ? parsed : null;
210
+ } catch {
211
+ return null;
212
+ }
213
+}
plugins/_editor/helpers/markdown_sessions.py
+184
-6
@@ -21,6 +21,10 @@ class MarkdownSession:
21
text: str = ""
22
dirty: bool = False
23
active: bool = False
24
+ base_sha256: str = ""
25
+ base_version: str = ""
26
+ external_modified: bool = False
27
+ external_version: str = ""
28
opened_at: float = field(default_factory=time.time)
29
updated_at: float = field(default_factory=time.time)
30
last_active_at: float = field(default_factory=time.time)
@@ -50,9 +54,14 @@ class MarkdownSessionManager:
54
continue
55
if sid:
56
session.sid = sid
53
- if refresh and not session.dirty:
57
+ doc_sha = str(doc.get("sha256") or "")
58
+ should_reload = not session.dirty and (refresh or (doc_sha and doc_sha != session.base_sha256))
59
+ if should_reload:
60
session.text = document_store.read_text_for_editor(doc)
61
session.dirty = False
62
+ _set_session_base(session, doc)
63
+ elif session.dirty and doc_sha and doc_sha != session.base_sha256:
64
+ _mark_session_external(session, doc)
65
session.path = doc["path"]
66
session.title = doc["basename"]
67
session.updated_at = time.time()
@@ -69,6 +78,7 @@ class MarkdownSessionManager:
78
title=doc["basename"],
79
text=document_store.read_text_for_editor(doc),
80
)
81
+ _set_session_base(session, doc)
82
self._sessions[session.session_id] = session
83
self.activate(session.session_id)
84
return self._payload(session, doc)
@@ -89,12 +99,22 @@ class MarkdownSessionManager:
99
if text is not None:
100
session.text = str(text)
101
102
+ conflict = self._save_conflict(session)
103
+ if conflict is not None:
104
+ return conflict
105
+
106
updated = document_store.write_markdown(session.file_id, session.text)
107
session.updated_at = time.time()
108
session.dirty = False
109
session.path = updated["path"]
110
session.title = updated["basename"]
97
- self._refresh_file_sessions(updated, text=session.text, dirty=False)
111
+ _set_session_base(session, updated)
112
+ self._refresh_file_sessions(
113
+ updated,
114
+ text=session.text,
115
+ dirty=False,
116
+ source_session_id=session.session_id,
117
+ )
118
return {
119
"ok": True,
120
"document": _public_doc(updated),
@@ -114,7 +134,7 @@ class MarkdownSessionManager:
134
return {"ok": True, "session_id": session.session_id}
135
136
def renamed(self, file_id: str, doc: dict[str, Any], text: str | None = None) -> dict[str, Any]:
117
- updated = self._refresh_file_sessions(doc, text=text, dirty=False)
137
+ updated = self._refresh_file_sessions(doc, text=text, dirty=False, refresh_dirty=True)
138
return {"ok": True, "updated": updated, "file_id": file_id}
139
140
def refresh_document(self, file_id: str) -> dict[str, Any]:
@@ -135,6 +155,43 @@ class MarkdownSessionManager:
155
)
156
return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed}
157
158
+ def sync_external_file_mutations(self, paths: list[str] | tuple[str, ...] | str, context_id: str = "") -> dict[str, Any]:
159
+ raw_paths = [paths] if isinstance(paths, str) else list(paths or [])
160
+ normalized_paths = [str(path or "").strip() for path in raw_paths if str(path or "").strip()]
161
+ if not normalized_paths:
162
+ return {"ok": True, "matched": 0, "sessions": []}
163
+
164
+ matched_file_ids: set[str] = set()
165
+ matched_sessions: list[str] = []
166
+ for session in list(self._sessions.values()):
167
+ if not any(_paths_match(path, session.path, session.context_id) for path in normalized_paths):
168
+ continue
169
+ matched_file_ids.add(session.file_id)
170
+ matched_sessions.append(session.session_id)
171
+
172
+ for file_id in matched_file_ids:
173
+ sessions = [session for session in self._sessions.values() if session.file_id == file_id]
174
+ if not sessions:
175
+ continue
176
+ session = sessions[0]
177
+ try:
178
+ doc = document_store.register_document(session.path, context_id=session.context_id)
179
+ except Exception:
180
+ try:
181
+ doc = document_store.get_document(file_id)
182
+ except Exception:
183
+ continue
184
+ for target in sessions:
185
+ _mark_session_external(target, doc)
186
+ continue
187
+ self.refresh_document(file_id)
188
+
189
+ return {
190
+ "ok": True,
191
+ "matched": len(matched_file_ids),
192
+ "sessions": matched_sessions,
193
+ }
194
+
195
def list_open(self, context_id: str = "", limit: int = 20) -> list[dict[str, Any]]:
196
context_id = str(context_id or "")
197
sessions = [session for session in self._sessions.values() if session.context_id == context_id]
@@ -175,6 +232,8 @@ class MarkdownSessionManager:
232
"last_modified": last_modified,
233
"dirty": session.dirty,
234
"active": session.active,
235
+ "external_modified": session.external_modified,
236
+ "external_version": session.external_version,
237
"open_sessions": counts.get(session.file_id or session.path, 1),
238
"last_active_at": session.last_active_at,
239
})
@@ -205,18 +264,90 @@ class MarkdownSessionManager:
264
self.close(session_id)
265
return len(doomed)
266
208
- def _refresh_file_sessions(self, doc: dict[str, Any], text: str | None = None, dirty: bool | None = None) -> list[str]:
267
+ def _save_conflict(self, session: MarkdownSession) -> dict[str, Any] | None:
268
+ try:
269
+ doc = document_store.get_document(session.file_id)
270
+ except Exception as exc:
271
+ return {
272
+ "ok": False,
273
+ "code": "editor_document_missing",
274
+ "error": f"Editor save failed because the document metadata is missing: {exc}",
275
+ }
276
+
277
+ path = Path(doc["path"])
278
+ desired = str(session.text or "").encode("utf-8")
279
+ desired_sha = document_store.sha256_bytes(desired)
280
+ current_exists = path.exists()
281
+ current = path.read_bytes() if current_exists else b""
282
+ current_sha = document_store.sha256_bytes(current) if current_exists else ""
283
+ expected_sha = session.base_sha256 or str(doc.get("sha256") or "")
284
+
285
+ if expected_sha and current_sha != expected_sha:
286
+ if current_exists and desired_sha == current_sha:
287
+ updated = document_store.register_document(path, context_id=session.context_id)
288
+ session.dirty = False
289
+ session.path = updated["path"]
290
+ session.title = updated["basename"]
291
+ _set_session_base(session, updated)
292
+ self._refresh_file_sessions(
293
+ updated,
294
+ text=session.text,
295
+ dirty=False,
296
+ source_session_id=session.session_id,
297
+ )
298
+ return {
299
+ "ok": True,
300
+ "document": _public_doc(updated),
301
+ "version": document_store.item_version(updated),
302
+ }
303
+
304
+ latest_doc = _refresh_registered_doc(doc, context_id=session.context_id)
305
+ _mark_session_external(session, latest_doc)
306
+ return {
307
+ "ok": False,
308
+ "code": "external_change_conflict",
309
+ "error": (
310
+ "This file changed on disk since the Editor loaded it. "
311
+ "Reload it before saving to avoid overwriting the newer file."
312
+ ),
313
+ "document": _public_doc(latest_doc),
314
+ "version": document_store.item_version(latest_doc),
315
+ }
316
+
317
+ return None
318
+
319
+ def _refresh_file_sessions(
320
+ self,
321
+ doc: dict[str, Any],
322
+ text: str | None = None,
323
+ dirty: bool | None = None,
324
+ *,
325
+ source_session_id: str = "",
326
+ refresh_dirty: bool = False,
327
+ ) -> list[str]:
328
file_id = str(doc.get("file_id") or "").strip()
329
refreshed: list[str] = []
330
for session in self._sessions.values():
331
if session.file_id != file_id:
332
continue
214
- if text is not None:
333
+ can_replace_text = (
334
+ text is not None
335
+ and (
336
+ refresh_dirty
337
+ or not session.dirty
338
+ or (source_session_id and session.session_id == source_session_id)
339
+ )
340
+ )
341
+ if can_replace_text:
342
session.text = str(text)
343
session.path = doc["path"]
344
session.title = doc["basename"]
218
- if dirty is not None:
345
+ if dirty is not None and (can_replace_text or refresh_dirty or not session.dirty):
346
session.dirty = dirty
347
+ if can_replace_text or not session.dirty:
348
+ _set_session_base(session, doc)
349
+ elif text is not None:
350
+ _mark_session_external(session, doc)
351
session.updated_at = time.time()
352
refreshed.append(session.session_id)
353
return refreshed
@@ -232,6 +363,8 @@ class MarkdownSessionManager:
363
"text": session.text,
364
"dirty": session.dirty,
365
"active": session.active,
366
+ "external_modified": session.external_modified,
367
+ "external_version": session.external_version,
368
"context_id": session.context_id,
369
"document": _public_doc(doc),
370
"version": document_store.item_version(doc),
@@ -268,6 +401,51 @@ def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
401
}
402
403
404
+def _set_session_base(session: MarkdownSession, doc: dict[str, Any]) -> None:
405
+ session.base_sha256 = str(doc.get("sha256") or "")
406
+ session.base_version = document_store.item_version(doc)
407
+ session.external_modified = False
408
+ session.external_version = ""
409
+
410
+
411
+def _mark_session_external(session: MarkdownSession, doc: dict[str, Any]) -> None:
412
+ session.external_modified = True
413
+ try:
414
+ session.external_version = document_store.item_version(doc)
415
+ except Exception:
416
+ session.external_version = ""
417
+
418
+
419
+def _refresh_registered_doc(doc: dict[str, Any], context_id: str = "") -> dict[str, Any]:
420
+ try:
421
+ path = Path(doc["path"])
422
+ if path.exists():
423
+ return document_store.register_document(path, context_id=context_id)
424
+ except Exception:
425
+ pass
426
+ return doc
427
+
428
+
429
+def _paths_match(left: str, right: str, context_id: str = "") -> bool:
430
+ left_path = _normalize_path_for_compare(left, context_id=context_id)
431
+ right_path = _normalize_path_for_compare(right, context_id=context_id)
432
+ return bool(left_path and right_path and left_path == right_path)
433
+
434
+
435
+def _normalize_path_for_compare(path: str, context_id: str = "") -> str:
436
+ value = str(path or "").strip()
437
+ if not value:
438
+ return ""
439
+ try:
440
+ return str(document_store.normalize_path(value, context_id=context_id))
441
+ except Exception:
442
+ pass
443
+ try:
444
+ return str(document_store._path_from_a0(value).resolve(strict=False))
445
+ except Exception:
446
+ return str(Path(value).expanduser().resolve(strict=False))
447
+
448
+
449
def _apply_text_patch(text: str, patch: dict[str, Any]) -> str:
450
if "content" in patch:
451
return str(patch.get("content") or "")
plugins/_editor/helpers/open_files_context.py
+4
-3
@@ -11,12 +11,12 @@ def build_context(context_id: str = "", max_items: int = 20) -> str:
11
return ""
12
13
lines = [
14
- "These Markdown files are open in the Editor for the active Agent Zero context. Content is omitted; use `document_artifact` with action `read` before content-sensitive edits.",
14
+ "These Markdown files are open in the Editor for the active Agent Zero context. Content is omitted; use `text_editor` with action `read` before content-sensitive edits.",
15
]
16
for item in files:
17
lines.append(format_open_file_line(item))
18
lines.append(
19
- "Use `document_artifact` with action `edit` and file_id or path for saved edits; Editor sessions refresh after saved tool results."
19
+ "For these paths, `text_editor` is the canonical saved-edit tool. Use `open_in_canvas: true` only when the user asks to open the Editor UI; open Editor sessions refresh automatically after saved text edits."
20
)
21
return "\n".join(lines)
22
@@ -24,9 +24,10 @@ def build_context(context_id: str = "", max_items: int = 20) -> str:
24
def format_open_file_line(item: dict[str, Any]) -> str:
25
active = "active, " if item.get("active") else ""
26
dirty = "dirty, " if item.get("dirty") else "saved, "
27
+ external = "external changes pending, " if item.get("external_modified") else ""
28
return (
29
f"- {item.get('title', 'Untitled')} "
29
- f"(.{item.get('extension', 'md')}, {active}{dirty}"
30
+ f"(.{item.get('extension', 'md')}, {active}{dirty}{external}"
31
f"file_id={item.get('file_id', '')}, path={item.get('path', '')}, "
32
f"version={item.get('version', '')}, size={item.get('size', 0)} bytes, "
33
f"last_modified={item.get('last_modified', '')}, open_sessions={item.get('open_sessions', 1)})"
plugins/_office/api/office_session.py
+7
-88
@@ -14,9 +14,6 @@ class OfficeSession(ApiHandler):
14
return libreoffice.collect_status()
15
if action == "home":
16
return {"ok": True, "path": document_store.default_open_path(context_id)}
17
- if action == "desktop":
18
- # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
19
- return self._desktop()
17
if action == "close":
18
closed = document_store.close_session(
19
session_id=str(input.get("session_id") or ""),
@@ -24,11 +21,14 @@ class OfficeSession(ApiHandler):
21
)
22
return {"ok": True, "closed": closed}
23
if action == "create":
24
+ fmt = str(input.get("format") or "odt").lower().strip().lstrip(".")
25
+ if fmt not in desktop_session.OFFICIAL_EXTENSIONS:
26
+ return {"ok": False, "error": f"Office can only create LibreOffice formats, not .{fmt}."}
27
try:
28
doc = document_store.create_document(
29
kind=str(input.get("kind") or "document"),
30
title=str(input.get("title") or "Untitled"),
31
- fmt=str(input.get("format") or "odt"),
31
+ fmt=fmt,
32
content=str(input.get("content") or ""),
33
path=str(input.get("path") or ""),
34
context_id=context_id,
@@ -59,34 +59,15 @@ class OfficeSession(ApiHandler):
59
return self._save(input)
60
if action == "renamed":
61
return self._renamed(input, context_id)
62
- if action == "desktop_save":
63
- # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
64
- return self._desktop_save(input)
65
- if action == "desktop_sync":
66
- # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
67
- return self._desktop_sync(input)
68
- if action == "desktop_state":
69
- # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
70
- return self._desktop_state(input)
71
- if action == "desktop_shutdown":
72
- # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
73
- return self._desktop_shutdown(input)
62
return {"ok": False, "error": f"Unsupported office session action: {action}"}
63
64
async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
65
mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
66
if str(doc.get("extension") or "").lower() == "md":
67
return {
80
- "ok": True,
81
- "requires_editor": True,
82
- "file_id": doc["file_id"],
83
- "title": doc["basename"],
84
- "extension": doc["extension"],
85
- "path": doc["path"],
86
- "text": "",
68
+ "ok": False,
69
+ "error": "Markdown documents use the Editor surface.",
70
"document": _public_doc(doc),
88
- "version": document_store.item_version(doc),
89
- "mode": mode,
71
}
72
if str(doc.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
73
if input.get("open_in_desktop") is not True:
@@ -164,73 +145,11 @@ class OfficeSession(ApiHandler):
145
"refreshFiles": False,
146
}
147
167
- def _desktop(self) -> dict:
168
- desktop = desktop_session.get_manager().ensure_system_desktop()
169
- if not desktop.get("available"):
170
- return {
171
- "ok": False,
172
- "error": desktop.get("error") or "Official LibreOffice desktop session is unavailable.",
173
- "desktop": desktop,
174
- "libreoffice": libreoffice.collect_status(),
175
- }
176
- document = {
177
- "file_id": desktop_session.SYSTEM_FILE_ID,
178
- "path": desktop["path"],
179
- "basename": desktop["title"],
180
- "title": desktop["title"],
181
- "extension": "desktop",
182
- "size": 0,
183
- "version": 0,
184
- }
185
- return {
186
- "ok": True,
187
- "session_id": desktop["session_id"],
188
- "desktop_session_id": desktop["session_id"],
189
- "file_id": desktop_session.SYSTEM_FILE_ID,
190
- "title": desktop["title"],
191
- "extension": "desktop",
192
- "path": desktop["path"],
193
- "text": "",
194
- "document": document,
195
- "version": 0,
196
- "desktop": desktop,
197
- "store_session_id": "",
198
- "mode": "desktop",
199
- }
200
-
201
- def _desktop_save(self, input: dict) -> dict:
202
- session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
203
- if not session_id:
204
- return {"ok": False, "error": "desktop_session_id is required."}
205
- return desktop_session.get_manager().save(
206
- session_id,
207
- file_id=str(input.get("file_id") or ""),
208
- )
209
-
210
- def _desktop_sync(self, input: dict) -> dict:
211
- return desktop_session.get_manager().sync(
212
- session_id=str(input.get("desktop_session_id") or input.get("session_id") or ""),
213
- file_id=str(input.get("file_id") or ""),
214
- )
215
-
216
- def _desktop_state(self, input: dict) -> dict:
217
- include_screenshot = bool(input.get("include_screenshot") is True)
218
- return desktop_session.get_manager().state(
219
- include_screenshot=include_screenshot,
220
- context_id=str(input.get("ctxid") or input.get("context_id") or ""),
221
- )
222
-
223
- def _desktop_shutdown(self, input: dict) -> dict:
224
- save_first = input.get("save_first") is not False
225
- return desktop_session.get_manager().shutdown_system_desktop(
226
- save_first=save_first,
227
- source=str(input.get("source") or "api"),
228
- )
229
-
148
def _origin(self, request: Request) -> str:
149
origin = request.headers.get("Origin") or request.host_url.rstrip("/")
150
return origin.rstrip("/")
151
152
+
153
def _public_doc(doc: dict) -> dict:
154
result = {
155
"file_id": doc["file_id"],
plugins/_office/api/ws_office.py
+14
-13
@@ -22,8 +22,7 @@ class WsOffice(WsHandler):
22
if event in {"office_input", "office_save", "office_close"}:
23
return {
24
"ok": False,
25
- "requires_editor": True,
26
- "error": "Markdown editing moved to /plugins/_editor.",
25
+ "error": "Office WebSocket editing is not available for Markdown; use the Editor surface.",
26
}
27
except FileNotFoundError as exc:
28
return WsResult.error(code="OFFICE_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId"))
@@ -45,25 +44,27 @@ class WsOffice(WsHandler):
44
elif path:
45
doc = document_store.register_document(path, context_id=context_id)
46
else:
47
+ fmt = str(data.get("format") or "odt").lower().strip().lstrip(".")
48
+ if fmt not in desktop_session.OFFICIAL_EXTENSIONS:
49
+ return WsResult.error(
50
+ code="UNSUPPORTED_OFFICE_DOCUMENT",
51
+ message=f"Office can only create LibreOffice formats, not .{fmt}.",
52
+ correlation_id=data.get("correlationId"),
53
+ )
54
doc = document_store.create_document(
55
kind=str(data.get("kind") or "document"),
56
title=str(data.get("title") or "Untitled"),
51
- fmt=str(data.get("format") or "odt"),
57
+ fmt=fmt,
58
content=str(data.get("content") or ""),
59
context_id=context_id,
60
)
61
ext = str(doc.get("extension") or "").lower()
62
if ext == "md":
57
- return {
58
- "ok": True,
59
- "requires_editor": True,
60
- "file_id": doc["file_id"],
61
- "title": doc["basename"],
62
- "extension": doc["extension"],
63
- "path": doc["path"],
64
- "document": _public_doc(doc),
65
- "version": document_store.item_version(doc),
66
- }
63
+ return WsResult.error(
64
+ code="UNSUPPORTED_OFFICE_DOCUMENT",
65
+ message="Markdown documents use the Editor surface.",
66
+ correlation_id=data.get("correlationId"),
67
+ )
68
if ext in desktop_session.OFFICIAL_EXTENSIONS:
69
return {
70
"ok": True,
plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py
deleted
-12
@@ -1,12 +0,0 @@
1
-from __future__ import annotations
2
-
3
-from typing import Any
4
-
5
-from helpers.extension import Extension
6
-
7
-
8
-class DocumentResponseAffordance(Extension):
9
- """Compatibility shim for the retired response artifact affordance."""
10
-
11
- async def execute(self, **kwargs: Any):
12
- return None
plugins/_office/extensions/webui/get_tool_message_handler/office-artifact-handler.js
renamed
+5
-5
@@ -3,13 +3,13 @@ import {
3
drawProcessStep,
4
} from "/js/messages.js";
5
6
-export default async function registerDocumentArtifactHandler(extData) {
7
- if (extData?.tool_name === "document_artifact") {
8
- extData.handler = drawDocumentArtifactTool;
6
+export default async function registerOfficeArtifactHandler(extData) {
7
+ if (extData?.tool_name === "office_artifact") {
8
+ extData.handler = drawOfficeArtifactTool;
9
}
10
}
11
12
-function drawDocumentArtifactTool({
12
+function drawOfficeArtifactTool({
13
id,
14
type,
15
heading,
@@ -26,7 +26,7 @@ function drawDocumentArtifactTool({
26
return drawProcessStep({
27
id,
28
title,
29
- code: "DOC",
29
+ code: "OFF",
30
classes: undefined,
31
kvps: displayKvps,
32
content,
plugins/_office/extensions/webui/lib/document-actions.js
+5
-26
@@ -1,7 +1,6 @@
1
import { showButtonFeedback } from "/components/messages/action-buttons/simple-action-buttons.js";
2
import { open as openSurface } from "/js/surfaces.js";
3
4
-const EDITOR_FORMATS = ["md"];
4
const DESKTOP_FORMATS = ["odt", "ods", "odp", "docx", "xlsx", "pptx"];
5
6
function basename(path = "") {
@@ -105,28 +104,10 @@ export async function openDocumentInDesktop(document = {}) {
104
});
105
}
106
108
-export async function openDocumentInEditor(document = {}) {
109
- await openSurface("editor", {
110
- path: document.path || "",
111
- file_id: document.file_id || "",
112
- refresh: true,
113
- source: "message-action",
114
- });
115
-}
116
-
117
-export async function openDocumentArtifact(document = {}) {
118
- if (usesEditor(document)) {
119
- await openDocumentInEditor(document);
120
- return;
121
- }
107
+export async function openOfficeArtifact(document = {}) {
108
await openDocumentInDesktop(document);
109
}
110
125
-function usesEditor(doc = {}) {
126
- const format = String(doc.format || doc.extension || "").toLowerCase();
127
- return EDITOR_FORMATS.includes(format);
128
-}
129
-
111
function usesDesktop(doc = {}) {
112
const format = String(doc.format || doc.extension || "").toLowerCase();
113
return DESKTOP_FORMATS.includes(format);
@@ -137,7 +118,6 @@ function canvasActionTitle(doc = {}) {
118
if (["odt", "docx"].includes(format)) return "Open in canvas with Writer";
119
if (["ods", "xlsx"].includes(format)) return "Open in canvas with Calc";
120
if (["odp", "pptx"].includes(format)) return "Open in canvas with Impress";
140
- if (format === "md") return "Open Markdown in Editor";
121
return "Open in canvas";
122
}
123
@@ -145,7 +125,6 @@ function documentIcon(doc = {}) {
125
const format = String(doc.format || doc.extension || "").toLowerCase();
126
if (["ods", "xlsx"].includes(format)) return "table_chart";
127
if (["odp", "pptx"].includes(format)) return "slideshow";
148
- if (format === "md") return "article";
128
return usesDesktop(doc) ? "description" : "draft";
129
}
130
@@ -179,7 +158,7 @@ export function buildDocumentFileCard(document = {}) {
158
159
const detail = globalThis.document.createElement("span");
160
detail.className = "document-file-card-path";
182
- detail.textContent = statusLine(document) || "Document artifact";
161
+ detail.textContent = statusLine(document) || "Office artifact";
162
meta.appendChild(detail);
163
card.appendChild(meta);
164
@@ -191,11 +170,11 @@ export function buildDocumentFileCard(document = {}) {
170
}
171
172
if (document.path || document.file_id) {
194
- card.addEventListener("click", () => openDocumentArtifact(document));
173
+ card.addEventListener("click", () => openOfficeArtifact(document));
174
card.addEventListener("keydown", (event) => {
175
if (event.key !== "Enter" && event.key !== " ") return;
176
event.preventDefault();
198
- void openDocumentArtifact(document);
177
+ void openOfficeArtifact(document);
178
});
179
} else {
180
card.setAttribute("aria-disabled", "true");
@@ -258,7 +237,7 @@ export function buildDocumentFileActionButtons(document = {}) {
237
createDocumentActionButton(
238
"open_in_new",
239
"Open in canvas",
261
- () => openDocumentArtifact(document),
240
+ () => openOfficeArtifact(document),
241
{
242
className: "document-file-action-primary",
243
title: canvasActionTitle(document),
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+6
-71
@@ -1,8 +1,8 @@
1
import { store as officeStore } from "/plugins/_office/webui/office-store.js";
2
import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js";
3
-import { store as editorStore } from "/plugins/_editor/webui/editor-store.js";
3
import { open as openSurface } from "/js/surfaces.js";
4
5
+const OFFICE_FORMATS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
6
const SYNC_WINDOW_MS = 10 * 60 * 1000;
7
const syncedDocumentResults = new Set();
8
@@ -12,11 +12,7 @@ export default async function syncDocumentResultsIntoOpenSurfaces(context) {
12
for (const { args } of context.results) {
13
const payload = getDocumentPayload(args);
14
const toolName = getToolName(payload);
15
- if (toolName === "text_editor") {
16
- syncTextEditorMarkdownResult(args, payload);
17
- continue;
18
- }
19
- if (toolName !== "document_artifact") continue;
15
+ if (toolName !== "office_artifact") continue;
16
if (!shouldSyncOpenOfficeModal(args, payload)) continue;
17
18
const document = payload.document && typeof payload.document === "object" ? payload.document : {};
@@ -48,16 +44,6 @@ export default async function syncDocumentResultsIntoOpenSurfaces(context) {
44
}
45
}
46
51
-function syncTextEditorMarkdownResult(args = {}, payload = {}) {
52
- const target = textEditorTarget(payload);
53
- if (!target.path || target.extension !== "md") return;
54
- if (!shouldSyncTextEditorResult(args, payload)) return;
55
-
56
- globalThis.setTimeout(() => {
57
- void syncOpenEditorSurface(target);
58
- }, 0);
59
-}
60
-
47
function documentTarget(payload = {}, document = {}) {
48
const extension = documentExtension(payload, document);
49
return {
@@ -101,7 +87,6 @@ function pickPayloadFields(args = {}) {
87
"path",
88
"version",
89
"last_modified",
104
- "method",
90
]) {
91
if (args[key] != null && args[key] !== "") payload[key] = args[key];
92
}
@@ -118,15 +103,9 @@ function shouldSyncOpenOfficeModal(args = {}, payload = {}) {
103
return ["create", "open", "edit", "restore_version"].includes(action);
104
}
105
121
-function shouldSyncTextEditorResult(args = {}, payload = {}) {
122
- if (!isFresh(args.timestamp, payload.last_modified)) return false;
123
- const action = String(payload.action || payload.method || "").trim().toLowerCase().replace("-", "_");
124
- return ["write", "patch"].includes(action);
125
-}
126
-
106
function shouldOpenDocumentUiFromResult(payload = {}, document = {}) {
107
if (!isExplicitDocumentUiRequest(payload)) return false;
129
- return Boolean(documentExtension(payload, document));
108
+ return OFFICE_FORMATS.has(documentExtension(payload, document));
109
}
110
111
function isExplicitDocumentUiRequest(payload = {}) {
@@ -158,27 +137,8 @@ function documentExtension(payload = {}, document = {}) {
137
).toLowerCase();
138
}
139
161
-function textEditorTarget(payload = {}) {
162
- const path = String(payload.path || "").trim();
163
- return {
164
- path,
165
- file_id: "",
166
- extension: extensionFromPath(path),
167
- format: extensionFromPath(path),
168
- version: "",
169
- last_modified: payload.last_modified || "",
170
- };
171
-}
172
-
173
-function extensionFromPath(path = "") {
174
- const clean = String(path || "").split("?")[0].split("#")[0];
175
- const name = clean.split("/").filter(Boolean).pop() || "";
176
- const index = name.lastIndexOf(".");
177
- return index > 0 ? name.slice(index + 1).toLowerCase() : "";
178
-}
179
-
180
-function surfaceForDocument(payload = {}, document = {}) {
181
- return documentExtension(payload, document) === "md" ? "editor" : "desktop";
140
+function surfaceForDocument(_payload = {}, _document = {}) {
141
+ return "desktop";
142
}
143
144
function isOfficeModalOpen() {
@@ -201,37 +161,12 @@ function isDesktopSurfaceOpen() {
161
);
162
}
163
204
-function isEditorSurfaceOpen() {
205
- return Boolean(
206
- globalThis.document?.querySelector?.(
207
- '[data-surface-id="editor"] .editor-panel, .modal-inner[data-surface-id="editor"] .editor-panel, .modal-inner[data-canvas-surface="editor"] .editor-panel',
208
- ),
209
- );
210
-}
211
-
164
async function syncOpenDocumentSurfaces(document = {}) {
213
- if (documentExtension({}, document) === "md") {
214
- await syncOpenEditorSurface(document);
215
- return;
216
- }
165
+ if (!OFFICE_FORMATS.has(documentExtension({}, document))) return;
166
await syncOpenDesktopCanvas(document);
167
await syncOpenOfficeModal(document);
168
}
169
221
-async function syncOpenEditorSurface(document = {}) {
222
- const editor = editorStore;
223
- if (!editor || !isEditorSurfaceOpen()) return false;
224
- if (!hasSameDocument(editor, document)) return false;
225
- if (isDirtySameDocument(editor, document)) return false;
226
- await editor.openSession?.({
227
- path: document.path || "",
228
- file_id: document.file_id || "",
229
- refresh: true,
230
- source: "tool-result-sync",
231
- });
232
- return true;
233
-}
234
-
170
async function syncOpenDesktopCanvas(document = {}) {
171
const desktop = desktopStore;
172
if (!desktop || !isDesktopSurfaceOpen()) return false;
plugins/_office/extensions/webui/set_messages_after_loop/document-response-file-cards.js
+3
-1
@@ -15,6 +15,7 @@ const RESPONSE_CARD_ACTIONS = new Set([
15
"restore_version",
16
"update",
17
]);
18
+const OFFICE_FORMATS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
19
20
let pendingContextId = "";
21
let pendingDocuments = [];
@@ -57,7 +58,8 @@ function documentEntryFromToolResult(args = {}) {
58
const result = parseDocumentResult(String(args.content ?? ""));
59
const document = documentFromLog(args, result);
60
if (!document.path && !document.file_id) return null;
60
- if (toolName(args, result) !== "document_artifact") return null;
61
+ if (toolName(args, result) !== "office_artifact") return null;
62
+ if (!OFFICE_FORMATS.has(String(document.extension || document.format || "").toLowerCase())) return null;
63
if (!isResponseCardAction(document.action)) return null;
64
return {
65
document,
plugins/_office/helpers/artifact_editor.py
+12
-57
@@ -51,8 +51,8 @@ def read_artifact(doc: dict[str, Any], max_chars: int = 12000) -> dict[str, Any]
51
path = Path(doc["path"])
52
ext = str(doc["extension"]).lower()
53
if ext == "md":
54
- content = _read_markdown(path)
55
- elif ext == "odt":
54
+ raise ValueError("Office artifact reads are not available for Markdown; use text_editor.")
55
+ if ext == "odt":
56
content = _read_odt(path)
57
elif ext == "ods":
58
content = _read_ods(path)
@@ -104,7 +104,7 @@ def edit_artifact(
104
105
invalidate_sessions = bool(kwargs.pop("invalidate_sessions", False))
106
if ext == "md":
107
- updated, details = _edit_markdown(before, op, content=content, find=find, replace=replace, **kwargs)
107
+ raise ValueError("Office artifact edits are not available for Markdown; use text_editor.")
108
elif ext == "odt":
109
updated, details = _edit_odt(before, op, content=content, find=find, replace=replace, **kwargs)
110
elif ext == "ods":
@@ -125,14 +125,14 @@ def edit_artifact(
125
document_store.replace_document_bytes(
126
doc["file_id"],
127
updated,
128
- actor="document_artifact:edit",
128
+ actor="office_artifact:edit",
129
invalidate_sessions=invalidate_sessions,
130
)
131
if changed
132
else doc
133
)
134
if changed:
135
- _refresh_open_editor_sessions(updated_doc["file_id"])
135
+ _refresh_open_office_sessions(updated_doc["file_id"])
136
preview = read_artifact(updated_doc, max_chars=int(kwargs.get("preview_chars") or 4000))
137
payload = {
138
"ok": True,
@@ -269,19 +269,13 @@ def _looks_like_replace_operation(operation: str = "") -> bool:
269
return op in {"replace", "replace_text", "patch", "update"}
270
271
272
-def _refresh_open_editor_sessions(file_id: str) -> None:
273
- try:
274
- from plugins._editor.helpers import markdown_sessions
275
-
276
- markdown_sessions.get_manager().refresh_document(file_id)
277
- except Exception:
278
- # Direct artifact edits should never fail just because no canvas is open.
279
- pass
272
+def _refresh_open_office_sessions(file_id: str) -> None:
273
try:
274
from plugins._desktop.helpers import desktop_session
275
276
desktop_session.get_manager().refresh_document(file_id)
277
except Exception:
278
+ # Direct artifact edits should never fail just because no Office surface is open.
279
pass
280
281
@@ -296,7 +290,7 @@ def normalize_operation(
290
slides: Any = None,
291
) -> str:
292
op = str(operation or "").strip().lower().replace("-", "_")
299
- aliases = {
293
+ operation_map = {
294
"patch": "replace_text" if find else "set_text",
295
"update": "replace_text" if find else "set_text",
296
"replace": "replace_text",
@@ -321,7 +315,7 @@ def normalize_operation(
315
"add_slide": "append_slide",
316
"set_deck": "set_slides",
317
}
324
- op = aliases.get(op, op)
318
+ op = operation_map.get(op, op)
319
if op:
320
return op
321
if cells:
@@ -339,19 +333,6 @@ def normalize_operation(
333
raise ValueError("operation is required")
334
335
342
-def _read_markdown(path: Path) -> dict[str, Any]:
343
- text = path.read_text(encoding="utf-8", errors="replace")
344
- lines = [line for line in text.splitlines() if line.strip()]
345
- headings = [line.lstrip("#").strip() for line in lines if line.lstrip().startswith("#")]
346
- return {
347
- "kind": "document",
348
- "format": "markdown",
349
- "line_count": len(text.splitlines()),
350
- "headings": headings[:40],
351
- "text": text,
352
- }
353
-
354
-
336
def _read_odt(path: Path) -> dict[str, Any]:
337
root = _odf_content_root(path)
338
paragraphs = _odf_text_lines(root)
@@ -478,32 +459,6 @@ def _read_pptx(path: Path) -> dict[str, Any]:
459
}
460
461
481
-def _edit_markdown(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
482
- if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
483
- raise ValueError(f"Unsupported Markdown operation: {op}")
484
-
485
- text = before.decode("utf-8", errors="replace")
486
- if op == "set_text":
487
- updated = content
488
- details = {"lines_written": len(content.splitlines())}
489
- elif op == "append_text":
490
- separator = "" if not text or text.endswith("\n") else "\n"
491
- updated = f"{text}{separator}{content}"
492
- details = {"lines_appended": len(content.splitlines())}
493
- elif op == "prepend_text":
494
- separator = "" if not text or content.endswith("\n") else "\n"
495
- updated = f"{content}{separator}{text}"
496
- details = {"lines_prepended": len(content.splitlines())}
497
- else:
498
- if not find:
499
- raise ValueError("find is required for replace_text")
500
- replacement = "" if op == "delete_text" else replace
501
- count_limit = _int_or_none(kwargs.get("count"))
502
- updated, count = _replace_limited(text, find, replacement, count_limit)
503
- details = {"replacements": count}
504
- return updated.encode("utf-8"), details
505
-
506
-
462
def _edit_odt(before: bytes, op: str, *, content: str = "", find: str = "", replace: str = "", **kwargs: Any) -> tuple[bytes, dict[str, Any]]:
463
if op not in {"set_text", "append_text", "prepend_text", "replace_text", "delete_text"}:
464
raise ValueError(f"Unsupported ODT operation: {op}")
@@ -755,7 +710,7 @@ _CHART_SPEC_KEYS = {
710
"yvalues",
711
}
712
758
-_CHART_TYPE_ALIASES = {
713
+_CHART_TYPE_NORMALIZATIONS = {
714
"area": "area",
715
"bar": "bar",
716
"candlestick": "stock",
@@ -815,7 +770,7 @@ def _normalize_chart_spec(value: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
770
explicit_type = bool(spec.get("type") or spec.get("chart_type"))
771
chart_type = str(spec.get("type") or spec.get("chart_type") or "").strip().lower().replace("-", "_")
772
if chart_type:
818
- chart_type = _CHART_TYPE_ALIASES.get(chart_type, chart_type)
773
+ chart_type = _CHART_TYPE_NORMALIZATIONS.get(chart_type, chart_type)
774
spec["type"] = chart_type
775
spec["_explicit_type"] = explicit_type
776
spec["position"] = str(spec.get("position") or spec.get("anchor") or "H2")
@@ -832,7 +787,7 @@ def _create_xlsx_chart(workbook: Any, default_worksheet: Any, spec: dict[str, An
787
openpyxl = _require_openpyxl()
788
worksheet = _worksheet(workbook, str(spec.get("sheet") or default_worksheet.title))
789
chart_type = spec["type"] or _infer_default_chart_type(worksheet)
835
- if chart_type not in _CHART_TYPE_ALIASES.values():
790
+ if chart_type not in _CHART_TYPE_NORMALIZATIONS.values():
791
raise ValueError(f"Unsupported XLSX chart type: {chart_type}")
792
793
if spec["replace_existing"]:
plugins/_office/helpers/canvas_context.py
+9
-3
@@ -5,20 +5,26 @@ from typing import Any
5
from plugins._desktop.helpers import desktop_state
6
from plugins._office.helpers import document_store
7
8
+OFFICE_EXTENSIONS = document_store.OPEN_DOCUMENT_EXTENSIONS | document_store.OOXML_EXTENSIONS
9
+
10
11
def build_context(max_items: int = 6) -> str:
10
- documents = document_store.get_open_documents(limit=max_items)
12
+ documents = [
13
+ doc
14
+ for doc in document_store.get_open_documents(limit=max(max_items * 4, 20))
15
+ if str(doc.get("extension") or "").lower() in OFFICE_EXTENSIONS
16
+ ][:max_items]
17
desktop_context = build_desktop_context()
18
if not documents:
19
return desktop_context
20
21
lines = [
16
- "These document artifacts have active document sessions. Content is omitted; load skill `document-artifacts` for edit workflow, then use `document_artifact` with action `read` before content-sensitive edits.",
22
+ "These Office artifacts have active document sessions. Content is omitted; use `office_artifact` with action `read` before content-sensitive edits.",
23
]
24
for doc in documents:
25
lines.append(format_document_line(doc))
26
lines.append(
21
- "Use `document_artifact` with action `edit` and file_id or path for saved edits; tool results refresh the document canvas."
27
+ "Use `office_artifact` with action `edit` and file_id or path for saved Office edits; tool results refresh already-open document canvases automatically."
28
)
29
if desktop_context:
30
lines.extend(["", desktop_context])
plugins/_office/helpers/document_affordance.py
deleted
-62
@@ -1,62 +0,0 @@
1
-from __future__ import annotations
2
-
3
-from dataclasses import dataclass
4
-from typing import Any
5
-
6
-
7
-@dataclass(frozen=True)
8
-class ArtifactDecision:
9
- """Deprecated compatibility type for the retired response affordance."""
10
-
11
- kind: str
12
- fmt: str
13
- title: str
14
- content: str
15
- reason: str
16
-
17
-
18
-def decide_response_artifact(user_message: Any, response_text: str) -> None:
19
- """Response text never creates document artifacts.
20
-
21
- File creation is intentionally opt-in through the document_artifact tool.
22
- This function remains as a compatibility import point for older code and
23
- tests that still probe the retired affordance.
24
- """
25
-
26
- return None
27
-
28
-
29
-def format_created_response(basename: str, path: str) -> str:
30
- return (
31
- f"Created **{basename}**.\n\n"
32
- f"Path: `{path}`"
33
- )
34
-
35
-
36
-def is_subordinate_agent(agent: Any) -> bool:
37
- number = getattr(agent, "number", None)
38
- if number is not None:
39
- try:
40
- return int(number) > 0
41
- except (TypeError, ValueError):
42
- pass
43
-
44
- agent_name = str(getattr(agent, "agent_name", "") or "").strip().lower()
45
- if agent_name.startswith("a") and agent_name[1:].isdigit():
46
- return int(agent_name[1:]) > 0
47
- if agent_name.isdigit():
48
- return int(agent_name) > 0
49
-
50
- get_data = getattr(agent, "get_data", None)
51
- if callable(get_data):
52
- try:
53
- if get_data("_superior") is not None:
54
- return True
55
- except Exception:
56
- pass
57
-
58
- data = getattr(agent, "data", None)
59
- if isinstance(data, dict) and data.get("_superior") is not None:
60
- return True
61
-
62
- return False
plugins/_office/helpers/libreoffice_desktop.py
deleted
-6
@@ -1,6 +0,0 @@
1
-from __future__ import annotations
2
-
3
-# Compatibility facade for pre-split callers. New Desktop runtime ownership
4
-# lives in plugins._desktop.helpers.desktop_session.
5
-from plugins._desktop.helpers.desktop_session import * # noqa: F401,F403
6
-from plugins._desktop.helpers.desktop_session import DesktopSessionManager as LibreOfficeDesktopManager
plugins/_office/helpers/markdown_sessions.py
deleted
-10
@@ -1,10 +0,0 @@
1
-"""Compatibility shim for the Markdown session manager.
2
-
3
-The native Markdown editor now lives in the `_editor` builtin plugin. Keep this
4
-module as a narrow import bridge for older extension code that has not yet moved
5
-its import path, but do not add Office-owned Markdown behavior here.
6
-"""
7
-
8
-from plugins._editor.helpers.markdown_sessions import MarkdownSession, MarkdownSessionManager, get_manager
9
-
10
-__all__ = ["MarkdownSession", "MarkdownSessionManager", "get_manager"]
plugins/_office/hooks.py
+2
-39
@@ -126,7 +126,6 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
126
127
_retire_supervisor_program(errors)
128
_ensure_runtime_dependencies(installed, errors)
129
- _ensure_desktop_runtime_compat(installed, removed, migrated, warnings, errors)
129
return {
130
"ok": not errors,
131
"skipped": not cleanup_needed,
@@ -140,9 +139,9 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
139
140
def timezone_changed(timezone: str, previous_timezone: str | None = None) -> dict[str, Any]:
141
try:
143
- from plugins._office.helpers import libreoffice_desktop
142
+ from plugins._desktop.helpers import desktop_session
143
145
- return libreoffice_desktop.get_manager().sync_timezone(timezone)
144
+ return desktop_session.get_manager().sync_timezone(timezone)
145
except Exception as exc:
146
return {
147
"ok": False,
@@ -241,42 +240,6 @@ def _migrate_retired_plugin_state(
240
)
241
242
244
-def _ensure_desktop_runtime_compat(
245
- installed: list[str],
246
- removed: list[str],
247
- migrated: list[str],
248
- warnings: list[str],
249
- errors: list[str],
250
-) -> None:
251
- """Keep self-update compatibility for managers that only invoke _office/hooks.py.
252
-
253
- Agent Zero 1.10-1.13 self-update managers call the Office cleanup hook
254
- directly before starting the updated UI. Desktop runtime ownership now lives
255
- in _desktop, so this temporary delegate preserves the old pre-launch cleanup
256
- and package-preparation behavior for users updating from those releases.
257
- """
258
-
259
- try:
260
- from plugins._desktop import hooks as desktop_hooks
261
- except Exception as exc:
262
- warnings.append(f"Desktop runtime compatibility hook unavailable: {exc}")
263
- return
264
-
265
- try:
266
- result = desktop_hooks.cleanup_stale_runtime_state()
267
- except Exception as exc:
268
- errors.append(f"Desktop runtime compatibility hook failed: {exc}")
269
- return
270
-
271
- if not isinstance(result, dict):
272
- return
273
- installed.extend(str(item) for item in result.get("installed") or [])
274
- removed.extend(str(item) for item in result.get("removed") or [])
275
- migrated.extend(str(item) for item in result.get("migrated") or [])
276
- warnings.extend(str(item) for item in result.get("warnings") or [])
277
- errors.extend(str(item) for item in result.get("errors") or [])
278
-
279
-
243
def _remove_path(path: Path) -> bool:
244
if path.is_symlink() or path.is_file():
245
path.unlink(missing_ok=True)
plugins/_office/plugin.yaml
+1
-1
@@ -1,6 +1,6 @@
1
name: _office
2
title: LibreOffice
3
-description: ODF-first LibreOffice document artifacts and compatibility document tooling.
3
+description: ODF-first LibreOffice Office artifacts for Writer, Calc, and Impress files.
4
version: "0.1"
5
settings_sections:
6
- developer
plugins/_office/prompts/agent.system.tool.office_artifact.md
renamed
+8
-8
@@ -1,19 +1,19 @@
1
-### document_artifact
2
-create/open/read/edit reusable document artifacts in Agent Zero
3
-formats: md odt ods odp docx xlsx pptx
4
-default format: md
1
+### office_artifact
2
+create/open/read/edit/export Office artifacts in Agent Zero
3
+formats: odt ods odp docx xlsx pptx
4
+defaults: document->odt spreadsheet->ods presentation->odp
5
actions: create open read edit inspect export version_history restore_version status
6
common args: action kind title format content path file_id operation find replace
7
optional UI intent args: open_in_canvas open_in_desktop
8
+Office formats only; use `text_editor` for Markdown and plain text files
9
create/read/edit results save or update artifacts only; they do not open a surface automatically unless the user explicitly asks to open the document UI
9
-Markdown opens in the Editor surface; ODT/ODS/ODP/DOCX/XLSX/PPTX open in Desktop
10
-use action `open`, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop
10
+use action `open`, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the Office document/Desktop
11
+automatic refresh is separate from UI opening: already-open Desktop/Office surfaces refresh after saved tool results without any flag
12
for action `edit`, use operation and put append/prepend/set text in `content` (example: operation `append_text`, content "new line")
13
after create/edit, answer briefly with what changed and the saved path when useful; do not write faux UI action labels like "Open document" or "Download file"
13
-do not add a note saying the canvas/document UI was not opened automatically unless the user explicitly asks about UI behavior
14
ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests OOXML compatibility
15
DOCX/XLSX/PPTX are compatibility formats, not defaults
16
XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts when an embedded chart is required
17
chart types: line bar column pie area scatter stock ohlc candlestick
18
ODS/XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
19
-for nontrivial document artifact work, load skill `document-artifacts` or the specific Markdown/Writer/Calc/Impress skill first
19
+for nontrivial office artifact work, load the Writer/Calc/Impress skill that matches the requested format
plugins/_office/skills/calc-spreadsheets/SKILL.md
+3
-3
@@ -18,7 +18,7 @@ triggers:
18
- "sheet"
19
- "chart"
20
allowed_tools:
21
- - document_artifact
21
+ - office_artifact
22
---
23
24
# Calc Spreadsheets
@@ -33,7 +33,7 @@ Create a workbook:
33
34
```json
35
{
36
- "tool_name": "document_artifact",
36
+ "tool_name": "office_artifact",
37
"tool_args": {
38
"action": "create",
39
"kind": "spreadsheet",
@@ -50,7 +50,7 @@ Edit cells:
50
51
```json
52
{
53
- "tool_name": "document_artifact",
53
+ "tool_name": "office_artifact",
54
"tool_args": {
55
"action": "edit",
56
"file_id": "abc123",
plugins/_office/skills/impress-presentations/SKILL.md
+3
-3
@@ -18,7 +18,7 @@ triggers:
18
- "deck"
19
- "Impress"
20
allowed_tools:
21
- - document_artifact
21
+ - office_artifact
22
---
23
24
# Impress Presentations
@@ -33,7 +33,7 @@ Create:
33
34
```json
35
{
36
- "tool_name": "document_artifact",
36
+ "tool_name": "office_artifact",
37
"tool_args": {
38
"action": "create",
39
"kind": "presentation",
@@ -48,7 +48,7 @@ Edit slides:
48
49
```json
50
{
51
- "tool_name": "document_artifact",
51
+ "tool_name": "office_artifact",
52
"tool_args": {
53
"action": "edit",
54
"file_id": "abc123",
plugins/_office/skills/markdown-documents/SKILL.md
+7
-9
@@ -13,32 +13,30 @@ triggers:
13
- "report"
14
- "editable writing"
15
allowed_tools:
16
- - document_artifact
16
+ - text_editor
17
---
18
19
# Markdown Documents
20
21
Markdown is the default document format for normal writing, notes, reports, briefs, drafts, and collaborative text work unless the user explicitly asks for a binary office file. When they do ask for a LibreOffice office file, prefer ODF: ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only for explicit OOXML compatibility.
22
23
-The Editor surface is user-owned UI. Create or update the saved Markdown artifact, but never open the Editor automatically. Keep the final response to the saved/updated result and path; do not write faux UI action labels such as "Open document" or "Download file", and do not add a note saying the canvas was not opened automatically unless the user explicitly asks about UI behavior.
23
+The Editor surface is user-owned UI. Create or update the saved Markdown file, but never open the Editor automatically. Set `open_in_canvas: true` only when the user explicitly asks to open the canvas/Editor; otherwise already-open Editor sessions refresh automatically. Keep the final response to the saved/updated result and path; do not write faux UI action labels such as "Open document" or "Download file", and do not add a note saying the canvas was not opened automatically unless the user explicitly asks about UI behavior.
24
25
## Workflow
26
27
1. Decide whether a saved editable artifact is useful. Create one for substantial, reusable, or collaborative writing; do not create one for tiny one-shot edits or answers that can be completed cleanly in chat.
28
-2. Create Markdown with `document_artifact` using `action: "create"`, `kind: "document"`, and `format: "md"`.
29
-3. For edits to an existing Markdown artifact, read first when content matters, then use the `edit` action.
28
+2. Create Markdown with `text_editor` using `action: "write"` and an explicit `.md` path.
29
+3. For edits to an existing Markdown file, read first when content matters, then use `patch` for targeted changes or `write` for deliberate full replacement.
30
4. Report the saved file path briefly. Do not say it was opened unless the user explicitly opened it.
31
32
Minimal create:
33
34
```json
35
{
36
- "tool_name": "document_artifact",
36
+ "tool_name": "text_editor",
37
"tool_args": {
38
- "action": "create",
39
- "kind": "document",
40
- "title": "Project Brief",
41
- "format": "md",
38
+ "action": "write",
39
+ "path": "/a0/usr/workdir/Project Brief.md",
40
"content": "# Project Brief\n\nDraft text here."
41
}
42
}
plugins/_office/skills/office-artifacts/SKILL.md
renamed
+25
-27
@@ -1,14 +1,12 @@
1
---
2
-name: document-artifacts
3
-description: Use when creating, opening, reading, or editing editable document artifacts such as Markdown documents, LibreOffice-native ODT/ODS/ODP files, and compatibility DOCX/XLSX/PPTX files with the document_artifact tool.
2
+name: office-artifacts
3
+description: Use when creating, opening, reading, or editing Office artifacts such as LibreOffice-native ODT/ODS/ODP files and compatibility DOCX/XLSX/PPTX files with the office_artifact tool.
4
version: "1.4.0"
5
author: "Agent Zero Core Team"
6
-tags: ["documents", "markdown", "md", "odt", "ods", "odp", "docx", "xlsx", "pptx", "editor", "spreadsheets", "presentations", "libreoffice", "opendocument"]
6
+tags: ["office", "documents", "odt", "ods", "odp", "docx", "xlsx", "pptx", "spreadsheets", "presentations", "libreoffice", "opendocument"]
7
triggers:
8
- - "document artifact"
9
- - "markdown document"
10
- - "editable document"
11
- - "md"
8
+ - "office artifact"
9
+ - "office document"
10
- "odt"
11
- "ods"
12
- "odp"
@@ -19,42 +17,42 @@ triggers:
17
- "spreadsheet"
18
- "presentation"
19
allowed_tools:
22
- - document_artifact
20
+ - office_artifact
21
+ - text_editor
22
---
23
25
-# Document Artifacts
24
+# Office Artifacts
25
27
-Use `document_artifact` for substantial deliverables that should remain editable in the Markdown Editor surface or LibreOffice Desktop. Markdown remains the default for ordinary writing, notes, reports, briefs, and drafts when no binary office file is needed. For LibreOffice office files, ODF is first-class: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only when the user explicitly asks for OOXML compatibility, provides an existing file in that format, or needs that compatibility format.
26
+Use `office_artifact` for deliverables that must be real Office packages in LibreOffice Desktop. Use `text_editor` for Markdown and plain text. For LibreOffice office files, ODF is first-class: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only when the user explicitly asks for OOXML compatibility, provides an existing file in that format, or needs that compatibility format.
27
29
-The Editor and Desktop surfaces are user-owned. Creating, reading, or editing an artifact must save the file and update its state, but it must not open Editor or Desktop automatically if the user has not asked for that UI. Use the `open` action, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop. After create/edit, answer briefly with what changed and the saved path when useful; do not write faux UI action labels such as "Open document" or "Download file", and do not add a note saying the canvas was not opened automatically unless the user explicitly asks about UI behavior.
28
+The Desktop surface is user-owned UI. Creating, reading, or editing an artifact must save the file and update its state, but it must not open Desktop automatically if the user has not asked for that UI. Use the `open` action, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the Office document/Desktop. After create/edit, answer briefly with what changed and the saved path when useful; do not write faux UI action labels such as "Open document" or "Download file", and do not add a note saying the canvas was not opened automatically unless the user explicitly asks about UI behavior.
29
30
For format-specific work, prefer the matching skill when available:
31
33
-- `markdown-documents` for Markdown-first editable writing.
32
- `writer-documents` for Writer/ODT files and DOCX compatibility files.
33
- `calc-spreadsheets` for Calc/ODS spreadsheets and XLSX compatibility workbooks.
34
- `impress-presentations` for Impress/ODP decks and PPTX compatibility decks.
35
36
## Workflow
37
40
-1. Create or open the artifact with `tool_name: "document_artifact"` and `tool_args.action: "create"` or `"open"`.
38
+1. Create or open the Office artifact with `tool_name: "office_artifact"` and `tool_args.action: "create"` or `"open"`.
39
2. Before content-sensitive edits, call the `read` action with `file_id` or `path`.
40
3. Apply saved changes with the `edit` action.
41
4. Use `version_history` or `restore_version` when the user asks to audit or roll back.
42
45
-Document context may list opened files with `file_id`, path, version, size, and timestamp. It intentionally omits full file contents; use `read` when the content matters.
43
+Office context may list opened files with `file_id`, path, version, size, and timestamp. It intentionally omits full file contents; use `read` when the content matters.
44
45
## Minimal Calls
46
47
Create:
48
```json
49
{
52
- "tool_name": "document_artifact",
50
+ "tool_name": "office_artifact",
51
"tool_args": {
52
"action": "create",
53
"kind": "document",
54
"title": "Project Brief",
57
- "format": "md",
55
+ "format": "odt",
56
"content": "Draft text here."
57
}
58
}
@@ -65,7 +63,7 @@ For spreadsheets, `content` can be CSV, TSV, or a Markdown table; the tool write
63
Read:
64
```json
65
{
68
- "tool_name": "document_artifact",
66
+ "tool_name": "office_artifact",
67
"tool_args": {
68
"action": "read",
69
"file_id": "abc123"
@@ -73,10 +71,10 @@ Read:
71
}
72
```
73
76
-Edit text in a Markdown, ODT, DOCX, ODP, or PPTX file:
74
+Edit text in an ODT, DOCX, ODP, or PPTX file:
75
```json
76
{
79
- "tool_name": "document_artifact",
77
+ "tool_name": "office_artifact",
78
"tool_args": {
79
"action": "edit",
80
"file_id": "abc123",
@@ -87,10 +85,10 @@ Edit text in a Markdown, ODT, DOCX, ODP, or PPTX file:
85
}
86
```
87
90
-Append text to a Markdown, ODT, or DOCX file:
88
+Append text to an ODT or DOCX file:
89
```json
90
{
93
- "tool_name": "document_artifact",
91
+ "tool_name": "office_artifact",
92
"tool_args": {
93
"action": "edit",
94
"file_id": "abc123",
@@ -103,7 +101,7 @@ Append text to a Markdown, ODT, or DOCX file:
101
Set spreadsheet cells:
102
```json
103
{
106
- "tool_name": "document_artifact",
104
+ "tool_name": "office_artifact",
105
"tool_args": {
106
"action": "edit",
107
"path": "/a0/usr/workdir/documents/Budget.ods",
@@ -119,7 +117,7 @@ Set spreadsheet cells:
117
Create an embedded spreadsheet chart:
118
```json
119
{
122
- "tool_name": "document_artifact",
120
+ "tool_name": "office_artifact",
121
"tool_args": {
122
"action": "edit",
123
"file_id": "abc123",
@@ -140,7 +138,7 @@ Create an embedded spreadsheet chart:
138
139
## Edit Operations
140
143
-- MD, ODT, and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
141
+- ODT and DOCX: `set_text`, `append_text`, `prepend_text`, `replace_text`, `delete_text`.
142
- ODS and XLSX: `set_cells`, `append_rows`, `set_rows`, `replace_text`, `delete_text`.
143
- XLSX only: `create_chart` for embedded spreadsheet charts.
144
- ODP and PPTX: `set_slides`, `append_slide`, `replace_text`, `delete_text`.
@@ -160,10 +158,10 @@ Arguments:
158
- Prefer `file_id` from document context or prior tool output; use `path` when that is all you have.
159
- Use `read` before editing unless the current saved content is already known.
160
- Do not create an artifact for tiny one-shot edits or answers the agent can finish cleanly in chat or by directly editing the file.
163
-- For document-style writing requests with no requested binary format, create Markdown and let the Editor surface be the primary interactive editor.
161
+- For document-style writing requests with no requested binary format, use `text_editor` to create or edit Markdown and let the Editor surface be the primary interactive editor.
162
- For spreadsheet or presentation file requests with no OOXML compatibility requirement, create ODS or ODP.
163
- The Desktop runtime may be warmed during Agent Zero startup, but visible Desktop surface use remains opt-in. Treat LibreOffice GUI work as appropriate for explicit GUI requests, binary Office visual polish, or final layout inspection.
166
-- Never open Editor or Desktop automatically from a tool result. If the user has not opened it, leave the saved artifact available through the normal UI affordance.
164
+- Never open Editor or Desktop automatically from a tool result. If the user has not asked to open it, leave the saved artifact available through the normal UI affordance.
165
- Use native `create_chart` for embedded spreadsheet charts. Reach for Python/code execution only when the requested chart behavior is not supported by the tool.
168
-- Use `edit` for precise saved changes; use Editor for Markdown polish and Desktop for binary Office visual polish.
166
+- Use `edit` for precise saved Office changes; use Editor for Markdown polish and Desktop for binary Office visual polish.
167
- Direct edits update version history and refresh the document UI on edit/open results.
plugins/_office/skills/writer-documents/SKILL.md
+2
-2
@@ -16,7 +16,7 @@ triggers:
16
- "LibreOffice Writer"
17
- "Word document"
18
allowed_tools:
19
- - document_artifact
19
+ - office_artifact
20
---
21
22
# Writer Documents
@@ -31,7 +31,7 @@ Create:
31
32
```json
33
{
34
- "tool_name": "document_artifact",
34
+ "tool_name": "office_artifact",
35
"tool_args": {
36
"action": "create",
37
"kind": "document",
plugins/_office/tools/office_artifact.py
renamed
+50
-18
@@ -7,14 +7,16 @@ from typing import Any
7
from helpers.tool import Response, Tool
8
from plugins._office.helpers import artifact_editor, document_store, libreoffice
9
10
+OFFICE_EXTENSIONS = document_store.OPEN_DOCUMENT_EXTENSIONS | document_store.OOXML_EXTENSIONS
11
11
-class DocumentArtifact(Tool):
12
+
13
+class OfficeArtifact(Tool):
14
async def execute(
15
self,
16
action: str = "",
17
kind: str = "document",
18
title: str = "Untitled",
17
- format: str = "md",
19
+ format: str = "",
20
content: str = "",
21
path: str = "",
22
file_id: str = "",
@@ -30,10 +32,9 @@ class DocumentArtifact(Tool):
32
max_chars: int | str = 12000,
33
open_in_canvas: bool = False,
34
open_in_desktop: bool = False,
33
- method: str = "",
35
**kwargs: Any,
36
) -> Response:
36
- action = str(action or method or self.method or "status").strip().lower().replace("-", "_")
37
+ action = str(action or "status").strip().lower().replace("-", "_")
38
open_in_canvas = _truthy(
39
open_in_canvas
40
or kwargs.get("open_canvas")
@@ -46,10 +47,11 @@ class DocumentArtifact(Tool):
47
)
48
try:
49
if action == "create":
50
+ fmt = _default_office_format(kind, format)
51
doc = document_store.create_document(
52
kind=kind,
53
title=title,
52
- fmt=format,
54
+ fmt=fmt,
55
content=content,
56
path=path,
57
context_id=self._context_id(),
@@ -58,18 +60,18 @@ class DocumentArtifact(Tool):
60
validation = libreoffice.validate_odf(doc["path"])
61
if not validation.get("ok"):
62
return Response(
61
- message=f"document_artifact create failed: {validation.get('error')}",
63
+ message=f"{self.name} create failed: {validation.get('error')}",
64
break_loop=False,
65
)
66
if doc["extension"] == "docx":
67
validation = libreoffice.validate_docx(doc["path"])
68
if not validation.get("ok"):
69
return Response(
68
- message=f"document_artifact create failed: {validation.get('error')}",
70
+ message=f"{self.name} create failed: {validation.get('error')}",
71
break_loop=False,
72
)
73
return self._document_response(
72
- "Created document artifact.",
74
+ "Created office artifact.",
75
doc,
76
action=action,
77
open_in_canvas=open_in_canvas,
@@ -78,7 +80,7 @@ class DocumentArtifact(Tool):
80
if action == "open":
81
doc = self._document_from_input(file_id=file_id, path=path)
82
return self._document_response(
81
- "Opened document artifact.",
83
+ "Opened office artifact.",
84
doc,
85
action=action,
86
open_in_canvas=open_in_canvas,
@@ -146,8 +148,9 @@ class DocumentArtifact(Tool):
148
return Response(message="version_id is required for restore_version.", break_loop=False)
149
doc = self._document_from_input(file_id=file_id, path=path)
150
restored = document_store.restore_version(doc["file_id"], int(version_id))
151
+ _ensure_office_doc(restored)
152
return self._document_response(
150
- "Restored document artifact version.",
153
+ "Restored office artifact version.",
154
restored,
155
action=action,
156
open_in_canvas=open_in_canvas,
@@ -157,6 +160,7 @@ class DocumentArtifact(Tool):
160
doc = self._document_from_input(file_id=file_id, path=path)
161
target_format = str(kwargs.get("target_format") or kwargs.get("export_format") or "").lower().lstrip(".")
162
if target_format and target_format != doc["extension"]:
163
+ _ensure_office_format(target_format, "target_format")
164
result = libreoffice.convert_document(doc["path"], target_format)
165
if result.get("ok"):
166
payload = {
@@ -173,7 +177,7 @@ class DocumentArtifact(Tool):
177
open_in_desktop=open_in_desktop,
178
)
179
return Response(
176
- message=f"document_artifact export failed: {result.get('error')}",
180
+ message=f"{self.name} export failed: {result.get('error')}",
181
break_loop=False,
182
additional=self._additional(
183
doc,
@@ -183,7 +187,7 @@ class DocumentArtifact(Tool):
187
),
188
)
189
return self._document_response(
186
- "Document artifact export path is ready.",
190
+ "Office artifact export path is ready.",
191
doc,
192
action=action,
193
open_in_canvas=open_in_canvas,
@@ -191,14 +195,14 @@ class DocumentArtifact(Tool):
195
)
196
if action == "status":
197
return self._json_response({"ok": True, "action": action, "status": libreoffice.collect_status()}, action=action)
194
- return Response(message=f"Unknown document_artifact action: {action}", break_loop=False)
198
+ return Response(message=f"Unknown {self.name} action: {action}", break_loop=False)
199
except Exception as exc:
196
- return Response(message=f"document_artifact {action} failed: {exc}", break_loop=False)
200
+ return Response(message=f"{self.name} {action} failed: {exc}", break_loop=False)
201
202
def get_log_object(self):
203
return self.agent.context.log.log(
204
type="tool",
201
- heading=f"icon://description {self.agent.agent_name}: Using document artifact",
205
+ heading=f"icon://description {self.agent.agent_name}: Using office artifact",
206
content="",
207
kvps={**self.args, "_tool_name": self.name},
208
_tool_name=self.name,
@@ -206,9 +210,9 @@ class DocumentArtifact(Tool):
210
211
def _document_from_input(self, file_id: str = "", path: str = "") -> dict[str, Any]:
212
if file_id:
209
- return document_store.get_document(file_id)
213
+ return _ensure_office_doc(document_store.get_document(file_id))
214
if path:
211
- return document_store.register_document(path, context_id=self._context_id())
215
+ return _ensure_office_doc(document_store.register_document(path, context_id=self._context_id()))
216
raise ValueError("file_id or path is required")
217
218
def _context_id(self) -> str:
@@ -279,7 +283,7 @@ class DocumentArtifact(Tool):
283
}
284
return {
285
"_tool_name": self.name,
282
- "canvas_surface": "editor" if doc["extension"] == "md" else "desktop",
286
+ "canvas_surface": "desktop",
287
"action": action,
288
"open_in_canvas": bool(open_in_canvas),
289
"open_in_desktop": bool(open_in_desktop),
@@ -311,3 +315,31 @@ def _truthy(value: Any) -> bool:
315
if isinstance(value, (int, float)):
316
return value != 0
317
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
318
+
319
+
320
+def _default_office_format(kind: str, fmt: str = "") -> str:
321
+ normalized = str(fmt or "").strip().lower().lstrip(".")
322
+ if not normalized:
323
+ normalized_kind = str(kind or "").strip().lower()
324
+ if normalized_kind in {"spreadsheet", "sheet", "calc"}:
325
+ normalized = "ods"
326
+ elif normalized_kind in {"presentation", "slides", "deck", "impress"}:
327
+ normalized = "odp"
328
+ else:
329
+ normalized = "odt"
330
+ return _ensure_office_format(normalized, "format")
331
+
332
+
333
+def _ensure_office_format(fmt: str, label: str = "format") -> str:
334
+ normalized = str(fmt or "").strip().lower().lstrip(".")
335
+ if normalized not in OFFICE_EXTENSIONS:
336
+ raise ValueError(
337
+ f"{label} must be an Office format ({', '.join(sorted(OFFICE_EXTENSIONS))}); "
338
+ "use text_editor for Markdown and plain text files."
339
+ )
340
+ return normalized
341
+
342
+
343
+def _ensure_office_doc(doc: dict[str, Any]) -> dict[str, Any]:
344
+ _ensure_office_format(str(doc.get("extension") or ""), "document extension")
345
+ return doc
plugins/_office/webui/office-store.js
+1
-15
@@ -1,7 +1,6 @@
1
import { createStore } from "/js/AlpineStore.js";
2
import { callJsonApi } from "/js/api.js";
3
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
4
-import { open as openSurface } from "/js/surfaces.js";
4
import { getCurrentUserDateString } from "/js/time-utils.js";
5
6
const SAVE_MESSAGE_MS = 1800;
@@ -124,7 +123,7 @@ const model = {
123
const home = await callOffice("home");
124
workdirPath = home?.path || workdirPath;
125
} catch {
127
- // The file browser can still open with the static fallback.
126
+ // Keep the configured default path when the home lookup is unavailable.
127
}
128
}
129
await fileBrowserStore.open(workdirPath);
@@ -143,18 +142,6 @@ const model = {
142
this.error = response.error || "Document could not be opened.";
143
return null;
144
}
146
- if (response?.requires_editor) {
147
- const document = normalizeDocument(response.document || response);
148
- this.setMessage(`${documentLabel(document)} opens in Editor.`);
149
- await openSurface("editor", {
150
- path: document.path || response.path || "",
151
- file_id: document.file_id || response.file_id || "",
152
- refresh: true,
153
- source: "office-editor-handoff",
154
- });
155
- await this.refresh();
156
- return response;
157
- }
145
if (response?.requires_desktop || this.isDesktopDocument(response)) {
146
const document = normalizeDocument(response.document || response);
147
this.setMessage(`${documentLabel(document)} is ready. Use Open in Desktop to edit it.`);
@@ -187,7 +174,6 @@ const model = {
174
175
defaultTitle(kind, fmt) {
176
const date = getCurrentUserDateString();
190
- if (fmt === "md") return `Document ${date}`;
177
if (fmt === "odt") return `Writer ${date}`;
178
if (fmt === "docx") return `DOCX ${date}`;
179
if (kind === "spreadsheet") return `Spreadsheet ${date}`;
plugins/_text_editor/prompts/agent.system.tool.text_editor.md
+6
-1
@@ -1,9 +1,12 @@
1
### text_editor
2
-file read write patch with numbered lines
2
+canonical text and Markdown file read write patch with numbered lines
3
not code execution rejects binary
4
terminal (grep find sed) advance search/replace
5
actions: read write patch
6
common args: action path
7
+optional UI intent args: open_in_canvas
8
+use this tool for Markdown and plain text files; use `office_artifact` only for Office packages such as odt ods odp docx xlsx pptx
9
+if the user explicitly asks to open the Markdown file in the canvas/Editor after a write or patch, set `open_in_canvas: true`; otherwise omit UI flags because already-open Editor sessions refresh automatically
10
11
#### read
12
read file with numbered lines
@@ -29,6 +32,7 @@ usage:
32
#### write
33
create/overwrite file auto-creates dirs
34
args path content
35
+for Markdown files, include `open_in_canvas: true` only when the user explicitly asks to open the canvas/Editor
36
usage:
37
~~~json
38
{
@@ -47,6 +51,7 @@ usage:
51
edit existing file. prefer exact replace for simple "change X to Y"; use patch_text for context changes; use edits only right after read for tiny line edits
52
if the user says patch, change without rewriting, or don't rewrite, use action patch instead of write
53
args path plus exactly one of: old_text+new_text OR patch_text string OR edits [{from to content}]
54
+for Markdown files, include `open_in_canvas: true` only when the user explicitly asks to open the canvas/Editor
55
exact replace: `old_text` must be the exact current text span and must match once; `new_text` is the replacement
56
patch_text uses current file content, no prior read required
57
patch_text update-only forms:
plugins/_text_editor/tools/text_editor.py
+57
-9
@@ -1,3 +1,5 @@
1
+from pathlib import Path
2
+
3
from helpers.tool import Tool, Response
4
from helpers.extension import call_extensions_async
5
from helpers import plugins, runtime
@@ -147,7 +149,11 @@ class TextEditor(Tool):
149
total_lines=str(result["total_lines"]),
150
content=read_result["content"],
151
)
150
- return Response(message=msg, break_loop=False)
152
+ return Response(
153
+ message=msg,
154
+ break_loop=False,
155
+ additional=_result_additional("write", info, kwargs),
156
+ )
157
158
# ------------------------------------------------------------------
159
# PATCH
@@ -175,11 +181,11 @@ class TextEditor(Tool):
181
182
if patch_request and patch_request.mode == "patch_text":
183
return await self._patch_context(
178
- path, expanded, patch_request.patch_text
184
+ path, expanded, patch_request.patch_text, kwargs
185
)
186
if patch_request and patch_request.mode == "replace":
187
return await self._patch_replace(
182
- path, expanded, patch_request.old_text, patch_request.new_text
188
+ path, expanded, patch_request.old_text, patch_request.new_text, kwargs
189
)
190
191
return await self._patch_edits(
@@ -187,10 +193,11 @@ class TextEditor(Tool):
193
expanded,
194
info,
195
patch_request.edits if patch_request else edits,
196
+ kwargs,
197
)
198
199
async def _patch_edits(
193
- self, path: str, expanded: str, info: FileInfo, edits
200
+ self, path: str, expanded: str, info: FileInfo, edits, options: dict | None = None
201
) -> Response:
202
freshness_code = check_patch_freshness(self.agent, info, key=_MTIME_KEY)
203
if freshness_code:
@@ -246,10 +253,14 @@ class TextEditor(Tool):
253
total_lines=str(total_lines),
254
content=patch_content,
255
)
249
- return Response(message=msg, break_loop=False)
256
+ return Response(
257
+ message=msg,
258
+ break_loop=False,
259
+ additional=_result_additional("patch", post_info, options),
260
+ )
261
262
async def _patch_replace(
252
- self, path: str, expanded: str, old_text: str, new_text: str
263
+ self, path: str, expanded: str, old_text: str, new_text: str, options: dict | None = None
264
) -> Response:
265
# Extension point
266
ext_data = {
@@ -301,10 +312,14 @@ class TextEditor(Tool):
312
total_lines=str(total_lines),
313
content=patch_content,
314
)
304
- return Response(message=msg, break_loop=False)
315
+ return Response(
316
+ message=msg,
317
+ break_loop=False,
318
+ additional=_result_additional("patch", post_info, options),
319
+ )
320
321
async def _patch_context(
307
- self, path: str, expanded: str, patch_text
322
+ self, path: str, expanded: str, patch_text, options: dict | None = None
323
) -> Response:
324
patch_text = str(patch_text)
325
if not patch_text.strip():
@@ -359,7 +374,11 @@ class TextEditor(Tool):
374
total_lines=str(total_lines),
375
content=patch_content,
376
)
362
- return Response(message=msg, break_loop=False)
377
+ return Response(
378
+ message=msg,
379
+ break_loop=False,
380
+ additional=_result_additional("patch", post_info, options),
381
+ )
382
383
# ------------------------------------------------------------------
384
# Shared error helper
@@ -455,6 +474,35 @@ def _freshness_error_message(agent, info: FileInfo, code: str) -> str:
474
)
475
return agent.read_prompt(prompt, path=info["expanded"])
476
477
+
478
+def _result_additional(action: str, info: FileInfo, options: dict | None = None) -> dict:
479
+ path = str(info.get("expanded") or "")
480
+ extension = Path(path).suffix.lower().lstrip(".")
481
+ options = options or {}
482
+ open_in_canvas = _truthy(
483
+ options.get("open_in_canvas")
484
+ or options.get("open_canvas")
485
+ or options.get("open_document")
486
+ )
487
+ return {
488
+ "_tool_name": "text_editor",
489
+ "action": action,
490
+ "path": path,
491
+ "format": extension,
492
+ "extension": extension,
493
+ "open_in_canvas": open_in_canvas,
494
+ }
495
+
496
+
497
+def _truthy(value) -> bool:
498
+ if isinstance(value, bool):
499
+ return value
500
+ if value is None:
501
+ return False
502
+ if isinstance(value, (int, float)):
503
+ return value != 0
504
+ return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
505
+
506
# ------------------------------------------------------------------
507
# Config
508
# ------------------------------------------------------------------
tests/test_office_canvas_setup.py
+45
-24
@@ -277,6 +277,7 @@ def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths():
277
assert ".office-state-line > span:not(.material-symbols-outlined)" in desktop_web_panel
278
279
assert not (PROJECT_ROOT / "plugins" / "_office" / "helpers" / "desktop_state.py").exists()
280
+ assert not (PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop.py").exists()
281
assert not (PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop_routes.py").exists()
282
assert not (PROJECT_ROOT / "plugins" / "_office" / "assets" / "desktop").exists()
283
@@ -297,7 +298,7 @@ def test_plugin_owned_runtime_state_paths_are_declared():
298
assert "PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright" in docker_playwright
299
300
300
-def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests():
301
+def test_office_artifacts_only_open_desktop_from_explicit_document_ui_requests():
302
auto_open = read(
303
"plugins",
304
"_office",
@@ -313,7 +314,7 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
314
"extensions",
315
"webui",
316
"get_tool_message_handler",
316
- "document-artifact-handler.js",
317
+ "office-artifact-handler.js",
318
)
319
response_cards = read(
320
"plugins",
@@ -324,11 +325,19 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
325
"document-response-file-cards.js",
326
)
327
messages_css = read("webui", "css", "messages.css")
327
- document_tool = read("plugins", "_office", "tools", "document_artifact.py")
328
+ document_tool = read("plugins", "_office", "tools", "office_artifact.py")
329
office_api = read("plugins", "_office", "api", "office_session.py")
330
+ editor_sync = read(
331
+ "plugins",
332
+ "_editor",
333
+ "extensions",
334
+ "webui",
335
+ "set_messages_after_loop",
336
+ "sync-text-editor-results.js",
337
+ )
338
339
assert 'openSurface(surfaceForDocument' in auto_open
331
- assert 'return documentExtension(payload, document) === "md" ? "editor" : "desktop";' in auto_open
340
+ assert 'return "desktop";' in auto_open
341
assert "isExplicitDocumentUiRequest(payload)" in auto_open
342
assert 'action === "open"' in auto_open
343
assert "open_in_canvas" in auto_open
@@ -340,21 +349,21 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
349
assert "isOfficeCanvas" not in auto_open
350
assert "officeStore" in auto_open
351
assert "desktopStore" in auto_open
343
- assert "editorStore" in auto_open
352
assert "store?.previewEditDirty" in auto_open
345
- assert "syncOpenEditorSurface" in auto_open
346
- assert "isEditorSurfaceOpen" in auto_open
353
assert "syncOpenDesktopCanvas" in auto_open
354
assert "syncOpenOfficeModal" in auto_open
355
assert "isDesktopSurfaceOpen" in auto_open
356
assert "function documentTarget(payload = {}, document = {})" in auto_open
351
- assert "syncTextEditorMarkdownResult" in auto_open
352
- assert "textEditorTarget" in auto_open
353
- assert 'toolName === "text_editor"' in auto_open
354
- assert 'return ["write", "patch"].includes(action);' in auto_open
357
+ assert 'toolName !== "office_artifact"' in auto_open
358
+ assert "syncTextEditorResultsIntoOpenEditor" in editor_sync
359
+ assert 'toolName(payload) !== "text_editor"' in editor_sync
360
+ assert 'return ["write", "patch"].includes(action);' in editor_sync
361
+ assert "syncOpenEditorSurface" in editor_sync
362
+ assert "isEditorSurfaceOpen" in editor_sync
363
assert "void syncOpenDocumentSurfaces(target);" in auto_open
364
assert "void syncOpenDocumentSurfaces({ path, file_id: fileId });" not in auto_open
357
- assert "return documentExtension(payload, document) === \"md\" ? \"editor\" : \"desktop\";" in auto_open
365
+ assert "editorStore" not in auto_open
366
+ assert "text_editor" not in auto_open
367
assert "hasSameDocument" in auto_open
368
assert 'source: "tool-result-sync"' in auto_open
369
assert '".modal .office-panel"' not in auto_open
@@ -383,10 +392,10 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
392
assert "refreshResponseFileActions" in response_cards
393
assert "parseStoredDocuments" in response_cards
394
assert "openDocumentInDesktop" in document_actions
386
- assert "openDocumentInEditor" in document_actions
387
- assert "openDocumentArtifact" in document_actions
388
- assert 'await openSurface("editor"' in document_actions
389
- assert "await openDocumentInEditor(document);" in document_actions
395
+ assert "openDocumentInEditor" not in document_actions
396
+ assert "openOfficeArtifact" in document_actions
397
+ assert "openDocumentArtifact" not in document_actions
398
+ assert 'await openSurface("editor"' not in document_actions
399
assert "await openDocumentInDesktop(document);" in document_actions
400
assert 'ensureModalOpen("/plugins/_office/webui/main.html")' not in document_actions
401
assert 'ensureModalOpen("/plugins/_office/webui/main.html")' not in auto_open
@@ -397,11 +406,10 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
406
assert "Details" not in response_cards
407
assert "/api/download_work_dir_file" in document_actions
408
assert 'openSurface("desktop"' in document_actions
400
- assert 'openSurface("editor"' in document_actions
409
+ assert 'openSurface("editor"' not in document_actions
410
assert "Open in canvas with Writer" in document_actions
411
assert "Open in canvas with Calc" in document_actions
412
assert "Open in canvas with Impress" in document_actions
404
- assert 'const EDITOR_FORMATS = ["md"]' in document_actions
413
assert 'const DESKTOP_FORMATS = ["odt", "ods", "odp", "docx", "xlsx", "pptx"]' in document_actions
414
assert ".document-file-card" in messages_css
415
assert ".document-response-file-cards" in messages_css
@@ -412,7 +420,11 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
420
assert '"open_in_desktop": bool(open_in_desktop)' in document_tool
421
assert '"requires_desktop": True' in office_api
422
assert 'input.get("open_in_desktop") is not True' in office_api
415
- assert '"requires_editor": True' in office_api
423
+ assert 'action == "desktop"' not in office_api
424
+ assert 'action == "desktop_state"' not in office_api
425
+ assert 'action == "desktop_shutdown"' not in office_api
426
+ assert "Markdown documents use the Editor surface." in office_api
427
+ assert '"requires_editor": True' not in office_api
428
429
430
def test_editor_plugin_owns_markdown_sessions_and_active_context_extras():
@@ -426,7 +438,14 @@ def test_editor_plugin_owns_markdown_sessions_and_active_context_extras():
438
editor_ws = read("plugins", "_editor", "api", "ws_editor.py")
439
editor_context = read("plugins", "_editor", "helpers", "open_files_context.py")
440
office_ws = read("plugins", "_office", "api", "ws_office.py")
429
- office_markdown_sessions = read("plugins", "_office", "helpers", "markdown_sessions.py")
441
+ editor_result_sync = read(
442
+ "plugins",
443
+ "_editor",
444
+ "extensions",
445
+ "webui",
446
+ "set_messages_after_loop",
447
+ "sync-text-editor-results.js",
448
+ )
449
editor_extras = read(
450
"plugins",
451
"_editor",
@@ -467,9 +486,10 @@ def test_editor_plugin_owns_markdown_sessions_and_active_context_extras():
486
assert "editor_open_files" in editor_extras
487
assert "desktop_state" in desktop_context
488
assert 'pop("office_canvas"' in office_context
470
- assert "Markdown editing moved to /plugins/_editor." in office_ws
489
+ assert "Office WebSocket editing is not available for Markdown; use the Editor surface." in office_ws
490
assert "from plugins._office.helpers import document_store, markdown_sessions" not in office_ws
472
- assert "from plugins._editor.helpers.markdown_sessions import" in office_markdown_sessions
491
+ assert not (PROJECT_ROOT / "plugins" / "_office" / "helpers" / "markdown_sessions.py").exists()
492
+ assert "syncTextEditorResultsIntoOpenEditor" in editor_result_sync
493
494
495
def test_office_and_desktop_skills_are_rehomed_and_renamed():
@@ -478,13 +498,14 @@ def test_office_and_desktop_skills_are_rehomed_and_renamed():
498
499
assert not (office_skills / "linux-desktop").exists()
500
assert (desktop_skills / "linux-desktop" / "SKILL.md").exists()
481
- assert not (office_skills / "office-artifacts").exists()
501
+ assert (office_skills / "office-artifacts" / "SKILL.md").exists()
502
+ assert not (office_skills / "document-artifacts").exists()
503
assert not (office_skills / "word-documents").exists()
504
assert not (office_skills / "excel-workbooks").exists()
505
assert not (office_skills / "presentation-decks").exists()
506
507
expected = {
487
- "document-artifacts": office_skills / "document-artifacts" / "SKILL.md",
508
+ "office-artifacts": office_skills / "office-artifacts" / "SKILL.md",
509
"writer-documents": office_skills / "writer-documents" / "SKILL.md",
510
"calc-spreadsheets": office_skills / "calc-spreadsheets" / "SKILL.md",
511
"impress-presentations": office_skills / "impress-presentations" / "SKILL.md",
tests/test_office_document_affordance.py
deleted
-177
@@ -1,177 +0,0 @@
1
-from __future__ import annotations
2
-
3
-import sys
4
-from pathlib import Path
5
-from types import SimpleNamespace
6
-
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 plugins._office.helpers import document_affordance
13
-
14
-
15
-def substantial_text(prefix: str = "Here is the material.") -> str:
16
- paragraph = (
17
- "This section gives concrete context, constraints, tradeoffs, and next steps "
18
- "so the artifact has enough substance to be useful in a real shared workflow. "
19
- )
20
- return f"{prefix}\n\n" + paragraph * 8
21
-
22
-
23
-def standalone_report() -> str:
24
- paragraph = (
25
- "The team should align the operating model, clarify ownership, and preserve "
26
- "a concise decision trail so execution remains calm, inspectable, and repeatable. "
27
- )
28
- return (
29
- "# Retention Report\n\n"
30
- "## Executive Summary\n"
31
- f"{paragraph * 4}\n\n"
32
- "## Recommendations\n"
33
- f"{paragraph * 4}"
34
- )
35
-
36
-
37
-def test_explicit_docx_request_no_longer_creates_document_artifact_from_response_text():
38
- decision = document_affordance.decide_response_artifact(
39
- "Please create a DOCX report for the leadership review.",
40
- substantial_text(),
41
- )
42
-
43
- assert decision is None
44
-
45
-
46
-def test_explicit_spreadsheet_file_request_no_longer_creates_artifact_from_response_text():
47
- decision = document_affordance.decide_response_artifact(
48
- "Build an editable spreadsheet file for this budget.",
49
- substantial_text(),
50
- )
51
-
52
- assert decision is None
53
-
54
-
55
-def test_explicit_excel_request_no_longer_keeps_xlsx_decision_from_response_text():
56
- decision = document_affordance.decide_response_artifact(
57
- "Build an editable Excel XLSX file for this budget.",
58
- substantial_text(),
59
- )
60
-
61
- assert decision is None
62
-
63
-
64
-def test_explicit_presentation_file_request_no_longer_creates_artifact_from_response_text():
65
- decision = document_affordance.decide_response_artifact(
66
- "Create a presentation file for this roadmap.",
67
- substantial_text(),
68
- )
69
-
70
- assert decision is None
71
-
72
-
73
-def test_convert_into_document_no_longer_creates_artifact_from_response_text():
74
- decision = document_affordance.decide_response_artifact(
75
- "Convert this into a document.",
76
- substantial_text(),
77
- )
78
-
79
- assert decision is None
80
-
81
-
82
-def test_long_document_topic_does_not_create_artifact_without_handoff_signal():
83
- decision = document_affordance.decide_response_artifact(
84
- "Write a detailed explanation of the document handoff implementation.",
85
- substantial_text(),
86
- )
87
-
88
- assert decision is None
89
-
90
-
91
-def test_long_policy_question_does_not_create_artifact_without_create_intent():
92
- decision = document_affordance.decide_response_artifact(
93
- "What should our remote-work policy say about async updates?",
94
- substantial_text(),
95
- )
96
-
97
- assert decision is None
98
-
99
-
100
-def test_office_as_workplace_topic_is_not_a_handoff_signal():
101
- decision = document_affordance.decide_response_artifact(
102
- "Write a memo about office etiquette.",
103
- substantial_text(),
104
- )
105
-
106
- assert decision is None
107
-
108
-
109
-def test_deliverable_request_does_not_create_artifact_from_response_text():
110
- decision = document_affordance.decide_response_artifact(
111
- "Draft a report about retention risks.",
112
- substantial_text(),
113
- )
114
-
115
- assert decision is None
116
-
117
-
118
-def test_deliverable_request_with_artifact_shape_does_not_create_document_artifact():
119
- decision = document_affordance.decide_response_artifact(
120
- "Draft a report about retention risks.",
121
- standalone_report(),
122
- )
123
-
124
- assert decision is None
125
-
126
-
127
-def test_meta_discussion_about_auto_md_files_does_not_create_artifact():
128
- decision = document_affordance.decide_response_artifact(
129
- "Why are .md files being created automatically by the document affordance?",
130
- standalone_report(),
131
- )
132
-
133
- assert decision is None
134
-
135
-
136
-def test_chat_only_instruction_blocks_even_explicit_file_request():
137
- decision = document_affordance.decide_response_artifact(
138
- "Create a DOCX report, but just answer in chat.",
139
- standalone_report(),
140
- )
141
-
142
- assert decision is None
143
-
144
-
145
-def test_response_hook_is_inert_compatibility_shim():
146
- hook = (
147
- PROJECT_ROOT
148
- / "plugins"
149
- / "_office"
150
- / "extensions"
151
- / "python"
152
- / "tool_execute_after"
153
- / "_20_document_response_affordance.py"
154
- ).read_text(encoding="utf-8")
155
-
156
- assert "decide_response_artifact" not in hook
157
- assert "create_document" not in hook
158
- assert "hist_add_tool_result" not in hook
159
-
160
-
161
-def test_created_response_does_not_claim_canvas_was_opened():
162
- message = document_affordance.format_created_response(
163
- "Project Brief.md",
164
- "/a0/usr/workdir/Project Brief.md",
165
- )
166
-
167
- assert "Created **Project Brief.md**." in message
168
- assert "opened" not in message.lower()
169
- assert "Path: `/a0/usr/workdir/Project Brief.md`" in message
170
-
171
-
172
-def test_document_response_affordance_only_runs_for_primary_agent():
173
- assert document_affordance.is_subordinate_agent(SimpleNamespace(number=0, agent_name="A0")) is False
174
- assert document_affordance.is_subordinate_agent(SimpleNamespace(number=1, agent_name="A1")) is True
175
- assert document_affordance.is_subordinate_agent(SimpleNamespace(agent_name="A2")) is True
176
- assert document_affordance.is_subordinate_agent(SimpleNamespace(agent_name="0")) is False
177
- assert document_affordance.is_subordinate_agent(SimpleNamespace(data={"_superior": object()})) is True
tests/test_office_document_store.py
+65
-217
@@ -75,7 +75,7 @@ def office_state(tmp_path, monkeypatch):
75
)
76
77
78
-def test_document_artifact_create_defaults_to_markdown(office_state):
78
+def test_document_store_create_defaults_to_markdown(office_state):
79
doc = document_store.create_document("document", "Research Note", content="A precise note.")
80
81
assert doc["extension"] == "md"
@@ -168,44 +168,16 @@ def test_odf_and_ooxml_creation_and_direct_edits_still_work(office_state):
168
assert ods_rows[2][0] == "Research"
169
170
171
-def test_document_artifact_markdown_append_accepts_common_model_shapes(office_state):
171
+def test_office_artifact_helpers_reject_markdown_inputs(office_state):
172
doc = document_store.create_document("document", "Append Shapes", "md", "# Title\n\nBase")
173
174
- updated, payload = artifact_editor.edit_artifact(
175
- doc,
176
- operation="append_text",
177
- value="Added line 1\nAdded line 2",
178
- )
179
- assert payload["changed"] is True
180
- assert payload["lines_appended"] == 2
181
- assert artifact_editor.read_artifact(updated)["text"].endswith("Added line 1\nAdded line 2")
182
-
183
- updated, payload = artifact_editor.edit_artifact(
184
- updated,
185
- update={"add_lines": ["Added line 3", "Added line 4"]},
186
- )
187
- assert payload["operation"] == "append_text"
188
- assert payload["lines_appended"] == 2
189
- assert artifact_editor.read_artifact(updated)["text"].endswith(
190
- "Added line 1\nAdded line 2\nAdded line 3\nAdded line 4",
191
- )
192
-
193
- updated, payload = artifact_editor.edit_artifact(
194
- updated,
195
- edits=[{"op": "append_lines", "value": ["Added line 5", "Added line 6"]}],
196
- )
197
- assert payload["operation"] == "append_text"
198
- assert payload["lines_appended"] == 2
199
- assert artifact_editor.read_artifact(updated)["text"].endswith(
200
- "Added line 3\nAdded line 4\nAdded line 5\nAdded line 6",
201
- )
174
+ with pytest.raises(ValueError, match="use text_editor"):
175
+ artifact_editor.read_artifact(doc)
176
+ with pytest.raises(ValueError, match="use text_editor"):
177
+ artifact_editor.edit_artifact(doc, operation="set_text", content="# Updated")
178
179
204
-def test_document_artifact_markdown_append_rejects_empty_content(office_state):
205
- doc = document_store.create_document("document", "Empty Append", "md", "# Title")
206
-
207
- with pytest.raises(ValueError, match="content is required for append_text"):
208
- artifact_editor.edit_artifact(doc, operation="append_text")
180
+def test_office_artifact_direct_edits_cover_presentations_spreadsheets_and_decks(office_state):
181
182
odp = document_store.create_document(
183
"presentation",
@@ -297,7 +269,7 @@ def test_ods_direct_edit_preserves_rows_beyond_preview_window_and_blank_separato
269
assert parsed[0]["rows"][89][1] == 9000
270
271
300
-def test_document_artifact_accepts_method_alias_for_ods_create(office_state, monkeypatch):
272
+def test_office_artifact_creates_ods_with_action_contract(office_state, monkeypatch):
273
tool_module = types.ModuleType("helpers.tool")
274
275
class Response:
@@ -319,17 +291,17 @@ def test_document_artifact_accepts_method_alias_for_ods_create(office_state, mon
291
tool_module.Tool = Tool
292
monkeypatch.setitem(sys.modules, "helpers.tool", tool_module)
293
spec = importlib.util.spec_from_file_location(
322
- "test_document_artifact_tool",
323
- PROJECT_ROOT / "plugins" / "_office" / "tools" / "document_artifact.py",
294
+ "test_office_artifact_tool",
295
+ PROJECT_ROOT / "plugins" / "_office" / "tools" / "office_artifact.py",
296
)
325
- document_artifact_module = importlib.util.module_from_spec(spec)
297
+ office_artifact_module = importlib.util.module_from_spec(spec)
298
assert spec and spec.loader
327
- spec.loader.exec_module(document_artifact_module)
328
- DocumentArtifact = document_artifact_module.DocumentArtifact
299
+ spec.loader.exec_module(office_artifact_module)
300
+ OfficeArtifact = office_artifact_module.OfficeArtifact
301
330
- tool = DocumentArtifact(
302
+ tool = OfficeArtifact(
303
agent=None,
332
- name="document_artifact",
304
+ name="office_artifact",
305
method=None,
306
args={},
307
message="",
@@ -338,7 +310,7 @@ def test_document_artifact_accepts_method_alias_for_ods_create(office_state, mon
310
311
response = asyncio.run(
312
tool.execute(
341
- method="create",
313
+ action="create",
314
kind="document",
315
title="New Calc Workbook",
316
format="ods",
@@ -354,11 +326,12 @@ def test_document_artifact_accepts_method_alias_for_ods_create(office_state, mon
326
327
328
def test_odf_is_advertised_and_docx_remains_explicit_compatibility(office_state):
357
- prompt = (PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.system.tool.document_artifact.md").read_text(
329
+ prompt = (PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.system.tool.office_artifact.md").read_text(
330
encoding="utf-8",
331
)
332
361
- assert "formats: md odt ods odp docx xlsx pptx" in prompt
333
+ assert "formats: odt ods odp docx xlsx pptx" in prompt
334
+ assert "use `text_editor` for Markdown and plain text files" in prompt
335
assert "ODF is first-class for LibreOffice" in prompt
336
assert "DOCX/XLSX/PPTX are compatibility formats" in prompt
337
assert "`method` is accepted as an alias for action" not in prompt
@@ -399,14 +372,14 @@ def test_non_project_creation_uses_configured_workdir(office_state):
372
373
374
def test_sessions_and_canvas_context_are_neutral(office_state):
402
- doc = document_store.create_document("document", "Canvas Context", "md", "Private body text.")
375
+ doc = document_store.create_document("document", "Canvas Context", "odt", "Private body text.")
376
session = document_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
377
378
open_docs = document_store.get_open_documents()
379
context = canvas_context.build_context()
380
381
assert open_docs[0]["file_id"] == doc["file_id"]
409
- assert "document artifacts" in context
382
+ assert "Office artifacts" in context
383
assert "Private body text" not in context
384
assert document_store.close_session(session_id=session["session_id"]) == 1
385
assert document_store.get_open_documents() == []
@@ -497,17 +470,6 @@ def test_document_rename_saves_dirty_markdown_and_removes_original(office_state)
470
assert renamed.read_text(encoding="utf-8") == "# Clean Rename\n\nFresh text"
471
472
500
-def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeypatch):
501
- manager = editor_markdown_sessions.MarkdownSessionManager()
502
- monkeypatch.setattr(editor_markdown_sessions, "_manager", manager, raising=False)
503
- doc = document_store.create_document("document", "Receiver", "md", "First")
504
- session = manager.open(doc)
505
-
506
- artifact_editor.edit_artifact(doc, operation="set_text", content="# Receiver\n\nSecond")
507
-
508
- assert manager._sessions[session["session_id"]].text == "# Receiver\n\nSecond"
509
-
510
-
473
def test_refresh_open_markdown_session_reloads_external_file_edits(office_state):
474
manager = editor_markdown_sessions.MarkdownSessionManager()
475
doc = document_store.create_document("document", "External Refresh", "md", "First")
@@ -534,6 +496,50 @@ def test_refresh_open_markdown_session_preserves_dirty_editor_text(office_state)
496
assert manager._sessions[session["session_id"]].dirty is True
497
498
499
+def test_external_text_editor_mutation_refreshes_clean_open_markdown_session(office_state):
500
+ manager = editor_markdown_sessions.MarkdownSessionManager()
501
+ doc = document_store.create_document("document", "Synced External Edit", "md", "First")
502
+ session = manager.open(doc, context_id="ctx-a")
503
+
504
+ Path(doc["path"]).write_text("# Synced External Edit\n\nSecond\n", encoding="utf-8")
505
+ result = manager.sync_external_file_mutations([doc["path"]])
506
+
507
+ assert result["matched"] == 1
508
+ assert manager._sessions[session["session_id"]].text == "# Synced External Edit\n\nSecond\n"
509
+ assert manager._sessions[session["session_id"]].dirty is False
510
+ assert manager._sessions[session["session_id"]].external_modified is False
511
+
512
+
513
+def test_external_text_editor_mutation_marks_dirty_markdown_session_pending(office_state):
514
+ manager = editor_markdown_sessions.MarkdownSessionManager()
515
+ doc = document_store.create_document("document", "Dirty Synced External Edit", "md", "First")
516
+ session = manager.open(doc, context_id="ctx-a")
517
+ manager.input(session["session_id"], text="Unsaved editor text")
518
+
519
+ Path(doc["path"]).write_text("External disk text\n", encoding="utf-8")
520
+ result = manager.sync_external_file_mutations([doc["path"]])
521
+
522
+ dirty_session = manager._sessions[session["session_id"]]
523
+ assert result["matched"] == 1
524
+ assert dirty_session.text == "Unsaved editor text"
525
+ assert dirty_session.dirty is True
526
+ assert dirty_session.external_modified is True
527
+
528
+
529
+def test_markdown_editor_save_rejects_stale_canvas_overwrite(office_state):
530
+ manager = editor_markdown_sessions.MarkdownSessionManager()
531
+ doc = document_store.create_document("document", "Stale Save Guard", "md", "First")
532
+ session = manager.open(doc, context_id="ctx-a")
533
+
534
+ Path(doc["path"]).write_text("# Stale Save Guard\n\nExternal newer text\n", encoding="utf-8")
535
+ result = manager.save(session["session_id"], text="# Stale Save Guard\n\nOlder canvas text\n")
536
+
537
+ assert result["ok"] is False
538
+ assert result["code"] == "external_change_conflict"
539
+ assert "External newer text" in Path(doc["path"]).read_text(encoding="utf-8")
540
+ assert "Older canvas text" not in Path(doc["path"]).read_text(encoding="utf-8")
541
+
542
+
543
def test_markdown_session_rejects_office_binaries(office_state):
544
manager = editor_markdown_sessions.MarkdownSessionManager()
545
doc = document_store.create_document("document", "Desktop Only", "odt", "Native text")
@@ -651,102 +657,6 @@ def test_desktop_gateway_patches_xpra_menu_script():
657
assert "window does not fit in canvas, offsets:" in source
658
659
654
-def test_office_session_desktop_state_action_defaults_without_screenshot(monkeypatch):
655
- api_module = types.ModuleType("helpers.api")
656
-
657
- class ApiHandler:
658
- def __init__(self, app=None, thread_lock=None):
659
- self.app = app
660
- self.thread_lock = thread_lock
661
-
662
- api_module.ApiHandler = ApiHandler
663
- api_module.Request = object
664
- monkeypatch.setitem(sys.modules, "helpers.api", api_module)
665
- monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
666
-
667
- from plugins._office.api import office_session
668
-
669
- calls = []
670
-
671
- class FakeManager:
672
- def state(self, *, include_screenshot=False, context_id=""):
673
- calls.append((include_screenshot, context_id))
674
- return {
675
- "ok": True,
676
- "display": ":120",
677
- "profile_dir": "/a0/usr/plugins/_desktop/profiles/agent-zero-desktop",
678
- "size": {"width": 1440, "height": 900},
679
- "pointer": {"x": 0, "y": 0, "screen": 0, "window": 0},
680
- "active_window": None,
681
- "windows": [],
682
- "screenshot": {"ok": False, "path": ""},
683
- "capabilities": {},
684
- "errors": [],
685
- }
686
-
687
- monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: FakeManager())
688
- handler = office_session.OfficeSession(app=None, thread_lock=None)
689
- request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080")
690
-
691
- default_result = asyncio.run(handler.process({"action": "desktop_state"}, request))
692
- screenshot_result = asyncio.run(
693
- handler.process({"action": "desktop_state", "include_screenshot": True}, request),
694
- )
695
-
696
- assert default_result["ok"] is True
697
- assert screenshot_result["ok"] is True
698
- assert calls == [(False, ""), (True, "")]
699
- monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
700
- api_package = sys.modules.get("plugins._office.api")
701
- if api_package is not None:
702
- monkeypatch.delattr(api_package, "office_session", raising=False)
703
-
704
-
705
-def test_office_session_desktop_shutdown_action_calls_manager(monkeypatch):
706
- api_module = types.ModuleType("helpers.api")
707
-
708
- class ApiHandler:
709
- def __init__(self, app=None, thread_lock=None):
710
- self.app = app
711
- self.thread_lock = thread_lock
712
-
713
- api_module.ApiHandler = ApiHandler
714
- api_module.Request = object
715
- monkeypatch.setitem(sys.modules, "helpers.api", api_module)
716
- monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
717
-
718
- from plugins._office.api import office_session
719
-
720
- calls = []
721
-
722
- class FakeManager:
723
- def shutdown_system_desktop(self, *, save_first=True, source="api"):
724
- calls.append({"save_first": save_first, "source": source})
725
- return {
726
- "ok": True,
727
- "closed": 1,
728
- "shutdown": True,
729
- "intentional_shutdown": True,
730
- "source": source,
731
- }
732
-
733
- monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: FakeManager())
734
- handler = office_session.OfficeSession(app=None, thread_lock=None)
735
- request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080")
736
-
737
- result = asyncio.run(
738
- handler.process({"action": "desktop_shutdown", "save_first": False, "source": "ui"}, request),
739
- )
740
-
741
- assert result["ok"] is True
742
- assert result["intentional_shutdown"] is True
743
- assert calls == [{"save_first": False, "source": "ui"}]
744
- monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
745
- api_package = sys.modules.get("plugins._office.api")
746
- if api_package is not None:
747
- monkeypatch.delattr(api_package, "office_session", raising=False)
748
-
749
-
660
def test_office_binary_open_requires_explicit_desktop_without_cold_session(office_state, monkeypatch):
661
api_module = types.ModuleType("helpers.api")
662
@@ -1226,8 +1136,6 @@ def test_cleanup_hook_moves_retired_office_state_to_plugin_state(tmp_path, monke
1136
(retired_state / "stale-cleanup-v3.done").write_text("ok\n", encoding="utf-8")
1137
plugin_state.mkdir(parents=True)
1138
1229
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1230
-
1139
result = hooks.cleanup_stale_runtime_state(force=True)
1140
1141
assert result["ok"] is True
@@ -1248,8 +1156,6 @@ def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp
1156
1157
monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1158
monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1251
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1252
-
1159
result = hooks.cleanup_stale_runtime_state(force=True)
1160
1161
assert result["ok"] is True
@@ -1270,8 +1176,6 @@ def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path
1176
1177
monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1178
monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1273
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1274
-
1179
result = hooks.cleanup_stale_runtime_state(force=True)
1180
1181
assert result["ok"] is True
@@ -1283,33 +1187,6 @@ def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path
1187
assert (document_state / "documents.sqlite3").read_text(encoding="utf-8") == "new-db\n"
1188
1189
1286
-def test_office_hook_desktop_compat_forwards_runtime_result(monkeypatch):
1287
- monkeypatch.setattr(
1288
- desktop_hooks,
1289
- "cleanup_stale_runtime_state",
1290
- lambda: {
1291
- "installed": ["xpra-server"],
1292
- "removed": ["firefox-esr"],
1293
- "migrated": ["desktop state"],
1294
- "warnings": ["desktop warning"],
1295
- "errors": ["desktop error"],
1296
- },
1297
- )
1298
- installed = []
1299
- removed = []
1300
- migrated = []
1301
- warnings = []
1302
- errors = []
1303
-
1304
- hooks._ensure_desktop_runtime_compat(installed, removed, migrated, warnings, errors)
1305
-
1306
- assert installed == ["xpra-server"]
1307
- assert removed == ["firefox-esr"]
1308
- assert migrated == ["desktop state"]
1309
- assert warnings == ["desktop warning"]
1310
- assert errors == ["desktop error"]
1311
-
1312
-
1190
def test_cleanup_hook_targets_legacy_collabora_runtime_artifacts():
1191
assert Path("/opt/cool") in hooks.RETIRED_WEB_RUNTIME_DIRS
1192
assert Path("/opt/collaboraoffice") in hooks.RETIRED_WEB_RUNTIME_DIRS
@@ -1356,30 +1233,6 @@ def test_installed_retired_web_packages_discovers_collabora_split_packages(monke
1233
]
1234
1235
1359
-def test_cleanup_hook_delegates_desktop_runtime_for_legacy_self_update(tmp_path, monkeypatch):
1360
- _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1361
- monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1362
- monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1363
- calls = []
1364
-
1365
- def fake_desktop_compat(installed, removed, migrated, warnings, errors):
1366
- calls.append("desktop")
1367
- installed.append("xpra-server")
1368
- removed.append("firefox-esr")
1369
- migrated.append("desktop state migrated")
1370
- warnings.append("desktop runtime prepared through office compatibility hook")
1371
-
1372
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", fake_desktop_compat)
1373
-
1374
- result = hooks.cleanup_stale_runtime_state(force=True)
1375
-
1376
- assert calls == ["desktop"]
1377
- assert result["installed"] == ["xpra-server"]
1378
- assert result["removed"] == ["firefox-esr"]
1379
- assert result["migrated"] == ["desktop state migrated"]
1380
- assert result["warnings"] == ["desktop runtime prepared through office compatibility hook"]
1381
-
1382
-
1236
def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeypatch):
1237
source = tmp_path / "sources.list.d" / "retired.sources"
1238
keyring = tmp_path / "keyrings" / "retired.gpg"
@@ -1409,8 +1262,6 @@ def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeyp
1262
monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1263
monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1264
monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1412
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1413
-
1265
def fake_ensure(installed, errors):
1266
assert not source.exists()
1267
installed.append("libreoffice-core")
@@ -1503,8 +1354,6 @@ def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path
1354
monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1355
monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1356
monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1506
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1507
-
1357
def fake_purge(removed, errors, **kwargs):
1358
removed.extend(kwargs["installed_packages"])
1359
@@ -1532,7 +1381,6 @@ def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path,
1381
monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1382
monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1383
monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1535
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1384
monkeypatch.setattr(hooks.shutil, "which", lambda name: "/usr/bin/supervisorctl" if name == "supervisorctl" else "")
1385
1386
def fake_supervisorctl(*args):
tests/test_skills_runtime.py
+1
-1
@@ -253,7 +253,7 @@ def test_skill_runtime_does_not_alias_old_office_skill_references():
253
254
def test_builtin_plugin_skill_delete_is_rejected_before_filesystem_delete():
255
with pytest.raises(PermissionError, match="Built-in plugin skills cannot be deleted"):
256
- runtime.delete_skill("/a0/plugins/_office/skills/document-artifacts")
256
+ runtime.delete_skill("/a0/plugins/_office/skills/office-artifacts")
257
258
259
def test_invalid_skill_frontmatter_reports_yaml_errors():
tests/test_text_editor_context_patch.py
+26
@@ -551,6 +551,32 @@ def test_text_editor_execute_accepts_action_alias_for_read(
551
assert "line-1" in response.message
552
553
554
+def test_text_editor_write_result_carries_markdown_canvas_intent(
555
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
556
+) -> None:
557
+ module, _calls = _load_text_editor_tool(monkeypatch)
558
+ target = tmp_path / "note.md"
559
+ tool = module.TextEditor(_FakeAgent(), "text_editor", "write", {}, "", None)
560
+
561
+ response = asyncio.run(
562
+ tool._write(
563
+ path=str(target),
564
+ content="# Note\n",
565
+ open_in_canvas=True,
566
+ )
567
+ )
568
+
569
+ assert target.read_text(encoding="utf-8") == "# Note\n"
570
+ assert response.additional == {
571
+ "_tool_name": "text_editor",
572
+ "action": "write",
573
+ "path": str(target),
574
+ "format": "md",
575
+ "extension": "md",
576
+ "open_in_canvas": True,
577
+ }
578
+
579
+
580
def test_text_editor_patch_text_rejects_simultaneous_edits(
581
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
582
) -> None:
tests/test_timezone_regressions.py
+19
-19
@@ -22,7 +22,7 @@ from helpers.task_scheduler import (
22
serialize_task_plan,
23
)
24
from plugins._memory.api.memory_dashboard import MemoryDashboard
25
-from plugins._office.helpers import libreoffice_desktop
25
+from plugins._desktop.helpers import desktop_session
26
27
28
@pytest.fixture
@@ -273,21 +273,21 @@ class FakeProcess:
273
274
def test_desktop_session_env_uses_session_timezone(isolated_localization, tmp_path):
275
set_test_timezone("Europe/Rome")
276
- session = libreoffice_desktop.DesktopSession(
277
- session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
278
- file_id=libreoffice_desktop.SYSTEM_FILE_ID,
276
+ session = desktop_session.DesktopSession(
277
+ session_id=desktop_session.SYSTEM_SESSION_ID,
278
+ file_id=desktop_session.SYSTEM_FILE_ID,
279
extension="desktop",
280
path=str(tmp_path),
281
title="Desktop",
282
display=120,
283
xpra_port=14500,
284
- token=libreoffice_desktop.SYSTEM_SESSION_ID,
284
+ token=desktop_session.SYSTEM_SESSION_ID,
285
url="/desktop/session/agent-zero-desktop/index.html",
286
profile_dir=tmp_path / "profile",
287
timezone="America/New_York",
288
)
289
290
- env = libreoffice_desktop.LibreOfficeDesktopManager()._session_env(session)
290
+ env = desktop_session.DesktopSessionManager()._session_env(session)
291
292
assert env["TZ"] == "America/New_York"
293
@@ -298,41 +298,41 @@ def test_desktop_timezone_sync_restarts_active_system_desktop(
298
tmp_path,
299
):
300
set_test_timezone("America/New_York")
301
- manager = libreoffice_desktop.LibreOfficeDesktopManager()
302
- old_session = libreoffice_desktop.DesktopSession(
303
- session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
304
- file_id=libreoffice_desktop.SYSTEM_FILE_ID,
301
+ manager = desktop_session.DesktopSessionManager()
302
+ old_session = desktop_session.DesktopSession(
303
+ session_id=desktop_session.SYSTEM_SESSION_ID,
304
+ file_id=desktop_session.SYSTEM_FILE_ID,
305
extension="desktop",
306
path=str(tmp_path),
307
title="Desktop",
308
display=120,
309
xpra_port=14500,
310
- token=libreoffice_desktop.SYSTEM_SESSION_ID,
310
+ token=desktop_session.SYSTEM_SESSION_ID,
311
url="/desktop/session/agent-zero-desktop/index.html",
312
profile_dir=tmp_path / "profile-old",
313
timezone="Europe/Rome",
314
processes={"xpra": FakeProcess()},
315
)
316
- replacement = libreoffice_desktop.DesktopSession(
317
- session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
318
- file_id=libreoffice_desktop.SYSTEM_FILE_ID,
316
+ replacement = desktop_session.DesktopSession(
317
+ session_id=desktop_session.SYSTEM_SESSION_ID,
318
+ file_id=desktop_session.SYSTEM_FILE_ID,
319
extension="desktop",
320
path=str(tmp_path),
321
title="Desktop",
322
display=120,
323
xpra_port=14500,
324
- token=libreoffice_desktop.SYSTEM_SESSION_ID,
324
+ token=desktop_session.SYSTEM_SESSION_ID,
325
url="/desktop/session/agent-zero-desktop/index.html",
326
profile_dir=tmp_path / "profile-new",
327
timezone="America/New_York",
328
processes={"xpra": FakeProcess()},
329
)
330
- manager._sessions[libreoffice_desktop.SYSTEM_SESSION_ID] = old_session
331
- restarted: list[libreoffice_desktop.DesktopSession] = []
330
+ manager._sessions[desktop_session.SYSTEM_SESSION_ID] = old_session
331
+ restarted: list[desktop_session.DesktopSession] = []
332
333
def fake_restart(session):
334
restarted.append(session)
335
- manager._sessions[libreoffice_desktop.SYSTEM_SESSION_ID] = replacement
335
+ manager._sessions[desktop_session.SYSTEM_SESSION_ID] = replacement
336
return replacement
337
338
monkeypatch.setattr(manager, "_restart_system_desktop_for_timezone_locked", fake_restart)
@@ -342,7 +342,7 @@ def test_desktop_timezone_sync_restarts_active_system_desktop(
342
assert result == {
343
"ok": True,
344
"restarted": True,
345
- "session_id": libreoffice_desktop.SYSTEM_SESSION_ID,
345
+ "session_id": desktop_session.SYSTEM_SESSION_ID,
346
"timezone": "America/New_York",
347
}
348
assert restarted == [old_session]
tests/test_tool_action_contracts.py
+3
-3
@@ -517,8 +517,8 @@ def test_corrected_tool_prompts_only_teach_action_contract():
517
project_root / "prompts/agent.system.tool.skills.md",
518
project_root / "prompts/agent.system.tool.scheduler.md",
519
project_root / "plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md",
520
- project_root / "plugins/_office/prompts/agent.system.tool.document_artifact.md",
521
- project_root / "plugins/_office/skills/document-artifacts/SKILL.md",
520
+ project_root / "plugins/_office/prompts/agent.system.tool.office_artifact.md",
521
+ project_root / "plugins/_office/skills/office-artifacts/SKILL.md",
522
project_root / "plugins/_office/skills/markdown-documents/SKILL.md",
523
project_root / "plugins/_office/skills/writer-documents/SKILL.md",
524
project_root / "plugins/_office/skills/calc-spreadsheets/SKILL.md",
@@ -528,7 +528,7 @@ def test_corrected_tool_prompts_only_teach_action_contract():
528
"text_editor:",
529
"skills_tool:",
530
"scheduler:",
531
- "document_artifact:",
531
+ "office_artifact:",
532
"`method`",
533
"`op`",
534
"`operation`",