Stabilize document artifact affordances

Make file creation opt-in through document_artifact, move document file cards to final responses, and keep the tool payload as a quiet execution record. Deduplicate response cards by file identity, refresh open Desktop canvas sessions after saved edits, and harden document_artifact edit input normalization for common append/update shapes. Update prompts, skills, styles, and regression coverage for response-only file actions and explicit-only canvas opening.

Alessandro committed May 12, 2026 at 06:59 UTC 55474443c9bb30fe182d62ce78f1d28e45b92f93
20 files changed +1067 -626
plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py
+3 -106
@@ -1,115 +1,12 @@
1 from __future__ import annotations
2
3 -import json
4 -from pathlib import Path
3 from typing import Any
4
5 from helpers.extension import Extension
8 -from helpers.print_style import PrintStyle
9 -from helpers.tool import Response
10 -from plugins._office.helpers import document_affordance, document_store
11 -
12 -
13 -HANDOFF_CREATED_FLAG = "_office_document_handoff_created"
6
7
8 class DocumentResponseAffordance(Extension):
17 - async def execute(
18 - self,
19 - tool_name: str = "",
20 - response: Response | None = None,
21 - **kwargs: Any,
22 - ):
23 - if not self.agent or response is None:
24 - return
25 - if document_affordance.is_subordinate_agent(self.agent):
26 - return
27 -
28 - if tool_name == "document_artifact":
29 - if (response.additional or {}).get("file_id"):
30 - self.agent.loop_data.params_persistent[HANDOFF_CREATED_FLAG] = True
31 - return
32 -
33 - if tool_name != "response":
34 - return
35 -
36 - tool = self.agent.loop_data.current_tool
37 - if not tool:
38 - return
39 - if self.agent.loop_data.params_persistent.get(HANDOFF_CREATED_FLAG):
40 - return
41 -
42 - text = str(tool.args.get("text") or tool.args.get("message") or response.message or "").strip()
43 - user_message = self.agent.last_user_message.content if self.agent.last_user_message else ""
44 - decision = document_affordance.decide_response_artifact(user_message, text)
45 - if decision is None:
46 - return
47 -
48 - try:
49 - doc = document_store.create_document(
50 - kind=decision.kind,
51 - title=decision.title,
52 - fmt=decision.fmt,
53 - content=decision.content,
54 - context_id=getattr(self.agent.context, "id", "") if self.agent.context else "",
55 - )
56 - except Exception as exc:
57 - PrintStyle().error(f"Document affordance failed: {exc}")
58 - return
59 -
60 - payload = {
61 - "ok": True,
62 - "message": "Created document artifact from response.",
63 - "document": public_doc(doc),
64 - }
65 - additional = document_additional(doc)
66 - content = json.dumps(payload, indent=2, ensure_ascii=False)
67 -
68 - self.agent.hist_add_tool_result("document_artifact", content, **additional)
69 - self.agent.loop_data.params_persistent[HANDOFF_CREATED_FLAG] = True
70 -
71 - display_path = document_store.display_path(doc["path"])
72 - note = document_affordance.format_created_response(doc["basename"], display_path)
73 - response.message = note
74 - tool.args["text"] = note
75 - tool.args["message"] = note
76 -
77 - log_item = self.agent.loop_data.params_temporary.get("log_item_response")
78 - if log_item:
79 - log_item.update(
80 - content=note,
81 - kvps={
82 - "action": "create",
83 - "kind": decision.kind,
84 - "title": decision.title,
85 - "format": decision.fmt,
86 - "_tool_name": "document_artifact",
87 - **additional,
88 - },
89 - )
90 -
91 -
92 -def public_doc(doc: dict[str, Any]) -> dict[str, Any]:
93 - return {
94 - "file_id": doc["file_id"],
95 - "path": document_store.display_path(doc["path"]),
96 - "basename": doc["basename"],
97 - "extension": doc["extension"],
98 - "size": doc["size"],
99 - "version": document_store.item_version(doc),
100 - "last_modified": doc["last_modified"],
101 - "exists": Path(doc["path"]).exists(),
102 - }
103 -
9 + """Compatibility shim for the retired response artifact affordance."""
10
105 -def document_additional(doc: dict[str, Any], action: str = "create") -> dict[str, Any]:
106 - return {
107 - "_tool_name": "document_artifact",
108 - "canvas_surface": "office",
109 - "action": action,
110 - "file_id": doc["file_id"],
111 - "title": doc["basename"],
112 - "format": doc["extension"],
113 - "path": document_store.display_path(doc["path"]),
114 - "version": document_store.item_version(doc),
115 - }
11 + async def execute(self, **kwargs: Any):
12 + return None
plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
+2 -34
@@ -1,16 +1,4 @@
1 import {
2 - createActionButton,
3 - copyToClipboard,
4 -} from "/components/messages/action-buttons/simple-action-buttons.js";
5 -import {
6 - buildDocumentFileActionButtons,
7 - documentFromLog,
8 - parseDocumentResult,
9 -} from "../lib/document-actions.js";
10 -import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
11 -import { store as speechStore } from "/components/chat/speech/speech-store.js";
12 -import {
13 - buildDetailPayload,
2 cleanStepTitle,
3 drawProcessStep,
4 } from "/js/messages.js";
@@ -34,35 +22,15 @@ function drawDocumentArtifactTool({
22 const args = arguments[0];
23 const title = cleanStepTitle(heading);
24 const displayKvps = { ...kvps };
37 - const contentText = String(content ?? "");
38 - const documentResult = parseDocumentResult(contentText);
39 - const document = documentFromLog(args, documentResult);
40 - const headerLabels = [
41 - kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
42 - document?.format && { label: String(document.format).toUpperCase(), class: "tool-name-badge" },
43 - ].filter(Boolean);
44 -
45 - const actionButtons = buildDocumentFileActionButtons(document);
46 -
47 - if (contentText.trim()) {
48 - actionButtons.push(
49 - createActionButton("detail", "", () =>
50 - stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })),
51 - ),
52 - createActionButton("speak", "", () => speechStore.speak(contentText)),
53 - createActionButton("copy", "", () => copyToClipboard(contentText)),
54 - );
55 - }
25
57 - const result = drawProcessStep({
26 + return drawProcessStep({
27 id,
28 title,
29 code: "DOC",
30 classes: undefined,
31 kvps: displayKvps,
32 content,
64 - actionButtons: actionButtons.filter(Boolean),
33 + actionButtons: [],
34 log: args,
35 });
67 - return result;
36 }
plugins/_office/extensions/webui/lib/document-actions.js
+202 -33
@@ -1,63 +1,189 @@
1 -import {
2 - createActionButton,
3 - copyToClipboard,
4 -} from "/components/messages/action-buttons/simple-action-buttons.js";
1 +import { showButtonFeedback } from "/components/messages/action-buttons/simple-action-buttons.js";
2 import { open as openSurface } from "/js/surfaces.js";
3
4 +const DESKTOP_FORMATS = ["md", "odt", "ods", "odp", "docx", "xlsx", "pptx"];
5 +
6 function basename(path = "") {
7 const value = String(path || "").split("?")[0].split("#")[0];
8 return value.split("/").filter(Boolean).pop() || "document";
9 }
10
12 -export function parseDocumentResult(content) {
13 - if (!content || typeof content !== "string") return {};
11 +function extensionFromPath(path = "") {
12 + const name = basename(path);
13 + const index = name.lastIndexOf(".");
14 + return index > 0 ? name.slice(index + 1).toLowerCase() : "";
15 +}
16 +
17 +function parseMaybeJson(value) {
18 + if (!value) return null;
19 + if (typeof value === "object") return value;
20 + if (typeof value !== "string") return null;
21 + const trimmed = value.trim();
22 + if (!trimmed.startsWith("{")) return null;
23 try {
15 - const parsed = JSON.parse(content);
16 - return parsed && typeof parsed === "object" ? parsed : {};
24 + const parsed = JSON.parse(trimmed);
25 + return parsed && typeof parsed === "object" ? parsed : null;
26 } catch {
18 - return {};
27 + return null;
28 }
29 }
30
22 -export function documentFromLog(args = {}, result = {}) {
23 - const kvps = args?.kvps || {};
24 - const document = result.document && typeof result.document === "object"
31 +function truthy(value) {
32 + if (value === true) return true;
33 + if (value === false || value == null) return false;
34 + if (typeof value === "number") return value !== 0;
35 + return ["1", "true", "yes", "y", "on"].includes(String(value).trim().toLowerCase());
36 +}
37 +
38 +function firstValue(...values) {
39 + for (const value of values) {
40 + if (value != null && String(value).trim() !== "") return value;
41 + }
42 + return "";
43 +}
44 +
45 +export function parseDocumentResult(content) {
46 + return parseMaybeJson(content) || {};
47 +}
48 +
49 +export function normalizeDocumentMetadata(args = {}, result = {}) {
50 + const kvps = parseMaybeJson(args?.kvps) || args?.kvps || {};
51 + const document = result?.document && typeof result.document === "object"
52 ? result.document
53 : {};
54 + const path = String(firstValue(
55 + result.path,
56 + kvps.path,
57 + args.path,
58 + document.path,
59 + ));
60 + const title = String(firstValue(
61 + result.title,
62 + kvps.title,
63 + kvps.basename,
64 + args.title,
65 + document.basename,
66 + basename(path),
67 + ));
68 + const format = String(firstValue(
69 + result.format,
70 + result.extension,
71 + kvps.format,
72 + kvps.extension,
73 + args.format,
74 + document.extension,
75 + extensionFromPath(path),
76 + )).toLowerCase().replace(/^\./, "");
77 +
78 return {
28 - file_id: kvps.file_id || document.file_id || "",
29 - path: kvps.path || document.path || "",
30 - title: kvps.title || kvps.basename || document.basename || "",
31 - format: kvps.format || kvps.extension || document.extension || "",
32 - version: kvps.version || document.version || "",
33 - last_modified: kvps.last_modified || document.last_modified || "",
79 + action: String(firstValue(result.action, kvps.action, args.action)).toLowerCase(),
80 + file_id: String(firstValue(result.file_id, kvps.file_id, args.file_id, document.file_id)),
81 + path,
82 + title,
83 + format,
84 + extension: format,
85 + size: firstValue(result.size, kvps.size, document.size),
86 + version: firstValue(result.version, kvps.version, args.version, document.version),
87 + last_modified: firstValue(result.last_modified, kvps.last_modified, args.last_modified, document.last_modified),
88 + exists: firstValue(result.exists, kvps.exists, document.exists),
89 + open_in_canvas: truthy(firstValue(result.open_in_canvas, kvps.open_in_canvas, args.open_in_canvas)),
90 + open_in_desktop: truthy(firstValue(result.open_in_desktop, kvps.open_in_desktop, args.open_in_desktop)),
91 };
92 }
93
37 -export async function openDocumentInDesktop(kvps = {}) {
94 +export function documentFromLog(args = {}, result = {}) {
95 + return normalizeDocumentMetadata(args, result);
96 +}
97 +
98 +export async function openDocumentInDesktop(document = {}) {
99 await openSurface("desktop", {
39 - path: kvps.path || "",
40 - file_id: kvps.file_id || "",
100 + path: document.path || "",
101 + file_id: document.file_id || "",
102 refresh: true,
103 source: "message-action",
104 });
105 }
106
46 -export async function openDocumentArtifact(kvps = {}) {
47 - await openDocumentInDesktop(kvps);
107 +export async function openDocumentArtifact(document = {}) {
108 + await openDocumentInDesktop(document);
109 }
110
111 function usesDesktop(doc = {}) {
112 const format = String(doc.format || doc.extension || "").toLowerCase();
52 - return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(format);
113 + return DESKTOP_FORMATS.includes(format);
114 }
115
55 -function desktopActionLabel(doc = {}) {
116 +function canvasActionTitle(doc = {}) {
117 const format = String(doc.format || doc.extension || "").toLowerCase();
57 - if (["odt", "docx"].includes(format)) return "Edit in Writer";
58 - if (["ods", "xlsx"].includes(format)) return "Edit in Calc";
59 - if (["odp", "pptx"].includes(format)) return "Edit in Impress";
60 - return "Open Document";
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";
121 + if (format === "md") return "Open Markdown in canvas";
122 + return "Open in canvas";
123 +}
124 +
125 +function documentIcon(doc = {}) {
126 + const format = String(doc.format || doc.extension || "").toLowerCase();
127 + if (["ods", "xlsx"].includes(format)) return "table_chart";
128 + if (["odp", "pptx"].includes(format)) return "slideshow";
129 + if (format === "md") return "article";
130 + return usesDesktop(doc) ? "description" : "draft";
131 +}
132 +
133 +function statusLine(doc = {}) {
134 + const parts = [];
135 + if (doc.path) parts.push(doc.path);
136 + if (doc.version) parts.push(`v${doc.version}`);
137 + return parts.join(" | ");
138 +}
139 +
140 +export function buildDocumentFileCard(document = {}) {
141 + const card = globalThis.document.createElement("span");
142 + card.className = "document-file-card";
143 + card.setAttribute("role", "button");
144 + card.setAttribute("tabindex", "0");
145 + card.setAttribute("aria-label", canvasActionTitle(document));
146 + card.setAttribute("title", canvasActionTitle(document));
147 +
148 + const icon = globalThis.document.createElement("span");
149 + icon.className = "material-symbols-outlined document-file-card-icon";
150 + icon.textContent = documentIcon(document);
151 + card.appendChild(icon);
152 +
153 + const meta = globalThis.document.createElement("span");
154 + meta.className = "document-file-card-meta";
155 +
156 + const name = globalThis.document.createElement("span");
157 + name.className = "document-file-card-name";
158 + name.textContent = document.title || basename(document.path);
159 + meta.appendChild(name);
160 +
161 + const detail = globalThis.document.createElement("span");
162 + detail.className = "document-file-card-path";
163 + detail.textContent = statusLine(document) || "Document artifact";
164 + meta.appendChild(detail);
165 + card.appendChild(meta);
166 +
167 + if (document.format) {
168 + const badge = globalThis.document.createElement("span");
169 + badge.className = "document-file-card-badge";
170 + badge.textContent = String(document.format).toUpperCase();
171 + card.appendChild(badge);
172 + }
173 +
174 + if (document.path || document.file_id) {
175 + card.addEventListener("click", () => openDocumentArtifact(document));
176 + card.addEventListener("keydown", (event) => {
177 + if (event.key !== "Enter" && event.key !== " ") return;
178 + event.preventDefault();
179 + void openDocumentArtifact(document);
180 + });
181 + } else {
182 + card.setAttribute("aria-disabled", "true");
183 + card.removeAttribute("tabindex");
184 + }
185 +
186 + return card;
187 }
188
189 export function downloadDocument(doc = {}) {
@@ -71,17 +197,60 @@ export function downloadDocument(doc = {}) {
197 globalThis.document.body.removeChild(link);
198 }
199
200 +export function createDocumentActionButton(icon, label, handler = null, options = {}) {
201 + const button = globalThis.document.createElement("button");
202 + button.type = "button";
203 + button.className = ["action-button", "document-file-action", options.className]
204 + .filter(Boolean)
205 + .join(" ");
206 + button.setAttribute("aria-label", options.ariaLabel || options.title || label);
207 + button.setAttribute("title", options.title || label);
208 +
209 + if (icon) {
210 + const iconEl = globalThis.document.createElement("span");
211 + iconEl.className = "material-symbols-outlined";
212 + iconEl.textContent = icon;
213 + button.appendChild(iconEl);
214 + }
215 +
216 + if (typeof handler === "function") {
217 + button.addEventListener("click", async (event) => {
218 + event.stopPropagation();
219 + const iconEl = button.querySelector(".material-symbols-outlined");
220 + const originalIcon = iconEl?.textContent || "";
221 + try {
222 + await handler();
223 + if (originalIcon) showButtonFeedback(button, true, originalIcon);
224 + } catch (err) {
225 + console.error("Document action failed:", err);
226 + if (originalIcon) showButtonFeedback(button, false, originalIcon);
227 + }
228 + });
229 + }
230 +
231 + return button;
232 +}
233 +
234 export function buildDocumentFileActionButtons(document = {}) {
235 const hasTarget = Boolean(document?.path || document?.file_id);
236 const buttons = [];
237 if (hasTarget) {
78 - const icon = usesDesktop(document) ? "desktop_windows" : "article";
79 - buttons.push(createActionButton(icon, desktopActionLabel(document), () => openDocumentArtifact(document)));
238 + buttons.push(
239 + createDocumentActionButton(
240 + "open_in_new",
241 + "Open in canvas",
242 + () => openDocumentArtifact(document),
243 + {
244 + className: "document-file-action-primary",
245 + title: canvasActionTitle(document),
246 + ariaLabel: canvasActionTitle(document),
247 + },
248 + ),
249 + );
250 }
251 if (document?.path) {
252 buttons.push(
83 - createActionButton("download", "Download", () => downloadDocument(document)),
84 - createActionButton("content_copy", "Path", () => copyToClipboard(document.path)),
253 + createDocumentActionButton("download", "Download", () => downloadDocument(document)),
254 );
255 }
256 return buttons;
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+82 -18
@@ -1,4 +1,5 @@
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 { open as openSurface } from "/js/surfaces.js";
4
5 const SYNC_WINDOW_MS = 10 * 60 * 1000;
@@ -34,16 +35,8 @@ export default async function syncDocumentResultsIntoOpenOfficeModal(context) {
35 continue;
36 }
37
37 - globalThis.setTimeout(async () => {
38 - if (!isOfficeModalOpen()) return;
39 - const office = officeStore;
40 - if (!office || isDirtySameDocument(office, { path, file_id: fileId })) return;
41 - await office.openSession?.({
42 - path,
43 - file_id: fileId,
44 - refresh: true,
45 - source: "tool-result-sync",
46 - });
38 + globalThis.setTimeout(() => {
39 + void syncOpenDocumentSurfaces({ path, file_id: fileId });
40 }, 0);
41 }
42 }
@@ -129,20 +122,91 @@ function documentExtension(payload = {}, document = {}) {
122 }
123
124 function isOfficeModalOpen() {
132 - return Boolean(
125 + if (
126 globalThis.isModalOpen?.("/plugins/_office/webui/main.html")
127 || globalThis.isModalOpen?.("plugins/_office/webui/main.html")
135 - || globalThis.document?.querySelector?.(".office-modal .office-panel, .modal .office-panel"),
128 + ) {
129 + return true;
130 + }
131 +
132 + const panels = Array.from(globalThis.document?.querySelectorAll?.(".modal-inner.office-modal .office-panel") || []);
133 + return panels.some((panel) => !isDesktopPanel(panel));
134 +}
135 +
136 +function isDesktopSurfaceOpen() {
137 + return Boolean(
138 + globalThis.document?.querySelector?.(
139 + '[data-surface-id="desktop"] .office-panel, .modal-inner[data-surface-id="desktop"] .office-panel, .modal-inner[data-canvas-surface="desktop"] .office-panel',
140 + ),
141 );
142 }
143
139 -function isDirtySameDocument(office, document = {}) {
140 - if (!office?.dirty || !office?.session) return false;
141 - const path = String(document.path || "");
142 - const fileId = String(document.file_id || "");
144 +async function syncOpenDocumentSurfaces(document = {}) {
145 + await syncOpenDesktopCanvas(document);
146 + await syncOpenOfficeModal(document);
147 +}
148 +
149 +async function syncOpenDesktopCanvas(document = {}) {
150 + const desktop = desktopStore;
151 + if (!desktop || !isDesktopSurfaceOpen()) return false;
152 + if (!hasSameDocument(desktop, document)) return false;
153 + if (isDirtySameDocument(desktop, document)) return false;
154 + await desktop.openSession?.({
155 + path: document.path || "",
156 + file_id: document.file_id || "",
157 + refresh: true,
158 + source: "tool-result-sync",
159 + });
160 + return true;
161 +}
162 +
163 +async function syncOpenOfficeModal(document = {}) {
164 + const office = officeStore;
165 + if (!office || !isOfficeModalOpen()) return false;
166 + if (!hasSameDocument(office, document)) return false;
167 + if (isDirtySameDocument(office, document)) return false;
168 + await office.openSession?.({
169 + path: document.path || "",
170 + file_id: document.file_id || "",
171 + refresh: true,
172 + source: "tool-result-sync",
173 + });
174 + return true;
175 +}
176 +
177 +function isDesktopPanel(panel = null) {
178 + return Boolean(
179 + panel?.closest?.('[data-surface-id="desktop"], [data-canvas-surface="desktop"]'),
180 + );
181 +}
182 +
183 +function hasSameDocument(store, document = {}) {
184 + return documentEntries(store).some((entry) => documentsMatch(entry, document));
185 +}
186 +
187 +function isDirtySameDocument(store, document = {}) {
188 + return documentEntries(store).some((entry) => {
189 + if (!documentsMatch(entry, document)) return false;
190 + const isActive = entry === store?.session || (entry.tab_id && entry.tab_id === store?.activeTabId);
191 + return Boolean(entry.dirty || (isActive && store?.dirty));
192 + });
193 +}
194 +
195 +function documentEntries(store) {
196 + const entries = [];
197 + if (store?.session) entries.push(store.session);
198 + if (Array.isArray(store?.tabs)) entries.push(...store.tabs);
199 + return entries;
200 +}
201 +
202 +function documentsMatch(entry = {}, document = {}) {
203 + const path = String(document.path || "").trim();
204 + const fileId = String(document.file_id || "").trim();
205 + const entryPath = String(entry.path || entry.document?.path || "").trim();
206 + const entryFileId = String(entry.file_id || entry.document?.file_id || "").trim();
207 return Boolean(
144 - (fileId && office.session.file_id === fileId)
145 - || (path && office.session.path === path),
208 + (fileId && entryFileId === fileId)
209 + || (path && entryPath === path),
210 );
211 }
212
plugins/_office/extensions/webui/set_messages_after_loop/document-response-file-cards.js new
+199
@@ -0,0 +1,199 @@
1 +import {
2 + buildDocumentFileActionButtons,
3 + buildDocumentFileCard,
4 + documentFromLog,
5 + parseDocumentResult,
6 +} from "../lib/document-actions.js";
7 +
8 +const PENDING_TTL_MS = 2 * 60 * 1000;
9 +const RESPONSE_CARD_ACTIONS = new Set([
10 + "create",
11 + "edit",
12 + "export",
13 + "open",
14 + "patch",
15 + "restore_version",
16 + "update",
17 +]);
18 +
19 +let pendingContextId = "";
20 +let pendingDocuments = [];
21 +
22 +export default async function injectDocumentCardsIntoFinalResponses(context) {
23 + if (!context?.results?.length) return;
24 +
25 + const contextId = currentContextId();
26 + if (pendingContextId !== contextId || context.historyEmpty) {
27 + pendingContextId = contextId;
28 + pendingDocuments = [];
29 + }
30 + prunePendingDocuments();
31 +
32 + for (const entry of context.results) {
33 + if (String(entry.args?.type || "") === "user") {
34 + pendingDocuments = [];
35 + continue;
36 + }
37 +
38 + const documentEntry = documentEntryFromToolResult(entry.args);
39 + if (documentEntry) {
40 + addPendingDocument(documentEntry);
41 + continue;
42 + }
43 +
44 + if (!isPrimaryResponse(entry.args, entry.result)) continue;
45 + if (!pendingDocuments.length) {
46 + refreshResponseFileActions(entry.result.element);
47 + continue;
48 + }
49 +
50 + injectResponseFileCards(entry.result.element, pendingDocuments);
51 + pendingDocuments = [];
52 + }
53 +}
54 +
55 +function documentEntryFromToolResult(args = {}) {
56 + if (String(args?.type || "") !== "tool") return null;
57 + const result = parseDocumentResult(String(args.content ?? ""));
58 + const document = documentFromLog(args, result);
59 + if (!document.path && !document.file_id) return null;
60 + if (toolName(args, result) !== "document_artifact") return null;
61 + if (!isResponseCardAction(document.action)) return null;
62 + return {
63 + document,
64 + log: args,
65 + };
66 +}
67 +
68 +function toolName(args = {}, result = {}) {
69 + return String(
70 + args?._tool_name
71 + || args?.kvps?._tool_name
72 + || result?._tool_name
73 + || result?.tool_name
74 + || "",
75 + ).trim();
76 +}
77 +
78 +function currentContextId() {
79 + return String(globalThis.getContext?.() || "");
80 +}
81 +
82 +function addPendingDocument(entry) {
83 + const key = documentIdentityKey(entry.document);
84 + const duplicateIndex = pendingDocuments.findIndex((item) => documentIdentityKey(item.document) === key);
85 + if (duplicateIndex >= 0) pendingDocuments.splice(duplicateIndex, 1);
86 + pendingDocuments.push({
87 + ...entry,
88 + createdAt: Date.now(),
89 + });
90 +}
91 +
92 +function prunePendingDocuments(now = Date.now()) {
93 + pendingDocuments = pendingDocuments.filter(
94 + (entry) => now - (entry.createdAt || now) <= PENDING_TTL_MS,
95 + );
96 +}
97 +
98 +function isPrimaryResponse(args = {}, result = {}) {
99 + if (String(args?.type || "") !== "response") return false;
100 + if (Number(args?.agentno || 0) > 0) return false;
101 + return Boolean(result?.element?.querySelector?.(".message-agent-response"));
102 +}
103 +
104 +function injectResponseFileCards(responseElement, entries) {
105 + const message = responseElement?.querySelector?.(".message-agent-response");
106 + const body = message?.querySelector?.(".message-body");
107 + if (!message || !body) return;
108 +
109 + const uniqueEntries = uniqueDocumentEntries(entries);
110 + if (!uniqueEntries.length) return;
111 +
112 + let wrapper = body.querySelector(":scope > .document-response-file-cards");
113 + if (!wrapper) {
114 + wrapper = document.createElement("div");
115 + wrapper.className = "document-response-file-cards";
116 + const content = body.querySelector(":scope > .msg-content");
117 + if (content) content.after(wrapper);
118 + else body.appendChild(wrapper);
119 + }
120 + wrapper.dataset.documents = JSON.stringify(uniqueEntries.map(({ document }) => document));
121 + wrapper.replaceChildren(...uniqueEntries.map(({ document }) => buildDocumentFileCard(document)));
122 +
123 + injectResponseActionButtons(message, uniqueEntries);
124 +}
125 +
126 +function refreshResponseFileActions(responseElement) {
127 + const message = responseElement?.querySelector?.(".message-agent-response");
128 + if (!message) return;
129 +
130 + const wrapper = message.querySelector(":scope .document-response-file-cards");
131 + const documents = parseStoredDocuments(wrapper);
132 + if (!documents.length) return;
133 +
134 + injectResponseActionButtons(
135 + message,
136 + documents.map((document) => ({ document })),
137 + );
138 +}
139 +
140 +function injectResponseActionButtons(message, entries) {
141 + const bar = message.querySelector(":scope > .step-action-buttons");
142 + if (!bar) return;
143 +
144 + bar.querySelectorAll(".document-response-file-action").forEach((button) => button.remove());
145 +
146 + const buttons = [];
147 + for (const entry of entries) {
148 + for (const button of buildDocumentFileActionButtons(entry.document)) {
149 + button.classList.add("document-response-file-action");
150 + buttons.push(button);
151 + }
152 + }
153 +
154 + const firstAction = Array.from(bar.children).find((child) => !child.classList.contains("expand-btn"));
155 + for (const button of buttons) {
156 + bar.insertBefore(button, firstAction || null);
157 + }
158 +}
159 +
160 +function uniqueDocumentEntries(entries = []) {
161 + const uniqueByDocument = new Map();
162 + for (const entry of entries) {
163 + const key = documentIdentityKey(entry.document);
164 + if (uniqueByDocument.has(key)) uniqueByDocument.delete(key);
165 + uniqueByDocument.set(key, entry);
166 + }
167 + return Array.from(uniqueByDocument.values());
168 +}
169 +
170 +function parseStoredDocuments(wrapper) {
171 + const raw = wrapper?.dataset?.documents;
172 + if (!raw) return [];
173 + try {
174 + const documents = JSON.parse(raw);
175 + return Array.isArray(documents) ? documents.filter(Boolean) : [];
176 + } catch {
177 + return [];
178 + }
179 +}
180 +
181 +function documentKey(document = {}) {
182 + return [
183 + document.file_id || "",
184 + document.path || "",
185 + document.version || "",
186 + ].join(":");
187 +}
188 +
189 +function documentIdentityKey(document = {}) {
190 + const path = String(document.path || "").trim();
191 + if (path) return `path:${path}`;
192 + const fileId = String(document.file_id || "").trim();
193 + if (fileId) return `file:${fileId}`;
194 + return documentKey(document);
195 +}
196 +
197 +function isResponseCardAction(action = "") {
198 + return RESPONSE_CARD_ACTIONS.has(String(action || "").trim().toLowerCase().replace("-", "_"));
199 +}
plugins/_office/helpers/artifact_editor.py
+143
@@ -86,7 +86,20 @@ def edit_artifact(
86 """Apply a direct saved edit to an Office artifact and return updated metadata."""
87 path = Path(doc["path"])
88 ext = str(doc["extension"]).lower()
89 + operation, content, find, replace, cells, rows, chart, slides, kwargs = _normalize_edit_inputs(
90 + operation=operation,
91 + content=content,
92 + find=find,
93 + replace=replace,
94 + cells=cells,
95 + rows=rows,
96 + chart=chart,
97 + slides=slides,
98 + kwargs=kwargs,
99 + )
100 op = normalize_operation(operation, content=content, find=find, cells=cells, rows=rows, chart=chart, slides=slides)
101 + if op in {"append_text", "prepend_text"} and content == "":
102 + raise ValueError(f"content is required for {op}")
103 before = path.read_bytes()
104
105 invalidate_sessions = bool(kwargs.pop("invalidate_sessions", False))
@@ -132,6 +145,130 @@ def edit_artifact(
145 return updated_doc, payload
146
147
148 +def _normalize_edit_inputs(
149 + *,
150 + operation: str = "",
151 + content: str = "",
152 + find: str = "",
153 + replace: str = "",
154 + cells: Any = None,
155 + rows: Any = None,
156 + chart: Any = None,
157 + slides: Any = None,
158 + kwargs: dict[str, Any] | None = None,
159 +) -> tuple[str, str, str, str, Any, Any, Any, Any, dict[str, Any]]:
160 + kwargs = dict(kwargs or {})
161 + edit_spec = _edit_spec_from_kwargs(kwargs)
162 +
163 + operation = _first_text(
164 + operation,
165 + edit_spec.get("operation"),
166 + edit_spec.get("op"),
167 + edit_spec.get("edit"),
168 + edit_spec.get("type"),
169 + )
170 +
171 + lines = _first_present(
172 + edit_spec.get("add_lines"),
173 + edit_spec.get("append_lines"),
174 + edit_spec.get("lines"),
175 + kwargs.get("add_lines"),
176 + kwargs.get("append_lines"),
177 + )
178 + if not operation and lines is not None:
179 + operation = "append_text"
180 +
181 + if content == "":
182 + content = _text_from_lines(lines)
183 + if content == "":
184 + content = _first_text(
185 + edit_spec.get("content"),
186 + edit_spec.get("text"),
187 + edit_spec.get("value"),
188 + edit_spec.get("body"),
189 + kwargs.get("content"),
190 + kwargs.get("text"),
191 + kwargs.get("value"),
192 + kwargs.get("body"),
193 + )
194 +
195 + if find == "":
196 + find = _first_text(
197 + edit_spec.get("find"),
198 + edit_spec.get("old_text"),
199 + edit_spec.get("old"),
200 + kwargs.get("old_text"),
201 + kwargs.get("old"),
202 + )
203 +
204 + if replace == "":
205 + replace = _first_text(
206 + edit_spec.get("replace"),
207 + edit_spec.get("replacement"),
208 + edit_spec.get("new_text"),
209 + edit_spec.get("new"),
210 + kwargs.get("replacement"),
211 + kwargs.get("new_text"),
212 + kwargs.get("new"),
213 + )
214 +
215 + if replace == "" and _looks_like_replace_operation(operation):
216 + replace = _first_text(edit_spec.get("value"), kwargs.get("value"))
217 +
218 + cells = cells if cells is not None else _first_present(edit_spec.get("cells"), kwargs.get("cells"))
219 + rows = rows if rows is not None else _first_present(edit_spec.get("rows"), kwargs.get("rows"))
220 + chart = chart if chart is not None else _first_present(edit_spec.get("chart"), kwargs.get("chart"))
221 + slides = slides if slides is not None else _first_present(edit_spec.get("slides"), kwargs.get("slides"))
222 +
223 + return operation, content, find, replace, cells, rows, chart, slides, kwargs
224 +
225 +
226 +def _edit_spec_from_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
227 + for key in ("edit", "edits", "update", "patch"):
228 + spec = _first_mapping(kwargs.get(key))
229 + if spec:
230 + return spec
231 + return {}
232 +
233 +
234 +def _first_mapping(value: Any) -> dict[str, Any]:
235 + if isinstance(value, dict):
236 + return value
237 + if isinstance(value, list):
238 + for item in value:
239 + if isinstance(item, dict):
240 + return item
241 + return {}
242 +
243 +
244 +def _first_present(*values: Any) -> Any:
245 + for value in values:
246 + if value is not None:
247 + return value
248 + return None
249 +
250 +
251 +def _first_text(*values: Any) -> str:
252 + for value in values:
253 + text = _text_from_lines(value)
254 + if text != "":
255 + return text
256 + return ""
257 +
258 +
259 +def _text_from_lines(value: Any) -> str:
260 + if value is None:
261 + return ""
262 + if isinstance(value, list):
263 + return "\n".join(str(item) for item in value)
264 + return str(value)
265 +
266 +
267 +def _looks_like_replace_operation(operation: str = "") -> bool:
268 + op = str(operation or "").strip().lower().replace("-", "_")
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._office.helpers import markdown_sessions
@@ -164,7 +301,13 @@ def normalize_operation(
301 "update": "replace_text" if find else "set_text",
302 "replace": "replace_text",
303 "append": "append_text",
304 + "append_line": "append_text",
305 + "append_lines": "append_text",
306 + "add_line": "append_text",
307 + "add_lines": "append_text",
308 "prepend": "prepend_text",
309 + "prepend_line": "prepend_text",
310 + "prepend_lines": "prepend_text",
311 "write": "set_text",
312 "set": "set_text",
313 "set_content": "set_text",
plugins/_office/helpers/document_affordance.py
+8 -392
@@ -1,178 +1,13 @@
1 from __future__ import annotations
2
3 -import re
3 from dataclasses import dataclass
4 from typing import Any
5
6
8 -MIN_ARTIFACT_CHARS = 700
9 -MIN_ARTIFACT_WORDS = 110
10 -MIN_EXPLICIT_ARTIFACT_CHARS = 240
11 -MIN_EXPLICIT_ARTIFACT_WORDS = 35
12 -
13 -CREATE_TERMS = {
14 - "author",
15 - "build",
16 - "compose",
17 - "convert",
18 - "create",
19 - "draft",
20 - "format",
21 - "generate",
22 - "make",
23 - "prepare",
24 - "produce",
25 - "save",
26 - "turn",
27 - "write",
28 -}
29 -
30 -DOCUMENT_TERMS = {
31 - "article",
32 - "brief",
33 - "contract",
34 - "cv",
35 - "doc",
36 - "document",
37 - "docx",
38 - "draft",
39 - "essay",
40 - "guide",
41 - "letter",
42 - "manual",
43 - "markdown",
44 - "memo",
45 - "odt",
46 - "open document",
47 - "opendocument",
48 - "policy",
49 - "proposal",
50 - "report",
51 - "resume",
52 - "spec",
53 - "story",
54 - "whitepaper",
55 - "writer",
56 -}
57 -
58 -SPREADSHEET_TERMS = {
59 - "budget",
60 - "calc",
61 - "excel",
62 - "ods",
63 - "sheet",
64 - "spreadsheet",
65 - "table",
66 - "workbook",
67 - "xlsx",
68 -}
69 -
70 -PRESENTATION_TERMS = {
71 - "deck",
72 - "impress",
73 - "odp",
74 - "ppt",
75 - "pptx",
76 - "presentation",
77 - "slide",
78 - "slides",
79 -}
80 -
81 -DELIVERABLE_TERMS = {
82 - "brief",
83 - "contract",
84 - "cv",
85 - "letter",
86 - "manual",
87 - "memo",
88 - "policy",
89 - "proposal",
90 - "report",
91 - "resume",
92 - "spec",
93 - "whitepaper",
94 -}
95 -
96 -EXPLICIT_FORMAT_TERMS = {
97 - "docx",
98 - "md",
99 - "markdown",
100 - "odp",
101 - "ods",
102 - "odt",
103 - "pptx",
104 - "xlsx",
105 -}
106 -
107 -HANDOFF_TERMS = {
108 - "artifact",
109 - "artifacts",
110 - "canvas",
111 - "document canvas",
112 - "download",
113 - "downloadable",
114 - "editable",
115 - "export",
116 - "open it",
117 - "save it",
118 - "save this",
119 -}
120 -
121 -FILE_HANDOFF_TERMS = {
122 - "file",
123 - "files",
124 -}
125 -
126 -CHAT_ONLY_TERMS = {
127 - "answer in chat",
128 - "in chat",
129 - "just answer",
130 - "just reply",
131 - "no file",
132 - "no files",
133 -}
134 -
135 -META_DISCUSSION_TERMS = {
136 - "affordance",
137 - "automatically",
138 - "auto",
139 - "disable",
140 - "issue",
141 - "less triggered",
142 - "problem",
143 - "speedbump",
144 - "stop",
145 - "trigger",
146 - "triggered",
147 - "why",
148 -}
149 -
150 -OOXML_COMPAT_TERMS = {
151 - "docx",
152 - "excel",
153 - "microsoft word",
154 - "powerpoint",
155 - "ppt",
156 - "pptx",
157 - "word",
158 - "xlsx",
159 -}
160 -
161 -ODF_DOCUMENT_TERMS = {"odt", "open document text", "opendocument text", "writer"}
162 -ODF_SPREADSHEET_TERMS = {"calc", "ods", "open document spreadsheet", "opendocument spreadsheet"}
163 -ODF_PRESENTATION_TERMS = {"impress", "odp", "open document presentation", "opendocument presentation"}
164 -
165 -SKIP_RESPONSE_PREFIXES = (
166 - "i can't",
167 - "i cannot",
168 - "i'm sorry",
169 - "sorry,",
170 - "i can help",
171 -)
172 -
173 -
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
@@ -180,236 +15,17 @@ class ArtifactDecision:
15 reason: str
16
17
183 -def decide_response_artifact(user_message: Any, response_text: str) -> ArtifactDecision | None:
184 - user_text = flatten_text(user_message).strip()
185 - response_text = str(response_text or "").strip()
186 - if not user_text or not response_text:
187 - return None
188 -
189 - lowered_user = normalize_text(user_text)
190 - lowered_response = normalize_text(response_text[:240])
191 - if any(term in lowered_user for term in CHAT_ONLY_TERMS):
192 - return None
193 - if lowered_response.startswith(SKIP_RESPONSE_PREFIXES):
194 - return None
195 - if looks_like_tool_or_status_response(response_text):
196 - return None
18 +def decide_response_artifact(user_message: Any, response_text: str) -> None:
19 + """Response text never creates document artifacts.
20
198 - kind, fmt = infer_kind_and_format(lowered_user)
199 - intent = artifact_intent(lowered_user, response_text)
200 - if not intent:
201 - return None
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
203 - explicit_artifact = intent == "explicit_handoff"
204 - if not is_substantial(response_text, explicit_artifact):
205 - return None
206 -
207 - title = infer_title(user_text, response_text, kind)
208 - return ArtifactDecision(
209 - kind=kind,
210 - fmt=fmt,
211 - title=title,
212 - content=response_text,
213 - reason=intent,
214 - )
215 -
216 -
217 -def flatten_text(value: Any) -> str:
218 - if value is None:
219 - return ""
220 - if isinstance(value, str):
221 - return value
222 - if isinstance(value, dict):
223 - preferred_keys = ("user_message", "user_intervention", "message", "content", "text")
224 - skipped_keys = {*preferred_keys, "attachments", "system_message", "raw_content"}
225 - preferred = []
226 - for key in preferred_keys:
227 - if key in value:
228 - preferred.append(flatten_text(value[key]))
229 - remaining = [
230 - flatten_text(child)
231 - for key, child in value.items()
232 - if key not in skipped_keys
233 - ]
234 - return "\n".join(part for part in [*preferred, *remaining] if part)
235 - if isinstance(value, (list, tuple, set)):
236 - return "\n".join(part for item in value if (part := flatten_text(item)))
237 - return str(value)
238 -
239 -
240 -def normalize_text(value: str) -> str:
241 - return re.sub(r"\s+", " ", value.lower()).strip()
242 -
243 -
244 -def infer_kind_and_format(lowered_user: str) -> tuple[str, str]:
245 - if has_any(lowered_user, PRESENTATION_TERMS):
246 - if has_any(lowered_user, {"powerpoint", "ppt", "pptx"}):
247 - return "presentation", "pptx"
248 - return "presentation", "odp"
249 - if has_any(lowered_user, SPREADSHEET_TERMS):
250 - if has_any(lowered_user, {"excel", "xlsx"}):
251 - return "spreadsheet", "xlsx"
252 - return "spreadsheet", "ods"
253 - if has_any(lowered_user, {"docx", "microsoft word", "word"}):
254 - return "document", "docx"
255 - if has_any(lowered_user, ODF_DOCUMENT_TERMS):
256 - return "document", "odt"
257 - return "document", "md"
258 -
259 -
260 -def artifact_intent(lowered_user: str, response_text: str) -> str | None:
261 - if not has_document_creation_intent(lowered_user):
262 - return None
263 - if has_explicit_handoff_signal(lowered_user):
264 - return "explicit_handoff"
26 return None
27
28
268 -def has_document_creation_intent(lowered_user: str) -> bool:
269 - return has_any(lowered_user, CREATE_TERMS) and has_any(
270 - lowered_user,
271 - DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS,
272 - )
273 -
274 -
275 -def has_explicit_handoff_signal(lowered_user: str) -> bool:
276 - if looks_like_affordance_meta_discussion(lowered_user):
277 - return False
278 -
279 - creation = r"(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format|convert|turn|save|export)"
280 - handoff = r"(?:file|files|artifact|artifacts|canvas|download|downloadable|editable file|open in canvas)"
281 - format_name = (
282 - r"(?:md|markdown|odt|ods|odp|docx|xlsx|pptx|writer|calc|impress|word|excel|"
283 - r"powerpoint|document|spreadsheet|workbook|presentation|deck|slides)"
284 - )
285 -
286 - if re.search(rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+\b{handoff}\b", lowered_user):
287 - return True
288 - if re.search(
289 - rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+(?:as|to|into)\s+"
290 - rf"(?:a|an|the)?\s*{format_name}\b",
291 - lowered_user,
292 - ):
293 - return True
294 - if re.search(rf"\b{creation}\b(?:\W+\w+){{0,10}}\W+\b(?:md|markdown|odt|ods|odp|docx|xlsx|pptx|writer|calc|impress)\b", lowered_user):
295 - return True
296 - return bool(
297 - has_any(lowered_user, FILE_HANDOFF_TERMS | HANDOFF_TERMS)
298 - and has_any(
299 - lowered_user,
300 - EXPLICIT_FORMAT_TERMS | ODF_DOCUMENT_TERMS | ODF_SPREADSHEET_TERMS | ODF_PRESENTATION_TERMS | OOXML_COMPAT_TERMS,
301 - )
302 - )
303 -
304 -
305 -def looks_like_affordance_meta_discussion(lowered_user: str) -> bool:
306 - if not has_any(lowered_user, META_DISCUSSION_TERMS):
307 - return False
308 - return has_any(
309 - lowered_user,
310 - DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS | EXPLICIT_FORMAT_TERMS | FILE_HANDOFF_TERMS,
311 - )
312 -
313 -
314 -def has_any(text: str, terms: set[str]) -> bool:
315 - return any(re.search(rf"\b{re.escape(term)}\b", text) for term in terms)
316 -
317 -
318 -def is_substantial(text: str, explicit_artifact: bool) -> bool:
319 - word_count = len(re.findall(r"\w+", text))
320 - char_count = len(text)
321 - if explicit_artifact:
322 - return char_count >= MIN_EXPLICIT_ARTIFACT_CHARS and word_count >= MIN_EXPLICIT_ARTIFACT_WORDS
323 - return char_count >= MIN_ARTIFACT_CHARS and word_count >= MIN_ARTIFACT_WORDS
324 -
325 -
326 -def looks_like_tool_or_status_response(text: str) -> bool:
327 - stripped = text.strip()
328 - if stripped.startswith("{") and '"tool_name"' in stripped[:300]:
329 - return True
330 - if "/a0/usr/workdir/" in stripped or "/a0/usr/projects/" in stripped:
331 - return True
332 - return False
333 -
334 -
335 -def looks_like_standalone_artifact(text: str) -> bool:
336 - lines = [line.strip() for line in text.splitlines() if line.strip()]
337 - if not lines or not title_from_response(text):
338 - return False
339 -
340 - heading_count = 0
341 - formal_marker_count = 0
342 - for line in lines[:40]:
343 - normalized = normalize_text(line)
344 - if re.match(r"^(#{1,4}\s+|\*\*.+\*\*$|[0-9]+[.)]\s+[A-Z])", line):
345 - heading_count += 1
346 - if re.match(
347 - r"^(executive summary|summary|purpose|scope|background|introduction|"
348 - r"recommendations?|conclusion|to:|from:|subject:|date:)\b",
349 - normalized,
350 - ):
351 - formal_marker_count += 1
352 -
353 - return heading_count >= 2 or formal_marker_count >= 2
354 -
355 -
356 -def infer_title(user_text: str, response_text: str, kind: str) -> str:
357 - response_title = title_from_response(response_text)
358 - if response_title:
359 - return response_title
360 -
361 - request_title = title_from_request(user_text)
362 - if request_title:
363 - return request_title
364 -
365 - return {
366 - "spreadsheet": "Spreadsheet",
367 - "presentation": "Presentation",
368 - }.get(kind, "Document")
369 -
370 -
371 -def title_from_response(response_text: str) -> str:
372 - for raw_line in response_text.splitlines()[:8]:
373 - line = raw_line.strip()
374 - if not line:
375 - continue
376 - for pattern in (
377 - r"^#{1,3}\s+(.+?)\s*$",
378 - r"^\*\*(.+?)\*\*\s*$",
379 - r"^__(.+?)__\s*$",
380 - ):
381 - match = re.match(pattern, line)
382 - if match:
383 - return clean_title(match.group(1))
384 - if len(line) <= 80 and not line.endswith((".", "?", "!", ":")):
385 - return clean_title(line)
386 - break
387 - return ""
388 -
389 -
390 -def title_from_request(user_text: str) -> str:
391 - text = re.sub(r"\s+", " ", user_text).strip()
392 - quoted = re.search(r"[\"'“”](.{4,90}?)[\"'“”]", text)
393 - if quoted:
394 - return clean_title(quoted.group(1))
395 -
396 - cleaned = re.sub(
397 - r"\b(write|draft|compose|create|generate|prepare|produce|make|build|author)\b",
398 - "",
399 - text,
400 - flags=re.IGNORECASE,
401 - )
402 - cleaned = re.sub(r"\b(a|an|the|new|for me|please|docx|document|file)\b", "", cleaned, flags=re.IGNORECASE)
403 - cleaned = clean_title(cleaned)
404 - return cleaned if 4 <= len(cleaned) <= 80 else ""
405 -
406 -
407 -def clean_title(value: str) -> str:
408 - value = re.sub(r"[*_`#>\[\]{}]", "", value)
409 - value = re.sub(r"\s+", " ", value).strip(" .:-")
410 - return value[:90].strip(" .:-")
411 -
412 -
29 def format_created_response(basename: str, path: str) -> str:
30 return (
31 f"Created **{basename}**.\n\n"
plugins/_office/prompts/agent.system.tool.document_artifact.md
+4 -2
@@ -3,11 +3,13 @@ create/open/read/edit reusable document artifacts in Agent Zero
3 formats: md odt ods odp docx xlsx pptx
4 default format: md
5 actions: create open read edit inspect export version_history restore_version status
6 -common args: action kind title format content path file_id
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 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 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 -created/updated artifacts are shown with explicit Download, Open Document, or Desktop edit message actions
10 +for action `edit`, use operation and put append/prepend/set text in `content` (example: operation `append_text`, content "new line")
11 +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"
12 +do not add a note saying the canvas/document UI was not opened automatically unless the user explicitly asks about UI behavior
13 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
14 DOCX/XLSX/PPTX are compatibility formats, not defaults
15 XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts when an embedded chart is required
plugins/_office/skills/calc-spreadsheets/SKILL.md
+1 -1
@@ -25,7 +25,7 @@ allowed_tools:
25
26 Use ODS when the user asks for a spreadsheet, workbook, editable table, budget, formulas, or Calc file. Use XLSX only when the user asks for Excel/XLSX compatibility, provides an existing `.xlsx`, or needs embedded spreadsheet charts supported by the tool.
27
28 -The document UI and Desktop are user-owned. Creating or editing an ODS or XLSX must save the workbook and return action buttons, but must not open a document modal or Desktop surface automatically. Use Desktop/Calc only for explicit GUI requests, visual chart/layout polish, or final visual confirmation.
28 +The document UI and Desktop are user-owned. Creating or editing an ODS or XLSX must save the workbook, but must not open a document modal or Desktop surface automatically. Use Desktop/Calc only for explicit GUI requests, visual chart/layout polish, or final visual confirmation. 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 ## Workflow
31
plugins/_office/skills/document-artifacts/SKILL.md
+15 -1
@@ -26,7 +26,7 @@ allowed_tools:
26
27 Use `document_artifact` for substantial deliverables that should remain editable in the custom document editor 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.
28
29 -The document UI and Desktop are user-owned. Creating, reading, or editing an artifact must save the file and update its state, but it must not open a document modal or Desktop surface automatically if the user has not asked for that UI. Tool results provide explicit Download, Open Document, or Desktop edit actions for the user. 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.
29 +The document UI and Desktop are user-owned. Creating, reading, or editing an artifact must save the file and update its state, but it must not open a document modal or Desktop surface 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.
30
31 For format-specific work, prefer the matching skill when available:
32
@@ -87,6 +87,19 @@ Edit text in a Markdown, ODT, DOCX, ODP, or PPTX file:
87 }
88 ```
89
90 +Append text to a Markdown, ODT, or DOCX file:
91 +```json
92 +{
93 + "tool_name": "document_artifact",
94 + "tool_args": {
95 + "action": "edit",
96 + "file_id": "abc123",
97 + "operation": "append_text",
98 + "content": "\nAdded line 1\nAdded line 2"
99 + }
100 +}
101 +```
102 +
103 Set spreadsheet cells:
104 ```json
105 {
@@ -134,6 +147,7 @@ Create an embedded spreadsheet chart:
147
148 Arguments:
149
150 +- `set_text`, `append_text`, and `prepend_text` use `content` for the text being written; do not put that text in value, update, or edits.
151 - `replace_text` and `delete_text` require `find`; `replace_text` uses `replace`.
152 - `set_cells` accepts `{ "A1": "value", "Sheet2!B3": 42 }` or `[{"sheet":"Sheet1","cell":"A1","value":"value"}]`.
153 - `rows` accepts an array of rows. `content` can also be CSV, TSV, or a Markdown table.
plugins/_office/skills/impress-presentations/SKILL.md
+1 -1
@@ -25,7 +25,7 @@ allowed_tools:
25
26 Use ODP when the user asks for a presentation, slides, a deck, or an Impress artifact. Use PPTX only when the user asks for PowerPoint/PPTX compatibility or provides an existing `.pptx`.
27
28 -The document UI and Desktop are user-owned. Creating or editing an ODP or PPTX must save the deck and return action buttons, but must not open a document modal or Desktop surface automatically. Use Desktop/Impress only for explicit GUI requests, visual layout polish, or final visual confirmation.
28 +The document UI and Desktop are user-owned. Creating or editing an ODP or PPTX must save the deck, but must not open a document modal or Desktop surface automatically. Use Desktop/Impress only for explicit GUI requests, visual layout polish, or final visual confirmation. 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 ## Workflow
31
plugins/_office/skills/markdown-documents/SKILL.md
+2 -2
@@ -20,7 +20,7 @@ allowed_tools:
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 document editor is user-owned UI. Create or update the saved Markdown artifact, but never open the document modal automatically. The document message will provide explicit Download, Open Document, or Desktop edit actions.
23 +The document editor is user-owned UI. Create or update the saved Markdown artifact, but never open the document modal 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
@@ -49,4 +49,4 @@ Practical rules:
49 - Prefer Markdown over ODT/DOCX for writing unless a binary Writer/Word file is explicitly needed.
50 - Keep agent-only cleanup simple: if the user asks to fix a typo, update the file and finish; do not force a document-editor workflow.
51 - Use clear headings and Markdown tables when they improve editability.
52 -- The custom Markdown editor is available when the user chooses Open Document.
52 +- The custom Markdown editor is available through the response file card.
plugins/_office/skills/writer-documents/SKILL.md
+1 -1
@@ -23,7 +23,7 @@ allowed_tools:
23
24 Use ODT for LibreOffice Writer documents. Use DOCX only when the user explicitly asks for Word/DOCX/OOXML compatibility, provides an existing `.docx`, or needs that compatibility format. For ordinary writing with no binary requirement, use Markdown instead.
25
26 -The document UI and Desktop are user-owned. Creating or editing an ODT or DOCX must save the file and return action buttons, but must not open a document modal or Desktop surface automatically. Use Desktop/Writer only for explicit GUI requests, visual layout polish, or final visual confirmation.
26 +The document UI and Desktop are user-owned. Creating or editing an ODT or DOCX must save the file, but must not open a document modal or Desktop surface automatically. Use Desktop/Writer only for explicit GUI requests, visual layout polish, or final visual confirmation. 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.
27
28 ## Workflow
29
tests/test_download_toast_regressions.py
+116
@@ -1,4 +1,8 @@
1 from pathlib import Path
2 +import shutil
3 +import subprocess
4 +
5 +import pytest
6
7
8 PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -8,6 +12,80 @@ def read(*parts: str) -> str:
12 return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
13
14
15 +def extract_js_function(source: str, name: str) -> str:
16 + start = source.find(f"function {name}(")
17 + if start < 0:
18 + raise AssertionError(f"Could not find JavaScript function: {name}")
19 + brace = source.find("{", start)
20 + if brace < 0:
21 + raise AssertionError(f"Could not find opening brace for JavaScript function: {name}")
22 + depth = 0
23 + quote = ""
24 + escape = False
25 + line_comment = False
26 + block_comment = False
27 + regex_literal = False
28 + regex_char_class = False
29 + index = brace
30 +
31 + while index < len(source):
32 + char = source[index]
33 + next_char = source[index + 1] if index + 1 < len(source) else ""
34 +
35 + if line_comment:
36 + line_comment = char != "\n"
37 + elif block_comment:
38 + if char == "*" and next_char == "/":
39 + block_comment = False
40 + index += 1
41 + elif regex_literal:
42 + if escape:
43 + escape = False
44 + elif char == "\\":
45 + escape = True
46 + elif char == "[":
47 + regex_char_class = True
48 + elif char == "]":
49 + regex_char_class = False
50 + elif char == "/" and not regex_char_class:
51 + regex_literal = False
52 + elif quote:
53 + if escape:
54 + escape = False
55 + elif char == "\\":
56 + escape = True
57 + elif char == quote:
58 + quote = ""
59 + elif char == "/" and next_char == "/":
60 + line_comment = True
61 + index += 1
62 + elif char == "/" and next_char == "*":
63 + block_comment = True
64 + index += 1
65 + elif char == "/" and previous_non_space(source, index) in {"=", "(", ",", ":"}:
66 + regex_literal = True
67 + regex_char_class = False
68 + elif char in {"'", '"', "`"}:
69 + quote = char
70 + elif char == "{":
71 + depth += 1
72 + elif char == "}":
73 + depth -= 1
74 + if depth == 0:
75 + return source[start:index + 1]
76 +
77 + index += 1
78 +
79 + raise AssertionError(f"Could not find complete JavaScript function: {name}")
80 +
81 +
82 +def previous_non_space(source: str, index: int) -> str:
83 + cursor = index - 1
84 + while cursor >= 0 and source[cursor].isspace():
85 + cursor -= 1
86 + return source[cursor] if cursor >= 0 else ""
87 +
88 +
89 def test_notification_store_supports_persistent_grouped_toasts():
90 store = read("webui", "components", "notifications", "notification-store.js")
91 api = read("api", "notification_create.py")
@@ -61,3 +139,41 @@ def test_file_browser_zip_downloads_emit_grouped_preparing_and_downloading_toast
139 directory_prepare = store.index("this.showDownloadPreparingToast(downloadToastGroup);", directory_start)
140 directory_fetch = store.index("const resp = await fetchApi(`/download_work_dir_file", directory_start)
141 assert directory_prepare < directory_fetch
142 +
143 +
144 +def test_message_path_links_keep_spaces_in_file_names():
145 + # This regression executes convertPathsToLinks with Node.js to catch browser-path parsing drift.
146 + if not shutil.which("node"):
147 + pytest.skip("Node.js is required to execute the message path-linking regression.")
148 +
149 + messages = read("webui", "js", "messages.js")
150 + function_source = extract_js_function(messages, "convertPathsToLinks")
151 +
152 + script = f"""
153 +{function_source}
154 +
155 +function assertIncludes(value, expected) {{
156 + if (!value.includes(expected)) {{
157 + throw new Error(`Expected ${{JSON.stringify(value)}} to include ${{JSON.stringify(expected)}}`);
158 + }}
159 +}}
160 +
161 +function assertNotIncludes(value, expected) {{
162 + if (value.includes(expected)) {{
163 + throw new Error(`Expected ${{JSON.stringify(value)}} not to include ${{JSON.stringify(expected)}}`);
164 + }}
165 +}}
166 +
167 +const spaced = convertPathsToLinks("Location: /a0/usr/workdir/New Document.md");
168 +assertIncludes(spaced, 'data-path="/a0/usr/workdir/New Document.md"');
169 +assertIncludes(spaced, '>New Document.md</a>');
170 +assertNotIncludes(spaced, '>New</a> Document.md');
171 +
172 +const sentence = convertPathsToLinks("Saved at /a0/usr/workdir/New Document.md and ready.");
173 +assertIncludes(sentence, 'data-path="/a0/usr/workdir/New Document.md"');
174 +assertNotIncludes(sentence, 'and ready</a>');
175 +
176 +const directory = convertPathsToLinks("Directory: /a0/usr/workdir is ready");
177 +assertIncludes(directory, 'data-path="/a0/usr/workdir"');
178 +"""
179 + subprocess.run(["node", "-e", script], check=True, text=True)
tests/test_office_canvas_setup.py
+63 -5
@@ -220,6 +220,23 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
220 "auto-open-document-results.js",
221 )
222 document_actions = read("plugins", "_office", "extensions", "webui", "lib", "document-actions.js")
223 + document_handler = read(
224 + "plugins",
225 + "_office",
226 + "extensions",
227 + "webui",
228 + "get_tool_message_handler",
229 + "document-artifact-handler.js",
230 + )
231 + response_cards = read(
232 + "plugins",
233 + "_office",
234 + "extensions",
235 + "webui",
236 + "set_messages_after_loop",
237 + "document-response-file-cards.js",
238 + )
239 + messages_css = read("webui", "css", "messages.css")
240 document_tool = read("plugins", "_office", "tools", "document_artifact.py")
241 office_api = read("plugins", "_office", "api", "office_session.py")
242
@@ -234,16 +251,57 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
251 assert "syncDocumentResultsIntoOpenOfficeModal" in auto_open
252 assert "isOfficeCanvas" not in auto_open
253 assert "officeStore" in auto_open
254 + assert "desktopStore" in auto_open
255 + assert "syncOpenDesktopCanvas" in auto_open
256 + assert "syncOpenOfficeModal" in auto_open
257 + assert "isDesktopSurfaceOpen" in auto_open
258 + assert "hasSameDocument" in auto_open
259 + assert 'source: "tool-result-sync"' in auto_open
260 + assert '".modal .office-panel"' not in auto_open
261 + assert "normalizeDocumentMetadata" in document_actions
262 + assert "buildDocumentFileCard" in document_actions
263 + assert "document-file-card" in document_actions
264 + assert "buildDocumentFileCard" not in document_handler
265 + assert "buildDocumentFileActionButtons" not in document_handler
266 + assert "document-file-card-wrapper" not in document_handler
267 + assert "message-document-artifact" not in document_handler
268 + assert "actionButtons: []" in document_handler
269 + assert "injectDocumentCardsIntoFinalResponses" in response_cards
270 + assert "buildDocumentFileCard" in response_cards
271 + assert "buildDocumentFileActionButtons" in response_cards
272 + assert "message-agent-response" in response_cards
273 + assert "document-response-file-cards" in response_cards
274 + assert "document-response-file-action" in response_cards
275 + assert "RESPONSE_CARD_ACTIONS" in response_cards
276 + assert "documentIdentityKey" in response_cards
277 + assert "uniqueByDocument" in response_cards
278 + assert "PENDING_TTL_MS" in response_cards
279 + assert "pendingContextId" in response_cards
280 + assert "globalThis.getContext" in response_cards
281 + assert "prunePendingDocuments" in response_cards
282 + assert "wrapper.dataset.documents" in response_cards
283 + assert "refreshResponseFileActions" in response_cards
284 + assert "parseStoredDocuments" in response_cards
285 assert "openDocumentInDesktop" in document_actions
286 assert "openDocumentArtifact" in document_actions
239 - assert "await openDocumentInDesktop(kvps);" in document_actions
287 + assert "await openDocumentInDesktop(document);" in document_actions
288 assert 'ensureModalOpen("/plugins/_office/webui/main.html")' not in document_actions
289 assert 'ensureModalOpen("/plugins/_office/webui/main.html")' not in auto_open
242 - assert "Open Document" in document_actions
290 + assert "Open in canvas" in document_actions
291 + assert "Copy path" not in document_actions
292 + assert "copyToClipboard" not in document_actions
293 + assert "Details" not in document_handler
294 + assert "Details" not in response_cards
295 + assert "/api/download_work_dir_file" in document_actions
296 assert 'openSurface("desktop"' in document_actions
244 - assert "Edit in Writer" in document_actions
245 - assert "Edit in Calc" in document_actions
246 - assert "Edit in Impress" in document_actions
297 + assert "Open in canvas with Writer" in document_actions
298 + assert "Open in canvas with Calc" in document_actions
299 + assert "Open in canvas with Impress" in document_actions
300 + assert '"md", "odt", "ods", "odp", "docx", "xlsx", "pptx"' in document_actions
301 + assert ".document-file-card" in messages_css
302 + assert ".document-response-file-cards" in messages_css
303 + assert ".document-file-action-label" not in messages_css
304 + assert ".process-step-detail-content.document-file-card-wrapper" not in messages_css
305 assert "open_in_canvas: bool = False" in document_tool
306 assert '"open_in_canvas": bool(open_in_canvas)' in document_tool
307 assert '"open_in_desktop": bool(open_in_desktop)' in document_tool
tests/test_office_document_affordance.py
+28 -25
@@ -34,62 +34,49 @@ def standalone_report() -> str:
34 )
35
36
37 -def test_explicit_docx_request_creates_document_artifact():
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 not None
44 - assert decision.kind == "document"
45 - assert decision.fmt == "docx"
46 - assert decision.reason == "explicit_handoff"
43 + assert decision is None
44
45
49 -def test_explicit_spreadsheet_file_request_creates_spreadsheet_artifact():
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
55 - assert decision is not None
56 - assert decision.kind == "spreadsheet"
57 - assert decision.fmt == "ods"
58 - assert decision.reason == "explicit_handoff"
52 + assert decision is None
53
54
61 -def test_explicit_excel_request_keeps_xlsx_compatibility_format():
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
67 - assert decision is not None
68 - assert decision.kind == "spreadsheet"
69 - assert decision.fmt == "xlsx"
61 + assert decision is None
62
63
72 -def test_explicit_presentation_file_request_uses_odp_by_default():
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
78 - assert decision is not None
79 - assert decision.kind == "presentation"
80 - assert decision.fmt == "odp"
70 + assert decision is None
71
72
83 -def test_convert_into_document_creates_document_artifact():
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
89 - assert decision is not None
90 - assert decision.kind == "document"
91 - assert decision.fmt == "md"
92 - assert decision.reason == "explicit_handoff"
79 + assert decision is None
80
81
82 def test_long_document_topic_does_not_create_artifact_without_handoff_signal():
@@ -119,7 +106,7 @@ def test_office_as_workplace_topic_is_not_a_handoff_signal():
106 assert decision is None
107
108
122 -def test_deliverable_request_needs_standalone_artifact_shape():
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(),
@@ -128,7 +115,7 @@ def test_deliverable_request_needs_standalone_artifact_shape():
115 assert decision is None
116
117
131 -def test_deliverable_request_with_artifact_shape_creates_document_artifact():
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(),
@@ -155,6 +142,22 @@ def test_chat_only_instruction_blocks_even_explicit_file_request():
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",
tests/test_office_document_store.py
+43 -1
@@ -158,6 +158,46 @@ def test_odf_and_ooxml_creation_and_direct_edits_still_work(office_state):
158 assert ods_rows[1][1] == 12500
159 assert ods_rows[2][0] == "Research"
160
161 +
162 +def test_document_artifact_markdown_append_accepts_common_model_shapes(office_state):
163 + doc = document_store.create_document("document", "Append Shapes", "md", "# Title\n\nBase")
164 +
165 + updated, payload = artifact_editor.edit_artifact(
166 + doc,
167 + operation="append_text",
168 + value="Added line 1\nAdded line 2",
169 + )
170 + assert payload["changed"] is True
171 + assert payload["lines_appended"] == 2
172 + assert artifact_editor.read_artifact(updated)["text"].endswith("Added line 1\nAdded line 2")
173 +
174 + updated, payload = artifact_editor.edit_artifact(
175 + updated,
176 + update={"add_lines": ["Added line 3", "Added line 4"]},
177 + )
178 + assert payload["operation"] == "append_text"
179 + assert payload["lines_appended"] == 2
180 + assert artifact_editor.read_artifact(updated)["text"].endswith(
181 + "Added line 1\nAdded line 2\nAdded line 3\nAdded line 4",
182 + )
183 +
184 + updated, payload = artifact_editor.edit_artifact(
185 + updated,
186 + edits=[{"op": "append_lines", "value": ["Added line 5", "Added line 6"]}],
187 + )
188 + assert payload["operation"] == "append_text"
189 + assert payload["lines_appended"] == 2
190 + assert artifact_editor.read_artifact(updated)["text"].endswith(
191 + "Added line 3\nAdded line 4\nAdded line 5\nAdded line 6",
192 + )
193 +
194 +
195 +def test_document_artifact_markdown_append_rejects_empty_content(office_state):
196 + doc = document_store.create_document("document", "Empty Append", "md", "# Title")
197 +
198 + with pytest.raises(ValueError, match="content is required for append_text"):
199 + artifact_editor.edit_artifact(doc, operation="append_text")
200 +
201 odp = document_store.create_document(
202 "presentation",
203 "Roadmap ODP",
@@ -314,7 +354,9 @@ def test_odf_is_advertised_and_docx_remains_explicit_compatibility(office_state)
354 assert "DOCX/XLSX/PPTX are compatibility formats" in prompt
355 assert "`method` is accepted as an alias for action" not in prompt
356 assert "they do not open a surface automatically" in prompt
317 - assert "explicit Download, Open Document, or Desktop edit message actions" in prompt
357 + assert "do not write faux UI action labels" in prompt
358 + assert '"Open document" or "Download file"' in prompt
359 + assert "explicit Download, Open Document, or Desktop edit message actions" not in prompt
360 doc = document_store.create_document("document", "Use ODT", "odt", "")
361 assert doc["extension"] == "odt"
362
tests/test_tool_action_contracts.py
+6
@@ -540,6 +540,12 @@ def test_corrected_tool_prompts_only_teach_action_contract():
540 assert "action" in text
541 for token in forbidden:
542 assert token not in text
543 + if "document" in path.name or "_office/skills" in str(path):
544 + assert "faux UI action labels" in text
545 + assert "Open document" in text
546 + assert "Download file" in text
547 + assert "Canvas was not opened automatically" not in text
548 + assert "Open Document, or Desktop edit actions" not in text
549
550
551 def test_computer_use_remote_is_skill_gated():
webui/css/messages.css
+142 -1
@@ -440,6 +440,134 @@
440 overflow: hidden;
441 }
442
443 +.document-response-file-cards {
444 + display: flex;
445 + flex-direction: column;
446 + gap: var(--spacing-xs);
447 + margin: var(--spacing-sm) 0 0;
448 + width: min(100%, 34rem);
449 +}
450 +
451 +.document-file-card {
452 + display: grid;
453 + grid-template-columns: auto minmax(0, 1fr) auto;
454 + align-items: center;
455 + gap: var(--spacing-sm);
456 + width: min(100%, 34rem);
457 + min-height: 3.25rem;
458 + padding: var(--spacing-sm);
459 + box-sizing: border-box;
460 + border: 1px solid var(--color-border);
461 + border-radius: 8px;
462 + background: rgba(255, 255, 255, 0.035);
463 + color: var(--color-text);
464 + cursor: pointer;
465 + text-align: left;
466 + transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
467 +}
468 +
469 +.document-file-card:hover,
470 +.document-file-card:focus-visible {
471 + border-color: var(--color-primary);
472 + background: rgba(255, 255, 255, 0.06);
473 + outline: none;
474 +}
475 +
476 +.document-file-card:active {
477 + transform: translateY(1px);
478 +}
479 +
480 +.document-file-card[aria-disabled="true"] {
481 + cursor: default;
482 + opacity: 0.72;
483 +}
484 +
485 +.document-file-card-icon {
486 + display: inline-flex;
487 + align-items: center;
488 + justify-content: center;
489 + width: 2rem;
490 + height: 2rem;
491 + border-radius: 8px;
492 + background: rgba(255, 255, 255, 0.075);
493 + color: var(--color-primary);
494 + font-size: 1.2rem;
495 + flex-shrink: 0;
496 +}
497 +
498 +.document-file-card-meta {
499 + display: flex;
500 + min-width: 0;
501 + flex-direction: column;
502 + gap: 0.1rem;
503 +}
504 +
505 +.document-file-card-name {
506 + min-width: 0;
507 + overflow: hidden;
508 + color: var(--color-text);
509 + font-size: var(--font-size-small);
510 + font-weight: 600;
511 + line-height: 1.25;
512 + text-overflow: ellipsis;
513 + white-space: nowrap;
514 +}
515 +
516 +.document-file-card-path {
517 + min-width: 0;
518 + overflow: hidden;
519 + color: var(--color-text-muted);
520 + font-family: var(--font-family-code);
521 + font-size: var(--font-size-xs);
522 + line-height: 1.35;
523 + text-overflow: ellipsis;
524 + white-space: nowrap;
525 +}
526 +
527 +.document-file-card-badge {
528 + align-self: start;
529 + padding: 0.15rem 0.35rem;
530 + border: 1px solid var(--color-border);
531 + border-radius: 6px;
532 + color: var(--color-text-muted);
533 + font-size: 0.62rem;
534 + font-weight: 700;
535 + letter-spacing: 0;
536 + line-height: 1;
537 +}
538 +
539 +.step-action-buttons .document-file-action {
540 + padding: 2px;
541 + border: 0;
542 + border-radius: 4px;
543 + color: var(--color-message-text);
544 + line-height: 1;
545 +}
546 +
547 +.step-action-buttons .document-file-action:hover,
548 +.step-action-buttons .document-file-action:focus-visible {
549 + background: transparent;
550 + color: var(--color-text);
551 + outline: none;
552 +}
553 +
554 +.step-action-buttons .document-file-action .material-symbols-outlined {
555 + font-size: 0.9rem;
556 +}
557 +
558 +.light-mode .document-file-card {
559 + background: rgba(0, 0, 0, 0.025);
560 +}
561 +
562 +.light-mode .document-file-card:hover,
563 +.light-mode .document-file-card:focus-visible {
564 + background: rgba(0, 0, 0, 0.045);
565 +}
566 +
567 +.light-mode .document-file-card-icon {
568 + background: rgba(0, 0, 0, 0.055);
569 +}
570 +
571 /* Math (KaTeX) */
572 .katex {
573 line-height: 1.2 !important;
@@ -513,6 +641,19 @@
641 word-break: break-word;
642 overflow-wrap: anywhere;
643 }
644 +
645 + .document-file-card {
646 + grid-template-columns: auto minmax(0, 1fr);
647 + }
648 +
649 + .document-file-card-badge {
650 + grid-column: 2;
651 + justify-self: start;
652 + }
653 +
654 + .step-action-buttons .document-file-action {
655 + padding-inline: 2px;
656 + }
657 }
658
659 .light-mode .msg-kvps tr {
@@ -784,4 +925,4 @@
925 to {
926 opacity: 0;
927 }
787 -}
\ No newline at end of file
928 +}
webui/js/messages.js
+6 -3
@@ -1692,17 +1692,20 @@ function convertPathsToLinks(str) {
1692 let html = "";
1693 for (const part of parts) {
1694 conc += "/" + part;
1695 - html += `/<a href="#" class="path-link" onclick="openFileLink('${conc}');">${part}</a>`;
1695 + html += `/<a href="#" class="path-link" data-path="${conc}" onclick="event.preventDefault(); openFileLink(this.dataset.path);">${part}</a>`;
1696 }
1697 return html;
1698 }
1699
1700 const prefix = `(?:^|[> \`'"\\n]|&#39;|&quot;)`;
1701 + const pathPart = `[a-zA-Z0-9_.~@%+=,()\\-]+(?: [a-zA-Z0-9_.~@%+=,()\\-]+)*`;
1702 + const spacedFilePath = `\\/(?:${pathPart}\\/)*${pathPart}\\.[a-zA-Z0-9]{1,12}`;
1703 const folder = `[a-zA-Z0-9_\\/.\\-]`;
1704 const file = `[a-zA-Z0-9_\\-\\/]`;
1703 - const suffix = `(?<!\\.)`;
1705 + const simplePath = `\\/${folder}*${file}(?<!\\.)`;
1706 + const suffix = `(?=$|[\\s.,;:!?\\)\\]\\}]|&#39;|&quot;)`;
1707 const pathRegex = new RegExp(
1705 - `(?<=${prefix})\\/${folder}*${file}${suffix}`,
1708 + `(?<=${prefix})(?:${spacedFilePath}|${simplePath})${suffix}`,
1709 "g",
1710 );
1711