diff --git a/CHANGELOG.md b/CHANGELOG.md index 337d0b2..5513c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format is based on Keep a Changelog, and this project follows semantic versi ## [Unreleased] +## [0.1.25] - 2026-06-26 + +### Added + +- Added first-class grouped-header summary answers so agents can inspect row-1 header spans, labels, and merged/unmerged status without chasing broad workbook overviews or stored result handles. +- Added explicit row and column structure operations, including insert/delete row support and richer delete-column guidance, so authorized destructive requests route to real structural changes instead of style or row-height workarounds. +- Added merge-aware multi-range previews for grouped headers so merge operations and center alignment can be batched and applied together. + +### Fixed + +- Fixed grouped-header color routing so row 1 grouped headers stay visually distinct from row 2 column headers, including exact `target.address` handling and darker grouped-header defaults. +- Fixed freeze-pane workflows so agents can apply, unfreeze, and answer frozen row/column status through live workbook state instead of cached style summaries. +- Fixed batched column width updates and column reorder/swap operations so widths, formulas, values, and formatting move with the affected columns. +- Fixed merged-header alignment normalization so center/middle alignment requests are translated to Office.js-compatible alignment values. + +### Changed + +- Improved `excel.agent.run` guidance, capability metadata, packaged skill docs, and generated MCP surface docs for batched updates, merge operations, freeze panes, grouped headers, and structural worksheet edits. +- Improved preview/apply regression coverage for OpenCode Excel workflows, including header styling, width preservation, merge batching, row/column deletion, freeze panes, and grouped-header summaries. + ## [0.1.24] - 2026-06-25 ### Fixed diff --git a/README.md b/README.md index faebdf0..dce409e 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,41 @@ Agents call `excel.agent.run` with natural language plus optional structured fie The backend keeps verbose workbook context local and returns compact proof, resource links, telemetry, warnings, and next actions. Caller LLMs may provide canonical `intent.action`, `intent.targetHints`, explicit `target`, and structured `values`, but the backend still owns ambiguity checks, stale-context checks, permissions, locks, backups, validation, and rollback metadata. +For styling review, agents should use `intent.action: "style_overview"` or `detailLevel: "style_overview"` with `mode: "answer"` to get current style context, column groups, grouped-header suggestions, and workflow hints without full data reads. For workbook design review, such as deciding which columns should be free text, dates, money, ID/text codes, dropdowns, or lookups/references from related sheets, agents should use `intent.action: "workbook_design_overview"` with `mode: "answer"` once before reading related sheets manually. It returns column-by-column recommendations, related-sheet hints, and next workflows without broad-reading empty data rows. For broad styling/readability work, agents should use `intent.action: "improve_visual_readability"` with `mode: "preview_update"` rather than issuing many primitive style calls. Options live under `values.visualReadability`; standard mode compiles safe column-first layout/formatting/highlight rules, comprehensive mode can include preview-only validation/formula suggestions, `stylePreservationMode` defaults to `protected_regions` so summary/template areas and grouped header bands stay guarded while ordinary table body styling, widths, alignment, and date/money formats can still be intentionally improved, `strict` preserves every detected existing style, `none` allows an explicit redesign, `referenceStyle` can preview adaptation from another sheet, and `presentationMode` can preview print/export suggestions. Apply still requires `apply_update` with the returned operation token, `nextAction: "call_apply_update"`, and `operationCount > 0`; if a preview reports `operationCount: 0` or `nextAction: "answer_now"`, agents should explain the skipped reasons instead of applying or decomposing the work into primitive style calls. Use `intent.action: "grouped_header"` for the separate structural preview that inserts a visual group row, merges group labels, and restyles the shifted table header. Grouped-header groups should use `{ "label": "...", "startColumn": "A", "endColumn": "B" }`; `{ "columns": ["A", "B"] }` and `{ "range": "A:B" }` are also accepted. Do not reuse an `operationId` from visual readability when creating a grouped-header preview. + +Grouped headers are structure-level styling. If apply is blocked by `DESTRUCTIVE_ACTION_BLOCKED` or `PERMISSION_DENIED`, the public agent path can enable the required policy with `intent.action: "set_permissions"` and `values.permissions` such as `{ "allowWrites": true, "allowDestructiveActions": true, "scopeToWorkbook": true, "requireConfirmationFor": [] }`; after that, create and apply a fresh grouped-header preview. + +Example OpenCode prompts: + +```text +Use open-workbook. Inspect the active sheet with a style overview first, without reading every data cell. Suggest visual readability improvements including grouped headers, one consistent palette, safe widths, alignment, filters, number formats, and highlights. Do not apply yet. +``` + +```text +Preview a grouped_header workflow for this sheet. Add a higher-level grouped header row above the existing column headers, merge group labels, and use matching group colors. Wait for approval before apply_update. +``` + +```json +{ + "mode": "preview_update", + "intent": { "action": "grouped_header" }, + "target": { "sheetName": "Invoices", "tableName": "InvoicesTable" }, + "values": { + "stylePreservationMode": "none", + "groupedHeader": { + "groups": [ + { "label": "สถานะ", "startColumn": "A", "endColumn": "B" }, + { "label": "ข้อมูลงาน", "startColumn": "C", "endColumn": "E" } + ] + } + } +} +``` + +```text +Apply the safe visual readability preview in one apply_update. Include opt-in buckets layout, validation, and freeze_panes only if they were present in the preview. +``` + With the shared daemon, multiple MCP sessions get distinct trusted agent identities. `status` and `prepare` include compact collaboration summaries for active agents, open tasks, locks, queued/applying transactions, conflicts, and recent events. ## Common Commands diff --git a/apps/backend/package.json b/apps/backend/package.json index f49fc8b..d526d18 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,6 +1,6 @@ { "name": "@components-kit/open-workbook-backend", - "version": "0.1.24", + "version": "0.1.25", "description": "Local backend broker for Open Workbook add-in sessions, backups, snapshots, plans, and permissions.", "license": "MIT", "type": "module", diff --git a/apps/backend/src/agent-action-handlers.test.ts b/apps/backend/src/agent-action-handlers.test.ts index ca393b6..c4d542d 100644 --- a/apps/backend/src/agent-action-handlers.test.ts +++ b/apps/backend/src/agent-action-handlers.test.ts @@ -37,6 +37,15 @@ describe("agent action handlers", () => { expect(findAgentActionHandler(natural, undefined, true)?.id).toBe("format_range"); }); + it("routes header row color-only changes to formatting instead of table row updates", () => { + const input: AgentRunInput = { + request: "Change row 1 header grouping fill to a darker color", + target: { sheetName: "Invoices", range: "A1:O1" } + }; + + expect(findAgentActionHandler(input, undefined, true)?.id).toBe("format_range"); + }); + it("matches promoted range-core actions by caller intent", () => { const target: AgentRunInput["target"] = { sheetName: "Data", range: "A1:B2" }; @@ -75,6 +84,21 @@ describe("agent action handlers", () => { expect(findAgentActionHandler({ request: "Do it", intent: { action: "copy_table_structure" }, target }, "copy_table_structure", true)?.id).toBe("copy_table_structure"); }); + it("routes freeze and unfreeze pane requests to the sheet freeze panes operation", () => { + const target: AgentRunInput["target"] = { sheetName: "Invoices", range: "A1:O1002" }; + + expect(findAgentActionHandler({ request: "unfreeze all panes on Invoices", target }, undefined, false)?.id).toBe("freeze_panes"); + expect(findAgentActionHandler({ request: "freeze top row and first column", target }, undefined, false)?.id).toBe("freeze_panes"); + expect(findAgentActionHandler({ request: "Do it", intent: { action: "freeze_panes" }, target }, "freeze_panes", false)?.id).toBe("freeze_panes"); + }); + + it("does not route read-only freeze pane questions to the mutation handler", () => { + const target: AgentRunInput["target"] = { sheetName: "Invoices", range: "A1:O1002" }; + + expect(findAgentActionHandler({ request: "Which column is frozen on Invoices?", target }, undefined, false)).toBeUndefined(); + expect(findAgentActionHandler({ request: "Check current freeze panes column", target }, undefined, false)).toBeUndefined(); + }); + it("matches promoted sheet-core actions by caller intent", () => { const target: AgentRunInput["target"] = { sheetName: "Report" }; @@ -97,6 +121,17 @@ describe("agent action handlers", () => { expect(findAgentActionHandler({ request: "Show cols B:C", target: { sheetName: "Data", range: "B:C" } }, undefined, true)?.id).toBe("unhide_columns"); }); + it("routes explicit deletion wording by row, column, or cell scope", () => { + expect(findAgentActionHandler({ request: "Delete this row", target: { sheetName: "Data", range: "5:5" } }, undefined, true)?.id).toBe("delete_rows"); + expect(findAgentActionHandler({ request: "Delete col B", target: { sheetName: "Data", range: "B:B" } }, undefined, true)?.id).toBe("delete_columns"); + expect(findAgentActionHandler({ request: "Remove this cell", target: { sheetName: "Data", range: "B5" } }, undefined, true)?.id).toBe("clear_values"); + }); + + it("routes column swap and move wording to column reorder", () => { + expect(findAgentActionHandler({ request: "Swap cols A and B", target: { sheetName: "Data", range: "A:B" } }, undefined, true)?.id).toBe("reorder_range_columns"); + expect(findAgentActionHandler({ request: "Move column B before column A", target: { sheetName: "Data", range: "A:B" } }, undefined, true)?.id).toBe("reorder_range_columns"); + }); + it("matches promoted workbook mutation actions by caller intent", () => { expect(findAgentActionHandler({ request: "Do it", intent: { action: "restore_workbook_backup" } }, "restore_workbook_backup", false)?.id).toBe("restore_workbook_backup"); expect(findAgentActionHandler({ request: "Do it", intent: { action: "import_local_config" } }, "import_local_config", false)?.id).toBe("import_local_config"); @@ -119,6 +154,15 @@ describe("agent action handlers", () => { expect(findAgentActionHandler({ request: "Do it", intent: { action: "delete_name" } }, "delete_name", false)?.id).toBe("delete_name"); }); + it("does not route grouped header creation wording to named-range creation", () => { + expect(findAgentActionHandler({ + request: "Create merged group header cells for the Invoices sheet with labels and colors." + }, undefined, false)?.id).not.toBe("create_name"); + expect(findAgentActionHandler({ + request: "Create a named range for the input cells." + }, undefined, false)?.id).toBe("create_name"); + }); + it("matches promoted region mutation actions by caller intent", () => { expect(findAgentActionHandler({ request: "Do it", intent: { action: "register_region" } }, "register_region", false)?.id).toBe("register_region"); expect(findAgentActionHandler({ request: "Do it", intent: { action: "clear_region_values" } }, "clear_region_values", false)?.id).toBe("clear_region_values"); diff --git a/apps/backend/src/agent-action-handlers.ts b/apps/backend/src/agent-action-handlers.ts index 1770aa3..eedecc5 100644 --- a/apps/backend/src/agent-action-handlers.ts +++ b/apps/backend/src/agent-action-handlers.ts @@ -100,7 +100,8 @@ export type AgentActionHandlerId = | "protect_sheet" | "unprotect_sheet" | "clear_sheet" - | "set_sheet_tab_color"; + | "set_sheet_tab_color" + | "freeze_panes"; export interface AgentActionHandlerDefinition { id: AgentActionHandlerId; @@ -117,7 +118,8 @@ export interface AgentActionHandlerDefinition { matches: (input: AgentRunInput, request: string) => boolean; } -const RANGE_MUTATION_WORDS = /\b(filter|filters|autofilter|auto\s*filter|style|format|formatting|conditional|validation|dropdown|drop\s*down|select\s+list|border|borders|fill|font|alignment|range|cells?|rows?|columns?|cols?|header row)\b/; +const RANGE_MUTATION_WORDS = /\b(filter|filters|autofilter|auto\s*filter|style|format|formatting|conditional|validation|dropdown|drop\s*down|select\s+list|border|borders|fill|font|alignment|color|colour|dark|darker|range|cells?|rows?|columns?|cols?|header row)\b/; +const STYLE_ONLY_WORDS = /\b(style|format|formatting|fill|font|bold|italic|alignment|align|center|centered|colour|color|background|highlight|border|borders|dark|darker|light|lighter)\b/; export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ { @@ -318,7 +320,7 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ intentAction: "create_name", requiresResolvedTarget: false, riskKind: "structure_change", - matches: (_input, request) => /\b(create|add)\b/.test(request) && /\b(named range|name|named item)\b/.test(request) + matches: (_input, request) => /\b(create|add|define|register)\b/.test(request) && /\b(named range|named item|name manager|workbook name|defined name)\b/.test(request) }, { id: "update_name", @@ -596,7 +598,7 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ intentAction: "update_table_rows", requiresResolvedTarget: true, riskKind: "broad_range_write", - matches: (_input, request) => /\b(update|change|edit)\b/.test(request) && /\b(rows?|records?|table)\b/.test(request) + matches: (_input, request) => /\b(update|change|edit)\b/.test(request) && /\b(rows?|records?|table)\b/.test(request) && !STYLE_ONLY_WORDS.test(request) }, { id: "create_table", @@ -620,7 +622,10 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ intentAction: "reorder_table_columns", requiresResolvedTarget: true, riskKind: "structure_change", - matches: (_input, request) => /\b(reorder|move|rearrange|swap)\b/.test(request) && /\b(columns?|table columns?)\b/.test(request) + matches: (input, request) => + /\b(reorder|move|rearrange|swap)\b/.test(request) && + /\b(columns?|cols?)\b/.test(request) && + (input.target?.tableName !== undefined || /\btable\b/.test(request)) }, { id: "reorder_range_columns", @@ -628,7 +633,7 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ intentAction: "reorder_range_columns", requiresResolvedTarget: true, riskKind: "structure_change", - matches: (_input, request) => /\b(reorder|rearrange|swap)\b/.test(request) && /\b(columns?|cols?)\b/.test(request) && !/\btable\b/.test(request) + matches: (_input, request) => /\b(reorder|rearrange|swap|move)\b/.test(request) && /\b(columns?|cols?)\b/.test(request) && !/\btable\b/.test(request) }, { id: "clear_table_data", @@ -690,6 +695,14 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ riskKind: "structure_change", matches: (_input, request) => /\b(repair|fix)\b/.test(request) && /\btable structure\b/.test(request) }, + { + id: "freeze_panes", + capabilityName: "excel.sheet.freeze_panes", + intentAction: "freeze_panes", + requiresResolvedTarget: false, + riskKind: "safe_format", + matches: (_input, request) => !isReadOnlyFreezeQuestion(request) && /\b(unfreeze|freeze|frozen)\b/.test(request) && /\b(panes?|rows?|columns?|cols?|header|top|first|all)\b/.test(request) + }, { id: "autofit_columns", capabilityName: "excel.range.autofit_columns", @@ -728,7 +741,7 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ intentAction: "clear_range", requiresResolvedTarget: true, riskKind: "destructive", - matches: (_input, request) => /\b(clear|remove|delete|wipe)\b/.test(request) && /\b(all|everything|range|cells?)\b/.test(request) + matches: (_input, request) => /\b(clear|remove|delete|wipe)\b/.test(request) && /\b(all|everything|entire range|whole range)\b/.test(request) }, { id: "normalize_headers", @@ -816,7 +829,11 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ intentAction: "clear_values", requiresResolvedTarget: true, riskKind: "destructive", - matches: (_input, request) => /\b(clear|remove|delete|wipe)\b/.test(request) && /\b(data|values?|contents?|test data|input data)\b/.test(request) && !/\b(formats?|formatting|styles?)\b/.test(request) + matches: (_input, request) => + /\b(clear|remove|delete|wipe)\b/.test(request) + && /\b(data|values?|contents?|test data|input data|cells?|this cell|selected cell|current cell)\b/.test(request) + && !/\b(formats?|formatting|styles?)\b/.test(request) + && !(/\b(delete|remove|drop)\b/.test(request) && /\b(rows?|cols?|columns?)\b/.test(request)) }, { id: "clear_values_raw", @@ -856,7 +873,9 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ intentAction: "write_data_validation", requiresResolvedTarget: true, riskKind: "safe_format", - matches: (_input, request) => /\b(data\s+validation|validation|dropdown|drop\s*down|select\s+list|selection\s+list)\b/.test(request) + matches: (_input, request) => + /\b(add|set|apply|write|create|update|change|replace)\b/.test(request) + && /\b(data\s+validation|validation|dropdown|drop\s*down|select\s+list|selection\s+list)\b/.test(request) }, { id: "write_conditional_formatting", @@ -896,7 +915,7 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ intentAction: "delete_columns", requiresResolvedTarget: true, riskKind: "destructive", - matches: (_input, request) => /\b(delete|remove)\b/.test(request) && /\bcolumns?\b/.test(request) + matches: (_input, request) => /\b(delete|remove)\b/.test(request) && /\b(cols?|columns?)\b/.test(request) }, { id: "merge_range", @@ -920,19 +939,44 @@ export const AGENT_ACTION_HANDLERS: AgentActionHandlerDefinition[] = [ intentAction: "format_range", requiresResolvedTarget: true, riskKind: "safe_format", - matches: (_input, request) => /\b(style|format|formatting|header\s+row|borders?)\b/.test(request) + matches: (_input, request) => /\b(style|format|formatting|header\s+row|fill|font|alignment|align|center|centered|color|colour|background|highlight|borders?)\b/.test(request) } ]; export function findAgentActionHandler(input: AgentRunInput, action: AgentIntentAction | undefined, requiresResolvedTarget: boolean): AgentActionHandlerDefinition | undefined { const request = input.request.toLowerCase(); const scopeHandlers = AGENT_ACTION_HANDLERS.filter((handler) => handler.requiresResolvedTarget === requiresResolvedTarget); - if (action !== undefined) { - return scopeHandlers.find((handler) => handler.intentAction === action); + const effectiveAction = action ?? intentActionFromValues(input); + if (effectiveAction !== undefined) { + return scopeHandlers.find((handler) => handler.intentAction === effectiveAction); } return scopeHandlers.find((handler) => handler.matches(input, request)); } +function intentActionFromValues(input: AgentRunInput): AgentIntentAction | undefined { + const values = input.values as Record | undefined; + if (!values) { + return undefined; + } + if (isTruthy(values.delete_rows ?? values.deleteRows ?? values.deleteRow ?? values.remove_rows ?? values.removeRows ?? values.removeRow)) { + return "delete_rows"; + } + if (isTruthy(values.insert_rows ?? values.insertRows ?? values.insertRow ?? values.add_rows ?? values.addRows ?? values.addRow)) { + return "insert_rows"; + } + if (isTruthy(values.delete_columns ?? values.deleteColumns ?? values.deleteColumn ?? values.delete_col ?? values.deleteCol ?? values.remove_columns ?? values.removeColumns ?? values.removeColumn ?? values.remove_col ?? values.removeCol)) { + return "delete_columns"; + } + if (isTruthy(values.insert_columns ?? values.insertColumns ?? values.insertColumn ?? values.insert_col ?? values.insertCol ?? values.add_columns ?? values.addColumns ?? values.addColumn ?? values.add_col ?? values.addCol)) { + return "insert_columns"; + } + return undefined; +} + +function isTruthy(value: unknown): boolean { + return value === true || value === "true" || value === 1 || value === "1"; +} + function hasStyleCopyEndpoints(input: AgentRunInput): boolean { const values = input.values as Record | undefined; return Boolean(values?.source && values?.destination); @@ -953,3 +997,8 @@ function hasFormulaLikeValue(values: AgentRunInput["values"]): boolean { : [Object.values(values)]; return matrix.flat().some((value) => typeof value === "string" && value.trim().startsWith("=")); } + +function isReadOnlyFreezeQuestion(request: string): boolean { + return /\b(which|what|where|show|tell|check|read|inspect|current|currently|is|are|has|have|status)\b/.test(request) + && /\b(freeze|frozen)\b/.test(request); +} diff --git a/apps/backend/src/agent-action-policy.ts b/apps/backend/src/agent-action-policy.ts index 0b3817f..8c76c0a 100644 --- a/apps/backend/src/agent-action-policy.ts +++ b/apps/backend/src/agent-action-policy.ts @@ -63,6 +63,7 @@ export const AGENT_ACTION_REGISTRY: AgentActionDefinition[] = [ { kind: "sheet.unprotect", risk: "structure_change", previewRequired: true, confirmationRequired: true }, { kind: "sheet.clear", risk: "destructive", previewRequired: true, confirmationRequired: true }, { kind: "sheet.set_tab_color", risk: "safe_format", previewRequired: true, confirmationRequired: true }, + { kind: "sheet.freeze_panes", risk: "safe_format", previewRequired: true, confirmationRequired: true }, { kind: "workbook.calculate", risk: "read_only", previewRequired: true, confirmationRequired: true }, { kind: "workbook.save", risk: "destructive", previewRequired: true, confirmationRequired: true }, { kind: "workbook.snapshot", risk: "read_only", previewRequired: true, confirmationRequired: true }, @@ -112,6 +113,7 @@ export const AGENT_ACTION_REGISTRY: AgentActionDefinition[] = [ { kind: "style.copy_dimensions_many", risk: "safe_format", previewRequired: true, confirmationRequired: true }, { kind: "workflow.replace_styled_table", risk: "destructive", previewRequired: true, confirmationRequired: true }, { kind: "style.repair_consistency", risk: "safe_format", previewRequired: true, confirmationRequired: true }, + { kind: "visual_readability.apply", risk: "safe_format", previewRequired: true, confirmationRequired: true }, { kind: "clean.transform", risk: "broad_range_write", previewRequired: true, confirmationRequired: true }, { kind: "clean.transform_many", risk: "broad_range_write", previewRequired: true, confirmationRequired: true } ]; @@ -160,6 +162,7 @@ export function riskForOperationKind( | "style.copy_dimensions_many" | "workflow.replace_styled_table" | "style.repair_consistency" + | "visual_readability.apply" | "clean.transform" | "clean.transform_many" | "workbook.snapshot" diff --git a/apps/backend/src/agent-intent.test.ts b/apps/backend/src/agent-intent.test.ts index f225f0d..fbe40a0 100644 --- a/apps/backend/src/agent-intent.test.ts +++ b/apps/backend/src/agent-intent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeAgentIntent } from "./agent-intent.js"; +import { modeForIntentAction, normalizeAgentIntent } from "./agent-intent.js"; describe("agent intent normalization", () => { it("accepts common agent aliases for canonical actions", () => { @@ -17,4 +17,23 @@ describe("agent intent normalization", () => { expect(filter.accepted).toBe(true); expect(filter.action).toBe("filter_range"); }); + + it("accepts improve_visual_readability as a high-level structured action", () => { + const intent = normalizeAgentIntent({ + request: "Make this sheet easier to read", + intent: { action: "improve_visual_readability" } + }); + + expect(intent.accepted).toBe(true); + expect(intent.action).toBe("improve_visual_readability"); + expect(intent.rejectedReason).toBeUndefined(); + }); + + it("routes style/design overview and permission actions through answer mode", () => { + expect(modeForIntentAction("style_overview")).toBe("answer"); + expect(modeForIntentAction("workbook_design_overview")).toBe("answer"); + expect(modeForIntentAction("get_permissions")).toBe("answer"); + expect(modeForIntentAction("set_permissions")).toBe("answer"); + expect(modeForIntentAction("allow_destructive_actions")).toBe("answer"); + }); }); diff --git a/apps/backend/src/agent-intent.ts b/apps/backend/src/agent-intent.ts index 5c04003..ec87ad8 100644 --- a/apps/backend/src/agent-intent.ts +++ b/apps/backend/src/agent-intent.ts @@ -34,7 +34,21 @@ const ACTION_ALIASES: Record = { conditional_formatting: "write_conditional_formatting", add_conditional_formatting: "write_conditional_formatting", formula_format: "write_conditional_formatting", - swap_columns: "reorder_range_columns" + swap_columns: "reorder_range_columns", + delete_row: "delete_rows", + remove_row: "delete_rows", + delete_selected_row: "delete_rows", + remove_selected_row: "delete_rows", + insert_row: "insert_rows", + add_row: "insert_rows", + delete_column: "delete_columns", + delete_col: "delete_columns", + remove_column: "delete_columns", + remove_col: "delete_columns", + insert_column: "insert_columns", + insert_col: "insert_columns", + add_column: "insert_columns", + add_col: "insert_columns" }; export function normalizeAgentIntent(input: AgentRunInput): NormalizedAgentIntent { @@ -76,6 +90,11 @@ export function modeForIntentAction(action: AgentIntentAction): AgentRunMode { if (action === "detect_external_changes") return "answer"; if (action === "export_local_config") return "answer"; if (action === "read_embedded_local_config") return "answer"; + if (action === "get_permissions") return "answer"; + if (action === "set_permissions") return "answer"; + if (action === "allow_destructive_actions") return "answer"; + if (action === "style_overview") return "answer"; + if (action === "workbook_design_overview") return "answer"; if (action === "read_formulas") return "answer"; if (action === "read_formula_patterns") return "answer"; if (action === "get_formula_dependency_graph") return "answer"; diff --git a/apps/backend/src/agent-operation-store.ts b/apps/backend/src/agent-operation-store.ts index 034c8fa..d2d3c93 100644 --- a/apps/backend/src/agent-operation-store.ts +++ b/apps/backend/src/agent-operation-store.ts @@ -43,6 +43,7 @@ export type PendingAgentAction = | { kind: "batch"; operations: ExcelOperation[] } | { kind: "style.copy_dimensions_many"; requests: StyleCopyRequest[] } | { kind: "workflow.replace_styled_table"; operations: ExcelOperation[]; styleCopies: StyleCopyRequest[] } + | { kind: "visual_readability.apply"; operations: ExcelOperation[]; request: { workbookId: WorkbookId; sheetName: string; formulaRanges: string[]; ruleCount: number; skippedRuleCount: number } } | { kind: "table.append_rows"; request: TableAppendRowsRequest } | { kind: "table.update_rows"; request: TableUpdateRowsRequest } | { kind: "table.create"; request: TableCreateRequest } @@ -125,6 +126,7 @@ export interface PendingAgentOperation { workbookContextId: string; workbookId: WorkbookId; action: PendingAgentAction; + workflowKind?: string; changes: NonNullable; createdAt: number; summary: string; diff --git a/apps/backend/src/agent-orchestrator.preview-apply.test.ts b/apps/backend/src/agent-orchestrator.preview-apply.test.ts index 0063be4..7560deb 100644 --- a/apps/backend/src/agent-orchestrator.preview-apply.test.ts +++ b/apps/backend/src/agent-orchestrator.preview-apply.test.ts @@ -3,6 +3,1040 @@ import { AgentOrchestrator } from "./agent-orchestrator.js"; import { FakeAgentRuntime, createCachedMetadata, selectionInfo, sheets, workbookId } from "./agent-orchestrator.test-support.js"; describe("AgentOrchestrator Preview Apply Safety", () => { + it("applies visual readability to Thai invoice body after grouped headers without skipping all rules", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_thai_invoices_grouped"); + const headers = [ + "สถานะวางบิล", + "จ่ายผู้รับเหมาช่วงแล้ว?", + "เลขบุ๊คกิ้ง", + "ลูกค้า", + "สถานะงาน", + "วันที่โหลดสินค้า", + "ราคางาน", + "ค่ายกตู้ขึ้น", + "ค่ายกตู้ลง", + "ค่ายกรวม", + "ค่าใช้จ่ายอื่น", + "ยอดวางบิลรวม", + "ภาษีหัก ณ ที่จ่าย", + "ยอดรับสุทธิ", + "งานจ้างช่วง?" + ]; + const invoiceColumns = headers.map((header, index) => ({ + name: header, + normalizedName: header, + inferredType: "unknown" as const, + role: "unknown" as const, + importance: 0.55, + index, + letter: String.fromCharCode("A".charCodeAt(0) + index) + })); + metadata.workbook = { ...metadata.workbook, activeSheet: "Invoices", name: "มิถุนายน.xlsx" }; + metadata.sheets = [{ + id: "sheet:Invoices", + name: "Invoices", + index: 0, + usedRange: "A1:O1002", + rowCount: 1002, + columnCount: 15, + kind: "transaction", + headers: [{ + id: "header:Invoices:wide", + sheetName: "Invoices", + row: 2, + range: "A2:O1002", + confidence: 0.9, + columns: invoiceColumns + }], + tableIds: ["table:InvoicesTable"], + sectionIds: ["section:Invoices:grouped-header"], + summaryBlockIds: ["summary:Invoices:grouped-header"], + formulaRegionIds: [] + }]; + metadata.tables = [{ + id: "table:InvoicesTable", + sheetName: "Invoices", + name: "InvoicesTable", + range: "A2:O1002", + columns: invoiceColumns + }]; + metadata.sections = [{ + id: "section:Invoices:grouped-header", + sheetName: "Invoices", + label: "Grouped header", + kind: "summary", + range: "A1:O2", + columns: [], + labels: ["สถานะ", "ข้อมูลการจอง", "ยอดเงิน", "งานจ้างช่วง"], + rowCount: 2, + columnCount: 15, + nonEmptyCellCount: 19, + confidence: 0.95 + }]; + metadata.summaryBlocks = [{ + id: "summary:Invoices:grouped-header", + sheetName: "Invoices", + range: "A1:O2", + labels: ["สถานะ", "ข้อมูลการจอง", "ยอดเงิน", "งานจ้างช่วง"], + confidence: 0.95 + }]; + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Make Invoices easier to read. Header is already good, focus on each column and data cells.", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Invoices", tableName: "InvoicesTable" } + }); + + const visualPlan = (preview.answer as any).visualPlan; + expect(preview.status).toBe("PREVIEW_READY"); + expect(preview.nextAction).toBe("call_apply_update"); + expect((preview.metrics as any).operationCount).toBeGreaterThan(0); + expect((preview.metrics as any).skippedRuleCount).toBeLessThan((preview.metrics as any).groupedOperationCount); + expect((preview.answer as any).detected).toMatchObject({ + headerRow: 2, + headerRange: "A2:O2", + dataRange: "A3:O1002" + }); + expect((preview.answer as any).columnRoles.map((column: any) => [column.column, column.role])).toEqual(expect.arrayContaining([ + ["A", "status"], + ["C", "id"], + ["D", "entity"], + ["E", "status"], + ["F", "date"], + ["G", "money"] + ])); + expect(visualPlan.skipped.map((skip: any) => skip.reason).join(" ")).not.toMatch(/column\.G\.number_format.*protected/); + + const applied = await agent.run({ + request: "Apply visual readability preview", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.map((operation) => operation.kind)).toEqual(expect.arrayContaining([ + "range.write_styles_many", + "range.write_number_formats_many" + ])); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.apply_autofilter")).toBe(false); + expect(visualPlan.skipped).toEqual(expect.arrayContaining([ + expect.objectContaining({ ruleId: "layout.filter", reason: expect.stringContaining("already provided by the detected Excel table") }) + ])); + const numberFormatOperation = runtime.lastBatchOperations.find((operation) => operation.kind === "range.write_number_formats_many") as any; + expect(numberFormatOperation.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: expect.objectContaining({ sheetName: "Invoices", address: "F3:F1002" }) }), + expect.objectContaining({ target: expect.objectContaining({ sheetName: "Invoices", address: "G3:G1002" }) }), + expect.objectContaining({ target: expect.objectContaining({ sheetName: "Invoices", address: "N3:N1002" }) }) + ])); + expect(numberFormatOperation.entries.find((entry: any) => entry.target.address === "G3:G1002").numberFormat[0][0]).toBe("#,##0.00"); + const styleOperation = runtime.lastBatchOperations.find((operation) => operation.kind === "range.write_styles_many") as any; + expect(styleOperation.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: expect.objectContaining({ sheetName: "Invoices", address: "A3:A1002" }), style: expect.objectContaining({ horizontalAlignment: "Center" }) }), + expect.objectContaining({ target: expect.objectContaining({ sheetName: "Invoices", address: "G3:G1002" }), style: expect.objectContaining({ horizontalAlignment: "Right" }) }) + ])); + }); + + it("previews and applies visual readability safe operations through the update lifecycle", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Make this sheet easier to read", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" }, + values: { + visualReadability: { + styleDepth: "standard", + profile: "auto", + density: "comfortable", + preserveFormulas: true, + preserveExistingStyle: true + } + } + }); + const applied = await agent.run({ + request: "Apply visual readability preview", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + expect(preview.status).toBe("PREVIEW_READY"); + expect(preview.operationId).toBeTruthy(); + expect(preview.confirmationToken).toBeTruthy(); + expect((preview.answer as any).kind).toBe("visual_readability_preview"); + expect((preview.answer as any).defaults).toEqual({ + styleDepth: "standard", + profile: "record_tracker", + density: "comfortable", + preserveFormulas: true, + preserveExistingStyle: true, + stylePreservationMode: "protected_regions", + allowValidationSuggestions: false, + allowFormulaSuggestions: false, + allowReplaceConditionalFormatting: false, + allowReplaceDataValidation: false, + allowInsertRowsOrColumns: false, + applySuggestionBuckets: [] + }); + expect((preview.answer as any).detected).toMatchObject({ + sheetName: "Data", + usedRange: "A1:D4", + headerRow: 1, + dataRange: "A2:D4", + tableRanges: ["A1:D4"], + hasFilter: true, + detectionSource: "metadata" + }); + expect((preview.answer as any).columnRoles.map((column: any) => [column.column, column.header, column.role])).toEqual([ + ["A", "Date", "date"], + ["B", "Account", "entity"], + ["C", "Amount", "money"], + ["D", "Status", "status"] + ]); + expect((preview.answer as any).sheetType).toBe("record_tracker"); + const visualPlan = (preview.answer as any).visualPlan; + expect(visualPlan.compilerStatus).toBe("preview_compiled_apply_pending"); + expect(visualPlan.counts.totalRules).toBeGreaterThan(0); + expect(visualPlan.counts.columnRules).toBeGreaterThan(0); + expect(visualPlan.counts.groupRules).toBeGreaterThan(0); + expect(visualPlan.counts.conditionalRules).toBeGreaterThan(0); + expect(visualPlan.ruleScopes.column).toBeGreaterThan(0); + expect(visualPlan.operationCount).toBeGreaterThan(0); + expect(visualPlan.skipped.map((skip: any) => skip.ruleId)).toContain("layout.freeze_header"); + expect(visualPlan.ruleIds).toEqual(expect.arrayContaining([ + "layout.header_style", + "layout.filter", + "column.A.width", + "column.C.number_format", + "conditional.D.missing_required" + ])); + expect(preview.changes.length).toBeGreaterThan(0); + expect(preview.resourceLinks.map((link) => link.uri)).toContain(`excel://agent/operations/${preview.operationId}`); + expect(preview.warnings.join(" ")).toContain("safe visual operation"); + expect(applied.status).toBe("SUCCESS"); + expect((applied.answer as any).kind).toBe("apply_update_result"); + expect((applied.answer as any).ok).toBe(true); + expect(runtime.lastBatchOperations.map((operation) => operation.kind)).toEqual([ + "range.write_styles_many", + "range.write_conditional_formatting", + "range.write_conditional_formatting", + "range.write_conditional_formatting", + "range.write_number_formats_many" + ]); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.apply_autofilter")).toBe(false); + expect(visualPlan.skipped).toEqual(expect.arrayContaining([ + expect.objectContaining({ ruleId: "layout.filter", reason: expect.stringContaining("already provided by the detected Excel table") }) + ])); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.write_values" || operation.kind === "range.write_formulas")).toBe(false); + const styleOperation = runtime.lastBatchOperations.find((operation) => operation.kind === "range.write_styles_many") as any; + expect(styleOperation.entries.every((entry: any) => entry.preserveValues === true)).toBe(true); + const numberFormatOperation = runtime.lastBatchOperations.find((operation) => operation.kind === "range.write_number_formats_many") as any; + expect(numberFormatOperation.entries.every((entry: any) => entry.preserveValues === true)).toBe(true); + expect(runtime.lastBatchOperations.filter((operation) => operation.kind === "range.write_conditional_formatting").map((operation: any) => operation.rule.formula)).toEqual(expect.arrayContaining([ + '=AND(COUNTA($A2:$D2)>0,$A2="")', + '=AND(COUNTA($A2:$D2)>0,$D2="")' + ])); + expect(runtime.writeBatchCount).toBe(1); + }); + + it("keeps basic visual readability previews to layout and column rules", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Make this sheet easier to read", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" }, + values: { + visualReadability: { + styleDepth: "basic" + } + } + }); + + const visualPlan = (preview.answer as any).visualPlan; + expect(preview.status).toBe("PREVIEW_READY"); + expect(visualPlan.compilerStatus).toBe("preview_compiled_apply_pending"); + expect(visualPlan.counts.totalRules).toBeGreaterThan(0); + expect(visualPlan.counts.columnRules).toBeGreaterThan(0); + expect(visualPlan.counts.groupRules).toBe(0); + expect(visualPlan.counts.conditionalRules).toBe(0); + expect(visualPlan.operationCount).toBeGreaterThan(0); + expect(visualPlan.ruleIds).toEqual(expect.arrayContaining([ + "layout.header_style", + "column.A.width", + "column.C.number_format" + ])); + expect(visualPlan.ruleIds.some((ruleId: string) => ruleId.startsWith("group."))).toBe(false); + expect(visualPlan.ruleIds.some((ruleId: string) => ruleId.startsWith("conditional."))).toBe(false); + expect(runtime.writeBatchCount).toBe(0); + }); + + it("keeps comprehensive visual readability validation and formula suggestions preview-only", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Run a comprehensive visual readability update and include suggestions", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" }, + values: { + visualReadability: { + styleDepth: "comprehensive" + } + } + }); + const applied = await agent.run({ + request: "Apply visual readability preview", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + const visualPlan = (preview.answer as any).visualPlan; + expect(preview.status).toBe("PREVIEW_READY"); + expect(visualPlan.counts.validationSuggestions).toBeGreaterThan(0); + expect(visualPlan.counts.formulaSuggestions).toBeGreaterThan(0); + expect(visualPlan.validationSuggestions).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "validation.D.dropdown", risk: "medium", existingValidation: "not_detected" }) + ])); + expect(visualPlan.formulaSuggestions).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "formula.overdue_flag", risk: "medium" }) + ])); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.write_data_validation" || operation.kind === "range.write_formulas")).toBe(false); + }); + + it("applies visual readability validation suggestions only when the validation bucket is requested", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Make this sheet easier to read and add dropdowns", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" }, + values: { + visualReadability: { + applySuggestionBuckets: ["validation"] + } + } + }); + const applied = await agent.run({ + request: "Apply visual readability preview", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + const visualPlan = (preview.answer as any).visualPlan; + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).defaults.applySuggestionBuckets).toEqual(["validation"]); + expect(visualPlan.validationSuggestions).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "validation.D.dropdown", target: "D2:D4" }) + ])); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.write_data_validation")).toBe(true); + expect(runtime.lastBatchOperations.find((operation) => operation.kind === "range.write_data_validation")).toMatchObject({ + target: { sheetName: "Data", address: "D2:D4" }, + validation: { type: "list", source: ["Open", "In Progress", "Blocked", "Done"] } + }); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.write_formulas")).toBe(false); + }); + + it("applies freeze column suggestions when the freeze_panes bucket is requested", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Make this easier to read and freeze first column", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" }, + values: { + visualReadability: { + applySuggestionBuckets: ["freeze_panes"] + } + } + }); + const applied = await agent.run({ + request: "Apply visual readability preview", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + const visualPlan = (preview.answer as any).visualPlan; + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).defaults.freezePanes).toEqual({ columns: 1 }); + expect(visualPlan.ruleIds).toEqual(expect.arrayContaining(["layout.freeze_header", "layout.freeze_columns"])); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.filter((operation) => operation.kind === "sheet.freeze_panes")).toEqual([ + expect.objectContaining({ kind: "sheet.freeze_panes", sheetName: "Data", rows: 1, columns: 1 }) + ]); + }); + + it("previews and applies direct unfreeze pane requests without visual readability", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Unfreeze all panes in Data", + mode: "preview_update", + intent: { action: "freeze_panes" }, + target: { sheetName: "Data" } + }); + const applied = await agent.run({ + request: "Apply unfreeze panes", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).kind).toBe("freeze_panes_preview"); + expect((preview.answer as any).freezePanes).toEqual({ rows: 0, columns: 0 }); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.filter((operation) => operation.kind === "sheet.freeze_panes")).toEqual([ + expect.objectContaining({ kind: "sheet.freeze_panes", sheetName: "Data", rows: 0, columns: 0 }) + ]); + }); + + it("keeps visual readability reference-style adaptation preview-only", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Make Apr 2026 look like May 2026 without changing formulas", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Apr 2026" }, + values: { + visualReadability: { + referenceStyle: { + sheet: "May 2026", + adaptToTargetStructure: true, + preserveTargetValues: true, + preserveFormulas: true + } + } + } + }); + const applied = await agent.run({ + request: "Apply visual readability preview", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + const visualPlan = (preview.answer as any).visualPlan; + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).defaults.referenceStyle).toMatchObject({ + sheetName: "May 2026", + adaptToTargetStructure: true, + preserveTargetValues: true, + preserveFormulas: true + }); + expect(visualPlan.counts.referenceStyleSuggestions).toBeGreaterThan(0); + expect(visualPlan.referenceStyleSuggestions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "reference_style.header", + referenceSheetName: "May 2026", + preserveTargetValues: true, + preserveFormulas: true + }), + expect.objectContaining({ id: "reference_style.columns_by_role" }) + ])); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.write_values" || operation.kind === "range.write_formulas")).toBe(false); + }); + + it("keeps visual readability print and presentation suggestions preview-only", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Make Apr 2026 print ready", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Apr 2026" }, + values: { + visualReadability: { + presentationMode: "print_ready" + } + } + }); + const applied = await agent.run({ + request: "Apply visual readability preview", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + const visualPlan = (preview.answer as any).visualPlan; + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).defaults.presentationMode).toBe("print_ready"); + expect(visualPlan.counts.printSuggestions).toBeGreaterThan(0); + expect(visualPlan.printSuggestions).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "print.orientation", value: "landscape" }), + expect.objectContaining({ id: "print.fit_to_width" }), + expect.objectContaining({ id: "print.repeat_header", target: "A1:AG1" }) + ])); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.every((operation) => !String(operation.kind).includes("print"))).toBe(true); + }); + + it("verifies formula preservation when applying visual readability to formula ranges", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_visual_formula_preservation"); + metadata.formulaRegions = [{ id: "formula:apr-payment-variance", sheetName: "Apr 2026", range: "I2:I244", formulaCount: 243 }]; + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Make Apr 2026 easier to read", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Apr 2026" } + }); + const applied = await agent.run({ + request: "Apply visual readability preview", + mode: "apply_update", + workbookContextId: metadata.workbookContextId, + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).visualPlan.preservation.formulaRanges).toEqual(["I2:I244"]); + expect(applied.status).toBe("SUCCESS"); + expect((applied.answer as any).formulaPreservation).toMatchObject({ + checkedRanges: ["I2:I244"], + formulasChanged: 0, + unchanged: true + }); + expect((applied.answer as any).formulaPreservation.formulasChecked).toBeGreaterThan(0); + expect((applied.answer as any).telemetry).toMatchObject({ visualReadabilityApply: true, formulasChanged: 0 }); + expect(runtime.readBatchCount).toBe(2); + expect(runtime.writeBatchCount).toBe(1); + expect(runtime.lastWriteOperations.some((operation) => operation.kind === "range.write_values" || operation.kind === "range.write_formulas")).toBe(false); + }); + + it("fails visual readability apply when formula preservation changes are detected", async () => { + const runtime = new FakeAgentRuntime(); + runtime.mutateFormulaReadsAfterWrite = true; + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_visual_formula_preservation_failure"); + metadata.formulaRegions = [{ id: "formula:apr-payment-variance", sheetName: "Apr 2026", range: "I2:I244", formulaCount: 243 }]; + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Make Apr 2026 easier to read", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Apr 2026" } + }); + const applied = await agent.run({ + request: "Apply visual readability preview", + mode: "apply_update", + workbookContextId: metadata.workbookContextId, + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect(applied.status).toBe("VALIDATION_FAILED"); + expect((applied.answer as any).formulaPreservation).toMatchObject({ + checkedRanges: ["I2:I244"], + unchanged: false + }); + expect((applied.answer as any).formulaPreservation.formulasChanged).toBeGreaterThan(0); + expect(applied.warnings.join(" ")).toContain("Formula preservation failed"); + }); + + it("asks for a header range when visual readability structure is ambiguous", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Make this report easier to read", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Report" } + }); + + expect(preview.status).toBe("NEEDS_INPUT"); + expect(preview.summary).toContain("Could not confidently detect a header row"); + expect(preview.nextAction).toBe("ask_user"); + expect(preview.warnings.join(" ")).toContain("No visual styling operations were prepared"); + expect(runtime.writeBatchCount).toBe(0); + }); + + it("blocks oversized visual readability targets before compiling operations", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Make this huge sheet easier to read", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data", range: "A1:XFD200" } + }); + + expect(preview.status).toBe("VALIDATION_FAILED"); + expect(preview.summary).toContain("target is too large"); + expect(preview.nextAction).toBe("ask_user"); + expect(runtime.writeBatchCount).toBe(0); + }); + + it("blocks visual readability on hidden sheets by default", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_visual_hidden_sheet"); + metadata.sheets.push({ + id: "sheet:hidden", + name: "Hidden Data", + index: 99, + usedRange: "A1:B4", + rowCount: 4, + columnCount: 2, + isHidden: true, + kind: "transaction", + headers: [{ + id: "header:hidden", + sheetName: "Hidden Data", + row: 1, + range: "A1:B1", + confidence: 0.95, + columns: [ + { name: "Date", normalizedName: "date", inferredType: "date", role: "date", importance: 0.9, index: 0, letter: "A" }, + { name: "Status", normalizedName: "status", inferredType: "status", role: "status", importance: 0.9, index: 1, letter: "B" } + ] + }], + tableIds: [], + sectionIds: [], + summaryBlockIds: [], + formulaRegionIds: [] + }); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Make hidden data easier to read", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Hidden Data" } + }); + + expect(preview.status).toBe("VALIDATION_FAILED"); + expect(preview.summary).toContain("sheet is hidden"); + expect(preview.nextAction).toBe("ask_user"); + expect(runtime.writeBatchCount).toBe(0); + }); + + it("skips visual readability rules that overlap existing styled summary areas", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_visual_existing_style"); + metadata.summaryBlocks.push({ + id: "summary:data-title", + sheetName: "Data", + range: "A1:D1", + labels: ["Transactions"], + confidence: 0.95 + }); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Make Data easier to read", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" } + }); + const visualPlan = (preview.answer as any).visualPlan; + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).detected.existingStyleRanges).toEqual(["A1:D1"]); + expect(visualPlan.skipped).toEqual(expect.arrayContaining([ + expect.objectContaining({ ruleId: "layout.header_style", reason: expect.stringContaining("protected summary/template style area") }), + expect.objectContaining({ ruleId: "layout.header_alignment", reason: expect.stringContaining("protected summary/template style area") }) + ])); + expect(visualPlan.operationCount).toBeGreaterThan(0); + expect(preview.changes.some((change) => change.range === "A1:D1")).toBe(true); + expect(runtime.writeBatchCount).toBe(0); + }); + + it("supports strict style preservation when callers want all existing style protected", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_visual_strict_style"); + metadata.summaryBlocks.push({ + id: "summary:data-title", + sheetName: "Data", + range: "A1:D1", + labels: ["Transactions"], + confidence: 0.95 + }); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Make Data easier to read but preserve every existing style", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" }, + values: { + visualReadability: { + stylePreservationMode: "strict" + } + } + }); + const visualPlan = (preview.answer as any).visualPlan; + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).defaults.stylePreservationMode).toBe("strict"); + expect(visualPlan.skipped).toEqual(expect.arrayContaining([ + expect.objectContaining({ ruleId: "layout.header_style", reason: expect.stringContaining("protected summary/template style area") }) + ])); + expect(visualPlan.skipped.some((skip: any) => skip.ruleId === "column.A.width")).toBe(false); + expect(visualPlan.operationCount).toBeGreaterThan(0); + }); + + it("does not invite apply when a visual readability preview compiles zero operations", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_visual_zero_operations"); + const dataSheet = metadata.sheets.find((sheet) => sheet.name === "Data")!; + dataSheet.tableIds = []; + dataSheet.headers = [{ + id: "header:Data:1", + sheetName: "Data", + row: 1, + range: "A1:D1", + confidence: 0.95, + columns: [ + { name: "Date", normalizedName: "date", inferredType: "date", role: "date", importance: 0.97, index: 0, letter: "A" }, + { name: "Account", normalizedName: "account", inferredType: "text", role: "account", importance: 0.82, index: 1, letter: "B" }, + { name: "Amount", normalizedName: "amount", inferredType: "currency", role: "amount", importance: 0.99, index: 2, letter: "C" }, + { name: "Status", normalizedName: "status", inferredType: "text", role: "status", importance: 0.9, index: 3, letter: "D" } + ] + }]; + metadata.tables = []; + metadata.summaryBlocks.push({ + id: "summary:data-all", + sheetName: "Data", + range: "A1:D4", + labels: ["Protected Data"], + confidence: 0.95 + }); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Make Data easier to read but preserve protected layout", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" }, + values: { + visualReadability: { + styleDepth: "basic" + } + } + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect(preview.nextAction).toBe("answer_now"); + expect(preview.agentInstruction).toContain("Do not call apply_update"); + expect((preview.metrics as any).operationCount).toBe(0); + expect(preview.warnings.join(" ")).toContain("No apply-ready visual operations"); + }); + + it("suggests grouped headers for wide visual readability previews without applying structural edits by default", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_visual_grouped_header_suggestion"); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Make Apr 2026 easier to read with modern grouped headers if useful", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Apr 2026" } + }); + const applied = await agent.run({ + request: "Apply safe visual readability preview", + mode: "apply_update", + workbookContextId: metadata.workbookContextId, + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).groupedHeaderSuggestion).toMatchObject({ + kind: "grouped_header_suggestion", + requiresStructuralPreview: true, + defaultApplyBehavior: "suggest_only" + }); + expect((preview.answer as any).groupedHeaderSuggestion.operationsNeeded).toEqual(expect.arrayContaining([ + "insert_rows", + "merge_range", + "write_styles_many" + ])); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.insert_rows" || operation.kind === "range.merge" || operation.kind === "range.reorder_columns")).toBe(false); + }); + + it("compiles grouped header styling into one structural preview instead of named-range or value-only patches", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_grouped_header_regression"); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Create merged group header cells for Apr 2026 with a 2-layer grouped header and color bands.", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "grouped_header" }, + target: { sheetName: "Apr 2026" }, + values: { + groupedHeader: { + groups: [ + { label: "Transactions", startColumn: "A", endColumn: "N", fillColor: "#1F4E78", headerFillColor: "#D9EAF7" }, + { label: "Invoices", startColumn: "O", endColumn: "AE", fillColor: "#548235", headerFillColor: "#E2EFDA" }, + { label: "Summary", startColumn: "AF", endColumn: "AJ", fillColor: "#8064A2", headerFillColor: "#EDE7F6" } + ] + } + } + }); + const applied = await agent.run({ + request: "Apply grouped header preview", + mode: "apply_update", + workbookContextId: metadata.workbookContextId, + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any)).toMatchObject({ + kind: "grouped_header_preview", + sheetName: "Apr 2026", + headerRow: 1, + groupRow: 1, + shiftedHeaderRow: 2, + preservesExistingHeaderLabels: true + }); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.map((operation) => operation.kind)).toEqual([ + "range.insert_rows", + "range.write_values_many", + "range.merge", + "range.merge", + "range.merge", + "range.write_styles_many" + ]); + expect(runtime.lastBatchOperations[0]).toMatchObject({ + kind: "range.insert_rows", + target: { sheetName: "Apr 2026", address: "A1:AJ1" } + }); + expect(runtime.lastBatchOperations[1]).toMatchObject({ + kind: "range.write_values_many", + entries: [ + expect.objectContaining({ target: expect.objectContaining({ sheetName: "Apr 2026", address: "A1:A1" }), values: [["Transactions"]] }), + expect.objectContaining({ target: expect.objectContaining({ sheetName: "Apr 2026", address: "O1:O1" }), values: [["Invoices"]] }), + expect.objectContaining({ target: expect.objectContaining({ sheetName: "Apr 2026", address: "AF1:AF1" }), values: [["Summary"]] }) + ] + }); + const styleOperation = runtime.lastBatchOperations.at(-1) as any; + expect(styleOperation.entries.some((entry: any) => entry.target.address === "A1:N1" && entry.style.fillColor === "#1F4E78")).toBe(true); + expect(styleOperation.entries.some((entry: any) => entry.target.address === "A2:N2" && entry.style.fillColor === "#D9EAF7")).toBe(true); + }); + + it("keeps grouped header row darker than row 2 when matching header styling is requested", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_grouped_header_color_hierarchy"); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Apply matching header fill color and font styling to row 1 grouped header to match the style already applied to row 2.", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "format_range" }, + target: { sheetName: "Data", range: "A1:D1" } + }); + const applied = await agent.run({ + request: "Apply grouped header color hierarchy.", + mode: "apply_update", + workbookContextId: metadata.workbookContextId, + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any)).toMatchObject({ + kind: "style_preview", + sheetName: "Data", + range: "A1:D1", + style: { + fillColor: "#1A3C6E", + fontColor: "#FFFFFF", + fontBold: true, + horizontalAlignment: "center" + } + }); + expect(preview.warnings.join(" ")).toContain("visually distinct from row 2"); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations[0]).toMatchObject({ + kind: "range.write_styles", + target: { sheetName: "Data", address: "A1:D1" }, + style: { + fillColor: "#1A3C6E", + fontColor: "#FFFFFF", + fontBold: true, + horizontalAlignment: "center" + } + }); + }); + + it("accepts target.address as an exact format range without expanding to the used range", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_target_address_alias"); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Apply dark blue fill to row 1 only A1:D1", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "format_range" }, + target: { sheetName: "Data", address: "A1:D1" }, + values: { style: { fillColor: "#1A3C6E", fontColor: "#FFFFFF", fontBold: true, horizontalAlignment: "center" } } + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).range).toBe("A1:D1"); + expect((preview.answer as any).range).not.toBe("A1:D4"); + }); + + it("accepts OpenCode grouped header column arrays and does not trigger broad scope guard", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_grouped_header_columns_shape"); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Preview a grouped_header workflow for Transactions table. Add a higher-level grouped header row above the existing column headers and apply it to all rows of the table without changing data rows.", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "grouped_header" }, + target: { sheetName: "Data", tableName: "Transactions" }, + values: { + stylePreservationMode: "none", + groupedHeader: { + groups: [ + { label: "Timeline", columns: ["A"] }, + { label: "Account", columns: ["B"] }, + { label: "Financial", columns: ["C"] }, + { label: "Workflow", columns: ["D"] } + ] + } + } + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).kind).toBe("grouped_header_preview"); + expect((preview.answer as any).groups.map((group: any) => [group.label, group.startColumn, group.endColumn])).toEqual([ + ["Timeline", "A", "A"], + ["Account", "B", "B"], + ["Financial", "C", "C"], + ["Workflow", "D", "D"] + ]); + expect((preview.answer as any).groups[0].fillColor).toBe("#1A3C6E"); + expect((preview.answer as any).groups[0].headerFillColor).toBe("#D9EAF7"); + expect((preview.answer as any).operationCount).toBeGreaterThan(0); + expect((preview.answer as any).kind).not.toBe("broad_mutation_scope_guard"); + expect(preview.summary).not.toContain("15,015"); + expect((preview.metrics as any).workflowKind).toBe("grouped_header_preview"); + }); + + it("accepts grouped header ranges as group spans", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_grouped_header_range_shape"); + agent.metadataCache.set(metadata); + + const preview = await agent.run({ + request: "Preview a two-level grouped header for Apr 2026 using range-shaped group spans.", + mode: "preview_update", + workbookContextId: metadata.workbookContextId, + intent: { action: "grouped_header" }, + target: { sheetName: "Apr 2026" }, + values: { + groupedHeader: { + groups: [ + { label: "Transactions", range: "A:N" }, + { label: "Invoices", range: "O:AE" }, + { label: "Summary", range: "AF:AJ" } + ] + } + } + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).groups.map((group: any) => [group.label, group.startColumn, group.endColumn])).toEqual([ + ["Transactions", "A", "N"], + ["Invoices", "O", "AE"], + ["Summary", "AF", "AJ"] + ]); + expect((preview.answer as any).operationCount).toBe(6); + }); + + it("rejects preview_update calls that reuse a stale operationId from another workflow", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const visualPreview = await agent.run({ + request: "Make this sheet easier to read", + mode: "preview_update", + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" } + }); + const reused = await agent.run({ + request: "Apply the grouped_header preview to all rows of Transactions.", + mode: "preview_update", + operationId: visualPreview.operationId, + confirmationToken: visualPreview.confirmationToken, + intent: { action: "grouped_header" } + }); + const status = await agent.run({ + request: "Check grouped_header preview status.", + mode: "operation_status", + operationId: visualPreview.operationId, + intent: { action: "grouped_header" } + }); + + expect(visualPreview.status).toBe("PREVIEW_READY"); + expect(reused.status).toBe("VALIDATION_FAILED"); + expect((reused.answer as any).kind).toBe("invalid_preview_operation_reuse"); + expect(reused.warnings.join(" ")).toContain("operationId from a different preview"); + expect((status.answer as any).workflowKind).toBe("visual_readability_preview"); + expect(status.warnings.join(" ")).toContain("belongs to visual_readability_preview"); + expect(status.warnings.join(" ")).toContain("grouped_header_preview"); + }); + it("requires structured values for write previews even when request text includes rows", async () => { const runtime = new FakeAgentRuntime(); const agent = new AgentOrchestrator(runtime as any); @@ -1659,6 +2693,36 @@ Data rows: expect(runtime.runtimeMethodCalls["style.copy_dimensions"]).toBeUndefined(); }); + it("preserves column widths by default when replacing a styled table", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Rotate booking fields to headers and replace the old vertical table with same style", + mode: "preview_update", + intent: { action: "replace_range_with_styled_table" }, + target: { sheetName: "Report", range: "A1:C2" }, + values: { + headers: ["Dated", "Loading Date", "Qty"], + row: ["20/6/26", "20/6/26", "5X40'HQ"], + clearRange: "A1:B25", + headerStyleSource: { sheetName: "Data", range: "A1:C1" }, + bodyStyleSource: { sheetName: "Data", range: "A2:C2" }, + dimensions: ["fills", "fonts", "borders", "alignment"] + } + }); + const applied = await agent.run({ + request: "Apply styled table replacement", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastWriteOperations.map((operation) => operation.kind)).toEqual(["range.clear", "range.write_values"]); + }); + it("routes generic OCR field/value data to the styled table replacement workflow", async () => { const runtime = new FakeAgentRuntime(); const agent = new AgentOrchestrator(runtime as any); @@ -1868,7 +2932,99 @@ Data rows: ]); }); - it("applies inserted columns through the structural batch operation", async () => { + it("prepends real merge operations when batched style entries ask to merge header spans", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Merge row 1 grouped header spans and center align them on Invoices.", + mode: "preview_update", + intent: { action: "write_styles_many" }, + target: { sheetName: "Invoices" }, + values: { + entries: [ + { sheetName: "Invoices", range: "A1:B1", style: { horizontalAlignment: "center", verticalAlignment: "center" } }, + { sheetName: "Invoices", range: "C1:F1", style: { horizontalAlignment: "center", verticalAlignment: "center" } }, + { sheetName: "Invoices", range: "G1:N1", style: { horizontalAlignment: "center", verticalAlignment: "center" } }, + { sheetName: "Invoices", range: "O1:O1", style: { horizontalAlignment: "center", verticalAlignment: "center" } } + ] + } + }); + const applied = await agent.run({ + request: "Apply grouped header merge alignment.", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any)).toMatchObject({ kind: "merge_and_write_styles_many_preview", mergeCount: 3, rangeCount: 4 }); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.map((operation) => operation.kind)).toEqual([ + "range.merge", + "range.merge", + "range.merge", + "range.write_styles_many" + ]); + expect(runtime.lastBatchOperations.slice(0, 3)).toEqual([ + expect.objectContaining({ kind: "range.merge", target: expect.objectContaining({ sheetName: "Invoices", address: "A1:B1" }) }), + expect.objectContaining({ kind: "range.merge", target: expect.objectContaining({ sheetName: "Invoices", address: "C1:F1" }) }), + expect.objectContaining({ kind: "range.merge", target: expect.objectContaining({ sheetName: "Invoices", address: "G1:N1" }) }) + ]); + expect(runtime.lastBatchOperations.at(-1)).toMatchObject({ + kind: "range.write_styles_many", + entries: [ + expect.objectContaining({ target: expect.objectContaining({ address: "A1:B1" }), style: expect.objectContaining({ horizontalAlignment: "center", verticalAlignment: "center" }) }), + expect.objectContaining({ target: expect.objectContaining({ address: "C1:F1" }), style: expect.objectContaining({ horizontalAlignment: "center", verticalAlignment: "center" }) }), + expect.objectContaining({ target: expect.objectContaining({ address: "G1:N1" }), style: expect.objectContaining({ horizontalAlignment: "center", verticalAlignment: "center" }) }), + expect.objectContaining({ target: expect.objectContaining({ address: "O1:O1" }), style: expect.objectContaining({ horizontalAlignment: "center", verticalAlignment: "center" }) }) + ] + }); + }); + + it("supports explicit multi-range merge payloads with default center alignment", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Merge these row 1 header spans and center them vertically and horizontally.", + mode: "preview_update", + target: { sheetName: "Invoices" }, + values: { + merges: [ + { sheetName: "Invoices", range: "A1:B1" }, + { sheetName: "Invoices", range: "C1:F1" }, + { sheetName: "Invoices", range: "G1:N1" } + ] + } + }); + const applied = await agent.run({ + request: "Apply grouped header merges.", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any)).toMatchObject({ kind: "merge_ranges_preview", mergeCount: 3, styledRangeCount: 3 }); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations.map((operation) => operation.kind)).toEqual([ + "range.merge", + "range.merge", + "range.merge", + "range.write_styles_many" + ]); + expect(runtime.lastBatchOperations.at(-1)).toMatchObject({ + kind: "range.write_styles_many", + entries: [ + expect.objectContaining({ style: expect.objectContaining({ horizontalAlignment: "center", verticalAlignment: "center", wrapText: true }) }), + expect.objectContaining({ style: expect.objectContaining({ horizontalAlignment: "center", verticalAlignment: "center", wrapText: true }) }), + expect.objectContaining({ style: expect.objectContaining({ horizontalAlignment: "center", verticalAlignment: "center", wrapText: true }) }) + ] + }); + }); + + it("applies inserted columns through the structural batch operation", async () => { const runtime = new FakeAgentRuntime(); const agent = new AgentOrchestrator(runtime as any); @@ -2101,6 +3257,92 @@ Data rows: }); }); + it("accepts top-level style patches from agents and expands whole-column width targets", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Set column widths on Data sheet.", + mode: "preview_update", + intent: { action: "format_range" }, + target: { sheetName: "Data" }, + patches: [ + { target: { sheetName: "Data", range: "A:A" }, style: { columnWidth: 14 } }, + { target: { sheetName: "Data", range: "B:B" }, style: { columnWidth: 12 } } + ] + } as any); + const applied = await agent.run({ + request: "Apply column widths.", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).kind).toBe("write_styles_many_preview"); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations[0]).toMatchObject({ + kind: "range.write_styles_many", + entries: [ + { target: { sheetName: "Data", address: "A1:A4" }, style: { columnWidth: 73.5 } }, + { target: { sheetName: "Data", address: "B1:B4" }, style: { columnWidth: 63 } } + ] + }); + }); + + it("routes style-shaped values.patches to write_styles_many instead of writing objects into cells", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Set column widths on Data sheet for all columns in one batch", + mode: "preview_update", + intent: { action: "format_range" }, + values: { + patches: [ + { target: { sheetName: "Data", range: "A1" }, values: [[{ columnWidth: 14 }]] }, + { target: { sheetName: "Data", range: "B1" }, values: [[{ columnWidth: 12 }]] } + ] + } + }); + const applied = await agent.run({ + request: "Apply column widths.", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).kind).toBe("write_styles_many_preview"); + expect((preview.answer as any).rangeCount).toBe(2); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations[0]).toMatchObject({ + kind: "range.write_styles_many", + entries: [ + { target: { sheetName: "Data", address: "A1" }, style: { columnWidth: 73.5 } }, + { target: { sheetName: "Data", address: "B1" }, style: { columnWidth: 63 } } + ] + }); + expect(runtime.lastBatchOperations[0]?.kind).not.toBe("range.write_values_many"); + }); + + it("rejects empty style previews instead of applying a no-op", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Set column widths on Data sheet but forgot structured style values.", + mode: "preview_update", + intent: { action: "format_range" }, + target: { sheetName: "Data" } + }); + + expect(preview.status).toBe("NEEDS_INPUT"); + expect(preview.summary).toContain("Style update needs at least one supported style property"); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.write_styles")).toBe(false); + expect(runtime.lastBatchOperations.some((operation) => operation.kind === "range.write_styles_many")).toBe(false); + }); + it("previews dropdown data validation as a validation operation", async () => { const runtime = new FakeAgentRuntime(); const agent = new AgentOrchestrator(runtime as any); @@ -2129,6 +3371,43 @@ Data rows: }); }); + it("previews multi-range dropdown validation entries without broadening to the table", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Add dropdown data validation to column A and column E only.", + mode: "preview_update", + target: { sheetName: "Data", tableName: "Transactions" }, + values: { + validation: { type: "list", source: ["Open", "Closed"], inCellDropDown: true }, + entries: [ + { sheetName: "Data", range: "A3:A1002", validation: { type: "list", source: ["Open", "Closed"], inCellDropDown: true } }, + { sheetName: "Data", range: "E3:E1002", validation: { type: "list", source: ["Open", "Closed"], inCellDropDown: true } } + ] + } + }); + const applied = await agent.run({ + request: "Apply dropdown validation.", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect((preview.answer as any).kind).toBe("write_data_validation_preview"); + expect((preview.answer as any).entries.map((entry: any) => entry.range)).toEqual(["A3:A1002", "E3:E1002"]); + expect(applied.status).toBe("SUCCESS"); + expect(runtime.lastBatchOperations[0]).toMatchObject({ + kind: "range.write_data_validation", + entries: [ + { target: { sheetName: "Data", address: "A3:A1002" }, validation: { type: "list", source: ["Open", "Closed"] } }, + { target: { sheetName: "Data", address: "E3:E1002" }, validation: { type: "list", source: ["Open", "Closed"] } } + ] + }); + expect((runtime.lastBatchOperations[0] as any).target.address).not.toBe("A1:Z1000"); + }); + it("previews formula conditional formatting instead of sheet creation", async () => { const runtime = new FakeAgentRuntime(); const agent = new AgentOrchestrator(runtime as any); @@ -2895,6 +4174,16 @@ Data rows: target: { sheetName: "Data", range: "2:3" } } }, + { + capabilityName: "excel.range.delete_rows.corrected_from_bad_column_intent", + expectedOperationKind: "range.delete_rows", + input: { + request: "Please delete row 2", + mode: "preview_update", + intent: { action: "delete_columns" }, + target: { sheetName: "Data", range: "2:2" } + } + }, { capabilityName: "excel.range.insert_columns", expectedOperationKind: "range.insert_columns", diff --git a/apps/backend/src/agent-orchestrator.read-answer.test.ts b/apps/backend/src/agent-orchestrator.read-answer.test.ts index 2eb51e0..35c96ab 100644 --- a/apps/backend/src/agent-orchestrator.read-answer.test.ts +++ b/apps/backend/src/agent-orchestrator.read-answer.test.ts @@ -3,6 +3,114 @@ import { AgentOrchestrator } from "./agent-orchestrator.js"; import { FakeAgentRuntime, createCachedMetadata, selectionInfo, sheets, workbookId } from "./agent-orchestrator.test-support.js"; describe("AgentOrchestrator Read Answer Routing", () => { + it("answers style overview when caller omits mode but provides structured style_overview intent", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const result = await agent.run({ + request: "style overview of Data sheet", + intent: { action: "style_overview" }, + target: { sheetName: "Data" } + }); + + expect(result.status).toBe("SUCCESS"); + expect(result.mode).toBe("auto"); + expect((result.answer as any).kind).toBe("style_overview"); + expect(result.nextAction).toBe("answer_now"); + expect(result.operationId).toBeUndefined(); + }); + + it("summarizes grouped header row 1 without chasing design overview or full results", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_grouped_header_summary"); + metadata.workbook = { ...metadata.workbook, activeSheet: "Invoices" }; + metadata.sheets = [ + ...metadata.sheets, + { + id: "sheet:Invoices", + name: "Invoices", + index: 3, + usedRange: "A1:O1002", + rowCount: 1002, + columnCount: 15, + kind: "transaction", + headers: [], + tableIds: [], + sectionIds: [], + summaryBlockIds: [], + formulaRegionIds: [] + } + ]; + agent.metadataCache.set(metadata); + + const result = await agent.run({ + request: "Okay, can you look at grouped header at row 1 on Invoices, please summarize it", + mode: "answer", + workbookContextId: metadata.workbookContextId, + target: { sheetName: "Invoices", range: "A1:O1002" } + }); + + expect(result.status).toBe("SUCCESS"); + expect(result.nextAction).toBe("answer_now"); + expect(result.maxRecommendedFollowupCalls).toBe(0); + expect((result.answer as any)).toMatchObject({ + kind: "grouped_header_summary", + sheetName: "Invoices", + range: "A1:O1", + mergedRangeCount: 3, + mergeStatus: "merged_spans_detected", + spans: [ + { range: "A1:B1", label: "สถานะ", merged: true }, + { range: "C1:F1", label: "ข้อมูลการจอง", merged: true }, + { range: "G1:N1", label: "ค่าใช้จ่าย", merged: true } + ], + unmergedLabels: [ + { cell: "O1", label: "งานจ้างช่วง", merged: false } + ] + }); + expect(runtime.runtimeMethodCalls["range.read_merged_cells"]).toBe(1); + expect(runtime.readBatchCount).toBe(1); + expect(runtime.lastBatchOperations).toEqual([ + expect.objectContaining({ + kind: "range.read_full", + target: expect.objectContaining({ sheetName: "Invoices", address: "A1:O1" }) + }) + ]); + }); + + it("updates permission policy through public agent intents", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const updated = await agent.run({ + request: "Allow workbook styling structure changes for this workbook", + intent: { action: "set_permissions" }, + values: { + permissions: { + allowWrites: true, + allowDestructiveActions: true, + scopeToWorkbook: true, + requireConfirmationFor: [] + } + } + }); + const readBack = await agent.run({ + request: "Read permissions", + intent: { action: "get_permissions" } + }); + + expect(updated.status).toBe("SUCCESS"); + expect((updated.answer as any).kind).toBe("permissions_update"); + expect((updated.answer as any).result.permissions).toMatchObject({ + allowWrites: true, + allowDestructiveActions: true, + scope: { workbookId } + }); + expect(readBack.status).toBe("SUCCESS"); + expect((readBack.answer as any).result.permissions.allowDestructiveActions).toBe(true); + }); + it("fails fast with reload guidance when the Excel add-in session is stale", async () => { const runtime = new FakeAgentRuntime(); runtime.readiness = { @@ -542,6 +650,45 @@ describe("AgentOrchestrator Read Answer Routing", () => { expect((result.answer as any).result.data.rules[0].source).toEqual(["Open", "Closed", "Pending"]); }); + it("routes natural dropdown inspection to data-validation metadata without reading values", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const result = await agent.run({ + request: "Check data validation / dropdown on these specific cells. Is there any list validation?", + mode: "answer", + target: { sheetName: "Data", range: "D1:D4" } + }); + + expect(result.status).toBe("SUCCESS"); + expect((result.answer as any)).toMatchObject({ + kind: "range_metadata", + method: "range.read_data_validation" + }); + expect(runtime.runtimeMethodCalls["range.read_data_validation"]).toBe(1); + expect(result.telemetry.fullReadCellCount).toBe(0); + }); + + it("does not large-range guard full-column validation inspection", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const result = await agent.run({ + request: "Check data validation (dropdown lists) across the entire Data sheet. Which columns have dropdown validation?", + mode: "answer", + target: { sheetName: "Data" } + }); + + expect(result.status).toBe("SUCCESS"); + expect((result.answer as any)).toMatchObject({ + kind: "range_metadata", + method: "range.read_data_validation" + }); + expect((result.answer as any).kind).not.toBe("large_range_guard"); + expect(runtime.runtimeMethodCalls["range.read_data_validation"]).toBe(1); + expect(result.telemetry.fullReadCellCount).toBe(0); + }); + it("finds similar prior-period rows across related workbook sheets", async () => { const runtime = new FakeAgentRuntime(); const agent = new AgentOrchestrator(runtime as any); @@ -731,6 +878,72 @@ describe("AgentOrchestrator Read Answer Routing", () => { expect(runtime.runtimeMethodCalls["style.get_fingerprint"]).toBe(1); }); + it("answers freeze pane status questions without starting a mutation preview", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const result = await agent.run({ + request: "Which column is frozen on Data?", + mode: "answer", + target: { sheetName: "Data" } + }); + + expect(result.status).toBe("SUCCESS"); + expect((result.answer as any).kind).toBe("freeze_panes_status"); + expect((result.answer as any).readable).toBe(true); + expect((result.answer as any).lastFrozenColumn).toBe("C"); + expect((result.answer as any).firstUnfrozenColumn).toBe("D"); + expect(result.summary).toContain("columns A:C are frozen"); + expect(result.operationId).toBeUndefined(); + expect(result.nextAction).toBe("answer_now"); + }); + + it("uses intent.reason to answer freeze pane status even when caller chose the wrong read action", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const result = await agent.run({ + request: "Check Invoices", + mode: "answer", + target: { sheetName: "Data" }, + intent: { action: "read_style_summary", reason: "Check freeze panes on Data sheet" } + }); + + expect(result.status).toBe("SUCCESS"); + expect((result.answer as any).kind).toBe("freeze_panes_status"); + expect((result.answer as any).lastFrozenColumn).toBe("C"); + expect((result.answer as any).firstUnfrozenColumn).toBe("D"); + expect(runtime.runtimeMethodCalls["style.get_fingerprint"]).toBe(1); + }); + + it("does not treat legacy freeze pane notes as no frozen panes", async () => { + const runtime = new FakeAgentRuntime(); + runtime.getStyleFingerprint = async (request: any) => { + runtime.runtimeMethodCalls["style.get_fingerprint"] = (runtime.runtimeMethodCalls["style.get_fingerprint"] ?? 0) + 1; + return { + ok: true, + fingerprint: { + workbookId: request.workbookId, + sheetName: request.sheetName, + address: request.address, + dimensions: { freezePanes: { note: "Office.js freeze pane capture is tracked as a layout capability." } }, + warnings: [] + } + }; + }; + const agent = new AgentOrchestrator(runtime as any); + + const result = await agent.run({ + request: "Which column is frozen on Data?", + mode: "answer", + target: { sheetName: "Data" } + }); + + expect(result.status).toBe("SUCCESS"); + expect((result.answer as any).readable).toBe(false); + expect(result.summary).toContain("cannot be read"); + }); + it("infers style summary reads from auto-mode styling questions", async () => { const runtime = new FakeAgentRuntime(); const agent = new AgentOrchestrator(runtime as any); @@ -749,6 +962,107 @@ describe("AgentOrchestrator Read Answer Routing", () => { expect(runtime.runtimeMethodCalls["style.get_fingerprint"]).toBe(1); }); + it("returns a compact style overview with grouped header suggestions without reading table values", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_style_overview"); + agent.metadataCache.set(metadata); + + const result = await agent.run({ + request: "Give me a styling overview and best-practice suggestions for Apr 2026.", + mode: "answer", + workbookContextId: metadata.workbookContextId, + intent: { action: "style_overview" }, + target: { sheetName: "Apr 2026" }, + detailLevel: "style_overview" + }); + + expect(result.status).toBe("SUCCESS"); + expect((result.answer as any).kind).toBe("style_overview"); + expect((result.answer as any).detected.headerRange).toBe("A1:AJ1"); + expect((result.answer as any).freezePanes).toMatchObject({ + readable: true, + frozen: true, + rows: 2, + columns: 3, + lastFrozenColumn: "C", + firstUnfrozenColumn: "D" + }); + expect((result.answer as any).columnRoles).toEqual(expect.arrayContaining([ + expect.objectContaining({ column: "A", freezePane: expect.objectContaining({ isFrozen: true }) }), + expect.objectContaining({ column: "C", freezePane: expect.objectContaining({ isFrozen: true, isLastFrozenColumn: true }) }), + expect.objectContaining({ column: "D", freezePane: expect.objectContaining({ isFrozen: false, isFirstUnfrozenColumn: true }) }) + ])); + expect((result.answer as any).groupedHeaderSuggestion).toMatchObject({ + kind: "grouped_header_suggestion", + requiresStructuralPreview: true, + defaultApplyBehavior: "suggest_only" + }); + expect((result.answer as any).groupedHeaderSuggestion.groups.length).toBeGreaterThan(1); + expect((result.answer as any).recommendations.map((item: any) => item.id)).toContain("grouped_header"); + expect(result.telemetry.fullReadCellCount).toBe(0); + expect(runtime.runtimeMethodCalls["style.get_fingerprint"]).toBe(1); + expect(runtime.readBatchCount).toBe(0); + }); + + it("returns workbook design overview with column recommendations without sampling values", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_design_overview"); + agent.metadataCache.set(metadata); + + const result = await agent.run({ + request: "Before applying, please do a workbook design review for this sheet. For each column decide free text, date, number/money, ID/text code, dropdown list, or lookup/reference from another sheet.", + mode: "answer", + workbookContextId: metadata.workbookContextId, + intent: { action: "workbook_design_overview" }, + target: { sheetName: "Data" } + }); + + const answer = result.answer as any; + expect(result.status).toBe("SUCCESS"); + expect(answer.kind).toBe("workbook_design_overview"); + expect(answer.inspectionPolicy.fullReadCellCount).toBe(0); + expect(answer.columnRecommendations).toEqual(expect.arrayContaining([ + expect.objectContaining({ column: "A", header: "Date", recommendedBehavior: "date" }), + expect.objectContaining({ column: "B", header: "Account", recommendedBehavior: "lookup_reference" }), + expect.objectContaining({ column: "C", header: "Amount", recommendedBehavior: "number_money" }), + expect.objectContaining({ column: "D", header: "Status", recommendedBehavior: "dropdown_list" }) + ])); + expect(answer.relatedSheets).toEqual(expect.arrayContaining([ + expect.objectContaining({ sheetName: "Customer Master" }) + ])); + expect(answer.nextWorkflows.map((workflow: any) => workflow.intentAction)).toEqual(expect.arrayContaining(["improve_visual_readability", "write_data_validation"])); + expect(result.telemetry.fullReadCellCount).toBe(0); + expect(runtime.readBatchCount).toBe(0); + expect(runtime.runtimeMethodCalls["style.get_fingerprint"]).toBeUndefined(); + }); + + it("routes natural column-by-column design review prompts to workbook_design_overview", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + const metadata = createCachedMetadata("wbctx_design_natural"); + agent.metadataCache.set(metadata); + + const result = await agent.run({ + request: "Please review Apr 2026 column-by-column and recommend which columns should be dates, money, dropdowns, IDs, or lookups. Do not apply yet.", + mode: "answer", + workbookContextId: metadata.workbookContextId + }); + + const answer = result.answer as any; + expect(result.status).toBe("SUCCESS"); + expect(answer.kind).toBe("workbook_design_overview"); + expect(answer.sheet.name).toBe("Apr 2026"); + expect(answer.columnRecommendations).toEqual(expect.arrayContaining([ + expect.objectContaining({ header: "Transaction Date", recommendedBehavior: "date" }), + expect.objectContaining({ header: "Payment Variance", recommendedBehavior: "number_money" }), + expect.objectContaining({ header: "Container Size", recommendedBehavior: "dropdown_list" }) + ])); + expect(answer.inspectionPolicy.guidance).toContain("Do not broad-read empty data rows"); + expect(runtime.readBatchCount).toBe(0); + }); + it("keeps compact style summaries useful and retrieves full result handles", async () => { const runtime = new FakeAgentRuntime(); runtime.getStyleFingerprint = async (request: any) => { diff --git a/apps/backend/src/agent-orchestrator.target-resolution.test.ts b/apps/backend/src/agent-orchestrator.target-resolution.test.ts index 4c2436f..3aae06a 100644 --- a/apps/backend/src/agent-orchestrator.target-resolution.test.ts +++ b/apps/backend/src/agent-orchestrator.target-resolution.test.ts @@ -34,6 +34,45 @@ describe("AgentOrchestrator Target Resolution", () => { expect(result.telemetry.fullReadCellCount).toBeGreaterThan(0); }); + it("resolves explicit row deletion wording to a row range instead of the sheet used range", async () => { + const runtime = new FakeAgentRuntime(); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Please delete row 2 on Data", + mode: "preview_update" + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect(preview.proof[0]).toMatchObject({ sheetName: "Data", range: "2:2" }); + + await agent.run({ + request: "Apply row delete", + mode: "apply_update", + operationId: preview.operationId, + confirmationToken: preview.confirmationToken + }); + + expect(runtime.lastBatchOperations[0]).toMatchObject({ + kind: "range.delete_rows", + target: { sheetName: "Data", address: "2:2" } + }); + }); + + it("resolves this row deletion to the current selected worksheet row", async () => { + const runtime = new FakeAgentRuntime(); + runtime.selection = selectionInfo("Data", "C5", { row: 5, column: 3 }); + const agent = new AgentOrchestrator(runtime as any); + + const preview = await agent.run({ + request: "Delete this row", + mode: "preview_update" + }); + + expect(preview.status).toBe("PREVIEW_READY"); + expect(preview.proof[0]).toMatchObject({ sheetName: "Data", range: "5:5" }); + }); + it("treats headers plus rows as a live value read instead of schema metadata", async () => { const runtime = new FakeAgentRuntime(); const agent = new AgentOrchestrator(runtime as any); diff --git a/apps/backend/src/agent-orchestrator.test-support.ts b/apps/backend/src/agent-orchestrator.test-support.ts index 78850f4..a2c0347 100644 --- a/apps/backend/src/agent-orchestrator.test-support.ts +++ b/apps/backend/src/agent-orchestrator.test-support.ts @@ -26,6 +26,7 @@ export class FakeAgentRuntime { omitOkOnWrite = false; batchResultOverride: unknown | undefined; snapshotRangesOverride: unknown | undefined; + mutateFormulaReadsAfterWrite = false; validationResult: any; lastBatchOperations: BatchRequest["operations"] = []; lastBatchRequest: BatchRequest | undefined; @@ -38,6 +39,15 @@ export class FakeAgentRuntime { collaborationStatus: any; failStyleCopyOnCall: number | undefined; workbookContentVersion = 0; + permissionState = { + allowWrites: true, + allowDestructiveActions: false, + allowWorkbookActions: false, + allowMacroExecution: false, + requireConfirmationFor: [], + scope: {}, + lockedRegions: [] + }; sessions = { getActive: () => ({ activeWorkbook }) }; @@ -125,6 +135,29 @@ export class FakeAgentRuntime { return { ok: true, info: { workbook: activeWorkbook, sheetCount: sheets.length } }; } + getPermissions() { + this.recordRuntimeCall("permissions.get"); + return { ok: true, permissions: this.permissionState }; + } + + setPermissions(update: any) { + this.recordRuntimeCall("permissions.set"); + this.permissionState = { + ...this.permissionState, + ...update, + scope: update.scope ?? this.permissionState.scope, + lockedRegions: update.lockedRegions ?? this.permissionState.lockedRegions, + requireConfirmationFor: update.requireConfirmationFor ?? this.permissionState.requireConfirmationFor + }; + return this.getPermissions(); + } + + allowDestructiveActions(allow: boolean) { + this.recordRuntimeCall("permissions.allow_destructive_actions"); + this.permissionState = { ...this.permissionState, allowDestructiveActions: allow }; + return this.getPermissions(); + } + async closeWorkbook(requestWorkbookId: any, closeBehavior?: any) { this.recordRuntimeCall("workbook.close"); return { ok: true, workbookId: requestWorkbookId, closeBehavior }; @@ -176,6 +209,16 @@ export class FakeAgentRuntime { async readRangeMetadata(method: string, request: any) { this.recordRuntimeCall(method); + if (method === "range.read_merged_cells") { + return { + ok: true, + method, + request, + data: request.sheetName === "Invoices" && request.address === "A1:O1" + ? { address: "Invoices!A1:B1,Invoices!C1:F1,Invoices!G1:N1", areaCount: 3, cellCount: 14, isNullObject: false } + : { address: request.address, areaCount: 0, cellCount: 0, isNullObject: true } + }; + } if (method === "range.read_data_validation") { return { ok: true, @@ -301,7 +344,7 @@ export class FakeAgentRuntime { operationId: operation.operationId, snapshot: { values: valuesFor(sheetName, address), - formulas: formulasFor(sheetName, address), + formulas: this.formulasForRead(sheetName, address), text: valuesFor(sheetName, address).map((row) => row.map((value) => value === null || value === undefined ? "" : String(value))), numberFormat: numberFormatsFor(sheetName, address), style: { fillColor: "#FFFFFF", fontName: "Calibri", fontSize: 11 } @@ -327,6 +370,14 @@ export class FakeAgentRuntime { }; } + private formulasForRead(sheetName: string, address: string) { + const formulas = formulasFor(sheetName, address); + if (!this.mutateFormulaReadsAfterWrite || this.writeBatchCount === 0) { + return formulas; + } + return formulas.map((row) => row.map((formula, index) => formula && index === 0 ? `${formula}+0` : formula)); + } + async snapshotRanges(requestWorkbookId: WorkbookId, ranges: Array<{ workbookId?: WorkbookId; sheetName: string; address: string }>) { this.recordRuntimeCall("workbook.snapshot_ranges"); this.lastSnapshotRanges = ranges; @@ -526,7 +577,7 @@ export class FakeAgentRuntime { async getStyleFingerprint(request: any) { this.recordRuntimeCall("style.get_fingerprint"); - return { ok: true, fingerprint: { workbookId: request.workbookId, sheetName: request.sheetName, address: request.address ?? "A1:B20", dimensions: { fills: { hash: "fills" }, fonts: { hash: "fonts" } }, warnings: [] } }; + return { ok: true, fingerprint: { workbookId: request.workbookId, sheetName: request.sheetName, address: request.address ?? "A1:B20", dimensions: { fills: { hash: "fills" }, fonts: { hash: "fonts" }, freezePanes: { readable: true, frozen: true, rows: 2, columns: 3, lastFrozenColumn: "C", firstUnfrozenColumn: "D", lastFrozenRow: 2, firstUnfrozenRow: 3 } }, warnings: [] } }; } async compareStyleFingerprints(request: any) { @@ -1200,6 +1251,12 @@ export function selectionInfo(sheetName: string, address: string, position = { r } function valuesFor(sheetName: string, address: string) { + if (sheetName === "Invoices") { + if (address === "A1:O1") { + return [["สถานะ", "", "ข้อมูลการจอง", "", "", "", "ค่าใช้จ่าย", "", "", "", "", "", "", "", "งานจ้างช่วง"]]; + } + return [["สถานะ", "", "ข้อมูลการจอง", "", "", "", "ค่าใช้จ่าย", "", "", "", "", "", "", "", "งานจ้างช่วง"]]; + } if (sheetName === "Vendor Propose") { const rows = [ ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "Reference Diesel Rate : 37.50 THB/liter"], diff --git a/apps/backend/src/agent-orchestrator.ts b/apps/backend/src/agent-orchestrator.ts index 55fff77..9e636fb 100644 --- a/apps/backend/src/agent-orchestrator.ts +++ b/apps/backend/src/agent-orchestrator.ts @@ -4,6 +4,7 @@ import { type AgentCandidate, type AgentOperationId, type AgentProofReference, + type AgentRequiredFollowup, type AgentRunInput, type AgentRunExecutionContext, type AgentRunMode, @@ -50,6 +51,7 @@ import { type WorkbookLocalConfigImportRequest, type WorkbookRestoreFileBackupRequest, type BackupId, + type PermissionState, type SnapshotId, type WorkbookId } from "@components-kit/open-workbook-protocol"; @@ -70,7 +72,7 @@ import { findAgentCandidates, resolveAgentReadTarget, resolveAgentUpdateTarget, import type { RuntimeService } from "./runtime-service.js"; import { classifyAgentActionRisk, type AgentOperationRisk } from "./agent-action-policy.js"; import { routeAgentRequest, type IntentRoute } from "./agent-routing.js"; -import { isAgentIntentAction, normalizeAgentIntent, type AgentIntentAction, type NormalizedAgentIntent } from "./agent-intent.js"; +import { isAgentIntentAction, modeForIntentAction, normalizeAgentIntent, type AgentIntentAction, type NormalizedAgentIntent } from "./agent-intent.js"; import { findAgentActionHandler, type AgentActionHandlerDefinition, type AgentActionHandlerId } from "./agent-action-handlers.js"; import { buildSemanticWorkbookIndex } from "./semantic-workbook-index.js"; @@ -81,6 +83,16 @@ const AGENT_FRAGMENT_WINDOW_MS = 2 * 60 * 1000; const AGENT_FRAGMENT_REDIRECT_THRESHOLD = 2; type AgentResponseMode = NonNullable; +type RangeStructuralOperationKind = + | "range.clear_values" + | "range.insert_rows" + | "range.delete_rows" + | "range.insert_columns" + | "range.delete_columns" + | "range.hide_columns" + | "range.unhide_columns" + | "range.merge" + | "range.unmerge"; interface StoredAgentResult { resultId: string; @@ -320,6 +332,10 @@ export class AgentOrchestrator { return finish(await this.rollback(input)); } + if (mode === "preview_update" && input.operationId !== undefined) { + return finish(invalidPreviewOperationReuseOutput(input)); + } + const handleOutput = this.resourceHandleOutput(input, mode); if (handleOutput) { return finish(handleOutput, true); @@ -433,7 +449,7 @@ export class AgentOrchestrator { const sectionAnswer = sectionAnswerOutput(metadata, input, mode); return finish(sectionAnswer ?? this.findOutput(metadata, input, mode), cacheHit); } - if (effectiveMode === "preview_update" && isReadOnlyInspectionRequest(input.request) && !hasStructuredMutationPayload(input)) { + if (effectiveMode === "preview_update" && isReadOnlyInspectionRequest(input.request) && !hasStructuredMutationPayload(input) && (intent.action === undefined || modeForIntentAction(intent.action) !== "preview_update")) { internalCallCount += 1; const answerInput: AgentRunInput = { ...input, mode: "answer" }; return finish(await this.answerOutput(metadata, answerInput, "answer", runMetrics), cacheHit); @@ -601,6 +617,7 @@ export class AgentOrchestrator { operationId, workbookContextId: pending.workbookContextId, workbookId: pending.workbookId, + ...(pending.workflowKind !== undefined ? { workflowKind: pending.workflowKind } : {}), summary: pending.summary, changes: pending.changes, createdAt: pending.createdAt, @@ -760,10 +777,18 @@ export class AgentOrchestrator { if (workflowAnswer) { return workflowAnswer; } + const freezeStatusAnswer = await freezePaneStatusAnswerOutput(this.runtime, metadata, input, requestedMode, runMetrics); + if (freezeStatusAnswer) { + return freezeStatusAnswer; + } const workbookDumpGuard = workbookDumpGuardOutput(metadata, input, requestedMode); if (workbookDumpGuard) { return workbookDumpGuard; } + const designOverviewAnswer = workbookDesignOverviewAnswerOutput(metadata, input, requestedMode); + if (designOverviewAnswer) { + return designOverviewAnswer; + } const workbookAnswer = workbookOverviewAnswer(metadata, input, requestedMode); if (workbookAnswer) { return workbookAnswer; @@ -835,9 +860,18 @@ export class AgentOrchestrator { } const adjustedTarget = adjustReadRangeForSemanticColumn(metadata, input, resolved.sheetName, resolved.range); const normalizedRange = normalizeOperationRange(metadata, resolved.sheetName, adjustedTarget.range); + if (shouldSummarizeGroupedHeader(input)) { + return this.groupedHeaderSummaryOutput(metadata, input, requestedMode, resolved.sheetName, normalizedRange, runMetrics); + } if (isFormulaReadIntentAction(intentAction(input)) || shouldInspectFormulaInline(input)) { return this.formulaAnswerOutput(metadata, input, requestedMode, normalizedRange, resolved, runMetrics); } + if (hasRangeMetadataReadIntent(input)) { + const rangeMetadataAnswer = await this.rangeMetadataAnswerOutput(metadata, input, requestedMode, normalizedRange, resolved, runMetrics); + if (rangeMetadataAnswer) { + return rangeMetadataAnswer; + } + } const largeRangeGuard = largeRangeGuardOutput(metadata, input, requestedMode, resolved.sheetName, normalizedRange, resolved.candidate.label); if (largeRangeGuard) { return largeRangeGuard; @@ -846,8 +880,8 @@ export class AgentOrchestrator { if (rangeMetadataAnswer) { return rangeMetadataAnswer; } - const table = tableFromResolution(metadata, resolved); - if (table && (resolved.candidate.kind === "table" || input.target?.tableName)) { + const table = tableFromResolution(metadata, resolved) ?? explicitlyRequestedTable(metadata, input); + if (table && (resolved.candidate.kind === "table" || input.target?.tableName || requestExplicitlyNamesTable(input, table))) { return this.tableCompactAnswerOutput(metadata, input, requestedMode, resolved, table, runMetrics); } const profile = await this.readAndProfileRange(metadata.workbook.workbookId as WorkbookId, resolved.sheetName, normalizedRange, runMetrics); @@ -958,6 +992,29 @@ export class AgentOrchestrator { kind = "workbook_embedded_local_config"; summary = "Read embedded Open Workbook config from this workbook."; break; + case "get_permissions": + runMetrics.internalReadCount += 1; + result = this.runtime.getPermissions(); + kind = "permissions"; + summary = "Read current Open Workbook permission policy."; + break; + case "set_permissions": { + const update = permissionUpdateFromInput(input, workbookId); + if (Object.keys(update).length === 0) { + return safetyArtifactNeedsInput(metadata, requestedMode, "Permission update needs values.permissions or explicit permission fields such as allowWrites or allowDestructiveActions."); + } + result = this.runtime.setPermissions(update); + kind = "permissions_update"; + summary = "Updated Open Workbook permission policy."; + break; + } + case "allow_destructive_actions": { + const allow = booleanValue(input.values?.allow ?? input.values?.enabled ?? input.values?.allowDestructiveActions) ?? true; + result = this.runtime.allowDestructiveActions(allow); + kind = "permissions_destructive_actions"; + summary = `${allow ? "Allowed" : "Blocked"} Open Workbook structure/workbook actions.`; + break; + } default: return undefined; } @@ -1459,8 +1516,14 @@ export class AgentOrchestrator { requestedMode: AgentRunMode, runMetrics: AgentRunMetrics ): Promise | undefined> { - const action = intentAction(input) ?? (runMetrics.route.workflowRoute === "style.inspect" ? "read_style_summary" : undefined); + const action = intentAction(input) + ?? (input.detailLevel === "style_overview" ? "style_overview" : undefined) + ?? (runMetrics.route.workflowRoute === "style.inspect" && isStyleOverviewRequest(input.request) ? "style_overview" : undefined) + ?? (runMetrics.route.workflowRoute === "style.inspect" ? "read_style_summary" : undefined); const workbookId = metadata.workbook.workbookId as WorkbookId; + if (action === "style_overview") { + return this.styleOverviewOutput(metadata, input, requestedMode, runMetrics); + } if (action === "find_style_references" || shouldRunStyleReferenceSearch(input)) { const candidates = styleReferenceCandidates(metadata, input).slice(0, Math.max(1, Math.min(input.budget?.maxExamples ?? 5, 8))); const enriched = []; @@ -1637,6 +1700,50 @@ export class AgentOrchestrator { return undefined; } + private async styleOverviewOutput( + metadata: WorkbookMetadata, + input: AgentRunInput, + requestedMode: AgentRunMode, + runMetrics: AgentRunMetrics + ): Promise> { + const resolved = resolveAgentReadTarget(metadata, input); + if (!resolved.ok) { + return { + status: resolved.status, + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: resolved.summary, + ...(resolved.candidates !== undefined ? { candidates: resolved.candidates } : {}), + proof: [], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: resolved.nextAction, + warnings: resolved.warnings + }; + } + const workbookId = metadata.workbook.workbookId as WorkbookId; + runMetrics.internalReadCount += 1; + const fingerprint = await this.runtime.getStyleFingerprint({ + workbookId, + sheetName: resolved.sheetName, + address: resolved.range, + maxCellSamples: 240 + }); + const styleSummary = styleSummaryFromFingerprint((fingerprint as { fingerprint?: unknown }).fingerprint ?? fingerprint); + const overview = styleOverviewFromMetadata(metadata, resolved.sheetName, resolved.range, styleSummary); + return { + status: "SUCCESS", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: `Returned style overview for ${resolved.sheetName}!${resolved.range}.`, + answer: overview, + metrics: { source: "metadata_and_runtime_style_overview", fullReadCellCount: 0, internalReadCount: 1 }, + proof: [{ sheetName: resolved.sheetName, range: resolved.range, label: "style overview" }], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "answer_now", + warnings: styleWarnings(fingerprint) + }; + } + private async similarRowsAnswerOutput( metadata: WorkbookMetadata, input: AgentRunInput, @@ -1891,7 +1998,7 @@ export class AgentOrchestrator { warnings: profile.warning ? [profile.warning] : [] }; } - const method = rangeMetadataMethodForAction(action); + const method = rangeMetadataMethodForAction(action ?? inferredRangeMetadataReadAction(input)); if (!method) { return undefined; } @@ -1937,6 +2044,56 @@ export class AgentOrchestrator { }; } + private async groupedHeaderSummaryOutput( + metadata: WorkbookMetadata, + input: AgentRunInput, + requestedMode: AgentRunMode, + sheetName: string, + normalizedRange: string, + runMetrics: AgentRunMetrics + ): Promise> { + const workbookId = metadata.workbook.workbookId as WorkbookId; + const headerRange = groupedHeaderSummaryRange(metadata, sheetName, normalizedRange, input); + runMetrics.internalReadCount += 1; + const mergedResult = await this.runtime.readRangeMetadata("range.read_merged_cells", { workbookId, sheetName, address: headerRange }); + const snapshot = await this.readRangeSnapshot(workbookId, sheetName, headerRange, ["values", "text", "style"], runMetrics); + const mergedRanges = mergedRangesFromMetadataResult(mergedResult); + const spans = groupedHeaderSpansFromSnapshot(headerRange, snapshot, mergedRanges); + const unmergedLabels = groupedHeaderUnmergedLabels(headerRange, snapshot, mergedRanges); + const warnings = [ + ...styleWarnings(mergedResult), + ...(mergedRanges.length === 0 ? ["No merged areas were detected in the grouped header range."] : []) + ]; + return { + status: (mergedResult as { ok?: boolean }).ok === false ? "VALIDATION_FAILED" : "SUCCESS", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: `Summarized grouped header row for ${sheetName}!${headerRange}.`, + answer: { + kind: "grouped_header_summary", + sheetName, + range: headerRange, + mergedRangeCount: mergedRanges.length, + spans, + unmergedLabels, + mergeStatus: mergedRanges.length > 0 ? "merged_spans_detected" : "no_merged_spans_detected", + rawMergedCellSummary: (mergedResult as { data?: unknown }).data + }, + metrics: { + source: "runtime_grouped_header_summary", + internalReadCount: 2, + mergedRangeCount: mergedRanges.length, + labelCount: spans.length + unmergedLabels.length + }, + proof: [{ sheetName, range: headerRange, label: "grouped header summary" }], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "answer_now", + agentInstruction: "Answer from this grouped_header_summary. Do not call workbook_design_overview, semantic_index, fullResultUri, or broad value reads for the same grouped-header summary question.", + maxRecommendedFollowupCalls: 0, + warnings + }; + } + private async cleaningAnswerOutput( metadata: WorkbookMetadata, input: AgentRunInput, @@ -2289,6 +2446,15 @@ export class AgentOrchestrator { if (matchUpdate) { return matchUpdate; } + if (hasMergeBatchInput(input)) { + const mergePreview = this.previewMergeRangesWithStyles(metadata, input, requestedMode); + if (mergePreview) { + return mergePreview; + } + } + if (hasStyleBatchInput(input) && styleEntriesFromInput(metadata, metadata.workbook.workbookId as WorkbookId, input).length > 0) { + return this.previewWriteStylesMany(metadata, input, requestedMode); + } const patches = valuePatchesFromInput(input); if (patches.length > 0) { return this.previewPatchUpdate(metadata, input, requestedMode, patches); @@ -2825,6 +2991,12 @@ export class AgentOrchestrator { if (action === "replace_range_with_styled_table" || shouldPreviewReplaceStyledTable(input)) { return this.previewReplaceStyledTable(metadata, input, requestedMode); } + if (action === "grouped_header" || (!action && shouldPreviewGroupedHeader(input))) { + return this.previewGroupedHeader(metadata, input, requestedMode); + } + if (action === "improve_visual_readability") { + return this.previewVisualReadability(metadata, input, requestedMode); + } const workbookLevelHandler = findAgentActionHandler(input, action, false); if (workbookLevelHandler) { return this.previewActionHandler(metadata, input, requestedMode, workbookLevelHandler); @@ -2842,6 +3014,237 @@ export class AgentOrchestrator { return undefined; } + private previewVisualReadability(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode): Omit { + const options = visualReadabilityOptionsFromInput(input); + const requestedSheetName = input.target?.sheetName; + const fallbackSheetName = metadata.workbook.activeSheet ?? metadata.sheets[0]?.name; + const sheetName = requestedSheetName && !sameText(requestedSheetName, "active") && !sameText(requestedSheetName, "active_sheet") + ? requestedSheetName + : fallbackSheetName; + const sheet = metadata.sheets.find((candidate) => sameText(candidate.name, sheetName)) ?? metadata.sheets.find((candidate) => sameText(candidate.name, fallbackSheetName)); + if (!sheet) { + return { + status: "NEEDS_INPUT", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: "Visual readability preview needs a target sheet.", + candidates: findAgentCandidates(metadata, input).slice(0, 5), + proof: [], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "ask_user", + warnings: ["Provide target.sheetName or prepare a workbook context with an active sheet."] + }; + } + const range = input.target?.range ?? sheet.usedRange; + const detected = detectVisualReadabilityStructure(metadata, sheet, range); + const detectionFailure = visualReadabilityDetectionFailure(metadata, requestedMode, sheet.name, range, detected); + if (detectionFailure) { + return detectionFailure; + } + const columnRoles = inferVisualReadabilityColumns(metadata, sheet, detected); + const sheetType = inferVisualReadabilitySheetType(sheet, columnRoles); + const resolvedProfile = options.profile === "auto" ? profileForVisualSheetType(sheetType) : options.profile; + const visualPlan = compileVisualReadabilityPlan(metadata, sheet, detected, columnRoles, { ...options, profile: resolvedProfile }, sheetType); + const groupedHeaderSuggestion = groupedHeaderSuggestionFromColumns(detected, suggestedColumnGroups(columnRoles, detected)); + const compiledOperations = compileVisualReadabilityOperations(metadata.workbook.workbookId as WorkbookId, sheet.name, visualPlan.rules, visualPlan.validationSuggestions, { + preserveExistingStyle: options.preserveExistingStyle, + stylePreservationMode: options.stylePreservationMode, + allowReplaceConditionalFormatting: options.allowReplaceConditionalFormatting, + allowReplaceDataValidation: options.allowReplaceDataValidation, + applySuggestionBuckets: options.applySuggestionBuckets, + preservation: detected + }); + const formulaRanges = visualReadabilityFormulaCheckRanges(detected); + const pending = this.createPendingOperation(metadata, { + action: { + kind: "visual_readability.apply", + operations: compiledOperations.operations, + request: { + workbookId: metadata.workbook.workbookId as WorkbookId, + sheetName: sheet.name, + formulaRanges, + ruleCount: visualPlan.counts.totalRules, + skippedRuleCount: compiledOperations.skipped.length + } + }, + changes: visualPlan.previewExamples.length > 0 + ? visualPlan.previewExamples.map((example) => ({ sheetName: sheet.name, range: example.range, before: example.before, after: example.after })) + : [{ sheetName: sheet.name, ...(range ? { range } : {}), after: "visual readability compiled preview; no workbook styles will be changed yet" }], + summary: `Prepared visual readability preview for ${sheet.name}.`, + workflowKind: "visual_readability_preview" + }); + const hasApplyReadyOperations = compiledOperations.operations.length > 0; + const zeroOperationWarnings = !hasApplyReadyOperations + ? visualReadabilityZeroOperationWarnings(compiledOperations.skipped) + : []; + return { + status: "PREVIEW_READY", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + operationId: pending.operationId, + confirmationToken: pending.confirmationToken, + summary: pending.summary, + answer: { + kind: "visual_readability_preview", + action: "improve_visual_readability", + sheetName: sheet.name, + ...(range ? { range } : {}), + defaults: stripUndefinedRecord({ ...options, profile: resolvedProfile }), + detected, + columnRoles, + sheetType, + groupedHeaderSuggestion, + visualPlan: { + ...visualPlan, + ruleIds: visualPlan.rules.map((rule) => rule.id), + operationId: pending.operationId, + risk: pending.risk, + operationCount: compiledOperations.operations.length, + skipped: compiledOperations.skipped, + preservation: { + formulas: options.preserveFormulas ? "preserved" : "not_checked", + formulaRanges, + existingStyle: options.preserveExistingStyle ? options.stylePreservationMode : "not_checked" + } + } + }, + metrics: { operationRisk: pending.risk, targetFingerprintStatus: "matched", workflowKind: "visual_readability_preview", groupedOperationCount: visualPlan.counts.totalRules, operationCount: compiledOperations.operations.length, skippedRuleCount: compiledOperations.skipped.length }, + changes: pending.changes, + proof: range ? [{ sheetName: sheet.name, range, label: "visual readability target" }] : [], + resourceLinks: [operationResource(String(pending.operationId))], + nextAction: hasApplyReadyOperations ? "call_apply_update" : "answer_now", + ...(!hasApplyReadyOperations ? { agentInstruction: "Do not call apply_update for this visual readability preview because it compiled zero workbook operations. Explain the skipped reasons and ask for a narrower target or supported workflow." } : {}), + warnings: compiledOperations.skipped.length > 0 + ? [ + `Compiled ${compiledOperations.operations.length} safe visual operation(s). ${compiledOperations.skipped.length} rule(s) are preview-only because the current operation schema does not support them, an opt-in bucket is required, or preservation settings skipped them.`, + ...zeroOperationWarnings + ] + : [`Compiled ${compiledOperations.operations.length} safe visual operation(s) for apply.`] + }; + } + + private previewGroupedHeader(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode): Omit { + const workbookId = metadata.workbook.workbookId as WorkbookId; + const requestedSheetName = input.target?.sheetName + ?? stringValue((input.values as Record | undefined)?.sheetName) + ?? metadata.workbook.activeSheet + ?? metadata.sheets[0]?.name; + const sheet = requestedSheetName ? metadata.sheets.find((candidate) => sameText(candidate.name, requestedSheetName)) : undefined; + if (!sheet) { + return workbookLevelNeedsInput(metadata, requestedMode, "Grouped header preview needs target.sheetName."); + } + const range = input.target?.range ?? sheet.usedRange; + const detected = detectVisualReadabilityStructure(metadata, sheet, range); + const detectionFailure = visualReadabilityDetectionFailure(metadata, requestedMode, sheet.name, range, detected); + if (detectionFailure) { + return detectionFailure; + } + const columns = inferVisualReadabilityColumns(metadata, sheet, detected); + const groups = groupedHeaderGroupsFromInput(input, columns, detected); + if (groups.length < 2) { + return workbookLevelNeedsInput(metadata, requestedMode, "Grouped header preview needs at least two column groups or a wide table/header range that can be grouped."); + } + const headerRow = detected.headerRow ?? 1; + const groupRow = headerRow; + const shiftedHeaderRow = headerRow + 1; + const firstGroupColumn = groups[0]!.startColumn; + const lastGroupColumn = groups[groups.length - 1]!.endColumn; + const groupRowRange = `${firstGroupColumn}${groupRow}:${lastGroupColumn}${groupRow}`; + const shiftedHeaderRowRange = `${firstGroupColumn}${shiftedHeaderRow}:${lastGroupColumn}${shiftedHeaderRow}`; + const styleEntries: Extract["entries"] = [ + { target: { workbookId, sheetName: sheet.name, address: groupRowRange }, style: { rowHeight: groupedHeaderRowHeight(input, 34) }, preserveValues: true }, + { target: { workbookId, sheetName: sheet.name, address: shiftedHeaderRowRange }, style: { rowHeight: groupedHeaderRowHeight(input, 26, "headerRowHeight") }, preserveValues: true }, + ...groups.flatMap((group): Extract["entries"] => [ + { + target: { workbookId, sheetName: sheet.name, address: `${group.startColumn}${groupRow}:${group.endColumn}${groupRow}` }, + style: { + fillColor: group.fillColor, + fontColor: "#FFFFFF", + fontBold: true, + horizontalAlignment: "center", + verticalAlignment: "center" + }, + preserveValues: true + }, + { + target: { workbookId, sheetName: sheet.name, address: `${group.startColumn}${shiftedHeaderRow}:${group.endColumn}${shiftedHeaderRow}` }, + style: { + fillColor: group.headerFillColor, + fontColor: "#1F2937", + fontBold: true, + horizontalAlignment: "center", + verticalAlignment: "center", + wrapText: true, + borders: { edgeBottom: { style: "continuous", weight: "thin", color: group.fillColor } } + }, + preserveValues: true + } + ]) + ]; + const operations: ExcelOperation[] = [ + { + kind: "range.insert_rows", + operationId: makeId("op"), + workbookId, + destructiveLevel: "structure", + reason: input.request, + target: { workbookId, sheetName: sheet.name, address: groupRowRange } + }, + { + kind: "range.write_values_many", + operationId: makeId("op"), + workbookId, + destructiveLevel: "values", + reason: input.request, + entries: groups.map((group) => ({ + target: { workbookId, sheetName: sheet.name, address: `${group.startColumn}${groupRow}:${group.startColumn}${groupRow}` }, + values: [[group.label]], + preserveFormats: true + })) + }, + ...groups + .filter((group) => columnToNumber(group.endColumn) > columnToNumber(group.startColumn)) + .map((group): ExcelOperation => ({ + kind: "range.merge", + operationId: makeId("op"), + workbookId, + destructiveLevel: "structure", + reason: input.request, + target: { workbookId, sheetName: sheet.name, address: `${group.startColumn}${groupRow}:${group.endColumn}${groupRow}` }, + across: false + })), + { + kind: "range.write_styles_many", + operationId: makeId("op"), + workbookId, + destructiveLevel: "format", + reason: input.request, + entries: styleEntries + } + ]; + return this.previewBatchOperation( + metadata, + requestedMode, + operations, + [ + { sheetName: sheet.name, range: groupRowRange, after: "insert grouped visual header row above existing table headers" }, + ...groups.map((group) => ({ sheetName: sheet.name, range: `${group.startColumn}${groupRow}:${group.endColumn}${groupRow}`, after: `group header ${group.label}` })), + { sheetName: sheet.name, range: shiftedHeaderRowRange, after: "existing header row restyled with lighter group fills" } + ], + `Prepared grouped header preview for ${sheet.name}; existing headers shift from row ${headerRow} to row ${shiftedHeaderRow}.`, + { + kind: "grouped_header_preview", + sheetName: sheet.name, + headerRow, + groupRow, + shiftedHeaderRow, + groups, + operationCount: operations.length, + preservesExistingHeaderLabels: true + } + ); + } + private async previewTransformValues(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode): Promise> { const values = input.values ?? {}; const operation = transformOperationFromInput(input); @@ -3340,6 +3743,8 @@ export class AgentOrchestrator { return resolved ? this.previewAutofit(metadata, input, requestedMode, resolved, "columns") : undefined; case "autofit_rows": return resolved ? this.previewAutofit(metadata, input, requestedMode, resolved, "rows") : undefined; + case "freeze_panes": + return this.previewFreezePanes(metadata, input, requestedMode, resolved); case "clear_range": return resolved ? this.previewClearRange(metadata, input, requestedMode, resolved) : undefined; case "normalize_headers": @@ -3374,13 +3779,13 @@ export class AgentOrchestrator { } return resolved ? this.previewWriteConditionalFormatting(metadata, input, requestedMode, resolved) : undefined; case "insert_rows": - return resolved ? this.previewRangeStructuralOperation(metadata, input, requestedMode, resolved, "range.insert_rows") : undefined; + return resolved ? this.previewRangeStructuralOperation(metadata, input, requestedMode, resolved, structuralRangeOperationKind(input, resolved, "range.insert_rows")) : undefined; case "delete_rows": - return resolved ? this.previewRangeStructuralOperation(metadata, input, requestedMode, resolved, "range.delete_rows") : undefined; + return resolved ? this.previewRangeStructuralOperation(metadata, input, requestedMode, resolved, structuralRangeOperationKind(input, resolved, "range.delete_rows")) : undefined; case "insert_columns": - return resolved ? this.previewRangeStructuralOperation(metadata, input, requestedMode, resolved, "range.insert_columns") : undefined; + return resolved ? this.previewRangeStructuralOperation(metadata, input, requestedMode, resolved, structuralRangeOperationKind(input, resolved, "range.insert_columns")) : undefined; case "delete_columns": - return resolved ? this.previewRangeStructuralOperation(metadata, input, requestedMode, resolved, "range.delete_columns") : undefined; + return resolved ? this.previewRangeStructuralOperation(metadata, input, requestedMode, resolved, structuralRangeOperationKind(input, resolved, "range.delete_columns")) : undefined; case "hide_columns": return resolved ? this.previewRangeStructuralOperation(metadata, input, requestedMode, resolved, "range.hide_columns") : undefined; case "unhide_columns": @@ -3980,6 +4385,57 @@ export class AgentOrchestrator { return this.previewBatchOperation(metadata, requestedMode, [operation], [{ sheetName: resolved.sheetName, range: resolved.range, after: `autofit ${dimension}` }], `Prepared autofit ${dimension} on ${resolved.sheetName}!${resolved.range}.`, { kind: "autofit_preview", dimension, sheetName: resolved.sheetName, range: resolved.range }); } + private previewFreezePanes(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode, resolved?: Extract): Omit { + const sheetName = resolved?.sheetName ?? freezePanesSheetName(metadata, input); + if (!sheetName) { + return { + status: "NEEDS_INPUT", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: "Freeze panes needs a target sheet.", + candidates: findAgentCandidates(metadata, input).slice(0, 5), + proof: [], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "ask_user", + warnings: ["Provide target.sheetName or use a prepared workbook context with an active sheet."] + }; + } + const freeze = freezePanesFromInput(input); + if (!freeze) { + return { + status: "NEEDS_INPUT", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: "Freeze panes needs a clear request such as unfreeze panes, freeze top row, freeze first column, or values.freezePanes with row/column counts.", + proof: [{ sheetName, range: resolved?.range ?? usedRangeForSheet(metadata, sheetName), label: "freeze panes target sheet" }], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "ask_user", + warnings: [] + }; + } + const operation: ExcelOperation = { + kind: "sheet.freeze_panes", + operationId: makeId("op"), + workbookId: metadata.workbook.workbookId as WorkbookId, + destructiveLevel: "format", + reason: input.request, + sheetName, + rows: freeze.rows ?? 0, + columns: freeze.columns ?? 0 + }; + const after = freeze.rows === 0 && freeze.columns === 0 + ? "unfreeze all panes" + : `freeze panes at ${freeze.rows ?? 0} row(s) and ${freeze.columns ?? 0} column(s)`; + return this.previewBatchOperation( + metadata, + requestedMode, + [operation], + [{ sheetName, after }], + `Prepared freeze panes update on ${sheetName}.`, + { kind: "freeze_panes_preview", sheetName, freezePanes: { rows: operation.rows, columns: operation.columns } } + ); + } + private previewAutoFilterMutation(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode, resolved: Extract): Omit { if (isClearFilterRequest(input.request)) { return this.previewClearAutoFilter(metadata, input, requestedMode, resolved); @@ -4096,12 +4552,19 @@ export class AgentOrchestrator { ); } + private structuralOperationWarning(input: AgentRunInput, resolved: Extract, requestedKind: RangeStructuralOperationKind, actualKind: RangeStructuralOperationKind): string | undefined { + if (requestedKind === actualKind) { + return undefined; + } + return `Corrected ${String(input.intent?.action ?? "structural operation")} to ${actualKind.replace("range.", "").replace(/_/g, " ")} because ${resolved.range} is a ${structuralAddressShape(resolved.range) ?? "matching"} target.`; + } + private previewRangeStructuralOperation( metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode, resolved: Extract, - kind: "range.clear_values" | "range.insert_rows" | "range.delete_rows" | "range.insert_columns" | "range.delete_columns" | "range.hide_columns" | "range.unhide_columns" | "range.merge" | "range.unmerge" + kind: RangeStructuralOperationKind ): Omit { const workbookId = metadata.workbook.workbookId as WorkbookId; const values = input.values as Record | undefined; @@ -4118,7 +4581,7 @@ export class AgentOrchestrator { ? { ...base, kind, ...(typeof values?.across === "boolean" ? { across: values.across } : {}) } : base; const actionLabel = kind.replace("range.", "").replace(/_/g, " "); - return this.previewBatchOperation( + const output = this.previewBatchOperation( metadata, requestedMode, [operation], @@ -4126,11 +4589,13 @@ export class AgentOrchestrator { `Prepared ${actionLabel} on ${resolved.sheetName}!${resolved.range}.`, { kind: `${kind}_preview`, sheetName: resolved.sheetName, range: resolved.range } ); + const corrected = this.structuralOperationWarning(input, resolved, intentStructuralOperationKind(input) ?? kind, kind); + return corrected ? { ...output, warnings: [corrected, ...output.warnings] } : output; } private previewWriteStylesMany(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode): Omit { const workbookId = metadata.workbook.workbookId as WorkbookId; - const entries = styleEntriesFromInput(workbookId, input); + const entries = styleEntriesFromInput(metadata, workbookId, input); if (entries.length === 0) { return workbookLevelNeedsInput(metadata, requestedMode, "Multi-style writes need values.entries with sheetName, range, and style."); } @@ -4146,21 +4611,83 @@ export class AgentOrchestrator { }); if (redirect) return redirect; } - const operations: ExcelOperation[] = [{ + const mergeEntries = shouldMergeRangesFromRequest(input) ? mergeEntriesFromStyleEntries(entries) : []; + const operations: ExcelOperation[] = [ + ...mergeEntries.map((entry): ExcelOperation => ({ + kind: "range.merge", + operationId: makeId("op"), + workbookId, + destructiveLevel: "structure", + reason: input.request, + target: entry.target, + across: false + })), + { kind: "range.write_styles_many", operationId: makeId("op"), workbookId, destructiveLevel: "format", reason: input.request, - entries: entries.map((entry) => ({ target: entry.target, style: entry.style, preserveValues: true })) - }]; + entries: entries.map((entry) => ({ target: entry.target, style: entry.style, preserveValues: true as const })) + } + ]; + return this.previewBatchOperation( + metadata, + requestedMode, + operations, + [ + ...mergeEntries.map((entry) => ({ sheetName: entry.target.sheetName, range: entry.target.address, after: "merged range" })), + ...entries.map((entry) => ({ sheetName: entry.target.sheetName, range: entry.target.address, after: "styles updated" })) + ], + mergeEntries.length > 0 + ? `Prepared ${mergeEntries.length} merge(s) and style updates for ${entries.length} range(s).` + : `Prepared style updates for ${entries.length} range(s).`, + { kind: mergeEntries.length > 0 ? "merge_and_write_styles_many_preview" : "write_styles_many_preview", mergeCount: mergeEntries.length, rangeCount: entries.length } + ); + } + + private previewMergeRangesWithStyles(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode): Omit | undefined { + const workbookId = metadata.workbook.workbookId as WorkbookId; + const entries = mergeEntriesFromInput(metadata, workbookId, input); + if (entries.length === 0) { + return undefined; + } + const mergeEntries = entries.filter((entry) => isMultiCellRange(entry.target.address)); + const styleEntries = entries + .map((entry) => ({ target: entry.target, style: entry.style ?? defaultStyleForMergeRequest(input) })) + .filter((entry) => Object.keys(entry.style).length > 0); + const operations: ExcelOperation[] = [ + ...mergeEntries.map((entry): ExcelOperation => ({ + kind: "range.merge", + operationId: makeId("op"), + workbookId, + destructiveLevel: "structure", + reason: input.request, + target: entry.target, + across: false + })), + ...(styleEntries.length > 0 ? [{ + kind: "range.write_styles_many" as const, + operationId: makeId("op"), + workbookId, + destructiveLevel: "format" as const, + reason: input.request, + entries: styleEntries.map((entry) => ({ target: entry.target, style: entry.style, preserveValues: true as const })) + }] : []) + ]; + if (operations.length === 0) { + return undefined; + } return this.previewBatchOperation( metadata, requestedMode, operations, - entries.map((entry) => ({ sheetName: entry.target.sheetName, range: entry.target.address, after: "styles updated" })), - `Prepared style updates for ${entries.length} range(s).`, - { kind: "write_styles_many_preview", rangeCount: entries.length } + [ + ...mergeEntries.map((entry) => ({ sheetName: entry.target.sheetName, range: entry.target.address, after: "merged range" })), + ...styleEntries.map((entry) => ({ sheetName: entry.target.sheetName, range: entry.target.address, after: "styles updated" })) + ], + `Prepared ${mergeEntries.length} merge(s)${styleEntries.length > 0 ? ` and style updates for ${styleEntries.length} range(s)` : ""}.`, + { kind: "merge_ranges_preview", mergeCount: mergeEntries.length, rangeCount: entries.length, styledRangeCount: styleEntries.length } ); } @@ -4175,6 +4702,27 @@ export class AgentOrchestrator { if (!validation) { return workbookLevelNeedsInput(metadata, requestedMode, "Data validation writes need values.validation.source or values.options for the dropdown list."); } + const entries = dataValidationEntriesFromInput(workbookId, input, validation); + if (entries.length > 0) { + const operation: ExcelOperation = { + kind: "range.write_data_validation", + operationId: makeId("op"), + workbookId, + destructiveLevel: "format", + reason: input.request, + target: entries[0]!.target, + validation: entries[0]!.validation, + entries + }; + return this.previewBatchOperation( + metadata, + requestedMode, + [operation], + entries.map((entry) => ({ sheetName: entry.target.sheetName, range: entry.target.address, after: "data validation updated" })), + `Prepared data validation updates for ${entries.length} range(s).`, + { kind: "write_data_validation_preview", rangeCount: entries.length, entries: entries.map((entry) => ({ sheetName: entry.target.sheetName, range: entry.target.address, validation: entry.validation })) } + ); + } const operation: ExcelOperation = { kind: "range.write_data_validation", operationId: makeId("op"), @@ -4255,10 +4803,12 @@ export class AgentOrchestrator { } private previewBatchOperation(metadata: WorkbookMetadata, requestedMode: AgentRunMode, operations: ExcelOperation[], changes: NonNullable, summary: string, answer: unknown): Omit { + const workflowKind = previewWorkflowKind(answer); const pending = this.createPendingOperation(metadata, { action: { kind: "batch", operations }, changes, - summary + summary, + ...(workflowKind !== undefined ? { workflowKind } : {}) }); return { status: "PREVIEW_READY", @@ -4268,7 +4818,7 @@ export class AgentOrchestrator { confirmationToken: pending.confirmationToken, summary, answer, - metrics: { operationRisk: pending.risk, targetFingerprintStatus: "matched", safetyFingerprintOnly: true }, + metrics: { operationRisk: pending.risk, targetFingerprintStatus: "matched", safetyFingerprintOnly: true, ...(workflowKind !== undefined ? { workflowKind } : {}) }, changes, proof: changes.flatMap((change) => change.range ? [{ sheetName: change.sheetName, range: change.range, label: "preview target" }] : []).slice(0, 1), resourceLinks: [operationResource(String(pending.operationId))], @@ -4710,7 +5260,30 @@ export class AgentOrchestrator { if (/\b(clear|remove|delete|wipe)\b/i.test(input.request) && styleDimensionsFromAgentInput(input).length > 0) { return this.previewClearStyleDimensions(metadata, input, requestedMode, resolved); } - const style = styleFromInput(input); + const batchEntries = styleEntriesFromInput(metadata, metadata.workbook.workbookId as WorkbookId, input); + if (batchEntries.length > 0 && hasStyleBatchInput(input)) { + return this.previewWriteStylesMany(metadata, input, requestedMode); + } + let style = styleFromInput(input); + if (Object.keys(style).length === 0 && intentAction(input) === "format_range" && hasExactFormatRangeTarget(input)) { + style = defaultFormatRangeStyle(input); + } + if (Object.keys(style).length === 0) { + return { + status: "NEEDS_INPUT", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: "Style update needs at least one supported style property such as fillColor, fontColor, fontBold, alignment, rowHeight, or columnWidth.", + proof: [{ sheetName: resolved.sheetName, range: resolved.range, label: "style target" }], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "ask_user", + warnings: [ + "No apply-ready style properties were parsed, so no workbook preview was created.", + "For batched width changes, send intent.action write_styles_many with values.entries containing sheetName, range, and style.columnWidth." + ] + }; + } + const warnings = groupedHeaderStyleWarnings(input, style); const redirect = this.fragmentationRedirect(metadata, requestedMode, { family: "format_range", workbookContextId: metadata.workbookContextId, @@ -4748,7 +5321,7 @@ export class AgentOrchestrator { proof: [{ sheetName: resolved.sheetName, range: resolved.range, label: "style target" }], resourceLinks: [operationResource(String(pending.operationId))], nextAction: "call_apply_update", - warnings: [] + warnings }; } @@ -5322,18 +5895,19 @@ export class AgentOrchestrator { if (!pending) { return { status: "NOT_FOUND", mode: "operation_status", summary: "No pending or terminal operation was found for the supplied operationId.", proof: [], resourceLinks: [], nextAction: "ask_user", warnings: [] }; } + const mismatchWarning = operationWorkflowMismatchWarning(input, pending.workflowKind); return { status: pending.applyStatus === "applying" ? "IN_PROGRESS" : "SUCCESS", mode: "operation_status", workbookContextId: pending.workbookContextId, operationId: pending.operationId, - summary: `Operation ${pending.operationId} is ${pending.applyStatus ?? "previewed"}.`, + summary: mismatchWarning ?? `Operation ${pending.operationId} is ${pending.applyStatus ?? "previewed"}.`, answer: this.getOperationResource(String(pending.operationId)), changes: pending.changes, proof: [], resourceLinks: [operationResource(String(pending.operationId))], nextAction: pending.applyStatus === "previewed" ? "call_apply_update" : "answer_now", - warnings: [] + warnings: mismatchWarning ? [mismatchWarning] : [] }; } @@ -5476,10 +6050,11 @@ export class AgentOrchestrator { const validation = !applyFailed ? await this.runtime.validateWorkbook({ workbookId: pending.workbookId }) : undefined; const issueCount = validation?.issues?.length ?? 0; const validationFailed = validation?.ok === false; - const resultRecord = result as { transactionId?: string; backups?: string[]; rollbackAvailable?: boolean; telemetry?: unknown; warnings?: unknown[]; results?: unknown[]; error?: unknown }; + const resultRecord = result as { transactionId?: string; backups?: string[]; rollbackAvailable?: boolean; telemetry?: unknown; warnings?: unknown[]; results?: unknown[]; error?: unknown; formulaPreservation?: unknown }; const resultWarnings = Array.isArray(resultRecord.warnings) ? resultRecord.warnings.map(operationWarningMessage) : []; const errorWarning = applyErrorMessage(resultRecord.error); const invalidated = applyFailed ? { invalidatedContextIds: [] as string[], invalidatedResourceUris: [] as string[] } : this.invalidateWorkbookContext(pending.workbookContextId); + const permissionFollowup = this.structuralPermissionFollowup(resultWarnings, pending); const output: Omit = { status: applyFailed || validationFailed ? "VALIDATION_FAILED" : "SUCCESS", mode: "apply_update", @@ -5502,6 +6077,7 @@ export class AgentOrchestrator { partialFailure: applyFailed && Array.isArray(resultRecord.results) && resultRecord.results.some((step) => Boolean(step) && typeof step === "object" && (step as { ok?: unknown }).ok !== false), operationRisk: pending.risk, telemetry: resultRecord.telemetry, + ...(resultRecord.formulaPreservation !== undefined ? { formulaPreservation: resultRecord.formulaPreservation } : {}), ...(applyFailed && resultRecord.results !== undefined ? { stepResults: resultRecord.results } : {}) }, metrics: { operationRisk: pending.risk, targetFingerprintStatus: "matched" }, @@ -5510,8 +6086,19 @@ export class AgentOrchestrator { resourceLinks: resultRecord.transactionId ? [{ uri: `excel://transactions/${resultRecord.transactionId}`, name: "transaction", description: "Applied workbook transaction.", mimeType: "application/json" }] : [], invalidatedContextIds: invalidated.invalidatedContextIds, invalidatedResourceUris: invalidated.invalidatedResourceUris, - nextAction: applyFailed || validationFailed ? "manual_review" : "answer_now", - warnings: [...resultWarnings, ...(errorWarning && !resultWarnings.includes(errorWarning) ? [errorWarning] : []), ...(validation?.issues?.slice(0, 5).map((issue) => issue.message) ?? [])] + nextAction: permissionFollowup ? "ask_user" : applyFailed || validationFailed ? "manual_review" : "answer_now", + ...(permissionFollowup ? { + requiredFollowup: permissionFollowup, + taskOutcome: "needs_user_input" as const, + agentInstruction: "Ask the user for approval if needed, then call excel.agent.run with intent.action set_permissions and the provided permission values. After permission succeeds, create a fresh preview for the original workflow; do not retry this stale failed operationId.", + finalAnswer: "This structural update needs workbook structure permission before it can be applied." + } : {}), + warnings: [ + ...resultWarnings, + ...(errorWarning && !resultWarnings.includes(errorWarning) ? [errorWarning] : []), + ...(permissionFollowup ? ["Enable structure permission with intent.action set_permissions, then create a fresh preview before applying."] : []), + ...(validation?.issues?.slice(0, 5).map((issue) => issue.message) ?? []) + ] }; this.operations.markCompleted(operationId, output); return output; @@ -5524,6 +6111,23 @@ export class AgentOrchestrator { ); } + private structuralPermissionFollowup( + warnings: string[], + pending: NonNullable> + ): AgentRequiredFollowup | undefined { + const warningText = warnings.join(" "); + const blockedByPermissions = /\b(DESTRUCTIVE_ACTION_BLOCKED|PERMISSION_DENIED)\b/.test(warningText) + || warningText.includes("Structure and workbook actions are disabled"); + if (!blockedByPermissions || (pending.risk !== "structure_change" && pending.risk !== "destructive")) { + return undefined; + } + return { + mode: "answer", + nextAction: "answer_now", + instruction: "Call excel.agent.run with intent.action set_permissions and values.permissions {\"allowWrites\":true,\"allowDestructiveActions\":true,\"scopeToWorkbook\":true,\"requireConfirmationFor\":[]}. After it succeeds, create a fresh preview for the original structural workflow and apply that fresh preview." + }; + } + private applyPendingActionInContext(pending: NonNullable>, operationId: string) { switch (pending.action.kind) { case "batch": @@ -5568,6 +6172,8 @@ export class AgentOrchestrator { return this.applyReplaceStyledTableWorkflow(pending.workbookId, pending.action.operations, pending.action.styleCopies, operationId); case "style.repair_consistency": return this.runtime.repairStyleFromTemplate(pending.action.request); + case "visual_readability.apply": + return this.applyVisualReadabilityPlan(pending, operationId); case "clean.transform": return this.applyCleanMutation(pending.action.action, pending.action.request); case "clean.transform_many": @@ -5635,6 +6241,82 @@ export class AgentOrchestrator { } } + private async applyVisualReadabilityPlan(pending: NonNullable>, operationId: string) { + if (pending.action.kind !== "visual_readability.apply") { + return { ok: false, warnings: ["Internal visual readability apply received the wrong operation kind."] }; + } + const { request, operations } = pending.action; + const before = await this.readVisualFormulaSnapshot(request.workbookId, request.sheetName, request.formulaRanges, `${operationId}:before`); + if (before.ok === false) { + return before.result; + } + const applied = await this.runtime.applyBatch({ workbookId: request.workbookId, operations, mode: "apply", idempotencyKey: `agent:${operationId}:visual_readability` }); + const appliedRecord = applied && typeof applied === "object" ? applied as unknown as Record : {}; + if (appliedRecord.ok === false) { + return applied; + } + const after = await this.readVisualFormulaSnapshot(request.workbookId, request.sheetName, request.formulaRanges, `${operationId}:after`); + if (after.ok === false) { + return after.result; + } + const formulaDiff = compareVisualFormulaSnapshots(before.snapshot, after.snapshot); + const appliedWarnings = Array.isArray(appliedRecord.warnings) ? appliedRecord.warnings.filter((warning): warning is string => typeof warning === "string") : []; + const formulaWarning = formulaDiff.changedCount > 0 + ? `Formula preservation failed: ${formulaDiff.changedCount} formula cell(s) changed during visual readability apply.` + : undefined; + return { + ...appliedRecord, + ok: appliedRecord.ok !== false && formulaDiff.changedCount === 0, + warnings: formulaWarning ? [...appliedWarnings, formulaWarning] : appliedWarnings, + formulaPreservation: { + checkedRanges: request.formulaRanges, + formulasChecked: formulaDiff.checkedCount, + formulasChanged: formulaDiff.changedCount, + unchanged: formulaDiff.changedCount === 0 + }, + telemetry: { + ...(appliedRecord.telemetry && typeof appliedRecord.telemetry === "object" ? appliedRecord.telemetry as Record : {}), + visualReadabilityApply: true, + visualReadabilityRuleCount: request.ruleCount, + visualReadabilitySkippedRuleCount: request.skippedRuleCount, + formulasChecked: formulaDiff.checkedCount, + formulasChanged: formulaDiff.changedCount + } + }; + } + + private async readVisualFormulaSnapshot(workbookId: WorkbookId, sheetName: string, formulaRanges: string[], idempotencyKey: string): Promise< + | { ok: true; snapshot: Map } + | { ok: false; result: { ok: false; warnings: string[]; telemetry: Record } } + > { + if (formulaRanges.length === 0) { + return { ok: true, snapshot: new Map() }; + } + const operations: ExcelOperation[] = formulaRanges.map((address) => ({ + kind: "range.read_full", + operationId: makeId("op"), + workbookId, + destructiveLevel: "none", + reason: "Verify visual readability formula preservation.", + target: { workbookId, sheetName, address }, + facets: ["formulas"], + includeFormulas: true + })); + const result = await this.runtime.applyBatch({ workbookId, operations, mode: "validate", idempotencyKey }); + const record = result && typeof result === "object" ? result as unknown as Record : {}; + if (record.ok === false) { + return { + ok: false, + result: { + ok: false, + warnings: ["Could not read formulas for visual readability preservation check."], + telemetry: { visualReadabilityApply: true, formulaPreservationReadFailed: true } + } + }; + } + return { ok: true, snapshot: visualFormulaSnapshotFromBatchResult(formulaRanges, result) }; + } + private async applyStyleCopyRequests(requests: StyleCopyRequest[], operationId: string) { if (requests.length === 0) { return { ok: true, warnings: [], telemetry: { styleCopyCount: 0 } }; @@ -5805,6 +6487,7 @@ export class AgentOrchestrator { action: Parameters[0]["action"]; changes: NonNullable; summary: string; + workflowKind?: string; } ) { const risk = classifyAgentActionRisk(input.action); @@ -5813,6 +6496,7 @@ export class AgentOrchestrator { workbookContextId: metadata.workbookContextId, workbookId: metadata.workbook.workbookId as WorkbookId, action: input.action, + ...(input.workflowKind !== undefined ? { workflowKind: input.workflowKind } : {}), changes: input.changes, summary: input.summary, risk, @@ -5849,6 +6533,52 @@ function formulaMutationPreviewOutput( }; } +function previewWorkflowKind(answer: unknown): string | undefined { + if (!answer || typeof answer !== "object" || Array.isArray(answer)) { + return undefined; + } + return stringValue((answer as Record).kind); +} + +function invalidPreviewOperationReuseOutput(input: AgentRunInput): Omit { + const operationId = String(input.operationId ?? ""); + return { + status: "VALIDATION_FAILED", + mode: "preview_update", + ...(operationId ? { operationId } : {}), + summary: "preview_update cannot reuse an existing operationId. Use operation_status for an existing preview, apply_update to apply it, or call preview_update without operationId to create a fresh preview.", + answer: { + kind: "invalid_preview_operation_reuse", + operationId, + requestedMode: "preview_update", + validModes: ["operation_status", "apply_update"] + }, + proof: [], + resourceLinks: operationId ? [operationResource(operationId)] : [], + nextAction: "ask_user", + warnings: ["Do not continue a new preview workflow with an operationId from a different preview."] + }; +} + +function operationWorkflowMismatchWarning(input: AgentRunInput, storedWorkflowKind: string | undefined): string | undefined { + const requestedWorkflowKind = requestedWorkflowKindFromInput(input); + if (!requestedWorkflowKind || !storedWorkflowKind || requestedWorkflowKind === storedWorkflowKind) { + return undefined; + } + return `The supplied operationId belongs to ${storedWorkflowKind}, but this request appears to be for ${requestedWorkflowKind}. Create a fresh preview_update for the requested workflow instead of reusing this operationId.`; +} + +function requestedWorkflowKindFromInput(input: AgentRunInput): string | undefined { + const action = intentAction(input); + if (action === "grouped_header" || shouldPreviewGroupedHeader(input)) { + return "grouped_header_preview"; + } + if (action === "improve_visual_readability") { + return "visual_readability_preview"; + } + return undefined; +} + function backupLifecyclePreviewOutput( metadata: WorkbookMetadata, requestedMode: AgentRunMode, @@ -6212,6 +6942,112 @@ function isLargeTargetRangeRequest(input: AgentRunInput): boolean { return requestedCells !== undefined && requestedCells > AGENT_LARGE_RANGE_CELL_LIMIT; } +async function freezePaneStatusAnswerOutput( + runtime: RuntimeService, + metadata: WorkbookMetadata, + input: AgentRunInput, + requestedMode: AgentRunMode, + runMetrics: AgentRunMetrics + ): Promise | undefined> { + if (!isFreezePaneStatusQuestion(input)) { + return undefined; + } + const workbookId = metadata.workbook.workbookId as WorkbookId; + const sheetName = input.target?.sheetName ?? metadata.selection?.sheetName ?? metadata.sheets.find((sheet) => sheet.usedRange)?.name ?? metadata.sheets[0]?.name; + const sheet = sheetName ? metadata.sheets.find((candidate) => candidate.name === sheetName) : undefined; + const range = sheet?.usedRange ?? input.target?.range; + if (!sheetName) { + return { + status: "NEEDS_INPUT", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: "Freeze pane status needs a target sheet.", + proof: [], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "ask_user", + warnings: ["Provide target.sheetName or prepare a workbook context with an active sheet."] + }; + } + runMetrics.internalReadCount += 1; + const result = await runtime.getStyleFingerprint({ + workbookId, + sheetName, + ...(range !== undefined ? { address: range } : {}), + maxCellSamples: 0 + }); + if ((result as { ok?: boolean }).ok === false) { + return formulaRuntimeErrorOutput(metadata, requestedMode, `Freeze pane status is unavailable for ${sheetName}.`, result); + } + const fingerprint = (result as { fingerprint?: unknown }).fingerprint ?? result; + const freezePanes = freezePanesFromFingerprint(fingerprint); + const readable = freezePanes.readable !== false; + const frozen = freezePanes.frozen === true; + return { + status: "SUCCESS", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: readable + ? frozen + ? freezePaneSummary(sheetName, freezePanes) + : `No frozen panes are active on ${sheetName}.` + : "Freeze pane status cannot be read from the current Excel host API path.", + answer: { + kind: "freeze_panes_status", + source: "runtime_style_fingerprint", + sheetName, + ...freezePanes, + canApplyFreezePanes: true + }, + metrics: { source: "runtime_style_fingerprint" }, + proof: sheetName ? [{ sheetName, range: range ?? "A1", label: "freeze panes status target sheet" }] : [], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "answer_now", + warnings: readable ? [] : ["Current freeze pane split was not readable through the live Office.js capture path; ask to set or unfreeze panes if you want a deterministic change."] + }; +} + +function isFreezePaneStatusQuestion(input: AgentRunInput): boolean { + const request = freezePaneQuestionText(input).toLowerCase(); + return /\b(which|what|where|show|tell|check|read|inspect|current|currently|is|are|has|have|status)\b/.test(request) + && /\b(freeze|frozen)\b/.test(request) + && /\b(panes?|rows?|columns?|cols?|header|top|first)\b/.test(request); +} + +function freezePaneQuestionText(input: AgentRunInput): string { + const intent: Record = isRecord(input.intent) ? input.intent : {}; + return [ + input.request, + typeof intent.reason === "string" ? intent.reason : undefined, + ...(Array.isArray(intent.targetHints) ? intent.targetHints.filter((hint: unknown): hint is string => typeof hint === "string") : []) + ].filter((part): part is string => typeof part === "string" && part.trim().length > 0).join(" "); +} + +function freezePanesFromFingerprint(fingerprint: unknown): Record { + const record = isRecord(fingerprint) ? fingerprint : {}; + const dimensions = isRecord(record.dimensions) ? record.dimensions : {}; + const freezePanes = isRecord(dimensions.freezePanes) ? dimensions.freezePanes : {}; + return freezePanesFromDimension(freezePanes); +} + +function freezePaneSummary(sheetName: string, freezePanes: Record): string { + const columns = typeof freezePanes.columns === "number" ? freezePanes.columns : undefined; + const rows = typeof freezePanes.rows === "number" ? freezePanes.rows : undefined; + const lastFrozenColumn = typeof freezePanes.lastFrozenColumn === "string" ? freezePanes.lastFrozenColumn : undefined; + const firstUnfrozenColumn = typeof freezePanes.firstUnfrozenColumn === "string" ? freezePanes.firstUnfrozenColumn : undefined; + const parts: string[] = []; + if (columns !== undefined && columns > 0) { + parts.push(lastFrozenColumn && firstUnfrozenColumn + ? `columns A:${lastFrozenColumn} are frozen; first unfrozen column is ${firstUnfrozenColumn}` + : `${columns} column(s) are frozen`); + } + if (rows !== undefined && rows > 0) { + parts.push(`rows 1:${rows} are frozen; first unfrozen row is ${rows + 1}`); + } + return parts.length > 0 + ? `Freeze panes on ${sheetName}: ${parts.join("; ")}.` + : `Freeze panes are active on ${sheetName}.`; +} + function workbookOverviewAnswer(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode): Omit | undefined { const intent = workbookOverviewIntent(input); if (!hasWorkbookOverviewIntent(intent)) { @@ -6307,6 +7143,12 @@ function detailLevelAnswerOutput(metadata: WorkbookMetadata, input: AgentRunInpu if (input.detailLevel === "sheet_summary") { return sheetSummaryDetailOutput(metadata, input, requestedMode); } + if (input.detailLevel === "style_overview") { + return undefined; + } + if (input.detailLevel === "workbook_design_overview") { + return undefined; + } if (input.detailLevel === "full_table" && !isExplicitFullDataRequest(input.request)) { const output = sheetSummaryDetailOutput(metadata, input, requestedMode); return { @@ -6325,6 +7167,327 @@ function sheetOverviewAnswerOutput(metadata: WorkbookMetadata, input: AgentRunIn return sheetSummaryDetailOutput(metadata, input, requestedMode); } +function workbookDesignOverviewAnswerOutput(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode): Omit | undefined { + const action = intentAction(input); + if (action && action !== "workbook_design_overview") { + return undefined; + } + if (action !== "workbook_design_overview" && input.detailLevel !== "workbook_design_overview" && !isWorkbookDesignOverviewRequest(input.request)) { + return undefined; + } + const target = resolveWorkbookDesignTarget(metadata, input); + if (!target) { + return { + status: "NEEDS_INPUT", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: "Workbook design overview needs a target sheet or table.", + answer: { + kind: "workbook_design_overview_needs_target", + source: "cached_metadata", + candidateSheets: metadata.sheets.filter((sheet) => sheet.usedRange || sheet.headers.length > 0 || sheet.tableIds.length > 0).slice(0, 8).map((sheet) => ({ + sheetName: sheet.name, + kind: sheet.kind, + usedRange: sheet.usedRange + })) + }, + proof: [], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "ask_user", + warnings: ["Select a sheet/table or provide target.sheetName before requesting a workbook design overview."] + }; + } + const { sheet, table, range, columns } = target; + const detected = detectVisualReadabilityStructure(metadata, sheet, range); + const visualColumns = inferVisualReadabilityColumns(metadata, sheet, detected); + const relatedSheets = workbookDesignRelatedSheets(metadata, sheet, columns); + const columnRecommendations = columns.map((column) => workbookDesignColumnRecommendation(metadata, sheet, column, relatedSheets)); + const dropdownCount = columnRecommendations.filter((column) => column.recommendedBehavior === "dropdown_list").length; + const lookupCount = columnRecommendations.filter((column) => column.lookupRecommendation !== undefined).length; + const formatCount = columnRecommendations.filter((column) => column.formatRecommendation !== undefined).length; + const answer = stripUndefinedRecord({ + kind: "workbook_design_overview", + source: "cached_metadata_semantic_design", + workbook: { name: metadata.workbook.name, sheetCount: metadata.workbook.sheetCount }, + sheet: { name: sheet.name, kind: sheet.kind, usedRange: sheet.usedRange, rowCount: sheet.rowCount, columnCount: sheet.columnCount }, + table: table ? { name: table.name, range: table.range, headerRange: table.headerRange, dataRange: table.dataRange, columnCount: table.columns.length } : undefined, + target: { sheetName: sheet.name, range, tableName: table?.name }, + dataState: workbookDesignDataState(sheet, table), + inspectionPolicy: { + valuesRead: false, + fullReadCellCount: 0, + guidance: "Use this overview for template/design recommendations. Do not broad-read empty data rows just to infer column roles; use targeted validation/reference workflows only after the user chooses a recommendation." + }, + relatedSheets, + columnRecommendations, + groupSuggestions: suggestedColumnGroups(visualColumns, detected), + summary: { + columnCount: columns.length, + formatRecommendations: formatCount, + dropdownCandidates: dropdownCount, + lookupCandidates: lookupCount + }, + nextWorkflows: workbookDesignNextWorkflows(dropdownCount, lookupCount) + }); + return { + status: "SUCCESS", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: `Returned workbook design overview for ${sheet.name}!${range}: ${columns.length} column recommendation(s), ${dropdownCount} dropdown candidate(s), ${lookupCount} lookup/reference candidate(s).`, + answer, + metrics: { source: "cached_metadata_semantic_design", fullReadCellCount: 0, internalReadCount: 0, columnCount: columns.length, relatedSheetCount: relatedSheets.length }, + proof: [{ sheetName: sheet.name, range, label: "workbook design overview" }], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "answer_now", + warnings: [] + }; +} + +function isWorkbookDesignOverviewRequest(requestText: string): boolean { + if (isExplicitFullDataRequest(requestText)) { + return false; + } + if (/\b(style|styling|visual|readability|font|fonts|color|colors|border|borders|fills?|theme|header)\b/i.test(requestText)) { + return false; + } + const asksDesign = /\b(workbook|sheet|table|column[-\s]?by[-\s]?column|columns?)\b/i.test(requestText) + && /\b(design|data\s+entry|template|dropdown|lookup|reference|validation|column[-\s]?by[-\s]?column)\b/i.test(requestText) + && /\b(review|recommend|decide|should\s+be|for\s+each|each\s+column|every\s+column|column[-\s]?by[-\s]?column)\b/i.test(requestText); + const asksColumnTypes = /\b(free\s+text|date|money|number|id|code|dropdown|lookup|reference)\b/i.test(requestText) + && /\b(each|per|every|columns?|sheet|table)\b/i.test(requestText); + return asksDesign || asksColumnTypes; +} + +function resolveWorkbookDesignTarget(metadata: WorkbookMetadata, input: AgentRunInput): { sheet: WorkbookMetadata["sheets"][number]; table?: TableMetadata; range: string; columns: ColumnMetadata[] } | undefined { + const targetSheetName = stringValue(input.target?.sheetName) + ?? stringValue(input.target?.entity) + ?? findMentionedSheet(metadata, input)?.name + ?? (requestMentionsActiveSheet(input.request) ? metadata.workbook.activeSheet : undefined) + ?? metadata.workbook.activeSheet; + const sheet = targetSheetName + ? metadata.sheets.find((candidate) => sameText(candidate.name, targetSheetName)) + : undefined; + if (!sheet) { + return undefined; + } + const tableName = stringValue(input.target?.tableName); + const table = (tableName ? metadata.tables.find((candidate) => sameText(candidate.name, tableName) && sameText(candidate.sheetName, sheet.name)) : undefined) + ?? metadata.tables.find((candidate) => sameText(candidate.sheetName, sheet.name)); + const bestHeader = sheet.headers.slice().sort((left, right) => right.confidence - left.confidence)[0]; + const columns = table?.columns && table.columns.length > 0 ? table.columns : bestHeader?.columns ?? []; + const range = stripSheetName(input.target?.range ?? table?.range ?? bestHeader?.range ?? sheet.usedRange ?? "A1:A1"); + return { sheet, ...(table ? { table } : {}), range, columns }; +} + +function workbookDesignDataState(sheet: WorkbookMetadata["sheets"][number], table?: TableMetadata) { + const rowCount = sheet.rowCount ?? rowCountFromAddress(sheet.usedRange) ?? rowCountFromAddress(table?.range); + const tableDataRows = rowCountFromAddress(table?.dataRange); + const looksTemplateLike = Boolean(table && (tableDataRows ?? 0) >= 20); + return stripUndefinedRecord({ + kind: looksTemplateLike ? "template_or_structured_table" : sheet.kind === "template" ? "template" : "metadata_only", + rowCount, + tableDataRows, + recommendation: looksTemplateLike + ? "Treat the sheet as a template/structured table for design review. Infer from headers and related sheets; avoid repeated data-row reads unless the user asks for actual values." + : "Metadata is enough for initial design recommendations; sample values are optional proof, not required for column role decisions." + }); +} + +function rowCountFromAddress(address: string | undefined): number | undefined { + if (!address) return undefined; + const parsed = tryParseA1Address(stripSheetName(address)); + return parsed ? parsed.endRow - parsed.startRow + 1 : undefined; +} + +function workbookDesignRelatedSheets(metadata: WorkbookMetadata, sheet: WorkbookMetadata["sheets"][number], columns: ColumnMetadata[]): Array<{ sheetName: string; kind: string; usedRange?: string; confidence: number; reasons: string[] }> { + const targetHeaders = new Set(columns.map((column) => normalizeHeaderName(column.name)).filter(Boolean)); + const targetTokens = new Set(columns.flatMap((column) => workbookDesignHeaderTokens(column.name))); + return metadata.sheets + .filter((candidate) => candidate.name !== sheet.name) + .map((candidate) => { + const candidateColumns = candidate.headers.flatMap((header) => header.columns); + const exactOverlap = candidateColumns.filter((column) => targetHeaders.has(normalizeHeaderName(column.name))).length; + const tokenOverlap = candidateColumns.flatMap((column) => workbookDesignHeaderTokens(column.name)).filter((token) => targetTokens.has(token)).length; + const nameScore = workbookDesignSheetNameScore(candidate.name, columns); + const kindScore = candidate.kind === "lookup" ? 2 : candidate.kind === sheet.kind ? 1 : 0; + const score = exactOverlap * 3 + Math.min(tokenOverlap, 4) + nameScore + kindScore; + const reasons = [ + exactOverlap > 0 ? `${exactOverlap} matching header(s)` : undefined, + tokenOverlap > 0 ? `${Math.min(tokenOverlap, 4)} related header token(s)` : undefined, + nameScore > 0 ? "sheet name matches a column domain" : undefined, + candidate.kind === "lookup" ? "lookup sheet" : undefined + ].filter((reason): reason is string => Boolean(reason)); + return { candidate, score, reasons }; + }) + .filter((entry) => entry.score > 0) + .sort((left, right) => right.score - left.score || left.candidate.name.localeCompare(right.candidate.name)) + .slice(0, 8) + .map((entry) => stripUndefinedRecord({ + sheetName: entry.candidate.name, + kind: entry.candidate.kind, + usedRange: entry.candidate.usedRange, + confidence: Math.max(0.35, Math.min(0.95, entry.score / 10)), + reasons: entry.reasons + }) as { sheetName: string; kind: string; usedRange?: string; confidence: number; reasons: string[] }); +} + +function workbookDesignSheetNameScore(sheetName: string, columns: ColumnMetadata[]): number { + const normalizedSheet = normalizeComparableText(sheetName); + let score = 0; + for (const column of columns) { + const normalized = normalizeComparableText(column.name); + if ((/customer|ลูกค้า/.test(normalized) && /customer|ลูกค้า/.test(normalizedSheet)) + || (/driver|truck|คนขับ|ทะเบียน/.test(normalized) && /driver|truck|รถ|คนขับ/.test(normalizedSheet)) + || (/booking|บุ๊ค|จอง/.test(normalized) && /booking|บุ๊ค|จอง/.test(normalizedSheet))) { + score += 4; + } + } + return score; +} + +function workbookDesignHeaderTokens(value: string): string[] { + return normalizeComparableText(value) + .split(/[^a-z0-9ก-๙]+/i) + .filter((token) => token.length >= 3) + .slice(0, 8); +} + +function workbookDesignColumnRecommendation(metadata: WorkbookMetadata, sheet: WorkbookMetadata["sheets"][number], column: ColumnMetadata, relatedSheets: ReturnType) { + const header = column.name; + const normalized = normalizeComparableText(header); + const role = visualColumnRole(column); + const lookup = workbookDesignLookupRecommendation(metadata, column, relatedSheets); + const dropdown = workbookDesignDropdownRecommendation(column, lookup); + const behavior = lookup ? "lookup_reference" : dropdown ? "dropdown_list" : workbookDesignBehavior(column, role, normalized); + return stripUndefinedRecord({ + column: column.letter, + header, + currentInferredType: column.inferredType, + role, + recommendedBehavior: behavior, + formatRecommendation: workbookDesignFormatRecommendation(column, role, normalized), + dropdownRecommendation: dropdown, + lookupRecommendation: lookup, + applySafety: lookup ? "separate_preview_required" : dropdown ? "validation_bucket_or_separate_preview" : "safe_visual_formatting", + rationale: workbookDesignColumnRationale(column, role, behavior) + }); +} + +function workbookDesignBehavior(column: ColumnMetadata, role: string, normalized: string): string { + if (role === "date") return "date"; + if (role === "money" || role === "number") return "number_money"; + if (role === "id" || /เลข|number|no|code|id|ทะเบียน|phone|โทร|tax|ภาษี/.test(normalized)) return "id_text_code"; + if (role === "status" || role === "category") return "dropdown_list"; + return "free_text"; +} + +function workbookDesignFormatRecommendation(column: ColumnMetadata, role: string, normalized: string) { + if (role === "date" || /วันที่|date/.test(normalized)) { + return { type: "date", numberFormat: "dd/mm/yyyy", reason: "Date-like header should sort/filter as dates." }; + } + if (role === "money" || role === "number" || /ราคา|ยอด|ค่า|ภาษี|amount|price|fee|tax|total|net|gross/.test(normalized)) { + return { type: "money", numberFormat: "#,##0.00", reason: "Money-like columns should align right and use a consistent numeric format." }; + } + if (role === "id" || /เลข|booking|บุ๊ค|no|number|code|id|ทะเบียน|phone|โทร|tax id|เลขประจำตัว/.test(normalized)) { + return { type: "text_code", numberFormat: "@", reason: "Identifiers should stay text so leading zeros, hyphens, and registration codes are preserved." }; + } + return undefined; +} + +function workbookDesignDropdownRecommendation(column: ColumnMetadata, lookup: unknown) { + if (lookup) { + return undefined; + } + const normalized = normalizeComparableText(column.name); + if (/สถานะ|status|state|stage/.test(normalized)) { + return { + source: "suggested_static_options", + options: /จ่าย|payment|paid/.test(normalized) ? ["ยังไม่จ่าย", "จ่ายแล้ว", "รอตรวจสอบ"] : ["รอดำเนินการ", "กำลังดำเนินการ", "เสร็จแล้ว", "ยกเลิก"], + nextWorkflow: { intentAction: "write_data_validation", mode: "preview_update" } + }; + } + if (/container size|ขนาดตู้/.test(normalized)) { + return { + source: "suggested_static_options", + options: ["20GP", "40GP", "40HQ"], + nextWorkflow: { intentAction: "write_data_validation", mode: "preview_update" } + }; + } + if (/type|category|ประเภท/.test(normalized)) { + return { + source: "needs_source_or_existing_values", + options: [], + nextWorkflow: { intentAction: "read_data_validation", mode: "answer" } + }; + } + return undefined; +} + +function workbookDesignLookupRecommendation(metadata: WorkbookMetadata, column: ColumnMetadata, relatedSheets: ReturnType) { + const normalized = normalizeComparableText(column.name); + const target = relatedSheets.find((sheet) => { + const sheetName = normalizeComparableText(sheet.sheetName); + return (/ลูกค้า|customer/.test(normalized) && /customer|ลูกค้า/.test(sheetName)) + || (/booking|บุ๊ค|จอง/.test(normalized) && /booking|บุ๊ค|จอง/.test(sheetName)) + || (/driver|truck|คนขับ|ทะเบียน/.test(normalized) && /driver|truck|รถ|คนขับ/.test(sheetName)) + || (sheet.kind === "lookup" && workbookDesignRelatedSheetHasHeader(metadata, sheet.sheetName, column.name)); + }); + if (!target) { + return undefined; + } + const targetSheet = metadata.sheets.find((sheet) => sheet.name === target.sheetName); + const keyColumn = workbookDesignLookupKeyColumn(targetSheet, column); + return stripUndefinedRecord({ + sourceSheetName: target.sheetName, + sourceRange: target.usedRange, + keyColumn, + confidence: target.confidence, + nextWorkflow: { intentAction: "write_data_validation", mode: "preview_update", note: "Preview dropdown/source-list or lookup formula separately before applying." }, + reason: `${column.name} appears related to ${target.sheetName}; use that sheet as the source of truth instead of free text when possible.` + }); +} + +function workbookDesignRelatedSheetHasHeader(metadata: WorkbookMetadata, sheetName: string, header: string): boolean { + const normalized = normalizeHeaderName(header); + if (!normalized) return false; + const sheet = metadata.sheets.find((candidate) => candidate.name === sheetName); + return Boolean(sheet?.headers.some((entry) => entry.columns.some((column) => normalizeHeaderName(column.name) === normalized))); +} + +function workbookDesignLookupKeyColumn(sheet: WorkbookMetadata["sheets"][number] | undefined, sourceColumn: ColumnMetadata): string | undefined { + const normalizedSource = normalizeComparableText(sourceColumn.name); + const columns = sheet?.headers.flatMap((header) => header.columns) ?? []; + const preferred = columns.find((column) => { + const normalized = normalizeComparableText(column.name); + return (/customer|ลูกค้า/.test(normalizedSource) && /customer|ลูกค้า|ชื่อ/.test(normalized)) + || (/booking|บุ๊ค/.test(normalizedSource) && /booking|บุ๊ค|เลข/.test(normalized)) + || (/driver|truck|คนขับ|ทะเบียน/.test(normalizedSource) && /driver|truck|คนขับ|ทะเบียน|ชื่อ/.test(normalized)); + }) ?? columns[0]; + return preferred ? `${preferred.letter}:${preferred.name}` : undefined; +} + +function workbookDesignColumnRationale(column: ColumnMetadata, role: string, behavior: string): string { + if (behavior === "lookup_reference") return "Header matches a related lookup/source sheet."; + if (behavior === "dropdown_list") return "Header is status/category-like and should use controlled values."; + if (behavior === "date") return "Header and metadata indicate a date column."; + if (behavior === "number_money") return "Header and metadata indicate money/number values."; + if (behavior === "id_text_code") return "Identifier-like values should be preserved as text."; + return column.importance !== undefined && column.importance >= 0.85 ? `High-importance ${role} column; keep easy to scan.` : "No strong controlled-value or lookup signal; keep manual text entry."; +} + +function workbookDesignNextWorkflows(dropdownCount: number, lookupCount: number) { + const workflows: Array> = [ + { intentAction: "improve_visual_readability", mode: "preview_update", purpose: "Apply safe visual formatting, widths, alignment, filters, and number formats." } + ]; + if (dropdownCount > 0) { + workflows.push({ intentAction: "write_data_validation", mode: "preview_update", purpose: "Preview dropdown/data-validation rules for selected columns." }); + } + if (lookupCount > 0) { + workflows.push({ intentAction: "derive_values", mode: "preview_update", purpose: "Preview lookup/reference formulas or source-list behavior separately." }); + } + return workflows; +} + function semanticIndexDetailOutput(metadata: WorkbookMetadata, input: AgentRunInput, requestedMode: AgentRunMode): Omit { const semanticIndex = buildSemanticWorkbookIndex(metadata, { maxEntries: input.budget?.maxExamples ?? 25 }); const candidates = semanticIndex.entries.map((entry) => ({ @@ -8185,33 +9348,357 @@ function styleSummaryFromFingerprint(fingerprint: unknown) { borders: dimensions.borders, rowHeights: dimensions.rowHeights, columnWidths: dimensions.columnWidths, + freezePanes: dimensions.freezePanes, conditionalFormatting: dimensions.conditionalFormatting, dataValidation: dimensions.dataValidation }; } -function styleWarnings(result: unknown): string[] { - const fingerprint = isRecord(result) && isRecord(result.fingerprint) ? result.fingerprint : result; - const warnings = isRecord(fingerprint) && Array.isArray(fingerprint.warnings) ? fingerprint.warnings : []; - return warnings.map((warning) => isRecord(warning) && typeof warning.message === "string" ? warning.message : String(warning)).slice(0, 5); +function isStyleOverviewRequest(request: string): boolean { + if (/\bstyle\s+summar(y|ies)\b/i.test(request) || /\bread\b.{0,20}\bstyles?\b/i.test(request)) { + return false; + } + return /\b(style|styling|formatting|visual|readability|design)\b/i.test(request) + && /\b(overview|summary|review|inspect|look|suggest|recommend|improve|better|standardi[sz]e|best practice)\b/i.test(request); +} + +function styleOverviewFromMetadata(metadata: WorkbookMetadata, sheetName: string, range: string, styleSummary: ReturnType) { + const sheet = metadata.sheets.find((candidate) => sameText(candidate.name, sheetName)); + const tables = metadata.tables.filter((table) => sameText(table.sheetName, sheetName)); + const primaryTable = tables.find((table) => rangeOverlapsLoose(table.range, range)) ?? tables[0]; + const detected = detectVisualReadabilityStructure(metadata, sheet ?? { + id: `sheet:${sheetName}`, + index: 0, + name: sheetName, + kind: "unknown", + usedRange: range, + tableIds: [], + sectionIds: [], + summaryBlockIds: [], + formulaRegionIds: [], + headers: [], + isHidden: false + } as WorkbookMetadata["sheets"][number], range); + const columns = sheet ? inferVisualReadabilityColumns(metadata, sheet, detected) : columnsFromTable(primaryTable); + const columnGroups = suggestedColumnGroups(columns, detected); + const groupedHeaderSuggestion = groupedHeaderSuggestionFromColumns(detected, columnGroups); + const freezePanes = freezePaneOverview(styleSummary.freezePanes); + return stripUndefinedRecord({ + kind: "style_overview", + source: "cached_metadata_and_style_fingerprint", + sheetName, + range, + freezePanes, + table: primaryTable ? stripUndefinedRecord({ + name: primaryTable.name, + range: primaryTable.range, + headerRange: primaryTable.headerRange, + dataRange: primaryTable.dataRange, + columnCount: primaryTable.columns.length + }) : undefined, + detected: stripUndefinedRecord({ + headerRow: detected.headerRow, + headerRange: detected.headerRange, + dataRange: detected.dataRange, + tableRanges: detected.tableRanges, + hasFilter: detected.hasFilter, + confidence: detected.confidence + }), + currentStyle: stripUndefinedRecord({ + header: styleOverviewHeaderStyle(styleSummary), + fills: compactStyleDimension(styleSummary.fills), + fonts: compactStyleDimension(styleSummary.fonts), + borders: compactStyleDimension(styleSummary.borders), + alignment: compactStyleDimension(styleSummary.alignment), + rowHeights: compactStyleDimension(styleSummary.rowHeights), + columnWidths: compactStyleDimension(styleSummary.columnWidths), + freezePanes, + numberFormats: compactStyleDimension(styleSummary.numberFormats), + conditionalFormatting: compactStyleDimension(styleSummary.conditionalFormatting), + dataValidation: compactStyleDimension(styleSummary.dataValidation) + }), + columnRoles: columns.slice(0, 32).map((column) => stripUndefinedRecord({ + column: column.column, + header: column.header, + role: column.role, + inferredType: column.inferredType, + confidence: column.confidence, + freezePane: freezePaneColumnAnnotation(column.column, freezePanes) + })), + columnGroupSuggestions: columnGroups, + groupedHeaderSuggestion, + recommendations: styleOverviewRecommendations(groupedHeaderSuggestion, columns, styleSummary), + recommendedWorkflow: groupedHeaderSuggestion + ? { intentAction: "grouped_header", mode: "preview_update", requiresConfirmation: true } + : { intentAction: "improve_visual_readability", mode: "preview_update", requiresConfirmation: true } + }); } -function formulaReadAnswerFromSnapshot(snapshot: RangeSnapshot | undefined, patterns: unknown, sheetName: string, range: string) { - const values = snapshot?.values ?? []; - const text = snapshot?.text ?? []; - const rawFormulas = snapshot?.formulas ?? []; - const snapshotFormulas = normalizeFormulaOnlyMatrix(rawFormulas); - const patternFormulas = formulaMatrixFromPatternCells(patterns, "formula"); - const patternFormulasR1C1 = formulaMatrixFromPatternCells(patterns, "formulaR1C1"); - const formulas = mergeFormulaMatrices(snapshotFormulas, matrixFromUnknown(isRecord(patterns) ? patterns.formulas : undefined) ?? [], patternFormulas); - const formulasR1C1 = mergeFormulaMatrices(matrixFromUnknown(isRecord(patterns) ? patterns.formulasR1C1 : undefined) ?? [], patternFormulasR1C1); - const patternMatrix = mergePatternMatrices(matrixFromUnknown(isRecord(patterns) ? patterns.patternMatrix : undefined) ?? [], patterns); - const parsed = tryParseA1Address(stripSheetName(range)); - const rowCount = Math.max(values.length, text.length, formulas.length, formulasR1C1.length, patternMatrix.length); - const columnCount = Math.max(maxMatrixColumns(values), maxMatrixColumns(text), maxMatrixColumns(formulas), maxMatrixColumns(formulasR1C1), maxMatrixColumns(patternMatrix)); - const formulaColumns = new Set(); - for (const row of [formulas, formulasR1C1].flat()) { - row.forEach((formula, index) => { +function freezePaneOverview(rawFreezePanes: unknown): Record | undefined { + if (!isRecord(rawFreezePanes)) { + return undefined; + } + const freezePanes = freezePanesFromDimension(rawFreezePanes); + const readable = freezePanes.readable !== false; + const frozen = freezePanes.frozen === true; + return stripUndefinedRecord({ + readable, + frozen, + rows: numericRecordValue(freezePanes, "rows"), + columns: numericRecordValue(freezePanes, "columns"), + lastFrozenRow: numericRecordValue(freezePanes, "lastFrozenRow"), + firstUnfrozenRow: numericRecordValue(freezePanes, "firstUnfrozenRow"), + lastFrozenColumn: stringRecordValue(freezePanes, "lastFrozenColumn"), + firstUnfrozenColumn: stringRecordValue(freezePanes, "firstUnfrozenColumn"), + summary: readable + ? frozen + ? freezePaneSummary("this sheet", freezePanes).replace(/^Freeze panes on this sheet: /, "") + : "No frozen panes are active." + : stringRecordValue(freezePanes, "message") + }); +} + +function freezePaneColumnAnnotation(column: string, freezePanes: Record | undefined): Record | undefined { + if (!freezePanes || freezePanes.readable === false) { + return undefined; + } + const columnIndex = columnToNumber(column); + if (!Number.isFinite(columnIndex) || columnIndex <= 0) { + return undefined; + } + const frozenColumnCount = numericRecordValue(freezePanes, "columns") ?? 0; + const lastFrozenColumn = stringRecordValue(freezePanes, "lastFrozenColumn"); + const firstUnfrozenColumn = stringRecordValue(freezePanes, "firstUnfrozenColumn"); + return stripUndefinedRecord({ + isFrozen: frozenColumnCount > 0 && columnIndex <= frozenColumnCount, + isLastFrozenColumn: lastFrozenColumn !== undefined && sameText(column, lastFrozenColumn), + isFirstUnfrozenColumn: firstUnfrozenColumn !== undefined && sameText(column, firstUnfrozenColumn) + }); +} + +function freezePanesFromDimension(freezePanes: Record): Record { + if (freezePanes.readable !== true && freezePanes.frozen !== true && freezePanes.frozen !== false) { + return { + ...freezePanes, + readable: false, + message: "Freeze pane location was not captured by the loaded Excel taskpane." + }; + } + return freezePanes; +} + +function numericRecordValue(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function stringRecordValue(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.trim() ? value : undefined; +} + +function columnsFromTable(table: TableMetadata | undefined): VisualReadabilityColumnRole[] { + return table?.columns.map((column) => ({ + column: column.letter, + header: column.name, + role: visualColumnRole(column), + inferredType: column.inferredType, + confidence: Math.max(0.45, Math.min(0.98, column.importance ?? 0.7)), + signals: visualColumnSignals(column) + })) ?? []; +} + +function rangeOverlapsLoose(left: string | undefined, right: string | undefined): boolean { + if (!left || !right) return false; + return rangesOverlapAddresses(stripSheetName(left), stripSheetName(right)); +} + +function styleOverviewHeaderStyle(styleSummary: ReturnType) { + return stripUndefinedRecord({ + fills: firstStyleCells(styleSummary.fills, "fillColor"), + fonts: firstStyleCells(styleSummary.fonts, "fontBold"), + alignment: firstStyleCells(styleSummary.alignment, "horizontalAlignment"), + borders: compactStyleDimension(styleSummary.borders) + }); +} + +function firstStyleCells(dimension: unknown, key: string) { + if (!isRecord(dimension)) return undefined; + const cells = isRecord(dimension.cells) || Array.isArray(dimension.cells) ? dimension.cells : undefined; + if (!Array.isArray(cells)) return undefined; + return cells.filter((cell) => isRecord(cell) && cell.rowIndex === 0 && cell[key] !== undefined).slice(0, 8); +} + +function suggestedColumnGroups(columns: VisualReadabilityColumnRole[], detected: VisualReadabilityDetectedStructure): Array<{ label: string; startColumn: string; endColumn: string; columns: string[]; role: string; fillColor: string; headerFillColor: string }> { + const palette = [ + { fillColor: "#1A3C6E", headerFillColor: "#D9EAF7" }, + { fillColor: "#0F6B78", headerFillColor: "#D7EEF2" }, + { fillColor: "#548235", headerFillColor: "#E2EFDA" }, + { fillColor: "#C65911", headerFillColor: "#FCE4D6" }, + { fillColor: "#8064A2", headerFillColor: "#EDE7F6" }, + { fillColor: "#666666", headerFillColor: "#E7E6E6" } + ]; + return visualColumnGroups(columns, detected).map((group, index) => { + const role = normalizeComparableText(group.label).replace(/\s+/g, "_") || "group"; + const color = palette[index % palette.length]!; + return { + label: group.label, + startColumn: group.startColumn, + endColumn: group.endColumn, + columns: columnsInSpan(columns, group.startColumn, group.endColumn), + role, + fillColor: color.fillColor, + headerFillColor: color.headerFillColor + }; + }); +} + +function groupedHeaderGroupsFromInput(input: AgentRunInput, columns: VisualReadabilityColumnRole[], detected: VisualReadabilityDetectedStructure): Array<{ label: string; startColumn: string; endColumn: string; fillColor: string; headerFillColor: string }> { + const values = input.values as Record | undefined; + const groupedHeader = isRecord(values?.groupedHeader) ? values.groupedHeader as Record : values; + const rawGroups = Array.isArray(groupedHeader?.groups) ? groupedHeader.groups : undefined; + const inferred = suggestedColumnGroups(columns, detected); + if (!rawGroups) { + return inferred.map(({ label, startColumn, endColumn, fillColor, headerFillColor }) => ({ label, startColumn, endColumn, fillColor, headerFillColor })); + } + return rawGroups.flatMap((raw, index) => { + if (!isRecord(raw)) return []; + const columnSpan = groupedHeaderColumnSpan(raw); + const startColumn = stringValue(raw.startColumn ?? raw.start ?? raw.from ?? raw.column ?? columnSpan?.startColumn); + const endColumn = stringValue(raw.endColumn ?? raw.end ?? raw.to ?? raw.column ?? columnSpan?.endColumn ?? startColumn); + const label = stringValue(raw.label ?? raw.name ?? raw.title) ?? inferred[index]?.label; + if (!startColumn || !endColumn || !label) return []; + return [{ + label, + startColumn: startColumn.toUpperCase(), + endColumn: endColumn.toUpperCase(), + fillColor: colorString(raw.fillColor ?? raw.color) ?? inferred[index]?.fillColor ?? "#1A3C6E", + headerFillColor: colorString(raw.headerFillColor ?? raw.bodyFillColor ?? raw.lightFillColor) ?? inferred[index]?.headerFillColor ?? "#D9EAF7" + }]; + }); +} + +function groupedHeaderColumnSpan(raw: Record): { startColumn: string; endColumn: string } | undefined { + const columns = Array.isArray(raw.columns) + ? raw.columns.map((column) => stringValue(column)).filter((column): column is string => Boolean(column && /^[A-Z]+$/i.test(column.trim()))) + : []; + if (columns.length > 0) { + const indexes = columns.map((column) => columnToNumber(column.trim().toUpperCase())).filter((index) => Number.isFinite(index)); + if (indexes.length > 0) { + return { startColumn: columnLetter(Math.min(...indexes) - 1), endColumn: columnLetter(Math.max(...indexes) - 1) }; + } + } + const range = stringValue(raw.range ?? raw.address); + if (!range) { + return undefined; + } + const normalized = stripSheetName(range).replace(/\$/g, "").trim(); + const parsed = tryParseA1Address(normalized); + if (parsed) { + return { startColumn: columnLetter(parsed.startColumn - 1), endColumn: columnLetter(parsed.endColumn - 1) }; + } + const columnRange = /^([A-Z]+)\s*(?::|-|\bto\b)\s*([A-Z]+)$/i.exec(normalized); + const rangeStart = columnRange?.[1]; + const rangeEnd = columnRange?.[2]; + if (rangeStart && rangeEnd) { + return { startColumn: rangeStart.toUpperCase(), endColumn: rangeEnd.toUpperCase() }; + } + return undefined; +} + +function groupedHeaderRowHeight(input: AgentRunInput, defaultHeight: number, key = "groupRowHeight"): number { + const values = input.values as Record | undefined; + const groupedHeader = isRecord(values?.groupedHeader) ? values.groupedHeader as Record : values; + return numberValue(groupedHeader?.[key]) ?? defaultHeight; +} + +function columnsInSpan(columns: VisualReadabilityColumnRole[], startColumn: string, endColumn: string): string[] { + const start = columnToNumber(startColumn); + const end = columnToNumber(endColumn); + return columns + .filter((column) => { + const current = columnToNumber(column.column); + return current >= start && current <= end; + }) + .map((column) => column.column); +} + +function groupedHeaderSuggestionFromColumns(detected: VisualReadabilityDetectedStructure, groups: ReturnType) { + const multiColumnGroups = groups.filter((group) => columnToNumber(group.endColumn) > columnToNumber(group.startColumn)); + if (!detected.headerRange || groups.length < 2 || groups.reduce((total, group) => total + group.columns.length, 0) < 6 || multiColumnGroups.length === 0) { + return undefined; + } + return { + kind: "grouped_header_suggestion", + targetHeaderRange: detected.headerRange, + levels: 1, + insertAboveHeader: true, + styleExistingHeader: true, + requiresStructuralPreview: true, + defaultApplyBehavior: "suggest_only", + groups: groups.map((group) => ({ + label: group.label, + startColumn: group.startColumn, + endColumn: group.endColumn, + fillColor: group.fillColor, + headerFillColor: group.headerFillColor, + merge: columnToNumber(group.endColumn) > columnToNumber(group.startColumn) + })), + operationsNeeded: ["insert_rows", "write_values_many", "merge_range", "write_styles_many"] + }; +} + +function styleOverviewRecommendations(groupedHeaderSuggestion: unknown, columns: VisualReadabilityColumnRole[], styleSummary: ReturnType) { + const recommendations: Array> = []; + if (groupedHeaderSuggestion) { + recommendations.push({ + id: "grouped_header", + category: "structural_style", + title: "Add a grouped visual header above the table header.", + applySafety: "preview_required" + }); + } + if (columns.length >= 8) { + recommendations.push({ + id: "freeze_header", + category: "layout_format", + title: "Freeze the header area for wide-sheet scanning.", + applySafety: "opt_in" + }); + } + if (styleSummary.truncated) { + recommendations.push({ + id: "style_sample_limited", + category: "inspection", + title: "Style sampling was compact; inspect a smaller header/body range before high-fidelity template repair.", + applySafety: "read_only" + }); + } + return recommendations; +} + +function styleWarnings(result: unknown): string[] { + const fingerprint = isRecord(result) && isRecord(result.fingerprint) ? result.fingerprint : result; + const warnings = isRecord(fingerprint) && Array.isArray(fingerprint.warnings) ? fingerprint.warnings : []; + return warnings.map((warning) => isRecord(warning) && typeof warning.message === "string" ? warning.message : String(warning)).slice(0, 5); +} + +function formulaReadAnswerFromSnapshot(snapshot: RangeSnapshot | undefined, patterns: unknown, sheetName: string, range: string) { + const values = snapshot?.values ?? []; + const text = snapshot?.text ?? []; + const rawFormulas = snapshot?.formulas ?? []; + const snapshotFormulas = normalizeFormulaOnlyMatrix(rawFormulas); + const patternFormulas = formulaMatrixFromPatternCells(patterns, "formula"); + const patternFormulasR1C1 = formulaMatrixFromPatternCells(patterns, "formulaR1C1"); + const formulas = mergeFormulaMatrices(snapshotFormulas, matrixFromUnknown(isRecord(patterns) ? patterns.formulas : undefined) ?? [], patternFormulas); + const formulasR1C1 = mergeFormulaMatrices(matrixFromUnknown(isRecord(patterns) ? patterns.formulasR1C1 : undefined) ?? [], patternFormulasR1C1); + const patternMatrix = mergePatternMatrices(matrixFromUnknown(isRecord(patterns) ? patterns.patternMatrix : undefined) ?? [], patterns); + const parsed = tryParseA1Address(stripSheetName(range)); + const rowCount = Math.max(values.length, text.length, formulas.length, formulasR1C1.length, patternMatrix.length); + const columnCount = Math.max(maxMatrixColumns(values), maxMatrixColumns(text), maxMatrixColumns(formulas), maxMatrixColumns(formulasR1C1), maxMatrixColumns(patternMatrix)); + const formulaColumns = new Set(); + for (const row of [formulas, formulasR1C1].flat()) { + row.forEach((formula, index) => { if (formulaLike(formula)) formulaColumns.add(index); }); } @@ -8446,7 +9933,40 @@ function styleFromInput(input: AgentRunInput): NonNullable { + const base: NonNullable = { + fillColor: "#D9EAF7", + fontColor: "#1F2937", + fontBold: true, + horizontalAlignment: "center" + }; + if (isGroupedHeaderStyleRequest(input)) { + return { + ...base, + fillColor: "#1A3C6E", + fontColor: "#FFFFFF" + }; + } + return base; +} + +function hasExactFormatRangeTarget(input: AgentRunInput): boolean { + return typeof input.target?.range === "string" + || typeof input.target?.address === "string" + || (typeof input.target?.row === "number" && Number.isInteger(input.target.row) && input.target.row > 0); } function normalizeStyleRecord(value: unknown): NonNullable { @@ -8474,7 +9994,7 @@ function normalizeStyleRecord(value: unknown): NonNullable["borders"] : undefined; @@ -8484,6 +10004,20 @@ function normalizeStyleRecord(value: unknown): NonNullable 60) { + return width; + } + + const maxDigitPixelWidth = 7; + const padding = Math.trunc(128 / maxDigitPixelWidth); + const pixels = Math.trunc(((256 * width + padding) / 256) * maxDigitPixelWidth); + return Math.round(pixels * 0.75 * 100) / 100; +} + function styleFromRequest(request: string): NonNullable { const lower = request.toLowerCase(); const fillColor = @@ -8515,6 +10049,37 @@ function styleFromRequest(request: string): NonNullable }; } +function isGroupedHeaderStyleRequest(input: AgentRunInput): boolean { + const request = input.request.toLowerCase(); + const targetRange = stripSheetName(input.target?.range ?? input.target?.address ?? ""); + return /\b(grouped|grouping|category|top)\s+(?:header|headers|row)|\bheader\s+(?:group|grouping|category)|\brow\s*1\b/i.test(request) + || /^A1(?::[A-Z]+1)?$/i.test(targetRange); +} + +function hasExplicitFillColor( + input: AgentRunInput, + flattened: NonNullable, + structured: NonNullable +): boolean { + return flattened.fillColor !== undefined + || structured.fillColor !== undefined + || (/\b(fill|background|highlight|turn|make|set|color|colour|dark|darker|light|lighter|blue|black|white|red|green|yellow|orange|purple|gray|grey|#[0-9a-f]{6})\b/i.test(input.request) + && colorFromText(input.request.toLowerCase(), ["fill", "background", "highlight", "turn", "make", "set", "color", "colour"]) !== undefined); +} + +function groupedHeaderStyleWarnings(input: AgentRunInput, style: NonNullable): string[] { + if (!isGroupedHeaderStyleRequest(input)) { + return []; + } + if (style.fillColor === "#1A3C6E" && /\b(match|same|row\s*2|actual\s+header|column\s+header)\b/i.test(input.request)) { + return ["Grouped header row styling was kept darker than the actual column header row so row 1 remains visually distinct from row 2."]; + } + if (style.fillColor === "#D9EAF7") { + return ["Grouped header row is using the same light fill as the actual column header row; use a darker fill such as #1A3C6E to keep the hierarchy distinct."]; + } + return []; +} + function colorString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); @@ -8572,6 +10137,43 @@ function booleanValue(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } +function dataValidationEntriesFromInput( + workbookId: WorkbookId, + input: AgentRunInput, + defaultValidation: Extract["validation"] +): Array<{ + target: A1Range; + validation: Extract["validation"]; +}> { + const values = input.values as Record | undefined; + const rawInput = input as unknown as Record; + const rawEntries = Array.isArray(values?.entries) + ? values.entries + : Array.isArray(rawInput.entries) + ? rawInput.entries as unknown[] + : []; + const entries: Array<{ + target: A1Range; + validation: Extract["validation"]; + }> = []; + for (const rawEntry of rawEntries) { + if (!rawEntry || typeof rawEntry !== "object") { + continue; + } + const entry = rawEntry as Record; + const sheetName = stringValue(entry.sheetName ?? input.target?.sheetName ?? values?.sheetName); + const address = stringValue(entry.address ?? entry.range); + if (!sheetName || !address) { + continue; + } + entries.push({ + target: { workbookId, sheetName, address: unqualifiedAddress(address) }, + validation: dataValidationFromRecord(entry, defaultValidation) + }); + } + return entries; +} + function dataValidationFromInput(input: AgentRunInput): Extract["validation"] | undefined { const values = input.values as Record | undefined; const validation = values?.validation && typeof values.validation === "object" ? values.validation as Record : undefined; @@ -8592,6 +10194,41 @@ function dataValidationFromInput(input: AgentRunInput): Extract, + fallback: Extract["validation"] +): Extract["validation"] { + const validation = record.validation && typeof record.validation === "object" ? record.validation as Record : undefined; + const source = validation?.source ?? validation?.formula1 ?? record.source ?? record.options ?? record.allowedValues ?? fallback.source; + const options = Array.isArray(source) + ? source.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim()) + : typeof source === "string" + ? source.split(",").map((item) => item.trim()).filter(Boolean) + : fallback.source; + const next: Extract["validation"] = { + type: "list", + source: options + }; + const inCellDropDown = booleanValue(validation?.inCellDropDown ?? record.inCellDropDown) ?? fallback.inCellDropDown; + if (inCellDropDown !== undefined) next.inCellDropDown = inCellDropDown; + const ignoreBlanks = booleanValue(validation?.ignoreBlanks ?? record.ignoreBlanks) ?? fallback.ignoreBlanks; + if (ignoreBlanks !== undefined) next.ignoreBlanks = ignoreBlanks; + const prompt = validation?.prompt && typeof validation.prompt === "object" + ? validation.prompt as Extract["validation"]["prompt"] + : fallback.prompt; + if (prompt !== undefined) next.prompt = prompt; + const errorAlert = validation?.errorAlert && typeof validation.errorAlert === "object" + ? validation.errorAlert as Extract["validation"]["errorAlert"] + : fallback.errorAlert; + if (errorAlert !== undefined) next.errorAlert = errorAlert; + return next; +} + +function unqualifiedAddress(address: string): string { + const bangIndex = address.lastIndexOf("!"); + return bangIndex >= 0 ? address.slice(bangIndex + 1) : address; +} + function optionsFromRequest(request: string): string[] { const including = request.match(/\b(?:including|include|values?|options?|allowed values?)[:\s]+([^.;]+)/i)?.[1]; if (!including) { @@ -8693,11 +10330,13 @@ function normalizeStyleDimension(value: unknown): StyleDimension | undefined { } function styleEntriesFromInput( + metadata: WorkbookMetadata, workbookId: WorkbookId, input: AgentRunInput ): Array<{ target: A1Range; style: Extract["style"] }> { const values = input.values as Record | undefined; - const rawEntries = values?.entries; + const rawInput = input as unknown as Record; + const rawEntries = firstArrayValue(values?.entries, values?.patches, rawInput.entries, rawInput.patches); const entries: Array<{ target: A1Range; style: Extract["style"] }> = []; if (Array.isArray(rawEntries)) { for (const rawEntry of rawEntries) { @@ -8705,18 +10344,21 @@ function styleEntriesFromInput( continue; } const entry = rawEntry as Record; - const sheetName = stringValue(entry.sheetName); - const address = stringValue(entry.address ?? entry.range); + const target = entry.target && typeof entry.target === "object" ? entry.target as Record : {}; + const sheetName = stringValue(entry.sheetName ?? target.sheetName ?? input.target?.sheetName ?? values?.sheetName); + const rawAddress = stringValue(entry.address ?? entry.range ?? target.address ?? target.range); const style = entry.style && typeof entry.style === "object" ? normalizeStyleRecord(entry.style) - : normalizeStyleRecord(entry); + : normalizeStyleRecord(styleLikeValuePatchCell(entry) ?? entry); + const address = sheetName && rawAddress ? normalizeStyleTargetAddress(metadata, sheetName, rawAddress) : undefined; if (sheetName && address && Object.keys(style).length > 0) { entries.push({ target: { workbookId, sheetName, address }, style }); } } } const sheetName = stringValue(input.target?.sheetName ?? values?.sheetName); - const address = stringValue(input.target?.range ?? values?.address ?? values?.range); + const rawAddress = stringValue(input.target?.range ?? input.target?.address ?? values?.address ?? values?.range); + const address = sheetName && rawAddress ? normalizeStyleTargetAddress(metadata, sheetName, rawAddress) : undefined; const style = normalizeStyleRecord(values?.style ?? values); if (entries.length === 0 && sheetName && address && Object.keys(style).length > 0) { entries.push({ target: { workbookId, sheetName, address }, style }); @@ -8724,6 +10366,124 @@ function styleEntriesFromInput( return entries; } +function mergeEntriesFromInput( + metadata: WorkbookMetadata, + workbookId: WorkbookId, + input: AgentRunInput +): Array<{ target: A1Range; style?: Extract["style"] }> { + const values = input.values as Record | undefined; + const rawInput = input as unknown as Record; + const rawEntries = firstArrayValue(values?.mergeRanges, values?.merges, values?.entries, rawInput.mergeRanges, rawInput.merges, rawInput.entries); + const entries: Array<{ target: A1Range; style?: Extract["style"] }> = []; + if (Array.isArray(rawEntries)) { + for (const rawEntry of rawEntries) { + if (!rawEntry || typeof rawEntry !== "object") { + continue; + } + const entry = rawEntry as Record; + const target = entry.target && typeof entry.target === "object" ? entry.target as Record : {}; + const sheetName = stringValue(entry.sheetName ?? target.sheetName ?? input.target?.sheetName ?? values?.sheetName); + const rawAddress = stringValue(entry.address ?? entry.range ?? target.address ?? target.range); + const style = entry.style && typeof entry.style === "object" + ? normalizeStyleRecord(entry.style) + : normalizeStyleRecord(styleLikeValuePatchCell(entry) ?? entry); + const address = sheetName && rawAddress ? stripSheetName(rawAddress).trim() : undefined; + if (sheetName && address) { + entries.push({ + target: { workbookId, sheetName, address }, + ...(Object.keys(style).length > 0 ? { style } : {}) + }); + } + } + } + const sheetName = stringValue(input.target?.sheetName ?? values?.sheetName); + const rawAddress = stringValue(input.target?.range ?? input.target?.address ?? values?.address ?? values?.range); + if (entries.length === 0 && sheetName && rawAddress) { + const style = normalizeStyleRecord(values?.style ?? values); + entries.push({ + target: { workbookId, sheetName, address: stripSheetName(rawAddress).trim() }, + ...(Object.keys(style).length > 0 ? { style } : {}) + }); + } + return entries; +} + +function mergeEntriesFromStyleEntries( + entries: Array<{ target: A1Range; style: Extract["style"] }> +): Array<{ target: A1Range }> { + return entries + .filter((entry) => isMultiCellRange(entry.target.address)) + .map((entry) => ({ target: entry.target })); +} + +function isMultiCellRange(address: string): boolean { + const parsed = tryParseA1Address(stripSheetName(address)); + return Boolean(parsed && (parsed.endRow > parsed.startRow || parsed.endColumn > parsed.startColumn)); +} + +function shouldMergeRangesFromRequest(input: AgentRunInput): boolean { + return /\bmerge(?:d|s)?\b/i.test(input.request); +} + +function hasMergeBatchInput(input: AgentRunInput): boolean { + const values = input.values as Record | undefined; + const rawInput = input as unknown as Record; + return shouldMergeRangesFromRequest(input) + && (Array.isArray(values?.mergeRanges) + || Array.isArray(values?.merges) + || Array.isArray(rawInput.mergeRanges) + || Array.isArray(rawInput.merges)); +} + +function defaultStyleForMergeRequest(input: AgentRunInput): Extract["style"] { + if (!/\b(center|centered|centre|centred|middle|align|alignment)\b/i.test(input.request)) { + return {}; + } + return { + horizontalAlignment: "center", + verticalAlignment: "center", + wrapText: true + }; +} + +function styleLikeValuePatchCell(entry: Record): unknown { + const values = entry.values; + if (!Array.isArray(values) || values.length !== 1 || !Array.isArray(values[0]) || values[0].length !== 1) { + return undefined; + } + const cell = values[0][0]; + if (!cell || typeof cell !== "object" || Array.isArray(cell)) { + return undefined; + } + const style = normalizeStyleRecord(cell); + return Object.keys(style).length > 0 ? cell : undefined; +} + +function hasStyleBatchInput(input: AgentRunInput): boolean { + const values = input.values as Record | undefined; + const rawInput = input as unknown as Record; + return Array.isArray(values?.entries) + || Array.isArray(values?.patches) + || Array.isArray(rawInput.entries) + || Array.isArray(rawInput.patches); +} + +function firstArrayValue(...values: unknown[]): unknown[] | undefined { + return values.find((value): value is unknown[] => Array.isArray(value)); +} + +function normalizeStyleTargetAddress(metadata: WorkbookMetadata, sheetName: string, address: string): string { + const normalized = stripSheetName(address).trim(); + const wholeColumn = /^([A-Z]+):\1$/i.exec(normalized); + if (!wholeColumn) { + return normalized; + } + const used = tryParseA1Address(stripSheetName(usedRangeForSheet(metadata, sheetName))); + const endRow = Math.max(1, used?.endRow ?? 1); + const column = wholeColumn[1]!.toUpperCase(); + return `${column}1:${column}${endRow}`; +} + function rangeMetadataMethodForAction(action: AgentIntentAction | undefined): string | undefined { switch (action) { case "read_hyperlinks": @@ -8749,43 +10509,161 @@ function rangeMetadataMethodForAction(action: AgentIntentAction | undefined): st } } -function searchResultHasNoMatches(result: unknown): boolean { - if ((result as { ok?: boolean }).ok === false) { - return false; +function hasRangeMetadataReadIntent(input: AgentRunInput): boolean { + return rangeMetadataMethodForAction(intentAction(input) ?? inferredRangeMetadataReadAction(input)) !== undefined; +} + +function inferredRangeMetadataReadAction(input: AgentRunInput): AgentIntentAction | undefined { + const request = input.request.toLowerCase(); + const readVerb = /\b(read|show|check|inspect|review|tell|what|which|whether|does|do|has|have|list|find)\b/.test(request); + if (readVerb && /\b(data\s+validation|validation|dropdown|drop\s*down|select\s+list|selection\s+list|allowed values?)\b/.test(request)) { + return "read_data_validation"; } - const record = result && typeof result === "object" ? result as Record : {}; - const data = record.data && typeof record.data === "object" ? record.data as Record : record; - if (typeof data.count === "number") { - return data.count === 0; + if (readVerb && /\b(conditional\s+format|conditional\s+formatting)\b/.test(request)) { + return "read_conditional_formatting"; } - if (Array.isArray(data.matches)) { - return data.matches.length === 0; + if (readVerb && /\b(merged cells?|merge ranges?|merged ranges?)\b/.test(request)) { + return "read_merged_cells"; } - if (Array.isArray(data.areas)) { - return data.areas.length === 0; + if (readVerb && /\b(hyperlinks?|links?)\b/.test(request)) { + return "read_hyperlinks"; } - return false; + if (readVerb && /\b(comments?)\b/.test(request)) { + return "read_comments"; + } + if (readVerb && /\b(notes?)\b/.test(request)) { + return "read_notes"; + } + return undefined; } -function workflowPlanForAction(action: AgentIntentAction | undefined) { - const plans: Record = { - prepare_session: { - workflow: "excel.workflow.prepare_session", - title: "Prepare workbook session", - mutatesWorkbook: false, - steps: ["Read runtime status", "Read active workbook context", "Summarize workbook map", "Summarize collaboration state"], - requiredCapabilities: ["excel.runtime.get_status", "excel.runtime.get_active_context", "excel.runtime.get_capabilities", "excel.workbook.get_workbook_map", "excel.collab.get_status"], - continuation: "Use the returned workbookContextId for follow-up answer, preview, validation, or rollback calls.", - warnings: [] - }, +function shouldSummarizeGroupedHeader(input: AgentRunInput): boolean { + const request = input.request.toLowerCase(); + const readVerb = /\b(read|show|check|inspect|review|tell|what|which|summari[sz]e|examine|look(?:\s+at)?)\b/.test(request); + return readVerb + && /\bgroup(?:ed)?\s+headers?\b|\bheader\s+groups?\b|\bmerged\s+headers?\b/.test(request) + && !/\b(apply|update|change|set|make|merge\s+these|preview|fix|create|insert|delete|remove)\b/.test(request); +} + +function groupedHeaderSummaryRange(metadata: WorkbookMetadata, sheetName: string, normalizedRange: string, input: AgentRunInput): string { + const explicitRange = stringValue(input.target?.range ?? input.target?.address); + if (explicitRange && isSingleRowRange(explicitRange)) { + return stripSheetName(explicitRange); + } + const requestedRow = numberValue(/\brow\s*(\d+)\b/i.exec(input.request)?.[1]) ?? 1; + const parsed = tryParseA1Address(stripSheetName(normalizedRange)) + ?? tryParseA1Address(stripSheetName(usedRangeForSheet(metadata, sheetName))); + if (!parsed) { + return `A${requestedRow}:XFD${requestedRow}`; + } + return `${numberToColumn(parsed.startColumn)}${requestedRow}:${numberToColumn(parsed.endColumn)}${requestedRow}`; +} + +function isSingleRowRange(address: string): boolean { + const parsed = tryParseA1Address(stripSheetName(address)); + return Boolean(parsed && parsed.startRow === parsed.endRow); +} + +function mergedRangesFromMetadataResult(result: unknown): string[] { + const data = result && typeof result === "object" ? (result as Record).data : undefined; + if (!data || typeof data !== "object") { + return []; + } + const record = data as Record; + if (record.isNullObject === true) { + return []; + } + const address = stringValue(record.address); + if (!address) { + return []; + } + return address + .split(/\s*,\s*/) + .map((part) => stripSheetName(part).replace(/\$/g, "").trim()) + .filter((part) => part.length > 0); +} + +function groupedHeaderSpansFromSnapshot(headerRange: string, snapshot: RangeSnapshot | undefined, mergedRanges: string[]) { + return mergedRanges.map((range) => ({ + range, + label: groupedHeaderLabelForRange(headerRange, snapshot, range), + merged: true + })); +} + +function groupedHeaderUnmergedLabels(headerRange: string, snapshot: RangeSnapshot | undefined, mergedRanges: string[]) { + const parsedHeader = tryParseA1Address(stripSheetName(headerRange)); + if (!parsedHeader) { + return []; + } + const covered = new Set(); + for (const range of mergedRanges) { + const parsed = tryParseA1Address(stripSheetName(range)); + if (!parsed) continue; + for (let column = parsed.startColumn; column <= parsed.endColumn; column += 1) { + covered.add(column); + } + } + const textRow = (snapshot?.text ?? snapshot?.values ?? [])[0] ?? []; + const labels: Array<{ cell: string; label: unknown; merged: false }> = []; + for (let column = parsedHeader.startColumn; column <= parsedHeader.endColumn; column += 1) { + if (covered.has(column)) continue; + const offset = column - parsedHeader.startColumn; + const value = textRow[offset]; + if (value !== undefined && value !== null && String(value).trim().length > 0) { + labels.push({ cell: `${numberToColumn(column)}${parsedHeader.startRow}`, label: value, merged: false }); + } + } + return labels; +} + +function groupedHeaderLabelForRange(headerRange: string, snapshot: RangeSnapshot | undefined, range: string): unknown { + const parsedHeader = tryParseA1Address(stripSheetName(headerRange)); + const parsedRange = tryParseA1Address(stripSheetName(range)); + if (!parsedHeader || !parsedRange) { + return undefined; + } + const offset = parsedRange.startColumn - parsedHeader.startColumn; + return (snapshot?.text?.[0]?.[offset] ?? snapshot?.values?.[0]?.[offset]) as unknown; +} + +function searchResultHasNoMatches(result: unknown): boolean { + if ((result as { ok?: boolean }).ok === false) { + return false; + } + const record = result && typeof result === "object" ? result as Record : {}; + const data = record.data && typeof record.data === "object" ? record.data as Record : record; + if (typeof data.count === "number") { + return data.count === 0; + } + if (Array.isArray(data.matches)) { + return data.matches.length === 0; + } + if (Array.isArray(data.areas)) { + return data.areas.length === 0; + } + return false; +} + +function workflowPlanForAction(action: AgentIntentAction | undefined) { + const plans: Record = { + prepare_session: { + workflow: "excel.workflow.prepare_session", + title: "Prepare workbook session", + mutatesWorkbook: false, + steps: ["Read runtime status", "Read active workbook context", "Summarize workbook map", "Summarize collaboration state"], + requiredCapabilities: ["excel.runtime.get_status", "excel.runtime.get_active_context", "excel.runtime.get_capabilities", "excel.workbook.get_workbook_map", "excel.collab.get_status"], + continuation: "Use the returned workbookContextId for follow-up answer, preview, validation, or rollback calls.", + warnings: [] + }, create_formula_sheet: { workflow: "excel.workflow.create_formula_sheet", title: "Create formula sheet", @@ -9141,6 +11019,80 @@ function normalizeOperationRange(metadata: WorkbookMetadata, sheetName: string, return range; } +function structuralRangeOperationKind( + input: AgentRunInput, + resolved: Extract, + requestedKind: RangeStructuralOperationKind +): RangeStructuralOperationKind { + if (!isRowColumnStructuralKind(requestedKind)) { + return requestedKind; + } + const requestedRow = requestMentionsStructuralRows(input.request) || input.target?.row !== undefined; + const requestedColumn = requestMentionsStructuralColumns(input.request) || input.target?.column !== undefined; + const shape = structuralAddressShape(input.target?.range ?? input.target?.address ?? resolved.range); + if (requestedRow && !requestedColumn) { + return requestedKind.includes("insert") ? "range.insert_rows" : "range.delete_rows"; + } + if (requestedColumn && !requestedRow) { + return requestedKind.includes("insert") ? "range.insert_columns" : "range.delete_columns"; + } + if (shape === "row" && requestedKind.endsWith("_columns")) { + return requestedKind.includes("insert") ? "range.insert_rows" : "range.delete_rows"; + } + if (shape === "column" && requestedKind.endsWith("_rows")) { + return requestedKind.includes("insert") ? "range.insert_columns" : "range.delete_columns"; + } + return requestedKind; +} + +function intentStructuralOperationKind(input: AgentRunInput): RangeStructuralOperationKind | undefined { + switch (input.intent?.action) { + case "insert_rows": + return "range.insert_rows"; + case "delete_rows": + return "range.delete_rows"; + case "insert_columns": + return "range.insert_columns"; + case "delete_columns": + return "range.delete_columns"; + default: + return undefined; + } +} + +function isRowColumnStructuralKind(kind: RangeStructuralOperationKind): boolean { + return kind === "range.insert_rows" || kind === "range.delete_rows" || kind === "range.insert_columns" || kind === "range.delete_columns"; +} + +function requestMentionsStructuralRows(request: string): boolean { + return /\b(?:insert|delete|remove|drop)\b.*\brows?\b|\brows?\b.*\b(?:insert|delete|remove|drop)\b|\b(?:this|selected|active|current)\s+row\b/i.test(request); +} + +function requestMentionsStructuralColumns(request: string): boolean { + return /\b(?:insert|delete|remove|drop)\b.*\b(?:cols?|columns?)\b|\b(?:cols?|columns?)\b.*\b(?:insert|delete|remove|drop)\b|\b(?:this|selected|active|current)\s+col(?:umn)?\b/i.test(request); +} + +function structuralAddressShape(address: string): "row" | "column" | undefined { + const normalized = stripSheetName(address).replace(/\$/g, "").replace(/\s+/g, "").toUpperCase(); + if (/^\d+:\d+$/.test(normalized)) { + return "row"; + } + if (/^[A-Z]+:[A-Z]+$/.test(normalized)) { + return "column"; + } + const parsed = tryParseA1Address(normalized); + if (!parsed) { + return undefined; + } + if (parsed.startRow === parsed.endRow && parsed.startColumn !== parsed.endColumn) { + return "row"; + } + if (parsed.startColumn === parsed.endColumn && parsed.startRow !== parsed.endRow) { + return "column"; + } + return undefined; +} + interface SimilarRowRange { sheetName: string; range: string; @@ -9667,6 +11619,29 @@ function tableFromResolution(metadata: WorkbookMetadata, resolved: Extract table.sheetName === resolved.sheetName && table.range === resolved.range); } +function explicitlyRequestedTable(metadata: WorkbookMetadata, input: AgentRunInput): TableMetadata | undefined { + if (!/\btables?\b/i.test(input.request) && !input.target?.tableName) { + return undefined; + } + const requestedName = input.target?.tableName ? normalizeComparableText(input.target.tableName) : undefined; + const request = normalizeComparableText(input.request); + return metadata.tables.find((table) => { + const name = normalizeComparableText(table.name ?? table.id); + return name !== "" && (requestedName === name || request.includes(name)); + }); +} + +function requestExplicitlyNamesTable(input: AgentRunInput, table: TableMetadata): boolean { + if (input.target?.tableName) { + return normalizeComparableText(input.target.tableName) === normalizeComparableText(table.name ?? table.id); + } + if (!/\btables?\b/i.test(input.request)) { + return false; + } + const name = normalizeComparableText(table.name ?? table.id); + return name !== "" && normalizeComparableText(input.request).includes(name); +} + function tableRowUpdatesFromInput(input: AgentRunInput): TableUpdateRowsRequest["rows"] { const raw = input.values?.rows; if (!Array.isArray(raw)) { @@ -10246,6 +12221,29 @@ function minimalAnswerForBudget(answer: unknown, continuation: AgentRunOutput["c if (typed.kind === "style_reference_candidates") { return compactStyleReferenceCandidatesAnswer(typed, continuation?.resultUri, continuation?.fullResultUri); } + if (typed.kind === "style_overview") { + return compactStyleOverviewAnswer(typed, continuation?.resultUri, continuation?.fullResultUri); + } + if (typed.kind === "grouped_header_summary") { + return stripUndefinedRecord({ + kind: typed.kind, + sheetName: typed.sheetName, + range: typed.range, + mergedRangeCount: typed.mergedRangeCount, + mergeStatus: typed.mergeStatus, + spans: Array.isArray(typed.spans) ? typed.spans.slice(0, 12) : undefined, + unmergedLabels: Array.isArray(typed.unmergedLabels) ? typed.unmergedLabels.slice(0, 12) : undefined, + resultUri: typed.resultUri ?? continuation?.resultUri, + fullResultUri: typed.fullResultUri ?? continuation?.fullResultUri, + resource: continuation?.resultUri ?? (workbookContextId ? contextResource(String(workbookContextId)).uri : undefined) + }); + } + if (typed.kind === "workbook_design_overview") { + return compactWorkbookDesignOverviewAnswer(typed, continuation?.resultUri, continuation?.fullResultUri); + } + if (typed.kind === "visual_readability_preview") { + return compactVisualReadabilityPreviewAnswer(typed, continuation?.resultUri, continuation?.fullResultUri); + } if (typed.kind === "reference_sheet_analysis") { return stripUndefinedRecord({ kind: typed.kind, @@ -10650,6 +12648,12 @@ function compactAnswerForResponseMode(answer: unknown, responseMode: AgentRespon if (kind === "style_summary") { return compactStyleSummaryAnswer(typed, resultUri, fullResultUri); } + if (kind === "style_overview") { + return compactStyleOverviewAnswer(typed, resultUri, fullResultUri); + } + if (kind === "workbook_design_overview") { + return compactWorkbookDesignOverviewAnswer(typed, resultUri, fullResultUri); + } if (kind === "similar_rows") { return compactSimilarRowsAnswer(typed, resultUri, fullResultUri); } @@ -10665,6 +12669,9 @@ function compactAnswerForResponseMode(answer: unknown, responseMode: AgentRespon if (kind === "formula_patterns") { return compactFormulaPatternsAnswer(typed, responseMode, resultUri, fullResultUri); } + if (kind === "visual_readability_preview") { + return compactVisualReadabilityPreviewAnswer(typed, resultUri, fullResultUri); + } return compactGenericAnswer(typed, resultUri, fullResultUri); } @@ -11250,6 +13257,59 @@ function compactStyleSummaryAnswer(answer: Record, resultUri?: }); } +function compactStyleOverviewAnswer(answer: Record, resultUri?: string, fullResultUri?: string): Record { + const groupedHeaderSuggestion = answer.groupedHeaderSuggestion && typeof answer.groupedHeaderSuggestion === "object" + ? answer.groupedHeaderSuggestion as Record + : undefined; + return stripUndefinedRecord({ + kind: answer.kind, + source: answer.source, + sheetName: answer.sheetName, + range: answer.range, + freezePanes: answer.freezePanes, + table: answer.table, + detected: answer.detected, + currentStyle: answer.currentStyle, + columnRoles: Array.isArray(answer.columnRoles) ? answer.columnRoles.slice(0, 16) : undefined, + columnGroupSuggestions: Array.isArray(answer.columnGroupSuggestions) ? answer.columnGroupSuggestions.slice(0, 8) : undefined, + groupedHeaderSuggestion: groupedHeaderSuggestion ? stripUndefinedRecord({ + kind: groupedHeaderSuggestion.kind, + targetHeaderRange: groupedHeaderSuggestion.targetHeaderRange, + levels: groupedHeaderSuggestion.levels, + insertAboveHeader: groupedHeaderSuggestion.insertAboveHeader, + styleExistingHeader: groupedHeaderSuggestion.styleExistingHeader, + requiresStructuralPreview: groupedHeaderSuggestion.requiresStructuralPreview, + defaultApplyBehavior: groupedHeaderSuggestion.defaultApplyBehavior, + groups: Array.isArray(groupedHeaderSuggestion.groups) ? groupedHeaderSuggestion.groups.slice(0, 8) : undefined, + operationsNeeded: groupedHeaderSuggestion.operationsNeeded + }) : undefined, + recommendations: Array.isArray(answer.recommendations) ? answer.recommendations.slice(0, 8) : undefined, + recommendedWorkflow: answer.recommendedWorkflow, + resultUri, + fullResultUri + }); +} + +function compactWorkbookDesignOverviewAnswer(answer: Record, resultUri?: string, fullResultUri?: string): Record { + return stripUndefinedRecord({ + kind: answer.kind, + source: answer.source, + workbook: answer.workbook, + sheet: answer.sheet, + table: answer.table, + target: answer.target, + dataState: answer.dataState, + inspectionPolicy: answer.inspectionPolicy, + relatedSheets: Array.isArray(answer.relatedSheets) ? answer.relatedSheets.slice(0, 6) : undefined, + columnRecommendations: Array.isArray(answer.columnRecommendations) ? answer.columnRecommendations.slice(0, 40) : undefined, + groupSuggestions: Array.isArray(answer.groupSuggestions) ? answer.groupSuggestions.slice(0, 8) : undefined, + summary: answer.summary, + nextWorkflows: Array.isArray(answer.nextWorkflows) ? answer.nextWorkflows.slice(0, 5) : undefined, + resultUri, + fullResultUri + }); +} + function compactStyleDimension(value: unknown): unknown { if (!value || typeof value !== "object") { return value; @@ -11281,6 +13341,67 @@ function compactStyleDimension(value: unknown): unknown { return Object.keys(compact).length > 0 ? compact : { truncated: true, fullBytes: Buffer.byteLength(full) }; } +function compactVisualReadabilityPreviewAnswer(answer: Record, resultUri?: string, fullResultUri?: string): Record { + const visualPlan = answer.visualPlan && typeof answer.visualPlan === "object" ? answer.visualPlan as Record : undefined; + const detected = answer.detected && typeof answer.detected === "object" ? answer.detected as Record : undefined; + const rules = Array.isArray(visualPlan?.rules) ? visualPlan.rules as Array> : []; + const ruleIds = Array.isArray(visualPlan?.ruleIds) + ? visualPlan.ruleIds.filter((id) => typeof id === "string").slice(0, 32) + : rules.slice(0, 32).map((rule) => rule.id).filter((id) => typeof id === "string"); + const groupedHeaderSuggestion = answer.groupedHeaderSuggestion && typeof answer.groupedHeaderSuggestion === "object" ? answer.groupedHeaderSuggestion as Record : undefined; + return stripUndefinedRecord({ + kind: answer.kind, + action: answer.action, + sheetName: answer.sheetName, + range: answer.range, + defaults: answer.defaults, + sheetType: answer.sheetType, + detected: detected ? stripUndefinedRecord({ + sheetName: detected.sheetName, + usedRange: detected.usedRange, + headerRow: detected.headerRow, + headerRange: detected.headerRange, + dataRange: detected.dataRange, + tableRanges: detected.tableRanges, + hasFilter: detected.hasFilter, + hasFreezePane: detected.hasFreezePane, + formulaColumns: detected.formulaColumns, + existingStyleRanges: detected.existingStyleRanges, + detectionSource: detected.detectionSource, + confidence: detected.confidence + }) : undefined, + columnRoles: Array.isArray(answer.columnRoles) ? answer.columnRoles.slice(0, 12) : undefined, + groupedHeaderSuggestion: groupedHeaderSuggestion ? stripUndefinedRecord({ + kind: groupedHeaderSuggestion.kind, + targetHeaderRange: groupedHeaderSuggestion.targetHeaderRange, + requiresStructuralPreview: groupedHeaderSuggestion.requiresStructuralPreview, + defaultApplyBehavior: groupedHeaderSuggestion.defaultApplyBehavior, + groups: Array.isArray(groupedHeaderSuggestion.groups) ? groupedHeaderSuggestion.groups.slice(0, 8) : undefined, + operationsNeeded: groupedHeaderSuggestion.operationsNeeded + }) : undefined, + visualPlan: visualPlan ? stripUndefinedRecord({ + compilerStatus: visualPlan.compilerStatus, + summary: visualPlan.summary, + counts: visualPlan.counts, + ruleScopes: visualPlan.ruleScopes, + ruleIds, + validationSuggestions: Array.isArray(visualPlan.validationSuggestions) ? visualPlan.validationSuggestions.slice(0, 6) : undefined, + formulaSuggestions: Array.isArray(visualPlan.formulaSuggestions) ? visualPlan.formulaSuggestions.slice(0, 6) : undefined, + referenceStyleSuggestions: Array.isArray(visualPlan.referenceStyleSuggestions) ? visualPlan.referenceStyleSuggestions.slice(0, 6) : undefined, + printSuggestions: Array.isArray(visualPlan.printSuggestions) ? visualPlan.printSuggestions.slice(0, 6) : undefined, + previewExamples: Array.isArray(visualPlan.previewExamples) ? visualPlan.previewExamples.slice(0, 5) : undefined, + theme: visualPlan.theme, + operationId: visualPlan.operationId, + operationCount: visualPlan.operationCount, + skipped: Array.isArray(visualPlan.skipped) ? visualPlan.skipped.slice(0, 8) : undefined, + risk: visualPlan.risk, + preservation: visualPlan.preservation + }) : undefined, + resultUri, + fullResultUri + }); +} + function compactGenericAnswer(answer: Record, resultUri?: string, fullResultUri?: string): Record { const next = { ...answer }; for (const key of ["headers", "values", "formulas", "text", "numberFormat", "sample", "sparseRows", "rows", "emptySummary", "schema", "profile"]) { @@ -11850,6 +13971,59 @@ function workbookLocalConfigOptionsFromInput(input: AgentRunInput): { includePer return typeof values?.includePermissions === "boolean" ? { includePermissions: values.includePermissions } : {}; } +function permissionUpdateFromInput(input: AgentRunInput, workbookId: WorkbookId): Partial { + const values = input.values ?? {}; + const nested = typeof values.permissions === "object" && values.permissions !== null && !Array.isArray(values.permissions) + ? values.permissions as Record + : {}; + const valueFor = (key: keyof PermissionState | "scopeToWorkbook") => nested[key] ?? values[key]; + const update: Partial = {}; + const allowWrites = booleanValue(valueFor("allowWrites")); + if (allowWrites !== undefined) update.allowWrites = allowWrites; + const allowDestructiveActions = booleanValue(valueFor("allowDestructiveActions")); + if (allowDestructiveActions !== undefined) update.allowDestructiveActions = allowDestructiveActions; + const allowWorkbookActions = booleanValue(valueFor("allowWorkbookActions")); + if (allowWorkbookActions !== undefined) update.allowWorkbookActions = allowWorkbookActions; + const allowMacroExecution = booleanValue(valueFor("allowMacroExecution")); + if (allowMacroExecution !== undefined) update.allowMacroExecution = allowMacroExecution; + const requireConfirmationFor = destructiveLevelsFromUnknown(valueFor("requireConfirmationFor")); + if (requireConfirmationFor) update.requireConfirmationFor = requireConfirmationFor; + const scope = permissionScopeFromUnknown(valueFor("scope")); + if (scope) { + update.scope = scope; + } else if (booleanValue(valueFor("scopeToWorkbook")) === true) { + update.scope = { workbookId }; + } + return update; +} + +function destructiveLevelsFromUnknown(value: unknown): PermissionState["requireConfirmationFor"] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const levels = value.filter((item): item is PermissionState["requireConfirmationFor"][number] => + item === "none" || item === "values" || item === "format" || item === "structure" || item === "workbook" + ); + return [...new Set(levels)]; +} + +function permissionScopeFromUnknown(value: unknown): PermissionState["scope"] | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + const scope: PermissionState["scope"] = {}; + const scopedWorkbookId = stringValue(record.workbookId); + if (scopedWorkbookId) scope.workbookId = scopedWorkbookId as WorkbookId; + if (Array.isArray(record.sheetNames) && record.sheetNames.every((item) => typeof item === "string")) { + scope.sheetNames = [...record.sheetNames]; + } + if (Array.isArray(record.regionNames) && record.regionNames.every((item) => typeof item === "string")) { + scope.regionNames = [...record.regionNames]; + } + return Object.keys(scope).length > 0 ? scope : undefined; +} + function workbookLocalConfigImportRequestFromInput(metadata: WorkbookMetadata, input: AgentRunInput): WorkbookLocalConfigImportRequest | undefined { const values = input.values as Record | undefined; const config = values?.config; @@ -12313,7 +14487,7 @@ function broadMutationNeedsScopeOutput( ): Omit | undefined { const targetCells = cellCountFromAddress(resolved.range) ?? 0; const writeCells = matrixCellCount(matrix); - const backendSideAction = ["transform_values", "derive_values", "settle_reconciliation"].includes(intentAction(input) ?? ""); + const backendSideAction = ["transform_values", "derive_values", "settle_reconciliation", "grouped_header"].includes(intentAction(input) ?? ""); if (!isPotentialBroadMutationRequest(input) || backendSideAction || targetCells < 500 || writeCells >= targetCells) { return undefined; } @@ -12346,7 +14520,7 @@ function broadMutationExplicitTargetNeedsScopeOutput( const sheetName = input.target?.sheetName; const range = input.target?.range; const targetCells = typeof range === "string" ? cellCountFromAddress(range) : undefined; - const backendSideAction = ["transform_values", "derive_values", "settle_reconciliation"].includes(intentAction(input) ?? ""); + const backendSideAction = ["transform_values", "derive_values", "settle_reconciliation", "grouped_header"].includes(intentAction(input) ?? ""); if (!isPotentialBroadMutationRequest(input) || backendSideAction || !sheetName || !range || targetCells === undefined || targetCells < 500) { return undefined; } @@ -12800,6 +14974,13 @@ function shouldPreviewReplaceStyledTable(input: AgentRunInput): boolean { ); } +function shouldPreviewGroupedHeader(input: AgentRunInput): boolean { + const values = input.values as Record | undefined; + return Boolean(values?.groupedHeader) + || /\b(?:grouped?|multi[-\s]?level|two[-\s]?layer|2[-\s]?layer|higher[-\s]?level)\b/i.test(input.request) + && /\b(?:headers?|heading|column groups?|merge|merged|band|bands?)\b/i.test(input.request); +} + function replaceStyledTablePlanFromInput(metadata: WorkbookMetadata, input: AgentRunInput): { sheetName: string; writeRange: string; @@ -12843,7 +15024,7 @@ function replaceStyledTablePlanFromInput(metadata: WorkbookMetadata, input: Agen values: matrix, preserveFormats: true }); - if (values?.autofit !== false) { + if (shouldAutofitReplacementColumns(input, values)) { operations.push({ kind: "range.autofit_columns", operationId: makeId("op"), @@ -12867,6 +15048,14 @@ function replaceStyledTablePlanFromInput(metadata: WorkbookMetadata, input: Agen return { sheetName, writeRange, matrix, clearRanges, operations, styleCopies, changes }; } +function shouldAutofitReplacementColumns(input: AgentRunInput, values: Record | undefined): boolean { + const explicit = booleanValue(values?.autofit ?? values?.autoFit ?? values?.autofitColumns ?? values?.autoFitColumns); + if (explicit !== undefined) { + return explicit; + } + return /\b(?:autofit|auto\s*fit)\b/i.test(input.request); +} + function clearRangesFromReplaceInput(metadata: WorkbookMetadata, input: AgentRunInput, sheetName: string, writeRange: string): string[] { const values = input.values as Record | undefined; const rawRanges = values?.clearRanges; @@ -13702,6 +15891,1510 @@ function sameText(left: unknown, right: unknown): boolean { return typeof left === "string" && typeof right === "string" && left.trim().toLowerCase() === right.trim().toLowerCase(); } +type VisualReadabilityStyleDepth = "basic" | "standard" | "comprehensive"; +type VisualReadabilityDensity = "compact" | "comfortable" | "presentation"; +type VisualReadabilityPresentationMode = "working_sheet" | "print_ready" | "executive_report"; +type VisualReadabilitySheetType = "generic_table" | "tabular_data" | "record_tracker" | "numeric_report" | "input_template" | "summary_report" | "mixed_template" | "unknown"; +type VisualReadabilitySuggestionBucket = "layout" | "validation" | "reference_style" | "formula_helpers" | "structure" | "freeze_panes" | "print_settings"; +type VisualReadabilityStylePreservationMode = "protected_regions" | "strict" | "none"; + +interface VisualReadabilityDetectedStructure { + sheetName: string; + usedRange?: string; + headerRow?: number; + headerRange?: string; + dataRange?: string; + tableRanges: string[]; + hasFilter: boolean; + hasFreezePane: boolean; + mergedRanges: string[]; + hiddenRows: number[]; + hiddenColumns: string[]; + protectedRanges: string[]; + existingStyleRanges: string[]; + protectedStyleRanges: string[]; + formulaColumns: string[]; + formulaRanges: string[]; + totalRows: number[]; + subtotalRows: number[]; + existingConditionalFormattingRanges: string[]; + existingDataValidationRanges: string[]; + detectionSource: "metadata"; + confidence: number; +} + +interface VisualReadabilityColumnRole { + column: string; + header: string; + role: string; + inferredType: string; + confidence: number; + signals: string[]; +} + +type VisualRuleScope = "sheet" | "table" | "group" | "column" | "conditional_range" | "row" | "cell"; +type VisualRuleKind = "font" | "fill" | "border" | "alignment" | "number_format" | "width" | "height" | "wrap" | "freeze" | "filter"; + +interface VisualReadabilityRule { + id: string; + scope: VisualRuleScope; + target: string; + kind: VisualRuleKind; + value: unknown; + risk: "low" | "medium" | "high"; + reason: string; +} + +interface VisualReadabilityValidationSuggestion { + id: string; + target: string; + source: string[]; + risk: "medium" | "high"; + reason: string; + existingValidation: "preserved" | "not_detected"; +} + +interface VisualReadabilityFormulaSuggestion { + id: string; + suggestedColumnAfter: string; + formulaName: string; + formulaExample: string; + risk: "medium" | "high"; + reason: string; +} + +interface VisualReadabilityReferenceStyleOption { + sheetName: string; + adaptToTargetStructure: boolean; + preserveTargetValues: boolean; + preserveFormulas: boolean; +} + +interface VisualReadabilityReferenceStyleSuggestion { + id: string; + referenceSheetName: string; + target: string; + pattern: string; + risk: "medium" | "high"; + reason: string; + preserveTargetValues: true; + preserveFormulas: boolean; +} + +interface VisualReadabilityPrintSuggestion { + id: string; + target: string; + setting: string; + value: string; + presentationMode: VisualReadabilityPresentationMode; + risk: "medium" | "high"; + reason: string; +} + +interface VisualReadabilityPlanPreview { + compilerStatus: "preview_compiled_apply_pending"; + summary: string[]; + counts: { + totalRules: number; + layoutChanges: number; + groupRules: number; + columnRules: number; + conditionalRules: number; + rowRules: number; + cellRules: number; + validationSuggestions: number; + formulaSuggestions: number; + referenceStyleSuggestions: number; + printSuggestions: number; + }; + ruleScopes: Record; + rules: VisualReadabilityRule[]; + validationSuggestions: VisualReadabilityValidationSuggestion[]; + formulaSuggestions: VisualReadabilityFormulaSuggestion[]; + referenceStyleSuggestions: VisualReadabilityReferenceStyleSuggestion[]; + printSuggestions: VisualReadabilityPrintSuggestion[]; + previewExamples: Array<{ range: string; before: string; after: string; ruleId: string }>; + theme: { + name: string; + font: { family: string; bodySize: number; headerSize: number }; + density: VisualReadabilityDensity; + }; +} + +interface VisualReadabilityCompiledOperationSet { + operations: ExcelOperation[]; + skipped: Array<{ ruleId: string; target: string; reason: string }>; +} + +interface VisualReadabilityPreservationContext { + protectedRanges: string[]; + mergedRanges: string[]; + hiddenColumns: string[]; + existingStyleRanges: string[]; + protectedStyleRanges: string[]; + existingConditionalFormattingRanges: string[]; +} + +function visualReadabilityOptionsFromInput(input: AgentRunInput): { + styleDepth: VisualReadabilityStyleDepth; + profile: string; + density: VisualReadabilityDensity; + preserveFormulas: boolean; + preserveExistingStyle: boolean; + stylePreservationMode: VisualReadabilityStylePreservationMode; + allowValidationSuggestions: boolean; + allowFormulaSuggestions: boolean; + allowReplaceConditionalFormatting: boolean; + allowReplaceDataValidation: boolean; + allowInsertRowsOrColumns: boolean; + applySuggestionBuckets: VisualReadabilitySuggestionBucket[]; + freezePanes?: { rows?: number; columns?: number }; + referenceStyle?: VisualReadabilityReferenceStyleOption; + presentationMode?: VisualReadabilityPresentationMode; +} { + const values = input.values ?? {}; + const nested = typeof values.visualReadability === "object" && values.visualReadability !== null && !Array.isArray(values.visualReadability) + ? values.visualReadability as Record + : {}; + const optionValue = (key: string) => nested[key] ?? values[key]; + const referenceStyle = visualReadabilityReferenceStyle(optionValue("referenceStyle") ?? values.referenceStyle); + const presentationMode = visualReadabilityPresentationMode(optionValue("presentationMode")); + const applySuggestionBuckets = visualReadabilitySuggestionBuckets(optionValue("applySuggestionBuckets")); + const freezePanes = visualReadabilityFreezePanes(optionValue("freezePanes"), optionValue("freezeRows"), optionValue("freezeColumns"), input.request); + const preserveExistingStyle = optionValue("preserveExistingStyle"); + return { + styleDepth: visualReadabilityStyleDepth(optionValue("styleDepth")), + profile: stringValue(optionValue("profile")) ?? "auto", + density: visualReadabilityDensity(optionValue("density")), + preserveFormulas: booleanValue(optionValue("preserveFormulas")) ?? true, + preserveExistingStyle: booleanValue(preserveExistingStyle) ?? true, + stylePreservationMode: visualReadabilityStylePreservationMode(optionValue("stylePreservationMode"), preserveExistingStyle), + allowValidationSuggestions: booleanValue(optionValue("allowValidationSuggestions")) ?? false, + allowFormulaSuggestions: booleanValue(optionValue("allowFormulaSuggestions")) ?? false, + allowReplaceConditionalFormatting: booleanValue(optionValue("allowReplaceConditionalFormatting")) ?? false, + allowReplaceDataValidation: booleanValue(optionValue("allowReplaceDataValidation")) ?? false, + allowInsertRowsOrColumns: booleanValue(optionValue("allowInsertRowsOrColumns")) ?? false, + applySuggestionBuckets, + ...(freezePanes ? { freezePanes } : {}), + ...(referenceStyle ? { referenceStyle } : {}), + ...(presentationMode ? { presentationMode } : {}) + }; +} + +function visualReadabilityDetectionFailure( + metadata: WorkbookMetadata, + requestedMode: AgentRunMode, + sheetName: string, + range: string | undefined, + detected: VisualReadabilityDetectedStructure +): Omit | undefined { + if (detected.hiddenColumns.includes("sheet")) { + return { + status: "VALIDATION_FAILED", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: `Visual readability preview blocked ${sheetName} because the sheet is hidden.`, + proof: range ? [{ sheetName, range, label: "hidden visual readability target" }] : [], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "ask_user", + warnings: ["Unhide the sheet or explicitly target a visible sheet before applying visual readability styling."] + }; + } + const targetCellCount = range ? cellCountFromAddress(stripSheetName(range)) : undefined; + if (targetCellCount !== undefined && targetCellCount > 200_000) { + return { + status: "VALIDATION_FAILED", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: `Visual readability preview blocked ${sheetName}${range ? `!${range}` : ""} because the target is too large for a safe first-pass style plan.`, + proof: range ? [{ sheetName, range, label: "oversized visual readability target" }] : [], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "ask_user", + warnings: ["Retry with a smaller table/range or select the header and data range to style."] + }; + } + if (!detected.headerRange || detected.headerRow === undefined) { + return { + status: "NEEDS_INPUT", + mode: requestedMode, + workbookContextId: metadata.workbookContextId, + summary: "Could not confidently detect a header row for visual readability styling.", + proof: range ? [{ sheetName, range, label: "ambiguous visual readability target" }] : [], + resourceLinks: [contextResource(metadata.workbookContextId)], + nextAction: "ask_user", + warnings: [ + "Select the table/header range and retry, or provide target.range with the header row included.", + "No visual styling operations were prepared." + ] + }; + } + return undefined; +} + +function visualReadabilityFormulaCheckRanges(detected: VisualReadabilityDetectedStructure): string[] { + const ranges = detected.formulaRanges.length > 0 + ? detected.formulaRanges + : detected.formulaColumns.map((column) => columnTargetFromDetected(detected, column)); + return uniqueDefined(ranges).slice(0, 12); +} + +function compileVisualReadabilityOperations( + workbookId: WorkbookId, + sheetName: string, + rules: VisualReadabilityRule[], + validationSuggestions: VisualReadabilityValidationSuggestion[], + options: { + preserveExistingStyle: boolean; + stylePreservationMode: VisualReadabilityStylePreservationMode; + allowReplaceConditionalFormatting: boolean; + allowReplaceDataValidation: boolean; + applySuggestionBuckets: VisualReadabilitySuggestionBucket[]; + preservation: VisualReadabilityPreservationContext; + } +): VisualReadabilityCompiledOperationSet { + const styleEntries: Array<{ target: A1Range; style: NonNullable; preserveValues: true }> = []; + const numberFormatEntries: Array<{ target: A1Range; numberFormat: string[][]; preserveValues: true }> = []; + const operations: ExcelOperation[] = []; + const skipped: VisualReadabilityCompiledOperationSet["skipped"] = []; + const targetFor = (rule: VisualReadabilityRule): A1Range => ({ workbookId, sheetName, address: rule.target }); + const bucketEnabled = (bucket: VisualReadabilitySuggestionBucket) => options.applySuggestionBuckets.includes(bucket); + const freezePanes: { rows?: number; columns?: number; reasons: string[] } = { reasons: [] }; + for (const rule of rules) { + const retargetedRule = visualRuleWithPreservationSafeTarget(rule, options); + if (!retargetedRule) { + skipped.push({ ruleId: rule.id, target: rule.target, reason: "Target is fully inside a protected summary/template style area and was skipped." }); + continue; + } + const preservationSkip = visualRulePreservationSkip(retargetedRule, options); + if (preservationSkip) { + skipped.push(preservationSkip); + continue; + } + if (retargetedRule.kind === "width") { + const width = visualRuleWidth(retargetedRule.value); + if (width === undefined) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Width rule did not include a supported width value." }); + } else { + styleEntries.push({ target: targetFor(retargetedRule), style: { columnWidth: width }, preserveValues: true }); + } + continue; + } + if (retargetedRule.kind === "alignment") { + const alignment = visualRuleAlignment(retargetedRule.value); + if (Object.keys(alignment).length === 0) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Alignment rule did not include supported alignment values." }); + } else { + styleEntries.push({ target: targetFor(retargetedRule), style: alignment, preserveValues: true }); + } + continue; + } + if (retargetedRule.kind === "wrap") { + if (!bucketEnabled("layout")) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Wrap text is actionable through the layout bucket; no layout bucket was requested." }); + } else { + styleEntries.push({ target: targetFor(retargetedRule), style: { wrapText: booleanValue(retargetedRule.value) ?? true }, preserveValues: true }); + } + continue; + } + if (retargetedRule.kind === "height") { + const rowHeight = visualRuleHeight(retargetedRule.value); + if (!bucketEnabled("layout")) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Row height is actionable through the layout bucket; no layout bucket was requested." }); + } else if (rowHeight === undefined) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Height rule did not include a supported row height value." }); + } else { + styleEntries.push({ target: targetFor(retargetedRule), style: { rowHeight }, preserveValues: true }); + } + continue; + } + if (retargetedRule.kind === "number_format") { + const format = stringValue(retargetedRule.value); + const matrix = format ? repeatedNumberFormatMatrix(retargetedRule.target, format) : undefined; + if (!matrix) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Number-format rule did not include a supported format or bounded target." }); + } else { + numberFormatEntries.push({ target: targetFor(retargetedRule), numberFormat: matrix, preserveValues: true }); + } + continue; + } + if (retargetedRule.kind === "fill" || retargetedRule.kind === "font" || retargetedRule.kind === "border") { + if (retargetedRule.scope === "conditional_range") { + const conditionalRule = visualConditionalFormattingRule(retargetedRule); + if (!conditionalRule) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Conditional rule could not be expressed as a custom formula." }); + } else if (options.preserveExistingStyle && !options.allowReplaceConditionalFormatting) { + operations.push({ + kind: "range.write_conditional_formatting", + operationId: makeId("op"), + workbookId, + destructiveLevel: "format", + reason: retargetedRule.reason, + target: targetFor(retargetedRule), + rule: conditionalRule + }); + } else { + operations.push({ + kind: "range.write_conditional_formatting", + operationId: makeId("op"), + workbookId, + destructiveLevel: "format", + reason: retargetedRule.reason, + target: targetFor(retargetedRule), + rule: conditionalRule + }); + } + } else { + const style = visualRuleStyle(retargetedRule); + if (Object.keys(style).length === 0) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Style rule did not include supported style properties." }); + } else { + styleEntries.push({ target: targetFor(retargetedRule), style, preserveValues: true }); + } + } + continue; + } + if (retargetedRule.kind === "filter") { + skipped.push({ + ruleId: retargetedRule.id, + target: retargetedRule.target, + reason: "Filter affordance is already provided by the detected Excel table and was preserved without reapplying worksheet AutoFilter." + }); + continue; + } + if (retargetedRule.kind === "freeze") { + if (!bucketEnabled("freeze_panes")) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Freeze panes are actionable through the freeze_panes bucket; no freeze_panes bucket was requested." }); + } else { + const freeze = visualRuleFreezePanes(retargetedRule.value); + if (!freeze) { + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: "Freeze rule did not include supported row or column counts." }); + } else { + if (freeze.rows !== undefined) freezePanes.rows = freeze.rows; + if (freeze.columns !== undefined) freezePanes.columns = freeze.columns; + freezePanes.reasons.push(retargetedRule.reason); + } + } + continue; + } + skipped.push({ ruleId: retargetedRule.id, target: retargetedRule.target, reason: `${retargetedRule.kind} is preview-only until a matching workbook operation is available.` }); + } + if (bucketEnabled("validation")) { + for (const suggestion of validationSuggestions) { + if (suggestion.existingValidation === "preserved" && !options.allowReplaceDataValidation) { + skipped.push({ ruleId: suggestion.id, target: suggestion.target, reason: "Existing data validation on the target is preserved by default." }); + continue; + } + operations.push({ + kind: "range.write_data_validation", + operationId: makeId("op"), + workbookId, + destructiveLevel: "format", + reason: suggestion.reason, + target: { workbookId, sheetName, address: suggestion.target }, + validation: { + type: "list", + source: suggestion.source, + inCellDropDown: true, + ignoreBlanks: true + } + }); + } + } + if (freezePanes.rows !== undefined || freezePanes.columns !== undefined) { + operations.push({ + kind: "sheet.freeze_panes", + operationId: makeId("op"), + workbookId, + destructiveLevel: "format", + reason: freezePanes.reasons.join(" "), + sheetName, + ...(freezePanes.rows !== undefined ? { rows: freezePanes.rows } : {}), + ...(freezePanes.columns !== undefined ? { columns: freezePanes.columns } : {}) + }); + } + if (styleEntries.length > 0) { + operations.unshift({ + kind: "range.write_styles_many", + operationId: makeId("op"), + workbookId, + destructiveLevel: "format", + reason: "Apply visual readability style rules.", + entries: styleEntries + }); + } + if (numberFormatEntries.length > 0) { + operations.push({ + kind: "range.write_number_formats_many", + operationId: makeId("op"), + workbookId, + destructiveLevel: "format", + reason: "Apply visual readability number formats.", + entries: numberFormatEntries + }); + } + return { operations, skipped }; +} + +function visualRulePreservationSkip(rule: VisualReadabilityRule, options: { preserveExistingStyle: boolean; stylePreservationMode: VisualReadabilityStylePreservationMode; allowReplaceConditionalFormatting: boolean; preservation: VisualReadabilityPreservationContext }): { ruleId: string; target: string; reason: string } | undefined { + if (visualTargetOverlapsAny(rule.target, options.preservation.protectedRanges)) { + return { ruleId: rule.id, target: rule.target, reason: "Target overlaps a protected range and was skipped." }; + } + if (visualTargetOverlapsAny(rule.target, options.preservation.mergedRanges)) { + return { ruleId: rule.id, target: rule.target, reason: "Target overlaps merged cells and was skipped." }; + } + if (options.preservation.hiddenColumns.some((column) => column !== "sheet" && visualRuleTouchesColumn(rule.target, column))) { + return { ruleId: rule.id, target: rule.target, reason: "Target overlaps hidden columns and was skipped." }; + } + if (rule.kind === "filter" || rule.kind === "freeze") { + return undefined; + } + if (options.preserveExistingStyle && options.stylePreservationMode !== "none") { + const preservedStyleRanges = options.stylePreservationMode === "strict" + ? uniqueDefined([...options.preservation.existingStyleRanges, ...options.preservation.protectedStyleRanges]) + : options.preservation.protectedStyleRanges; + if (visualTargetOverlapsAny(rule.target, preservedStyleRanges)) { + return { ruleId: rule.id, target: rule.target, reason: "Target overlaps a protected summary/template style area and was skipped." }; + } + } + if (rule.scope === "conditional_range" && options.preserveExistingStyle && !options.allowReplaceConditionalFormatting && visualTargetOverlapsAny(rule.target, options.preservation.existingConditionalFormattingRanges)) { + return { ruleId: rule.id, target: rule.target, reason: "Existing conditional formatting on the target is preserved by default." }; + } + return undefined; +} + +function visualRuleWithPreservationSafeTarget( + rule: VisualReadabilityRule, + options: { + preserveExistingStyle: boolean; + stylePreservationMode: VisualReadabilityStylePreservationMode; + preservation: VisualReadabilityPreservationContext; + } +): VisualReadabilityRule | undefined { + if (!options.preserveExistingStyle || options.stylePreservationMode === "none") { + return rule; + } + if (rule.kind === "filter" || rule.kind === "freeze") { + return rule; + } + const protectedStyleRanges = options.stylePreservationMode === "strict" + ? uniqueDefined([...options.preservation.existingStyleRanges, ...options.preservation.protectedStyleRanges]) + : options.preservation.protectedStyleRanges; + const safeTarget = visualTargetExcludingLeadingProtectedRows(rule.target, protectedStyleRanges); + return safeTarget ? { ...rule, target: safeTarget } : undefined; +} + +function visualTargetExcludingLeadingProtectedRows(target: string, protectedRanges: string[]): string | undefined { + const parsedTarget = tryParseA1Address(stripSheetName(target)); + if (!parsedTarget) { + return target; + } + let startRow = parsedTarget.startRow; + for (const protectedRange of protectedRanges) { + const parsedProtected = tryParseA1Address(stripSheetName(protectedRange)); + if (!parsedProtected || !rangesOverlapAddresses(addressFromBounds(startRow, parsedTarget.startColumn, parsedTarget.endRow - startRow + 1, parsedTarget.endColumn - parsedTarget.startColumn + 1), stripSheetName(protectedRange))) { + continue; + } + const coversTargetStartColumns = parsedProtected.startColumn <= parsedTarget.startColumn && parsedProtected.endColumn >= parsedTarget.endColumn; + const startsBeforeOrAtTarget = parsedProtected.startRow <= startRow; + if (coversTargetStartColumns && startsBeforeOrAtTarget && parsedProtected.endRow >= startRow) { + startRow = parsedProtected.endRow + 1; + } + } + if (startRow > parsedTarget.endRow) { + return undefined; + } + return addressFromBounds(startRow, parsedTarget.startColumn, parsedTarget.endRow - startRow + 1, parsedTarget.endColumn - parsedTarget.startColumn + 1); +} + +function visualReadabilityZeroOperationWarnings(skipped: VisualReadabilityCompiledOperationSet["skipped"]): string[] { + if (skipped.length === 0) { + return ["No apply-ready visual operations were produced."]; + } + const reasons = skipped.map((entry) => entry.reason).join(" "); + const warnings: string[] = ["No apply-ready visual operations were produced; do not apply this preview."]; + if (/protected/.test(reasons)) { + warnings.push("Some rules were blocked by protected or template-style areas."); + } + if (/bucket/.test(reasons)) { + warnings.push("Some rules require an explicit opt-in suggestion bucket such as layout, validation, or freeze_panes."); + } + if (/unsupported|preview-only|operation schema/.test(reasons)) { + warnings.push("Some rules are preview-only because no supported workbook operation exists for them yet."); + } + return warnings; +} + +function visualTargetOverlapsAny(target: string, ranges: string[]): boolean { + const normalizedTarget = stripSheetName(target); + if (!tryParseA1Address(normalizedTarget)) { + return false; + } + return ranges.some((range) => { + const normalizedRange = stripSheetName(range); + return tryParseA1Address(normalizedRange) ? rangesOverlapAddresses(normalizedTarget, normalizedRange) : false; + }); +} + +function visualRuleTouchesColumn(target: string, column: string): boolean { + const parsed = tryParseA1Address(stripSheetName(target)); + const columnIndex = columnToNumber(column); + return parsed ? parsed.startColumn <= columnIndex && parsed.endColumn >= columnIndex : new RegExp(`^${column}(?:\\d|:|$)`, "i").test(stripSheetName(target)); +} + +function visualRuleWidth(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as Record; + return numberValue(record.preferred ?? record.max ?? record.min); +} + +function visualRuleHeight(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as Record; + return numberValue(record.preferred ?? record.height ?? record.max ?? record.min); +} + +function visualRuleFreezePanes(value: unknown): { rows?: number; columns?: number } | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const record = value as Record; + const rows = visualFreezeCount(record.rows ?? record.rowCount ?? record.row); + const columns = visualFreezeCount(record.columns ?? record.columnCount ?? record.column); + const freeze = stripUndefinedRecord({ rows, columns }) as { rows?: number; columns?: number }; + return freeze.rows !== undefined || freeze.columns !== undefined ? freeze : undefined; +} + +function visualRuleAlignment(value: unknown): NonNullable { + if (!value || typeof value !== "object") { + return {}; + } + const record = value as Record; + const style: NonNullable = {}; + const horizontalAlignment = stringValue(record.horizontalAlignment); + if (horizontalAlignment) style.horizontalAlignment = horizontalAlignment; + const verticalAlignment = stringValue(record.verticalAlignment); + if (verticalAlignment) style.verticalAlignment = verticalAlignment; + return style; +} + +function visualRuleStyle(rule: VisualReadabilityRule): NonNullable { + if (!rule.value || typeof rule.value !== "object") { + return {}; + } + const record = rule.value as Record; + const style: NonNullable = {}; + const fillColor = colorString(record.fillColor); + if (fillColor) style.fillColor = fillColor; + const fontColor = colorString(record.fontColor); + if (fontColor) style.fontColor = fontColor; + const fontBold = booleanValue(record.fontBold); + if (fontBold !== undefined) style.fontBold = fontBold; + const fontName = stringValue(record.fontName); + if (fontName) style.fontName = fontName; + const fontSize = numberValue(record.fontSize); + if (fontSize !== undefined) style.fontSize = fontSize; + const borders = visualRuleBorders(record); + if (borders) style.borders = borders; + return style; +} + +function visualRuleBorders(record: Record): NonNullable["borders"] | undefined { + const borders: Record = {}; + for (const edge of ["edgeTop", "edgeBottom", "edgeLeft", "edgeRight", "insideHorizontal", "insideVertical"]) { + const value = record[edge]; + if (value && typeof value === "object") { + borders[edge] = value; + } + } + return Object.keys(borders).length > 0 ? borders as NonNullable["borders"] : undefined; +} + +function repeatedNumberFormatMatrix(address: string, format: string): string[][] | undefined { + const parsed = tryParseA1Address(stripSheetName(address)); + if (!parsed) { + return undefined; + } + const rows = parsed.endRow - parsed.startRow + 1; + const columns = parsed.endColumn - parsed.startColumn + 1; + if (rows < 1 || columns < 1 || rows * columns > 20_000) { + return undefined; + } + return Array.from({ length: rows }, () => Array.from({ length: columns }, () => format)); +} + +function visualConditionalFormattingRule(rule: VisualReadabilityRule): Extract["rule"] | undefined { + const parsed = tryParseA1Address(stripSheetName(rule.target)); + if (!parsed) { + return undefined; + } + const column = visualRuleColumnFromId(rule.id); + if (!column) { + return undefined; + } + const firstRow = parsed.startRow; + const fillColor = rule.value && typeof rule.value === "object" ? colorString((rule.value as Record).fillColor) : undefined; + const style: NonNullable = { fillColor: fillColor ?? "#FFF2CC" }; + const formula = rule.id.includes("formula_error") + ? `=ISERROR($${column}${firstRow})` + : `=AND(COUNTA($${numberToColumn(parsed.startColumn)}${firstRow}:$${numberToColumn(parsed.endColumn)}${firstRow})>0,$${column}${firstRow}="")`; + return { type: "custom", formula, style }; +} + +function visualRuleColumnFromId(ruleId: string): string | undefined { + const match = /^(?:column|conditional)\.([A-Z]+)\./.exec(ruleId); + return match?.[1]; +} + +function visualFormulaSnapshotFromBatchResult(formulaRanges: string[], result: unknown): Map { + const snapshot = new Map(); + const record = result && typeof result === "object" ? result as Record : {}; + const entries = Array.isArray(record.readData) + ? record.readData + : Array.isArray(record.data) + ? record.data + : []; + for (const [rangeIndex, entry] of entries.entries()) { + const entryRecord = entry && typeof entry === "object" ? entry as Record : {}; + const formulas = entryRecord.snapshot && typeof entryRecord.snapshot === "object" && Array.isArray((entryRecord.snapshot as Record).formulas) + ? (entryRecord.snapshot as { formulas: unknown[][] }).formulas + : []; + const range = formulaRanges[rangeIndex] ?? `range_${rangeIndex}`; + for (const [rowIndex, row] of formulas.entries()) { + if (!Array.isArray(row)) { + continue; + } + for (const [columnIndex, value] of row.entries()) { + if (typeof value === "string" && value.length > 0) { + snapshot.set(`${range}:${rowIndex}:${columnIndex}`, value); + } + } + } + } + return snapshot; +} + +function compareVisualFormulaSnapshots(before: Map, after: Map): { checkedCount: number; changedCount: number } { + const keys = new Set([...before.keys(), ...after.keys()]); + let changedCount = 0; + for (const key of keys) { + if (before.get(key) !== after.get(key)) { + changedCount += 1; + } + } + return { checkedCount: keys.size, changedCount }; +} + +function visualReadabilityStyleDepth(value: unknown): VisualReadabilityStyleDepth { + const raw = stringValue(value)?.toLowerCase(); + return raw === "basic" || raw === "comprehensive" ? raw : "standard"; +} + +function visualReadabilityDensity(value: unknown): VisualReadabilityDensity { + const raw = stringValue(value)?.toLowerCase(); + return raw === "compact" || raw === "presentation" ? raw : "comfortable"; +} + +function visualReadabilityPresentationMode(value: unknown): VisualReadabilityPresentationMode | undefined { + const raw = stringValue(value)?.toLowerCase(); + if (raw === "working_sheet" || raw === "print_ready" || raw === "executive_report") { + return raw; + } + return undefined; +} + +function visualReadabilityStylePreservationMode(value: unknown, preserveExistingStyle: unknown): VisualReadabilityStylePreservationMode { + const raw = stringValue(value)?.toLowerCase().replace(/[\s-]+/g, "_"); + if (raw === "protected_regions" || raw === "strict" || raw === "none") { + return raw; + } + if (booleanValue(preserveExistingStyle) === false) { + return "none"; + } + return "protected_regions"; +} + +function visualReadabilitySuggestionBuckets(value: unknown): VisualReadabilitySuggestionBucket[] { + const rawValues = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[, ]+/) : []; + const allowed = new Set(["layout", "validation", "reference_style", "formula_helpers", "structure", "freeze_panes", "print_settings"]); + const buckets: VisualReadabilitySuggestionBucket[] = []; + for (const raw of rawValues) { + const normalized = stringValue(raw)?.trim().toLowerCase().replace(/[-\s]+/g, "_") as VisualReadabilitySuggestionBucket | undefined; + if (normalized && allowed.has(normalized) && !buckets.includes(normalized)) { + buckets.push(normalized); + } + } + return buckets; +} + +function visualReadabilityFreezePanes(value: unknown, rowValue: unknown, columnValue: unknown, request: string): { rows?: number; columns?: number } | undefined { + const record = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + const rows = visualFreezeCount(record.rows ?? record.rowCount ?? record.row ?? rowValue); + const columns = visualFreezeCount(record.columns ?? record.columnCount ?? record.column ?? columnValue); + const inferredColumns = columns ?? visualFreezeColumnCountFromRequest(request); + const inferredRows = rows ?? visualFreezeRowCountFromRequest(request); + const freezePanes = stripUndefinedRecord({ rows: inferredRows, columns: inferredColumns }) as { rows?: number; columns?: number }; + return freezePanes.rows !== undefined || freezePanes.columns !== undefined ? freezePanes : undefined; +} + +function freezePanesFromInput(input: AgentRunInput): { rows?: number; columns?: number } | undefined { + if (/\bunfreeze\b|\bremove\b.*\bfreeze|\bclear\b.*\bfreeze/i.test(input.request)) { + return { rows: 0, columns: 0 }; + } + const values = input.values as Record | undefined; + const nested = values?.freezePanes && typeof values.freezePanes === "object" && !Array.isArray(values.freezePanes) + ? values.freezePanes as Record + : {}; + return visualReadabilityFreezePanes( + values?.freezePanes, + nested.rows ?? nested.rowCount ?? nested.row ?? values?.freezeRows ?? values?.rows, + nested.columns ?? nested.columnCount ?? nested.column ?? values?.freezeColumns ?? values?.columns, + input.request + ); +} + +function freezePanesSheetName(metadata: WorkbookMetadata, input: AgentRunInput): string | undefined { + const requested = stringValue(input.target?.sheetName); + if (requested && !sameText(requested, "active") && !sameText(requested, "active_sheet")) { + return metadata.sheets.find((sheet) => sameText(sheet.name, requested))?.name ?? requested; + } + return metadata.workbook.activeSheet ?? metadata.sheets[0]?.name; +} + +function visualFreezeCount(value: unknown): number | undefined { + if (value === true) return 1; + if (value === false) return 0; + const count = numberValue(value); + return count !== undefined && Number.isFinite(count) && count >= 0 ? Math.floor(count) : undefined; +} + +function visualFreezeColumnCountFromRequest(request: string): number | undefined { + const lower = request.toLowerCase(); + const firstColumn = /\bfreeze\b.*\b(?:first\s+)?(?:col|column)\b/.test(lower) || /\bfreeze\b.*\bcolumn\s+a\b/.test(lower); + if (!firstColumn) { + return undefined; + } + const count = /\bfreeze\b.*\bfirst\s+(\d+)\s+(?:cols?|columns?)\b/.exec(lower)?.[1]; + return count ? Number.parseInt(count, 10) : 1; +} + +function visualFreezeRowCountFromRequest(request: string): number | undefined { + const lower = request.toLowerCase(); + const firstRow = /\bfreeze\b.*\b(?:first\s+|top\s+)?row\b/.test(lower) || /\bfreeze\b.*\bheader\b/.test(lower); + if (!firstRow) { + return undefined; + } + const count = /\bfreeze\b.*\b(?:first|top)\s+(\d+)\s+rows?\b/.exec(lower)?.[1]; + return count ? Number.parseInt(count, 10) : 1; +} + +function visualReadabilityReferenceStyle(value: unknown): VisualReadabilityReferenceStyleOption | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + const sheetName = stringValue(record.sheetName ?? record.sheet ?? record.referenceSheetName ?? record.referenceSheet); + if (!sheetName) { + return undefined; + } + return { + sheetName, + adaptToTargetStructure: booleanValue(record.adaptToTargetStructure) ?? true, + preserveTargetValues: booleanValue(record.preserveTargetValues) ?? true, + preserveFormulas: booleanValue(record.preserveFormulas) ?? true + }; +} + +function compileVisualReadabilityPlan( + metadata: WorkbookMetadata, + sheet: WorkbookMetadata["sheets"][number], + detected: VisualReadabilityDetectedStructure, + columns: VisualReadabilityColumnRole[], + options: ReturnType, + sheetType: VisualReadabilitySheetType +): VisualReadabilityPlanPreview { + const theme = visualReadabilityTheme(options.density); + const rules: VisualReadabilityRule[] = []; + if (detected.headerRange) { + rules.push({ + id: "layout.header_style", + scope: "row", + target: detected.headerRange, + kind: "fill", + value: { fillColor: theme.fills.header, fontBold: true, fontColor: theme.colors.headerText, borderBottom: theme.borders.headerBottom }, + risk: "low", + reason: "Apply calm header styling to create visual hierarchy." + }); + rules.push({ + id: "layout.header_alignment", + scope: "row", + target: detected.headerRange, + kind: "alignment", + value: { verticalAlignment: "Center" }, + risk: "low", + reason: "Keep header labels vertically centered." + }); + } + if (detected.dataRange && detected.hasFilter) { + const filterTarget = visualFilterTarget(detected); + rules.push({ + id: "layout.filter", + scope: "table", + target: filterTarget, + kind: "filter", + value: { enabled: true }, + risk: "low", + reason: "Preserve or enable filter affordance for table scanning." + }); + } + if (detected.headerRow !== undefined) { + const freezeRows = options.freezePanes?.rows ?? detected.headerRow; + rules.push({ + id: "layout.freeze_header", + scope: "sheet", + target: sheet.name, + kind: "freeze", + value: { rows: freezeRows }, + risk: "low", + reason: "Keep the detected header row visible while scrolling." + }); + } + if (options.freezePanes?.columns !== undefined) { + rules.push({ + id: "layout.freeze_columns", + scope: "sheet", + target: sheet.name, + kind: "freeze", + value: { columns: options.freezePanes.columns }, + risk: "low", + reason: `Keep the first ${options.freezePanes.columns} column(s) visible while scrolling.` + }); + } + if (options.freezePanes?.rows !== undefined && detected.headerRow === undefined) { + rules.push({ + id: "layout.freeze_rows", + scope: "sheet", + target: sheet.name, + kind: "freeze", + value: { rows: options.freezePanes.rows }, + risk: "low", + reason: `Keep the first ${options.freezePanes.rows} row(s) visible while scrolling.` + }); + } + + for (const column of columns) { + const target = columnTargetFromDetected(detected, column.column); + const width = visualWidthForRole(column.role); + rules.push({ + id: `column.${column.column}.width`, + scope: "column", + target, + kind: "width", + value: width, + risk: "low", + reason: `Set ${column.header || column.column} width from role ${column.role}.` + }); + rules.push({ + id: `column.${column.column}.alignment`, + scope: "column", + target, + kind: "alignment", + value: visualAlignmentForRole(column.role), + risk: "low", + reason: `Align ${column.header || column.column} from role ${column.role}.` + }); + const numberFormat = visualNumberFormatForRole(column.role); + if (numberFormat) { + rules.push({ + id: `column.${column.column}.number_format`, + scope: "column", + target, + kind: "number_format", + value: numberFormat, + risk: "low", + reason: `Normalize ${column.header || column.column} display format.` + }); + } + if (column.role === "notes") { + rules.push({ + id: `column.${column.column}.wrap`, + scope: "column", + target, + kind: "wrap", + value: true, + risk: "low", + reason: "Wrap long notes or descriptions without changing values." + }); + } + if (column.role === "formula_output") { + rules.push({ + id: `column.${column.column}.formula_fill`, + scope: "column", + target, + kind: "fill", + value: { fillColor: theme.fills.formula }, + risk: "low", + reason: "Distinguish calculated formula output from input cells." + }); + } + } + + if (options.styleDepth !== "basic") { + for (const group of visualColumnGroups(columns, detected)) { + rules.push({ + id: `group.${normalizeOperationName(group.label)}.${group.startColumn}_${group.endColumn}`, + scope: "group", + target: group.target, + kind: "border", + value: { edgeLeft: theme.borders.groupSeparator, fillColor: theme.fills.group }, + risk: "low", + reason: `Softly separate ${group.label} columns.` + }); + } + for (const column of columns.filter((candidate) => ["date", "entity", "status"].includes(candidate.role)).slice(0, 4)) { + rules.push({ + id: `conditional.${column.column}.missing_required`, + scope: "conditional_range", + target: detected.dataRange ?? columnTargetFromDetected(detected, column.column), + kind: "fill", + value: { condition: `${column.column} is blank on nonblank record rows`, fillColor: theme.fills.warning }, + risk: "low", + reason: `Highlight missing likely-required ${column.header || column.column} values.` + }); + } + for (const formulaColumn of detected.formulaColumns.slice(0, 4)) { + rules.push({ + id: `conditional.${formulaColumn}.formula_error`, + scope: "conditional_range", + target: detected.dataRange ?? columnTargetFromDetected(detected, formulaColumn), + kind: "fill", + value: { condition: `${formulaColumn} contains an Excel error`, fillColor: theme.fills.danger }, + risk: "low", + reason: "Highlight formula errors without replacing formulas." + }); + } + } + + const validationSuggestions = shouldCompileVisualValidationSuggestions(options) + ? compileVisualValidationSuggestions(detected, columns) + : []; + const formulaSuggestions = shouldCompileVisualFormulaSuggestions(options) + ? compileVisualFormulaSuggestions(detected, columns) + : []; + const referenceStyleSuggestions = compileVisualReferenceStyleSuggestions(metadata, sheet, detected, columns, options); + const printSuggestions = compileVisualPrintSuggestions(detected, options, sheetType); + const ruleScopes = countVisualRuleScopes(rules); + const counts = { + totalRules: rules.length, + layoutChanges: rules.filter((rule) => rule.scope === "sheet" || rule.scope === "table" || rule.id.startsWith("layout.")).length, + groupRules: ruleScopes.group, + columnRules: ruleScopes.column, + conditionalRules: ruleScopes.conditional_range, + rowRules: ruleScopes.row, + cellRules: ruleScopes.cell, + validationSuggestions: validationSuggestions.length, + formulaSuggestions: formulaSuggestions.length, + referenceStyleSuggestions: referenceStyleSuggestions.length, + printSuggestions: printSuggestions.length + }; + return { + compilerStatus: "preview_compiled_apply_pending", + summary: visualPlanSummary(sheetType, options, counts), + counts, + ruleScopes, + rules: rules.slice(0, 200), + validationSuggestions, + formulaSuggestions, + referenceStyleSuggestions, + printSuggestions, + previewExamples: rules.slice(0, 8).map((rule) => ({ + range: rule.target, + before: "current workbook formatting", + after: visualRuleAfterSummary(rule), + ruleId: rule.id + })), + theme: { + name: "office_clean", + font: theme.font, + density: options.density + } + }; +} + +function visualReadabilityTheme(density: VisualReadabilityDensity) { + return { + font: { family: "Calibri", bodySize: density === "compact" ? 10 : 11, headerSize: density === "presentation" ? 12 : 11 }, + colors: { headerText: "#1F2937" }, + fills: { + header: "#D9EAF7", + group: "#F3F6FA", + formula: "#EEF2FF", + warning: "#FFF2CC", + danger: "#FCE4D6" + }, + borders: { + headerBottom: { style: "continuous", weight: "thin", color: "#8EA9DB" }, + groupSeparator: { style: "continuous", weight: "thin", color: "#B7C9E2" } + } + }; +} + +function shouldCompileVisualValidationSuggestions(options: ReturnType): boolean { + return options.styleDepth === "comprehensive" || options.allowValidationSuggestions || options.applySuggestionBuckets.includes("validation"); +} + +function shouldCompileVisualFormulaSuggestions(options: ReturnType): boolean { + return options.styleDepth === "comprehensive" || options.allowFormulaSuggestions || options.applySuggestionBuckets.includes("formula_helpers"); +} + +function compileVisualValidationSuggestions(detected: VisualReadabilityDetectedStructure, columns: VisualReadabilityColumnRole[]): VisualReadabilityValidationSuggestion[] { + return columns + .filter((column) => column.role === "status" || column.role === "category") + .slice(0, 6) + .map((column) => ({ + id: `validation.${column.column}.dropdown`, + target: columnTargetFromDetected(detected, column.column), + source: visualValidationOptionsForRole(column.role, column.header), + risk: "medium", + reason: `Suggest a dropdown for ${column.header || column.column}; preview only and not applied by visual styling.`, + existingValidation: detected.existingDataValidationRanges.some((range) => rangesOverlapAddresses(stripSheetName(range), stripSheetName(columnTargetFromDetected(detected, column.column)))) ? "preserved" : "not_detected" + })); +} + +function visualValidationOptionsForRole(role: string, header: string): string[] { + const normalized = normalizeHeaderName(header); + if (role === "status") { + if (/\b(payment|invoice|billing)\b/i.test(normalized)) { + return ["Open", "Paid", "Overdue", "Disputed"]; + } + return ["Open", "In Progress", "Blocked", "Done"]; + } + if (/\bpriority\b/i.test(normalized)) { + return ["Low", "Medium", "High", "Critical"]; + } + return ["Option 1", "Option 2", "Option 3"]; +} + +function compileVisualFormulaSuggestions(detected: VisualReadabilityDetectedStructure, columns: VisualReadabilityColumnRole[]): VisualReadabilityFormulaSuggestion[] { + const suggestions: VisualReadabilityFormulaSuggestion[] = []; + const dateColumn = columns.find((column) => column.role === "date"); + const statusColumn = columns.find((column) => column.role === "status"); + const lastColumn = columns[columns.length - 1]?.column ?? columnsFromAddress(detected.usedRange ?? detected.headerRange ?? "A1:A1").at(-1) ?? "A"; + if (dateColumn && statusColumn) { + suggestions.push({ + id: "formula.overdue_flag", + suggestedColumnAfter: lastColumn, + formulaName: "Overdue flag", + formulaExample: `=AND($${dateColumn.column}2"Done")`, + risk: "medium", + reason: "Could add an overdue helper column, but formula insertion requires separate confirmation." + }); + } + const moneyColumns = columns.filter((column) => column.role === "money" || column.role === "number"); + if (moneyColumns.length >= 2) { + suggestions.push({ + id: "formula.variance", + suggestedColumnAfter: lastColumn, + formulaName: "Variance", + formulaExample: `=$${moneyColumns[0]!.column}2-$${moneyColumns[1]!.column}2`, + risk: "medium", + reason: "Could add a variance calculation, but formulas are not inserted by visual styling." + }); + } + return suggestions.slice(0, 6); +} + +function visualFilterTarget(detected: VisualReadabilityDetectedStructure): string { + return detected.tableRanges[0] ?? detected.usedRange ?? detected.headerRange ?? detected.dataRange ?? detected.sheetName; +} + +function compileVisualReferenceStyleSuggestions( + metadata: WorkbookMetadata, + sheet: WorkbookMetadata["sheets"][number], + detected: VisualReadabilityDetectedStructure, + columns: VisualReadabilityColumnRole[], + options: ReturnType +): VisualReadabilityReferenceStyleSuggestion[] { + const reference = options.referenceStyle; + if (!reference) { + return []; + } + const referenceSheet = metadata.sheets.find((candidate) => sameText(candidate.name, reference.sheetName)); + if (!referenceSheet) { + return [{ + id: "reference_style.sheet_not_found", + referenceSheetName: reference.sheetName, + target: detected.usedRange ?? sheet.name, + pattern: "reference sheet lookup", + risk: "high", + reason: "Reference style sheet was not found, so no style patterns will be adapted.", + preserveTargetValues: true, + preserveFormulas: reference.preserveFormulas + }]; + } + const referenceHeader = referenceSheet.headers.sort((left, right) => right.confidence - left.confidence)[0]; + const suggestions: VisualReadabilityReferenceStyleSuggestion[] = []; + if (referenceHeader?.range && detected.headerRange) { + suggestions.push({ + id: "reference_style.header", + referenceSheetName: referenceSheet.name, + target: detected.headerRange, + pattern: "header fill, font, and bottom border", + risk: "medium", + reason: `Adapt header styling from ${referenceSheet.name} to the detected target header without copying values.`, + preserveTargetValues: true, + preserveFormulas: reference.preserveFormulas + }); + } + if (reference.adaptToTargetStructure && columns.length > 0) { + suggestions.push({ + id: "reference_style.columns_by_role", + referenceSheetName: referenceSheet.name, + target: detected.usedRange ?? detected.headerRange ?? sheet.name, + pattern: "column widths, alignment, and number formats by semantic role", + risk: "medium", + reason: `Map style patterns from ${referenceSheet.name} by column role so extra or missing target columns are handled safely.`, + preserveTargetValues: true, + preserveFormulas: reference.preserveFormulas + }); + } + if (referenceSheet.kind === sheet.kind || referenceSheet.columnCount === sheet.columnCount) { + suggestions.push({ + id: "reference_style.layout", + referenceSheetName: referenceSheet.name, + target: detected.usedRange ?? sheet.name, + pattern: "freeze panes, filters, and table layout cues", + risk: "medium", + reason: `Reuse compatible layout cues from ${referenceSheet.name}; this remains preview-only in visual readability apply.`, + preserveTargetValues: true, + preserveFormulas: reference.preserveFormulas + }); + } + return suggestions.slice(0, 6); +} + +function compileVisualPrintSuggestions( + detected: VisualReadabilityDetectedStructure, + options: ReturnType, + sheetType: VisualReadabilitySheetType +): VisualReadabilityPrintSuggestion[] { + const mode = options.presentationMode; + if (!mode || mode === "working_sheet") { + return []; + } + const target = detected.usedRange ?? detected.headerRange ?? detected.sheetName; + const orientation = sheetType === "numeric_report" || sheetType === "summary_report" || (detected.usedRange && columnsFromAddress(detected.usedRange).length > 8) + ? "landscape" + : "portrait"; + const suggestions: VisualReadabilityPrintSuggestion[] = [ + { + id: "print.orientation", + target, + setting: "orientation", + value: orientation, + presentationMode: mode, + risk: "medium", + reason: "Suggest page orientation for print/export readability; visual apply does not change print settings." + }, + { + id: "print.fit_to_width", + target, + setting: "fitToWidth", + value: "1 page wide", + presentationMode: mode, + risk: "medium", + reason: "Suggest fitting the report to one page wide for sharing." + } + ]; + if (detected.headerRange) { + suggestions.push({ + id: "print.repeat_header", + target: detected.headerRange, + setting: "repeatRows", + value: `row ${detected.headerRow ?? 1}`, + presentationMode: mode, + risk: "medium", + reason: "Suggest repeating the detected header row when printed or exported." + }); + } + if (mode === "executive_report") { + suggestions.push({ + id: "print.hide_gridlines", + target, + setting: "showGridlines", + value: "false", + presentationMode: mode, + risk: "medium", + reason: "Suggest hiding gridlines for cleaner executive presentation output." + }); + } + return suggestions.slice(0, 6); +} + +function visualWidthForRole(role: string): { min: number; max: number } { + switch (role) { + case "date": return { min: 12, max: 14 }; + case "entity": return { min: 18, max: 30 }; + case "money": return { min: 12, max: 16 }; + case "number": return { min: 10, max: 14 }; + case "status": return { min: 12, max: 18 }; + case "notes": return { min: 28, max: 50 }; + case "id": return { min: 10, max: 16 }; + case "category": return { min: 12, max: 20 }; + default: return { min: 12, max: 24 }; + } +} + +function visualAlignmentForRole(role: string): { horizontalAlignment: string; verticalAlignment: string } { + if (role === "money" || role === "number") return { horizontalAlignment: "Right", verticalAlignment: "Center" }; + if (role === "date" || role === "status" || role === "category") return { horizontalAlignment: "Center", verticalAlignment: "Center" }; + if (role === "notes") return { horizontalAlignment: "Left", verticalAlignment: "Top" }; + return { horizontalAlignment: "Left", verticalAlignment: "Center" }; +} + +function visualNumberFormatForRole(role: string): string | undefined { + if (role === "date") return "dd/mm/yyyy"; + if (role === "money" || role === "number") return "#,##0.00"; + return undefined; +} + +function columnTargetFromDetected(detected: VisualReadabilityDetectedStructure, column: string): string { + const parsed = detected.dataRange ? tryParseA1Address(stripSheetName(detected.dataRange)) : detected.usedRange ? tryParseA1Address(stripSheetName(detected.usedRange)) : undefined; + return parsed ? `${column}${parsed.startRow}:${column}${parsed.endRow}` : `${column}:${column}`; +} + +function visualColumnGroups(columns: VisualReadabilityColumnRole[], detected: VisualReadabilityDetectedStructure): Array<{ label: string; startColumn: string; endColumn: string; target: string }> { + const groups: Array<{ label: string; columns: VisualReadabilityColumnRole[] }> = []; + for (const column of columns) { + const label = visualGroupLabel(column.role); + const last = groups[groups.length - 1]; + if (last?.label === label) { + last.columns.push(column); + } else { + groups.push({ label, columns: [column] }); + } + } + const parsed = detected.dataRange ? tryParseA1Address(stripSheetName(detected.dataRange)) : detected.usedRange ? tryParseA1Address(stripSheetName(detected.usedRange)) : undefined; + return groups + .filter((group) => group.columns.length > 0) + .map((group) => { + const startColumn = group.columns[0]!.column; + const endColumn = group.columns[group.columns.length - 1]!.column; + return { + label: group.label, + startColumn, + endColumn, + target: parsed ? `${startColumn}${parsed.startRow}:${endColumn}${parsed.endRow}` : `${startColumn}:${endColumn}` + }; + }); +} + +function visualGroupLabel(role: string): string { + if (role === "date") return "Dates"; + if (role === "money" || role === "number" || role === "formula_output") return "Metrics"; + if (role === "status") return "Status"; + if (role === "notes") return "Notes"; + return "Record Info"; +} + +function countVisualRuleScopes(rules: VisualReadabilityRule[]): Record { + return { + sheet: rules.filter((rule) => rule.scope === "sheet").length, + table: rules.filter((rule) => rule.scope === "table").length, + group: rules.filter((rule) => rule.scope === "group").length, + column: rules.filter((rule) => rule.scope === "column").length, + conditional_range: rules.filter((rule) => rule.scope === "conditional_range").length, + row: rules.filter((rule) => rule.scope === "row").length, + cell: rules.filter((rule) => rule.scope === "cell").length + }; +} + +function visualPlanSummary(sheetType: VisualReadabilitySheetType, options: ReturnType, counts: VisualReadabilityPlanPreview["counts"]): string[] { + return [ + `Detected ${sheetType} and selected ${options.profile} profile.`, + `Compiled ${counts.totalRules} ${options.styleDepth} visual readability rule(s).`, + "Supported safe operations are apply-ready; risky or unsupported rules remain skipped or preview-only." + ]; +} + +function visualRuleAfterSummary(rule: VisualReadabilityRule): string { + switch (rule.kind) { + case "width": return "role-based width"; + case "alignment": return "role-based alignment"; + case "number_format": return `number format ${String(rule.value)}`; + case "filter": return "filters enabled or preserved"; + case "freeze": return "freeze header suggestion"; + case "border": return "soft grouping border/fill"; + case "wrap": return "wrapped long text"; + case "fill": return rule.scope === "conditional_range" ? "conditional highlight rule" : "calm fill styling"; + case "font": return "font styling"; + case "height": return "row height"; + } +} + +function detectVisualReadabilityStructure(metadata: WorkbookMetadata, sheet: WorkbookMetadata["sheets"][number], targetRange?: string): VisualReadabilityDetectedStructure { + const sheetTables = metadata.tables.filter((table) => table.sheetName === sheet.name); + const firstTable = sheetTables[0]; + const firstTableRange = firstTable?.range ? tryParseA1Address(stripSheetName(firstTable.range)) : undefined; + const firstTableHeaderRange = firstTable?.headerRange ? stripSheetName(firstTable.headerRange) : undefined; + const firstTableHeader = firstTableHeaderRange ? tryParseA1Address(firstTableHeaderRange) : undefined; + const inferredTableHeaderRange = firstTableRange ? addressFromBounds(firstTableRange.startRow, firstTableRange.startColumn, 1, firstTableRange.endColumn - firstTableRange.startColumn + 1) : undefined; + const inferredTableDataRange = firstTableRange && firstTableRange.endRow > firstTableRange.startRow + ? addressFromBounds(firstTableRange.startRow + 1, firstTableRange.startColumn, firstTableRange.endRow - firstTableRange.startRow, firstTableRange.endColumn - firstTableRange.startColumn + 1) + : undefined; + const bestHeader = [ + ...sheet.headers, + ...sheetTables.flatMap((table) => table.headerRange ? [{ + id: `${table.id}:header`, + sheetName: table.sheetName, + row: tryParseA1Address(stripSheetName(table.headerRange))?.startRow ?? tryParseA1Address(stripSheetName(table.range))?.startRow ?? 1, + range: table.headerRange, + columns: table.columns, + confidence: 0.95 + } satisfies HeaderMetadata] : []) + ].sort((left, right) => right.confidence - left.confidence)[0]; + const usedRange = targetRange ?? sheet.usedRange; + const used = usedRange ? tryParseA1Address(stripSheetName(usedRange)) : undefined; + const bestHeaderParsed = bestHeader?.range ? tryParseA1Address(stripSheetName(bestHeader.range)) : undefined; + const bestHeaderLooksLikeWholeTable = Boolean(bestHeaderParsed && firstTableRange && rangesOverlapAddresses(stripSheetName(bestHeader!.range), stripSheetName(firstTable!.range)) && (bestHeaderParsed.endRow - bestHeaderParsed.startRow) >= 1); + const headerRow = firstTableHeader?.startRow + ?? (firstTableRange ? firstTableRange.startRow : undefined) + ?? (bestHeaderLooksLikeWholeTable ? bestHeaderParsed?.startRow : bestHeader?.row); + const headerRange = firstTableHeaderRange + ?? inferredTableHeaderRange + ?? (bestHeaderLooksLikeWholeTable && bestHeaderParsed ? addressFromBounds(bestHeaderParsed.startRow, bestHeaderParsed.startColumn, 1, bestHeaderParsed.endColumn - bestHeaderParsed.startColumn + 1) : bestHeader?.range); + const dataRange = inferredTableDataRange ?? firstTable?.dataRange ?? (used && headerRow !== undefined && used.endRow > headerRow + ? addressFromBounds(headerRow + 1, used.startColumn, used.endRow - headerRow, used.endColumn - used.startColumn + 1) + : usedRange); + const formulaColumns = uniqueDefined([ + ...columnsForVisualReadability(metadata, sheet, { headerRange, dataRange }).filter((column) => column.inferredType === "formula" || column.role === "formula").map((column) => column.letter), + ...metadata.formulaRegions.filter((region) => region.sheetName === sheet.name).flatMap((region) => columnsFromAddress(region.range)) + ]); + const protectedStyleRanges = uniqueDefined([ + ...metadata.summaryBlocks.filter((block) => block.sheetName === sheet.name).map((block) => block.range), + ...metadata.sections.filter((section) => section.sheetName === sheet.name && (section.kind === "summary" || section.kind === "metadata")).map((section) => section.range) + ]); + return { + sheetName: sheet.name, + ...(usedRange ? { usedRange } : {}), + ...(headerRow !== undefined ? { headerRow } : {}), + ...(headerRange ? { headerRange } : {}), + ...(dataRange ? { dataRange } : {}), + tableRanges: sheetTables.map((table) => table.range), + hasFilter: sheetTables.length > 0, + hasFreezePane: false, + mergedRanges: [], + hiddenRows: [], + hiddenColumns: sheet.isHidden ? ["sheet"] : [], + protectedRanges: [], + existingStyleRanges: protectedStyleRanges, + protectedStyleRanges, + formulaColumns, + formulaRanges: metadata.formulaRegions.filter((region) => region.sheetName === sheet.name).map((region) => region.range), + totalRows: [], + subtotalRows: [], + existingConditionalFormattingRanges: [], + existingDataValidationRanges: [], + detectionSource: "metadata", + confidence: bestHeader ? bestHeader.confidence : firstTable ? 0.9 : usedRange ? 0.65 : 0.35 + }; +} + +function inferVisualReadabilityColumns(metadata: WorkbookMetadata, sheet: WorkbookMetadata["sheets"][number], detected: VisualReadabilityDetectedStructure): VisualReadabilityColumnRole[] { + return columnsForVisualReadability(metadata, sheet, detected).map((column) => ({ + column: column.letter, + header: column.name, + role: visualColumnRole(column), + inferredType: column.inferredType, + confidence: Math.max(0.45, Math.min(0.98, column.importance ?? 0.7)), + signals: visualColumnSignals(column) + })); +} + +function columnsForVisualReadability(metadata: WorkbookMetadata, sheet: WorkbookMetadata["sheets"][number], detected: { headerRange?: string | undefined; dataRange?: string | undefined }): ColumnMetadata[] { + const tableColumns = metadata.tables.find((table) => table.sheetName === sheet.name && (!detected.dataRange || table.dataRange === detected.dataRange || table.range === detected.dataRange))?.columns + ?? metadata.tables.find((table) => table.sheetName === sheet.name)?.columns; + if (tableColumns && tableColumns.length > 0) { + return tableColumns; + } + const headerColumns = sheet.headers.sort((left, right) => right.confidence - left.confidence)[0]?.columns; + return headerColumns ?? []; +} + +function visualColumnRole(column: ColumnMetadata): string { + const normalized = normalizeComparableText(column.name); + if (column.inferredType === "formula" || column.role === "formula") return "formula_output"; + if (column.inferredType === "currency" || column.role === "amount" || /ราคา|ยอด|ค่า|รวม|สุทธิ|ภาษี|amount|price|cost|fee|total|net|tax|revenue|payment|paid|balance/.test(normalized)) return "money"; + if (column.inferredType === "date" || column.role === "date" || /วันที่|วัน|date/.test(normalized)) return "date"; + if (column.inferredType === "status" || column.role === "status" || /\b(status|state|stage)\b/i.test(column.name) || /สถานะ/.test(normalized)) return "status"; + if (column.role === "note" || /\b(note|comment|description|detail)\b/i.test(column.name) || /หมายเหตุ|รายละเอียด/.test(normalized)) return "notes"; + if (column.role === "identifier" || /\b(id|no\.?|number|code)\b/i.test(column.name) || /เลข|รหัส|ทะเบียน|โค้ด|booking|บุ๊ค|บุก|phone|โทร|tax/.test(normalized)) return "id"; + if (column.role === "vendor" || column.role === "account" || /ลูกค้า|customer|vendor|supplier|account|ผู้รับเหมา/.test(normalized)) return "entity"; + if (column.role === "category" || /\b(type|category|priority|owner)\b/i.test(column.name) || /ประเภท|หมวด/.test(normalized)) return "category"; + if (column.inferredType === "number") return "number"; + return "unknown"; +} + +function visualColumnSignals(column: ColumnMetadata): string[] { + return [ + column.name ? "header_text" : undefined, + column.inferredType !== "unknown" ? `type_${column.inferredType}` : undefined, + column.role && column.role !== "unknown" ? `role_${column.role}` : undefined, + column.importance !== undefined && column.importance >= 0.9 ? "high_importance" : undefined + ].filter((signal): signal is string => signal !== undefined); +} + +function inferVisualReadabilitySheetType(sheet: WorkbookMetadata["sheets"][number], columns: VisualReadabilityColumnRole[]): VisualReadabilitySheetType { + const roles = new Set(columns.map((column) => column.role)); + const moneyCount = columns.filter((column) => column.role === "money" || column.role === "number").length; + if (sheet.kind === "summary") return "summary_report"; + if (sheet.kind === "template") return "input_template"; + if (roles.has("status") && (roles.has("date") || roles.has("entity"))) return "record_tracker"; + if (moneyCount >= 2 || roles.has("formula_output")) return "numeric_report"; + if (columns.length >= 3) return "tabular_data"; + return sheet.usedRange ? "generic_table" : "unknown"; +} + +function profileForVisualSheetType(sheetType: VisualReadabilitySheetType): string { + switch (sheetType) { + case "tabular_data": + return "tabular_data"; + case "record_tracker": + return "record_tracker"; + case "numeric_report": + return "numeric_report"; + case "input_template": + return "input_template"; + case "summary_report": + return "summary_report"; + default: + return "office_clean"; + } +} + +function columnsFromAddress(address: string): string[] { + const parsed = tryParseA1Address(stripSheetName(address)); + if (!parsed) return []; + const columns: string[] = []; + for (let column = parsed.startColumn; column <= parsed.endColumn; column += 1) { + columns.push(numberToColumn(column)); + } + return columns; +} + +function uniqueDefined(values: Array): string[] { + return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))]; +} + function shouldDeriveAsFormula(input: AgentRunInput): boolean { const values = input.values ?? {}; const mode = normalizeOperationName(keyedStringValue(values, "outputMode", "mode", "writeMode") ?? ""); diff --git a/apps/backend/src/agent-routing.test.ts b/apps/backend/src/agent-routing.test.ts index 828f9ad..7806802 100644 --- a/apps/backend/src/agent-routing.test.ts +++ b/apps/backend/src/agent-routing.test.ts @@ -25,7 +25,8 @@ describe("agent workflow routing", () => { ["read_style_summary", "style.inspect", "targeted_read"], ["format_diagnostics", "format.diagnostics", "targeted_read"], ["find_target", "semantic_index.find", "metadata_only"], - ["write_values", "mutation.preview", "preview_only"] + ["write_values", "mutation.preview", "preview_only"], + ["improve_visual_readability", "mutation.preview", "preview_only"] ] as const)("prefers structured intent action %s for workflow routing", (action, workflowRoute, readPolicy) => { const route = routeAgentRequest("Thai or mixed language request", "auto", { source: "caller_structured", diff --git a/apps/backend/src/agent-routing.ts b/apps/backend/src/agent-routing.ts index 1f7f735..315a3df 100644 --- a/apps/backend/src/agent-routing.ts +++ b/apps/backend/src/agent-routing.ts @@ -138,6 +138,7 @@ function routeAgentWorkflow( if (action === "read_values") return workflow("range.read", intent?.confidence ?? 0.85, "Structured value read action.", "sampled_allowed", "targeted_read"); if (action === "read_formulas" || action === "read_formula_patterns" || action === "explain_formula") return workflow("range.read", intent?.confidence ?? 0.9, "Structured formula inspection action.", "sampled_allowed", "targeted_read"); if (action === "find_similar_rows") return workflow("range.read", intent?.confidence ?? 0.88, "Structured similar-row reference search action.", "sampled_allowed", "targeted_read"); + if (action === "improve_visual_readability") return workflow("mutation.preview", intent?.confidence ?? 0.9, "Structured visual readability preview action.", "sampled_allowed", "preview_only"); if (action && modeForIntentAction(action) === "preview_update") return workflow("mutation.preview", intent?.confidence ?? 0.9, "Structured mutation action.", "sampled_allowed", "preview_only"); const lower = request.toLowerCase(); diff --git a/apps/backend/src/agent-target-resolver.ts b/apps/backend/src/agent-target-resolver.ts index 764aba3..2ed5b1a 100644 --- a/apps/backend/src/agent-target-resolver.ts +++ b/apps/backend/src/agent-target-resolver.ts @@ -66,6 +66,8 @@ interface CandidateScore { } const A1_RANGE_PATTERN = /\b\$?[A-Z]{1,3}\$?\d+(?:\s*:\s*\$?[A-Z]{1,3}\$?\d+)?\b/i; +const ROW_RANGE_PATTERN = /\b(?:rows?\s+(\d{1,7})(?:\s*(?::|to|through|-)\s*(\d{1,7}))?|(\d{1,7})(?:\s*(?::|to|through|-)\s*(\d{1,7}))?\s+rows?)\b/i; +const COLUMN_RANGE_PATTERN = /\b(?:cols?|columns?)\s+([A-Z]{1,3})(?:\s*(?::|to|through|-)\s*([A-Z]{1,3}))?\b/i; export type AgentTargetResolution = { ok: true; @@ -234,7 +236,7 @@ function canonicalSheetFromExplicitTarget(candidates: AgentCandidate[] | undefin } function resolveExplicitSheetRangeTarget(metadata: WorkbookMetadata, input: AgentRunInput): Extract | undefined { - const range = input.target?.range; + const range = targetRange(input); if (!range) { return undefined; } @@ -267,6 +269,16 @@ function resolveExplicitSheetRangeTarget(metadata: WorkbookMetadata, input: Agen }; } +function targetRange(input: AgentRunInput): string | undefined { + if (input.target?.range ?? input.target?.address) { + return input.target.range ?? input.target.address; + } + if (typeof input.target?.row === "number" && Number.isInteger(input.target.row) && input.target.row > 0) { + return `${input.target.row}:${input.target.row}`; + } + return undefined; +} + function splitSheetQualifiedAddress(address: string): { sheetName: string; range: string } | undefined { const bang = address.lastIndexOf("!"); if (bang < 0) { @@ -354,15 +366,18 @@ function resolveExactAgentTarget( return exactSourceResolution({ ...sheetSource, range: parsedReference.range }, options); } } - const sheetHeaderBlock = resolveExactSheetHeaderBlock(metadata, input.request, options); - if (sheetHeaderBlock) { - return sheetHeaderBlock; + const sheetRangeMention = resolveMentionedSheetRange(metadata, input.request, options); + if (sheetRangeMention) { + return sheetRangeMention; } - const mentionedTable = resolveMentionedTable(metadata, input.request, options); if (mentionedTable) { return mentionedTable; } + const sheetHeaderBlock = resolveExactSheetHeaderBlock(metadata, input.request, options); + if (sheetHeaderBlock) { + return sheetHeaderBlock; + } const mentionedNamedRegion = resolveMentionedNamedRegion(metadata, input.request, options); if (mentionedNamedRegion) { @@ -450,14 +465,18 @@ function resolveSelectedTarget( warnings: ["Reload or reopen the OpenWorkbook Local taskpane if Excel is connected, then select a cell/range in Excel and retry the request."] }; } - const range = requestMentionsSelectedColumn(request) ? selectedColumnRange(metadata, selection) : selection.address; + const range = requestMentionsSelectedColumn(request) + ? selectedColumnRange(metadata, selection) + : requestMentionsSelectedRow(request) + ? selectedRowRange(selection) + : selection.address; return exactSourceResolution({ kind: "range", id: "selection:active", - label: requestMentionsSelectedColumn(request) ? "selected column" : selection.isSingleCell ? "selected cell" : "selected range", + label: requestMentionsSelectedColumn(request) ? "selected column" : requestMentionsSelectedRow(request) ? "selected row" : selection.isSingleCell ? "selected cell" : "selected range", sheetName: selection.sheetName, range, - searchValues: ["selected", "selection", "active cell", "current cell", "selected range", "selected column"] + searchValues: ["selected", "selection", "active cell", "current cell", "selected range", "selected column", "selected row"] }, options); } @@ -608,13 +627,24 @@ function selectedCellNeighborhoodRange(metadata: WorkbookMetadata, selection: No } function requestMentionsSelection(request: string): boolean { - return /\b(selection|highlighted|active cell|current cell|this cell|this range|this column|selected (?:cell|range|col(?:umn)?)|active column|current column)\b/i.test(request); + return /\b(selection|highlighted|active cell|current cell|this cell|this range|this column|this row|selected (?:cell|range|row|col(?:umn)?)|active column|active row|current column|current row)\b/i.test(request); } function requestMentionsSelectedColumn(request: string): boolean { return /\b(this|selected|active|current)\s+col(?:umn)?\b/i.test(request); } +function requestMentionsSelectedRow(request: string): boolean { + return /\b(this|selected|active|current)\s+row\b/i.test(request); +} + +function selectedRowRange(selection: NonNullable): string { + if (!selection.isSingleCell) { + return selection.address; + } + return `${selection.startCell.row}:${selection.startCell.row}`; +} + function selectedColumnRange(metadata: WorkbookMetadata, selection: NonNullable): string { if (!selection.isSingleCell) { return selection.address; @@ -834,6 +864,28 @@ function resolveMentionedTable( return undefined; } +function resolveMentionedSheetRange( + metadata: WorkbookMetadata, + request: string, + options: { requireRange: boolean; kindFilter?: Set } +): AgentTargetResolution | undefined { + const matches = metadata.sheets + .filter((sheet) => requestMentionsSheet(request, sheet.name)) + .map((sheet) => ({ sheet, range: parseRangeForSheetRequest(request, sheet.name) })) + .filter((entry): entry is { sheet: WorkbookMetadata["sheets"][number]; range: string } => Boolean(entry.range)); + if (matches.length !== 1 || !matches[0]) { + return undefined; + } + return exactSourceResolution({ + kind: "range", + id: `range:${matches[0].sheet.name}:${matches[0].range}`, + label: `${matches[0].sheet.name}!${matches[0].range}`, + sheetName: matches[0].sheet.name, + range: matches[0].range, + searchValues: [matches[0].sheet.name, matches[0].range] + }, options); +} + function toCandidate(source: CandidateSource, confidence: number | CandidateScore): AgentCandidate { const score = typeof confidence === "number" ? { confidence, matchedTargetHint: false } : confidence; const boundedConfidence = Number(Math.min(1, Math.max(0, score.confidence)).toFixed(3)); @@ -935,7 +987,7 @@ function semanticRoleForSheetKind(kind: WorkbookMetadata["sheets"][number]["kind } function parseSheetRangeReference(request: string): { sheetName: string; range: string } | undefined { - const a1Pattern = /(\$?[A-Z]{1,3}\$?\d+(?:\s*:\s*\$?[A-Z]{1,3}\$?\d+)?|\$?[A-Z]{1,3}\s*:\s*\$?[A-Z]{1,3})/i; + const a1Pattern = /(\$?[A-Z]{1,3}\$?\d+(?:\s*:\s*\$?[A-Z]{1,3}\$?\d+)?|\$?[A-Z]{1,3}\s*:\s*\$?[A-Z]{1,3}|\d{1,7}\s*:\s*\d{1,7})/i; const quoted = new RegExp(`['"]([^'"]+)['"]!\\s*${a1Pattern.source}`, "i").exec(request); if (quoted?.[1] && quoted[2]) { return { sheetName: quoted[1], range: normalizeA1Range(quoted[2]) }; @@ -960,9 +1012,45 @@ function parseRangeForSheetRequest(request: string, sheetName: string): string | if (explicit?.[1]) { return normalizeA1Range(explicit[1]); } + const rowRange = explicitRowRangeForSheetRequest(request); + if (rowRange) { + return rowRange; + } + const columnRange = explicitColumnRangeForSheetRequest(request); + if (columnRange) { + return columnRange; + } return undefined; } +function explicitRowRangeForSheetRequest(request: string): string | undefined { + if (!/\brows?\b/i.test(request)) { + return undefined; + } + const match = ROW_RANGE_PATTERN.exec(request); + const startText = match?.[1] ?? match?.[3]; + const endText = match?.[2] ?? match?.[4] ?? startText; + if (!startText) { + return undefined; + } + const start = Number(startText); + const end = Number(endText); + if (!Number.isInteger(start) || !Number.isInteger(end) || start <= 0 || end <= 0) { + return undefined; + } + return `${Math.min(start, end)}:${Math.max(start, end)}`; +} + +function explicitColumnRangeForSheetRequest(request: string): string | undefined { + const match = COLUMN_RANGE_PATTERN.exec(request); + if (!match?.[1]) { + return undefined; + } + const start = match[1].toUpperCase(); + const end = (match[2] ?? match[1]).toUpperCase(); + return `${start}:${end}`; +} + function resolveExactSheetHeaderBlock( metadata: WorkbookMetadata, request: string, diff --git a/apps/backend/src/capabilities/registry.test.ts b/apps/backend/src/capabilities/registry.test.ts index 0df284d..c8addb4 100644 --- a/apps/backend/src/capabilities/registry.test.ts +++ b/apps/backend/src/capabilities/registry.test.ts @@ -17,7 +17,7 @@ describe("backend capability registry", () => { const catalog = listExcelCapabilities(); const registry = listBackendCapabilityRegistry(); - expect(catalog).toHaveLength(308); + expect(catalog).toHaveLength(309); expect(registry).toHaveLength(catalog.length); expect(new Set(registry.map((entry) => entry.name)).size).toBe(catalog.length); expect(registry.map((entry) => entry.name).sort()).toEqual(catalog.map((capability) => capability.name).sort()); diff --git a/apps/backend/src/capabilities/registry.ts b/apps/backend/src/capabilities/registry.ts index ec0083f..f436447 100644 --- a/apps/backend/src/capabilities/registry.ts +++ b/apps/backend/src/capabilities/registry.ts @@ -401,6 +401,7 @@ const HOST_METHODS_BY_CAPABILITY: Record = Object.fromEntries( ["excel.sheet.unprotect", ["operation.execute_batch"]], ["excel.sheet.clear", ["operation.execute_batch"]], ["excel.sheet.set_tab_color", ["operation.execute_batch"]], + ["excel.sheet.freeze_panes", ["operation.execute_batch"]], ["excel.range.read_hyperlinks", ["range.read_hyperlinks"]], ["excel.range.read_comments", ["range.read_comments"]], ["excel.range.read_notes", ["range.read_notes"]], @@ -522,6 +523,7 @@ const OPERATION_KINDS_BY_CAPABILITY: Record = Object.fromEntri ["excel.sheet.unprotect", ["sheet.unprotect"]], ["excel.sheet.clear", ["sheet.clear"]], ["excel.sheet.set_tab_color", ["sheet.set_tab_color"]], + ["excel.sheet.freeze_panes", ["sheet.freeze_panes"]], ["excel.range.write_values", ["range.write_values"]], ["excel.range.write_values_many", ["range.write_values_many"]], ["excel.range.write_formulas", ["range.write_formulas"]], diff --git a/apps/backend/src/excel-capabilities.test.ts b/apps/backend/src/excel-capabilities.test.ts index 2f92665..88324c4 100644 --- a/apps/backend/src/excel-capabilities.test.ts +++ b/apps/backend/src/excel-capabilities.test.ts @@ -17,7 +17,7 @@ describe("excel capabilities", () => { it("keeps Excel operations available as internal backend capabilities", () => { const capabilities = listExcelCapabilities(); - expect(capabilities.length).toBe(308); + expect(capabilities.length).toBe(309); expect(getExcelCapability("excel.agent.run")).toBeTruthy(); expect(getExcelCapability("excel.range.write_values")?.mutatesWorkbook).toBe(true); expect(getExcelCapability("excel.range.write_data_validation")?.mutatesWorkbook).toBe(true); @@ -45,7 +45,7 @@ describe("excel capabilities", () => { const exposed = getPublicAgentToolCatalog(); expect(exposed.map((tool) => tool.name)).toEqual(["excel.agent.run"]); - expect(summary.total).toBe(308); + expect(summary.total).toBe(309); expect(summary.exposed).toBe(0); expect(summary.capabilities.some((capability) => capability.name === "excel.range.read_compact")).toBe(true); }); diff --git a/apps/backend/src/runtime-service.ts b/apps/backend/src/runtime-service.ts index 53fee8e..2766da9 100644 --- a/apps/backend/src/runtime-service.ts +++ b/apps/backend/src/runtime-service.ts @@ -7868,7 +7868,6 @@ function scopesFromOperation(workbookId: WorkbookId, operation: ExcelOperation): case "range.write_values": case "range.write_number_formats": case "range.write_styles": - case "range.write_data_validation": case "range.write_conditional_formatting": case "range.clear_style_dimensions": case "range.write_hyperlinks": @@ -7891,6 +7890,12 @@ function scopesFromOperation(workbookId: WorkbookId, operation: ExcelOperation): case "range.unmerge": case "range.restore_snapshot": return [rangeScope(operation.target)]; + case "range.write_data_validation": + return operation.entries?.length + ? operation.entries.map((entry) => rangeScope(entry.target)) + : operation.target + ? [rangeScope(operation.target)] + : []; case "range.reorder_columns": return [rangeScope(operation.target)]; case "range.write_values_many": @@ -7940,6 +7945,7 @@ function scopesFromOperation(workbookId: WorkbookId, operation: ExcelOperation): case "sheet.unprotect": case "sheet.clear": case "sheet.set_tab_color": + case "sheet.freeze_panes": return [{ type: "sheet", workbookId, sheetName: operation.sheetName }]; } } diff --git a/apps/excel-addin/package.json b/apps/excel-addin/package.json index b0b9127..c826c10 100644 --- a/apps/excel-addin/package.json +++ b/apps/excel-addin/package.json @@ -1,6 +1,6 @@ { "name": "@components-kit/open-workbook-excel-addin", - "version": "0.1.24", + "version": "0.1.25", "description": "Office.js Excel add-in runtime for Open Workbook local automation.", "license": "MIT", "private": true, diff --git a/apps/excel-addin/public/taskpane.html b/apps/excel-addin/public/taskpane.html index a3f396c..12c30c3 100644 --- a/apps/excel-addin/public/taskpane.html +++ b/apps/excel-addin/public/taskpane.html @@ -23,6 +23,6 @@
- + diff --git a/apps/excel-addin/src/host/executor-core.batch.test.ts b/apps/excel-addin/src/host/executor-core.batch.test.ts index 5b290a8..5da2a07 100644 --- a/apps/excel-addin/src/host/executor-core.batch.test.ts +++ b/apps/excel-addin/src/host/executor-core.batch.test.ts @@ -23,7 +23,7 @@ describe("Office.js batch executor production operations", () => { reason: "Style header", target: range("Sales", "A1:E1"), preserveValues: true, - style: { fillColor: "#000000", fontColor: "#FFFFFF", fontBold: true, horizontalAlignment: "center" } + style: { fillColor: "#000000", fontColor: "#FFFFFF", fontBold: true, horizontalAlignment: "center", columnWidth: 15 } }, { kind: "range.write_styles_many", @@ -80,14 +80,15 @@ describe("Office.js batch executor production operations", () => { expect(result.ok).toBe(true); expect(result.warnings).toEqual([]); expect(result.diffSummary?.destructiveLevel).toBe("structure"); - expect(result.telemetry).toMatchObject({ syncCount: 2, rangeCount: compiled.targetFingerprints.length, warningCount: 0 }); - expect(fixture.syncCount).toBe(2); + expect(result.telemetry).toMatchObject({ syncCount: 3, rangeCount: compiled.targetFingerprints.length, warningCount: 0 }); + expect(fixture.syncCount).toBe(3); expect(fixture.calls).toEqual(expect.arrayContaining([ { type: "getRange", sheetName: "Sales", address: "A1:E1" }, { type: "style", address: "A1:E1", property: "fill.color", value: "#000000" }, { type: "style", address: "A1:E1", property: "font.color", value: "#FFFFFF" }, { type: "style", address: "A1:E1", property: "font.bold", value: true }, - { type: "style", address: "A1:E1", property: "horizontalAlignment", value: "center" }, + { type: "style", address: "A1:E1", property: "horizontalAlignment", value: "Center" }, + { type: "style", address: "A1:E1", property: "columnWidth", value: 78.75 }, { type: "insert", address: "F:F", shift: "right" }, { type: "dataValidation.rule", address: "E2:E6", source: "Open,Reviewed,Closed" }, { type: "conditionalFormats.add", address: "A2:E6", formatType: "custom" }, @@ -96,7 +97,18 @@ describe("Office.js batch executor production operations", () => { { type: "conditionalStyle", address: "A2:E6", property: "font.bold", value: true } ])); expect(fixture.calls.some((call) => call.type === "worksheet.add" && call.sheetName.startsWith("__owb_reorder_"))).toBe(true); - expect(fixture.calls.some((call) => call.type === "copyFrom" && call.address === "A1:B6" && String(call.source).includes("__owb_reorder_"))).toBe(true); + expect(fixture.calls.some((call) => call.type === "copyFrom" && call.address === "A1:B6:col0" && String(call.source).includes("__owb_reorder_"))).toBe(true); + expect(fixture.calls.some((call) => call.type === "copyFrom" && call.address === "A1:B6:col1" && String(call.source).includes("__owb_reorder_"))).toBe(true); + expect(fixture.calls).toContainEqual({ + type: "set", + address: "A1:B6", + property: "formulasR1C1", + value: expect.arrayContaining([ + ["=A1:B6_R1C2", "=A1:B6_R1C1"] + ]) + }); + expect(fixture.calls).toContainEqual({ type: "style", address: "A1:B6:col0", property: "columnWidth", value: 18 }); + expect(fixture.calls).toContainEqual({ type: "style", address: "A1:B6:col1", property: "columnWidth", value: 12 }); expect(fixture.calls.some((call) => call.type === "worksheet.delete" && String(call.sheetName).includes("__owb_reorder_"))).toBe(true); }); @@ -196,6 +208,7 @@ describe("Office.js batch executor production operations", () => { op({ kind: "sheet.unprotect", sheetName: "Ops", password: "secret" }), op({ kind: "sheet.clear", sheetName: "Ops", applyTo: "contents" }), op({ kind: "sheet.set_tab_color", sheetName: "Ops", color: "#00B050" }), + op({ kind: "sheet.freeze_panes", sheetName: "Ops", columns: 1 }), op({ kind: "template.create_sheet_from_template", templateId: "template_ops" as any, newSheetName: "From Template", clearDataRegions: true }), op({ kind: "sheet.delete", sheetName: "Ops Clean" }) ]; @@ -237,6 +250,7 @@ describe("Office.js batch executor production operations", () => { { type: "worksheet.unprotect", sheetName: "Ops", password: "secret" }, { type: "getUsedRangeOrNullObject", sheetName: "Ops" }, { type: "worksheet.tabColor", sheetName: "Ops", value: "#00B050" }, + { type: "freezePanes.freezeColumns", sheetName: "Ops", count: 1 }, { type: "worksheet.delete", sheetName: "Ops Clean" } ])); }); @@ -334,6 +348,7 @@ const BEHAVIOR_COVERED_OPERATION_KINDS = new Set([ "sheet.unprotect", "sheet.clear", "sheet.set_tab_color", + "sheet.freeze_panes", "workbook.calculate", "workbook.save", "template.create_sheet_from_template" @@ -432,6 +447,12 @@ class FakeWorksheet { protect: (options: unknown, password?: string) => this.fixture.calls.push({ type: "worksheet.protect", sheetName: this.name, options, password }), unprotect: (password?: string) => this.fixture.calls.push({ type: "worksheet.unprotect", sheetName: this.name, password }) }; + readonly freezePanes = { + freezeRows: (count: number) => this.fixture.calls.push({ type: "freezePanes.freezeRows", sheetName: this.name, count }), + freezeColumns: (count: number) => this.fixture.calls.push({ type: "freezePanes.freezeColumns", sheetName: this.name, count }), + freezeAt: (range: FakeRange) => this.fixture.calls.push({ type: "freezePanes.freezeAt", sheetName: this.name, address: range.address }), + unfreeze: () => this.fixture.calls.push({ type: "freezePanes.unfreeze", sheetName: this.name }) + }; constructor(private readonly fixture: ExcelFixture, private sheetName: string) {} @@ -517,6 +538,16 @@ class FakeRange { this.fixture.calls.push({ type: "set", address: this.address, property: "formulas", value }); } + get formulasR1C1() { + return Array.from({ length: this.rowCount }, (_, rowIndex) => + Array.from({ length: this.columnCount }, (_, columnIndex) => `=${this.address}_R${rowIndex + 1}C${columnIndex + 1}`) + ); + } + + set formulasR1C1(value: unknown[][]) { + this.fixture.calls.push({ type: "set", address: this.address, property: "formulasR1C1", value }); + } + set numberFormat(value: unknown[][]) { this.fixture.calls.push({ type: "set", address: this.address, property: "numberFormat", value }); } @@ -582,6 +613,10 @@ class FakeRangeFormat { }; } + load(propertyPath: string) { + this.fixture.calls.push({ type: "format.load", address: this.address, propertyPath }); + } + set horizontalAlignment(value: string) { this.fixture.calls.push({ type: "style", address: this.address, property: "horizontalAlignment", value }); } @@ -590,6 +625,19 @@ class FakeRangeFormat { this.fixture.calls.push({ type: "style", address: this.address, property: "verticalAlignment", value }); } + set wrapText(value: boolean) { + this.fixture.calls.push({ type: "style", address: this.address, property: "wrapText", value }); + } + + set columnWidth(value: number) { + this.fixture.calls.push({ type: "style", address: this.address, property: "columnWidth", value }); + } + + get columnWidth() { + const columnIndex = /:col(\d+)$/.exec(this.address)?.[1]; + return columnIndex === undefined ? 12 : 12 + Number(columnIndex) * 6; + } + autofitColumns() { this.fixture.calls.push({ type: "autofitColumns", address: this.address }); } diff --git a/apps/excel-addin/src/host/executor-core.ts b/apps/excel-addin/src/host/executor-core.ts index 355b6b9..daa6418 100644 --- a/apps/excel-addin/src/host/executor-core.ts +++ b/apps/excel-addin/src/host/executor-core.ts @@ -88,7 +88,7 @@ interface ExecutionCounters { const ENGINE_NAME = "office-js-addin"; const ENGINE_VERSION = OPEN_WORKBOOK_VERSION; -const TASKPANE_BUNDLE_VERSION = "20260622-9"; +const TASKPANE_BUNDLE_VERSION = "20260626-3"; const CHUNK_CELL_LIMIT = 50_000; const OPEN_WORKBOOK_CUSTOM_XML_NAMESPACE = "https://open-workbook.dev/schema/local-config/1"; const EXCEL_API_VERSIONS = ["1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8", "1.9", "1.10", "1.11", "1.12", "1.13", "1.14", "1.15", "1.16", "1.17"] as const; @@ -977,17 +977,31 @@ export async function reorderTableColumns(request: TableReorderColumnsRequest): } const tableRange = table.getRange(); - tableRange.load("rowCount,columnCount"); + tableRange.load("rowCount,columnCount,formulasR1C1"); await context.sync(); + const sourceColumns = Array.from({ length: tableRange.columnCount }, (_, columnIndex) => tableRange.getColumn(columnIndex)); + for (const column of sourceColumns) { + column.format.load("columnWidth"); + } + await context.sync(); + + const originalFormulasR1C1 = cloneMatrix((tableRange as unknown as { formulasR1C1?: unknown[][] }).formulasR1C1 ?? []); + const reorderedFormulasR1C1 = reorderMatrixColumns(originalFormulasR1C1, sourceIndexes, tableRange.rowCount, tableRange.columnCount); + const reorderedColumnWidths = sourceIndexes.map((sourceIndex) => sourceColumns[sourceIndex]?.format.columnWidth); + const scratchSheet = context.workbook.worksheets.add(`__owb_reorder_${Date.now().toString(36)}`); const originalRange = scratchSheet.getRangeByIndexes(0, 0, tableRange.rowCount, tableRange.columnCount); - const reorderedRange = scratchSheet.getRangeByIndexes(0, tableRange.columnCount + 1, tableRange.rowCount, tableRange.columnCount); originalRange.copyFrom(tableRange, Excel.RangeCopyType.all); for (const [targetIndex, sourceIndex] of sourceIndexes.entries()) { - reorderedRange.getColumn(targetIndex).copyFrom(originalRange.getColumn(sourceIndex), Excel.RangeCopyType.all); + tableRange.getColumn(targetIndex).copyFrom(originalRange.getColumn(sourceIndex), Excel.RangeCopyType.all); + } + (tableRange as unknown as { formulasR1C1: unknown[][] }).formulasR1C1 = reorderedFormulasR1C1; + for (const [targetIndex, width] of reorderedColumnWidths.entries()) { + if (typeof width === "number" && Number.isFinite(width)) { + tableRange.getColumn(targetIndex).format.columnWidth = width; + } } - tableRange.copyFrom(reorderedRange, Excel.RangeCopyType.all); scratchSheet.delete(); const reloaded = loadTableInfoObjects(table); @@ -1307,7 +1321,14 @@ export async function executeBatch(payload: AddinExecuteBatchRequest): Promise { const worksheet = context.workbook.worksheets.getItem(request.sheetName); const range = request.address ? worksheet.getRange(stripSheetName(request.address)) : worksheet.getUsedRangeOrNullObject(); + const freezeLocation = getFreezePanesLocationOrNullObject(worksheet); range.load("address, rowCount, columnCount, numberFormat"); + freezeLocation?.load("address, rowIndex, columnIndex, rowCount, columnCount"); range.format.load("rowHeight, columnWidth, horizontalAlignment, verticalAlignment, wrapText"); range.format.fill.load("color"); range.format.font.load("name, size, color, bold, italic, underline"); @@ -1850,9 +1877,7 @@ export async function captureStyleFingerprint(request: StyleFingerprintRequest): dataValidation: { note: "Use excel.range.read_data_validation for detailed rule inspection." }, - freezePanes: { - note: "Office.js freeze pane capture is tracked as a layout capability." - }, + freezePanes: freezePanesSummary(freezeLocation), printSettings: { note: "Office.js print setting capture is tracked as a layout capability." }, @@ -1868,6 +1893,52 @@ export async function captureStyleFingerprint(request: StyleFingerprintRequest): }); } +function getFreezePanesLocationOrNullObject(worksheet: Excel.Worksheet): Excel.Range | undefined { + const freezePanes = (worksheet as unknown as { freezePanes?: { getLocationOrNullObject?: () => Excel.Range } }).freezePanes; + return freezePanes?.getLocationOrNullObject?.(); +} + +function freezePanesSummary(location: Excel.Range | undefined): Record { + if (!location) { + return { + readable: false, + message: "Office.js freeze panes API is unavailable in this host." + }; + } + if ((location as unknown as { isNullObject?: boolean }).isNullObject) { + return { + readable: true, + frozen: false, + rows: 0, + columns: 0 + }; + } + const rows = typeof location.rowCount === "number" && Number.isFinite(location.rowCount) ? Math.max(0, location.rowCount) : undefined; + const columns = typeof location.columnCount === "number" && Number.isFinite(location.columnCount) ? Math.max(0, location.columnCount) : undefined; + return { + readable: true, + frozen: true, + address: stripSheetName(location.address), + rowIndex: location.rowIndex, + columnIndex: location.columnIndex, + ...(rows !== undefined ? { rows } : {}), + ...(columns !== undefined ? { columns } : {}), + ...(columns !== undefined && columns > 0 ? { lastFrozenColumn: columnLetterFromIndex(columns - 1), firstUnfrozenColumn: columnLetterFromIndex(columns) } : {}), + ...(rows !== undefined && rows > 0 ? { lastFrozenRow: rows, firstUnfrozenRow: rows + 1 } : {}) + }; +} + +function columnLetterFromIndex(index: number): string { + let value = Math.floor(index) + 1; + let label = ""; + while (value > 0) { + const remainder = (value - 1) % 26; + label = String.fromCharCode(65 + remainder) + label; + value = Math.floor((value - 1) / 26); + } + return label; +} + export async function copyStyleDimensions(request: StyleCopyRequest): Promise { return Excel.run(async (context) => copyStyleDimensionsInContext(context, request)); } @@ -2955,10 +3026,10 @@ function restoreRangeSnapshot( range.format.font.italic = style.fontItalic; } if (style.horizontalAlignment) { - range.format.horizontalAlignment = style.horizontalAlignment as Excel.HorizontalAlignment; + range.format.horizontalAlignment = toHorizontalAlignment(style.horizontalAlignment); } if (style.verticalAlignment) { - range.format.verticalAlignment = style.verticalAlignment as Excel.VerticalAlignment; + range.format.verticalAlignment = toVerticalAlignment(style.verticalAlignment); } if (style.rowHeight !== undefined) { range.format.rowHeight = style.rowHeight; @@ -2988,22 +3059,116 @@ function applyRangeStyle(range: Excel.Range, style: NonNullable 60) { + return width; + } + + // Agents and users talk about Excel column widths in UI "character" units + // (for example, width 15). Office.js applies RangeFormat.columnWidth in + // physical width units, so convert the common UI scale before assigning. + const maxDigitPixelWidth = 7; + const padding = Math.trunc(128 / maxDigitPixelWidth); + const pixels = Math.trunc(((256 * width + padding) / 256) * maxDigitPixelWidth); + return Math.round(pixels * 0.75 * 100) / 100; +} + +function toHorizontalAlignment(value: string): Excel.HorizontalAlignment { + const normalized = value.trim().toLowerCase().replace(/[\s_-]+/g, ""); + const enumValues = Excel.HorizontalAlignment as unknown as Record; + switch (normalized) { + case "center": + case "centre": + case "centered": + case "centred": + return enumValues.center ?? ("Center" as Excel.HorizontalAlignment); + case "left": + return enumValues.left ?? ("Left" as Excel.HorizontalAlignment); + case "right": + return enumValues.right ?? ("Right" as Excel.HorizontalAlignment); + case "fill": + return enumValues.fill ?? ("Fill" as Excel.HorizontalAlignment); + case "justify": + return enumValues.justify ?? ("Justify" as Excel.HorizontalAlignment); + case "distributed": + return enumValues.distributed ?? ("Distributed" as Excel.HorizontalAlignment); + case "general": + return enumValues.general ?? ("General" as Excel.HorizontalAlignment); + default: + return value as Excel.HorizontalAlignment; + } +} + +function toVerticalAlignment(value: string): Excel.VerticalAlignment { + const normalized = value.trim().toLowerCase().replace(/[\s_-]+/g, ""); + const enumValues = Excel.VerticalAlignment as unknown as Record; + switch (normalized) { + case "center": + case "centre": + case "middle": + case "centered": + case "centred": + return enumValues.center ?? ("Center" as Excel.VerticalAlignment); + case "top": + return enumValues.top ?? ("Top" as Excel.VerticalAlignment); + case "bottom": + return enumValues.bottom ?? ("Bottom" as Excel.VerticalAlignment); + case "justify": + return enumValues.justify ?? ("Justify" as Excel.VerticalAlignment); + case "distributed": + return enumValues.distributed ?? ("Distributed" as Excel.VerticalAlignment); + default: + return value as Excel.VerticalAlignment; + } +} + +function applyFreezePanes( + worksheet: Excel.Worksheet, + operation: Extract +): void { + const freezePanes = (worksheet as unknown as { freezePanes?: { freezeAt?: (range: Excel.Range) => void; freezeRows?: (count: number) => void; freezeColumns?: (count: number) => void; unfreeze?: () => void } }).freezePanes; + if (!freezePanes) { + throw new Error("Office.js freeze panes API is unavailable in this host."); + } + const rows = Math.max(0, Math.floor(operation.rows ?? 0)); + const columns = Math.max(0, Math.floor(operation.columns ?? 0)); + if (rows > 0 && columns > 0) { + freezePanes.freezeAt?.(worksheet.getRangeByIndexes(rows, columns, 1, 1)); + return; + } + if (rows > 0) { + freezePanes.freezeRows?.(rows); + return; + } + if (columns > 0) { + freezePanes.freezeColumns?.(columns); + return; + } + freezePanes.unfreeze?.(); +} + function applyDataValidation( range: Excel.Range, validation: Extract["validation"] @@ -3078,7 +3243,7 @@ async function reorderRangeColumns( columnOrder: Array ): Promise { const range = getRange(context, target); - range.load("rowCount,columnCount"); + range.load("rowCount,columnCount,formulasR1C1"); await context.sync(); const sourceIndexes = resolveColumnOrder(columnOrder, range.columnCount); @@ -3086,17 +3251,44 @@ async function reorderRangeColumns( throw new Error("Column order must resolve to every column in the target range exactly once."); } + const sourceColumns = Array.from({ length: range.columnCount }, (_, columnIndex) => range.getColumn(columnIndex)); + for (const column of sourceColumns) { + column.format.load("columnWidth"); + } + await context.sync(); + + const originalFormulasR1C1 = cloneMatrix((range as unknown as { formulasR1C1?: unknown[][] }).formulasR1C1 ?? []); + const reorderedFormulasR1C1 = reorderMatrixColumns(originalFormulasR1C1, sourceIndexes, range.rowCount, range.columnCount); + const reorderedColumnWidths = sourceIndexes.map((sourceIndex) => sourceColumns[sourceIndex]?.format.columnWidth); + const scratchSheet = context.workbook.worksheets.add(`__owb_reorder_${Date.now().toString(36)}`); const originalRange = scratchSheet.getRangeByIndexes(0, 0, range.rowCount, range.columnCount); - const reorderedRange = scratchSheet.getRangeByIndexes(0, range.columnCount + 1, range.rowCount, range.columnCount); originalRange.copyFrom(range, Excel.RangeCopyType.all); for (const [targetIndex, sourceIndex] of sourceIndexes.entries()) { - reorderedRange.getColumn(targetIndex).copyFrom(originalRange.getColumn(sourceIndex), Excel.RangeCopyType.all); + range.getColumn(targetIndex).copyFrom(originalRange.getColumn(sourceIndex), Excel.RangeCopyType.all); + } + (range as unknown as { formulasR1C1: unknown[][] }).formulasR1C1 = reorderedFormulasR1C1; + for (const [targetIndex, width] of reorderedColumnWidths.entries()) { + if (typeof width === "number" && Number.isFinite(width)) { + range.getColumn(targetIndex).format.columnWidth = width; + } } - range.copyFrom(reorderedRange, Excel.RangeCopyType.all); scratchSheet.delete(); } +function reorderMatrixColumns(matrix: unknown[][], sourceIndexes: number[], rowCount: number, columnCount: number): unknown[][] { + return Array.from({ length: rowCount }, (_, rowIndex) => + Array.from({ length: columnCount }, (_, targetIndex) => { + const sourceIndex = sourceIndexes[targetIndex] ?? targetIndex; + return matrix[rowIndex]?.[sourceIndex] ?? null; + }) + ); +} + +function cloneMatrix(matrix: unknown[][]): unknown[][] { + return matrix.map((row) => [...row]); +} + function resolveColumnOrder(columnOrder: Array, columnCount: number): number[] { const indexes = columnOrder.map((item) => { if (typeof item === "number" && Number.isInteger(item)) { diff --git a/apps/excel-addin/src/host/registry.ts b/apps/excel-addin/src/host/registry.ts index 81d3c54..b1022c0 100644 --- a/apps/excel-addin/src/host/registry.ts +++ b/apps/excel-addin/src/host/registry.ts @@ -64,6 +64,7 @@ export const BATCH_OPERATION_KINDS = [ "sheet.unprotect", "sheet.clear", "sheet.set_tab_color", + "sheet.freeze_panes", "template.create_sheet_from_template" ] as const; @@ -310,6 +311,7 @@ export const HOST_METHOD_REGISTRY: HostMethodDefinition[] = [ "excel.sheet.unprotect", "excel.sheet.clear", "excel.sheet.set_tab_color", + "excel.sheet.freeze_panes", "excel.template.create_sheet_from_template" ], operationKinds: [...BATCH_OPERATION_KINDS], diff --git a/apps/excel-addin/src/host/runtime-version.test.ts b/apps/excel-addin/src/host/runtime-version.test.ts index 9845853..677aada 100644 --- a/apps/excel-addin/src/host/runtime-version.test.ts +++ b/apps/excel-addin/src/host/runtime-version.test.ts @@ -27,6 +27,6 @@ describe("add-in runtime version", () => { }; expect(getRuntimeCapabilities().engine.version).toBe(OPEN_WORKBOOK_VERSION); - expect(getRuntimeCapabilities().engine.taskpaneBundleVersion).toBe("20260622-9"); + expect(getRuntimeCapabilities().engine.taskpaneBundleVersion).toBe("20260626-3"); }); }); diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index db9c81d..01af7cf 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@components-kit/open-workbook-mcp-server", - "version": "0.1.24", + "version": "0.1.25", "description": "MCP stdio server exposing the Open Workbook excel.agent.run workflow surface.", "license": "MIT", "type": "module", diff --git a/apps/mcp-server/src/results.test.ts b/apps/mcp-server/src/results.test.ts index 89cec38..93a2d68 100644 --- a/apps/mcp-server/src/results.test.ts +++ b/apps/mcp-server/src/results.test.ts @@ -3,7 +3,7 @@ import type { AgentRunOutput } from "@components-kit/open-workbook-protocol"; import { agentJsonResult } from "./results.js"; describe("MCP result rendering", () => { - it("keeps text compact while preserving structured content and resources", () => { + it("keeps text compact while preserving structured content and resource links", () => { const output: AgentRunOutput = { status: "SUCCESS", mode: "answer", @@ -68,7 +68,7 @@ describe("MCP result rendering", () => { expect((result.structuredContent.telemetry as any).routeReasons).toBeUndefined(); expect((result.structuredContent.telemetry as any).workflowReasons).toBeUndefined(); expect((result.structuredContent.telemetry as any).semanticIndexStatus).toBeUndefined(); - expect(result.resources).toEqual([ + expect(result.structuredContent.resourceLinks).toEqual([ { uri: "excel://agent/results/agentres_1", name: "agent result", diff --git a/apps/mcp-server/src/results.ts b/apps/mcp-server/src/results.ts index 1b34533..b742d28 100644 --- a/apps/mcp-server/src/results.ts +++ b/apps/mcp-server/src/results.ts @@ -14,7 +14,6 @@ export function jsonResult(value: unknown) { export function agentJsonResult(value: AgentRunOutput) { const jsonSafeValue = JSON.parse(JSON.stringify(value)) as AgentRunOutput; const structuredContent = compactStructuredAgentResult(jsonSafeValue); - const resourceLinks = Array.isArray(jsonSafeValue.resourceLinks) ? jsonSafeValue.resourceLinks : []; return { content: [ { @@ -22,15 +21,7 @@ export function agentJsonResult(value: AgentRunOutput) { text: compactAgentResultText(structuredContent) } ], - structuredContent, - resources: resourceLinks - .filter((resource) => typeof resource?.uri === "string") - .map((resource) => ({ - uri: resource.uri, - name: resource.name ?? resource.uri, - description: resource.description, - mimeType: resource.mimeType ?? "application/json" - })) + structuredContent }; } diff --git a/apps/mcp-server/src/tools/agent-run.test.ts b/apps/mcp-server/src/tools/agent-run.test.ts index d3ca3bd..5b611fd 100644 --- a/apps/mcp-server/src/tools/agent-run.test.ts +++ b/apps/mcp-server/src/tools/agent-run.test.ts @@ -18,8 +18,14 @@ describe("excel.agent.run MCP schema", () => { expect(AGENT_INTENT_ACTIONS).toContain("format_diagnostics"); expect(AGENT_INTENT_ACTIONS).toContain("find_similar_rows"); expect(AGENT_INTENT_ACTIONS).toContain("read_formulas"); + expect(AGENT_INTENT_ACTIONS).toContain("improve_visual_readability"); + expect(AGENT_INTENT_ACTIONS).toContain("workbook_design_overview"); + expect(AGENT_INTENT_ACTIONS).toContain("get_permissions"); + expect(AGENT_INTENT_ACTIONS).toContain("set_permissions"); + expect(AGENT_INTENT_ACTIONS).toContain("allow_destructive_actions"); expect(AGENT_DETAIL_LEVELS).toContain("full_table"); expect(AGENT_DETAIL_LEVELS).toContain("semantic_index"); + expect(AGENT_DETAIL_LEVELS).toContain("workbook_design_overview"); }); it("exposes semantic and workflow telemetry fields in the output schema", () => { @@ -172,6 +178,10 @@ describe("excel.agent.run MCP schema", () => { expect(combined).toContain("Reconciliation Note"); expect(combined).toContain("Detail Notes"); expect(combined).toContain("transform_sheets"); + expect(combined).toContain("improve_visual_readability"); + expect(combined).toContain("values.visualReadability"); + expect(combined).toContain("comprehensive validation/formula suggestions remain preview-only"); + expect(combined).toContain("do not apply dropdowns, formulas, inserted rows/columns, or summary blocks through the visual styling apply path"); expect(combined).toContain("do not fetch full source/target columns"); expect(combined).not.toContain("use `excel.agent.run` with `mode: \"preview_update\"` and then `mode: \"apply_update\"` for scoped value edits"); expect(combined).not.toContain("group related range value edits with `values.patches` in one `preview_update`"); diff --git a/apps/mcp-server/src/tools/agent-run.ts b/apps/mcp-server/src/tools/agent-run.ts index 583f786..8a319c6 100644 --- a/apps/mcp-server/src/tools/agent-run.ts +++ b/apps/mcp-server/src/tools/agent-run.ts @@ -10,7 +10,7 @@ export function registerAgentTools(mcp: McpServer, runtime: RuntimeFacade, conte { title: "Run Open Workbook agent workflow", description: - "Single default Open Workbook interface. Send workbook intent; the backend handles discovery, cached metadata, target resolution, session-scoped write permission, preview/apply, validation, rollback, and compact proof without exposing low-level Excel tools. In default auto mode, safe exact small edits may auto-apply after workbook write access is allowed for the session and return taskOutcome apply_complete with maxRecommendedFollowupCalls 0; set autoApply false when you need preview-only behavior. Do not ask the user to confirm every small exact edit once session write access exists. For multiple explicit value edits from the same user request, use one mode:auto call with values.patches; independent row/range edits should still be grouped when targets and values are known. Do not issue parallel or sequential excel.agent.run update calls for related prompt work unless one grouped call fails with actionable details. For broad column/range changes like add prefix/suffix, replace text, fill blanks, normalize, or map values, call intent.action transform_values so the backend scans and previews bounded examples; do not read full columns into model context and generate a giant write matrix. For row-aware updates like fill Column X from Column Y, copy-if-blank, extract patterns, conditional maps, lookup-style derivations, or formula_like calculations such as Payment Variance = Actual Amount - Cash Amount, call intent.action derive_values so Open Workbook resolves source/target columns and compiles changed cells server-side. For exact formula inspection such as 'is this a formula?', 'raw formula', 'show formula', or 'formula in I165', call intent.action read_formulas; never infer formula existence from displayed values or numbers alone. Formula mutations, formula repairs, and formula-like broad derivations are preview/apply workflows with validation, not blind auto-applies. For full-range formula repair from one repeated same-sheet A1 pattern, send intent.action write_formulas with the full target range and one values.formula such as =H2-G2; Open Workbook expands relative references, so do not build large values.formulas arrays or add dummy values.values. For transaction settlement consistency involving Payment Variance, Reconciliation Note, and Detail Notes, call intent.action settle_reconciliation so Open Workbook inspects reference-month formula convention, compiles variance formula/value updates plus note updates as one grouped preview, and keeps note columns distinct by header/role. For workbook structure batches like adding a prefix/suffix to many sheet names, call intent.action transform_sheets so Open Workbook previews one bounded rename plan instead of issuing sheet-by-sheet calls. This tool can read the current live Excel selection; when the user says this, here, selected, current cell/range/row/column, or asks for values from the selected area, call excel.agent.run before asking for row or column numbers. A normal selected cell is incidental for broad workbook/worksheet overview requests. For sheet sections, use sheet_summary/semantic_index anchors; when editing by row label and column header, send values.semanticPatches with sectionId, rowMatch, columnMatch, and value instead of reading whole sections or guessing coordinates. For cross-sheet labels, historical examples, or 'how did we classify this before?' use intent.action find_similar_rows with the source row/range and any named reference sheet instead of broad-reading sheets. For formula/reference month work such as 'look at Apr 2026 for reference', use read_formulas/read_formula_patterns or validate_formula_against_template by matching headers and roles, then preview grouped formula/note repairs. For style/template references, use intent.action find_style_references before reading values. For dropdown values, read data validation/source-list proof before guessing from visible samples; exact source-list value corrections should use auto as bounded value writes. If Open Workbook is connected but a live read fails or returns a diagnostic, report that Open Workbook failure; do not fall back to Python/openpyxl/offline `.xlsx` parsing unless the user explicitly asks for offline file analysis. excel:// resultUri/fullResultUri values are internal Open Workbook handles, not web URLs; never use Webfetch/browser for them. To read stored detail, call excel.agent.run again with continuation.fullResultUri or paste the excel:// handle in request.", + "Single default Open Workbook interface. Send workbook intent; the backend handles discovery, cached metadata, target resolution, session-scoped write permission, preview/apply, validation, rollback, and compact proof without exposing low-level Excel tools. In default auto mode, safe exact small edits may auto-apply after workbook write access is allowed for the session and return taskOutcome apply_complete with maxRecommendedFollowupCalls 0; set autoApply false when you need preview-only behavior. Do not ask the user to confirm every small exact edit once session write access exists. For freeze/frozen pane status questions such as which columns/rows are frozen, call mode answer with the natural request text or put the freeze question in intent.reason; do not answer from cached workbook context, workbook_design_overview, or read_style_summary if they do not show it. For grouped-header summary questions such as summarizing row 1 grouped headers, call one mode answer request targeting the sheet/header range; the backend returns grouped_header_summary with spans, labels, and merged/unmerged status. Do not chase workbook_design_overview, semantic_index, fullResultUri, or broad row reads for the same summary. For explicit worksheet row or column structure changes, use mode preview_update with intent.action delete_rows/insert_rows/delete_columns/insert_columns and an exact row/column target such as target {sheetName:\"Invoices\", range:\"1:1\"} or target.row 1; apply the returned preview once. Do not emulate row deletion with row-height changes, blank writes, table conversion, or delete_columns. For multi-range merge plus style requests such as merging grouped header spans and centering them, send one preview_update with values.merges or values.entries containing sheetName/range/style entries; the preview must include range.merge operations before range.write_styles_many. Do not describe a merge if the returned preview only shows style updates. For column-by-column workbook design review requests such as deciding free text vs date vs money vs ID/text code vs dropdown vs lookup/reference, call intent.action workbook_design_overview once in mode answer; it returns column recommendations, related-sheet hints, and next workflows from cached metadata without reading Customer/Bookings/Drivers or empty data rows manually. For structural workbook styling such as grouped_header that inserts rows or merges group labels, if the user authorizes the formatting permission or an apply returns DESTRUCTIVE_ACTION_BLOCKED/PERMISSION_DENIED, call intent.action set_permissions with values.permissions {allowWrites:true, allowDestructiveActions:true, scopeToWorkbook:true, requireConfirmationFor:[]} and then create a fresh preview; do not tell the user you cannot grant it or ask for a manual Excel click unless set_permissions itself fails. For multiple explicit value edits from the same user request, use one mode:auto call with values.patches; independent row/range edits should still be grouped when targets and values are known. Do not issue parallel or sequential excel.agent.run update calls for related prompt work unless one grouped call fails with actionable details. For broad column/range changes like add prefix/suffix, replace text, fill blanks, normalize, or map values, call intent.action transform_values so the backend scans and previews bounded examples; do not read full columns into model context and generate a giant write matrix. For row-aware updates like fill Column X from Column Y, copy-if-blank, extract patterns, conditional maps, lookup-style derivations, or formula_like calculations such as Payment Variance = Actual Amount - Cash Amount, call intent.action derive_values so Open Workbook resolves source/target columns and compiles changed cells server-side. For exact formula inspection such as 'is this a formula?', 'raw formula', 'show formula', or 'formula in I165', call intent.action read_formulas; never infer formula existence from displayed values or numbers alone. Formula mutations, formula repairs, and formula-like broad derivations are preview/apply workflows with validation, not blind auto-applies. For full-range formula repair from one repeated same-sheet A1 pattern, send intent.action write_formulas with the full target range and one values.formula such as =H2-G2; Open Workbook expands relative references, so do not build large values.formulas arrays or add dummy values.values. For transaction settlement consistency involving Payment Variance, Reconciliation Note, and Detail Notes, call intent.action settle_reconciliation so Open Workbook inspects reference-month formula convention, compiles variance formula/value updates plus note updates as one grouped preview, and keeps note columns distinct by header/role. For workbook structure batches like adding a prefix/suffix to many sheet names, call intent.action transform_sheets so Open Workbook previews one bounded rename plan instead of issuing sheet-by-sheet calls. This tool can read the current live Excel selection; when the user says this, here, selected, current cell/range/row/column, or asks for values from the selected area, call excel.agent.run before asking for row or column numbers. A normal selected cell is incidental for broad workbook/worksheet overview requests. For sheet sections, use sheet_summary/semantic_index anchors; when editing by row label and column header, send values.semanticPatches with sectionId, rowMatch, columnMatch, and value instead of reading whole sections or guessing coordinates. For cross-sheet labels, historical examples, or 'how did we classify this before?' use intent.action find_similar_rows with the source row/range and any named reference sheet instead of broad-reading sheets. For formula/reference month work such as 'look at Apr 2026 for reference', use read_formulas/read_formula_patterns or validate_formula_against_template by matching headers and roles, then preview grouped formula/note repairs. For style/template references, use intent.action find_style_references before reading values. For dropdown values, read data validation/source-list proof before guessing from visible samples; exact source-list value corrections should use auto as bounded value writes. If Open Workbook is connected but a live read fails or returns a diagnostic, report that Open Workbook failure; do not fall back to Python/openpyxl/offline `.xlsx` parsing unless the user explicitly asks for offline file analysis. excel:// resultUri/fullResultUri values are internal Open Workbook handles, not web URLs; never use Webfetch/browser for them. To read stored detail, call excel.agent.run again with continuation.fullResultUri or paste the excel:// handle in request.", inputSchema: agentRunInputSchema(), outputSchema: agentRunOutputSchema(), annotations: { @@ -53,7 +53,7 @@ export function agentRunInputSchema() { responseMode: z.enum(["brief", "standard", "verbose"]).optional() }), "continuation"); const intentSchema = jsonObjectString(z.object({ - action: z.string().optional().describe("Optional internal action hint, such as read_values, read_formulas, write_values, derive_values, validate_workbook, or create_pivot_chart_summary. Invalid hints are ignored by the backend."), + action: z.string().optional().describe("Optional internal action hint, such as read_values, style_overview, workbook_design_overview, grouped_header, improve_visual_readability, set_permissions, read_formulas, write_values, derive_values, delete_rows, insert_rows, delete_columns, insert_columns, validate_workbook, or create_pivot_chart_summary. Invalid hints are ignored by the backend."), confidence: z.number().min(0).max(1).optional(), reason: z.string().optional(), targetHints: z.array(z.string()).optional() @@ -124,6 +124,7 @@ export function agentRunInputSchema() { target: targetSchema, values: z.array(z.array(z.any())).optional(), rows: z.array(z.array(z.any())).optional(), + style: styleSchema.optional(), reason: z.string().optional() })).optional() }).catchall(z.any()), "values"); diff --git a/docs/tool-surface.md b/docs/tool-surface.md index b7b41ea..2382320 100644 --- a/docs/tool-surface.md +++ b/docs/tool-surface.md @@ -17,7 +17,7 @@ The full protocol catalog remains available to backend/runtime tests and interna Default public tool: -- `excel.agent.run`: send workbook intent through `request` and optional `mode`, `intent`, `workbookContextId`, `target`, `values`, `operationId`, and `confirmationToken`. The backend performs deterministic orchestration over internal Excel capabilities and returns structured compact output. Natural-language targets are resolved against cached sheets, tables, headers, named ranges, registered regions, summary blocks, formula regions, and the derived semantic workbook index; close matches return `AMBIGUOUS_TARGET` with candidates instead of guessing. Candidates include retry hints when budget allows; retry with `target.candidateId` from a returned candidate to force that target in a follow-up call. Caller LLMs may provide canonical English `intent.action` hints from the protocol schema, including workbook lifecycle, range read/metadata/mutation, formula, names, regions, style, repair, cleaning, table, sheet, template, snapshot, backup, validation, calculate, and save actions. These hints improve routing but never bypass target resolution, preview/apply confirmation, risk policy, stale checks, or validation. Caller LLMs may also provide `intent.targetHints`; those hints are a bounded scoring signal for candidate ranking, not an exact target override, so explicit targets and ambiguity checks still win. Workbook overview, semantic-index, sheet-count, table-list, named-range, and vague `.xlsx` review questions answer from lightweight structure metadata before deeper reads. Schema/header-only requests can answer from cached metadata; formula pattern/dependency/trace/error/explanation requests use formula-native runtime reads; range metadata requests can inspect hyperlinks, comments, notes, merged cells, data validation, conditional formatting, blanks, search hits, and errors; row/value/sample/A1-range requests perform live targeted reads. Explicit two-sheet comparison requests read both targets in one agent call. Raw monthly sheets without Excel Tables can resolve exact sheet/range references and generic semantic header blocks. Related range value edits can be grouped with `values.patches`, where each patch has `target.sheetName`, `target.range`, and a 2D `values` matrix; the backend returns one preview operation that should be applied once. +- `excel.agent.run`: send workbook intent through `request` and optional `mode`, `intent`, `workbookContextId`, `target`, `values`, `operationId`, and `confirmationToken`. The backend performs deterministic orchestration over internal Excel capabilities and returns structured compact output. Natural-language targets are resolved against cached sheets, tables, headers, named ranges, registered regions, summary blocks, formula regions, and the derived semantic workbook index; close matches return `AMBIGUOUS_TARGET` with candidates instead of guessing. Candidates include retry hints when budget allows; retry with `target.candidateId` from a returned candidate to force that target in a follow-up call. Caller LLMs may provide canonical English `intent.action` hints from the protocol schema, including workbook lifecycle, range read/metadata/mutation, formula, names, regions, style, repair, cleaning, table, sheet, template, snapshot, backup, validation, calculate, and save actions. These hints improve routing but never bypass target resolution, preview/apply confirmation, risk policy, stale checks, or validation. Caller LLMs may also provide `intent.targetHints`; those hints are a bounded scoring signal for candidate ranking, not an exact target override, so explicit targets and ambiguity checks still win. Workbook overview, semantic-index, style-overview, sheet-count, table-list, named-range, and vague `.xlsx` review questions answer from lightweight structure metadata before deeper reads. Schema/header-only requests can answer from cached metadata; style-overview requests combine cached metadata with bounded style fingerprints to return current style context, column groups, grouped-header suggestions, and workflow hints without full data reads; formula pattern/dependency/trace/error/explanation requests use formula-native runtime reads; range metadata requests can inspect hyperlinks, comments, notes, merged cells, data validation, conditional formatting, blanks, search hits, and errors; row/value/sample/A1-range requests perform live targeted reads. Explicit two-sheet comparison requests read both targets in one agent call. Raw monthly sheets without Excel Tables can resolve exact sheet/range references and generic semantic header blocks. Related range value edits can be grouped with `values.patches`, where each patch has `target.sheetName`, `target.range`, and a 2D `values` matrix; the backend returns one preview operation that should be applied once. For multilingual prompts, the caller should keep `request` in the user's original language and normalize machine-routing fields into the canonical schema. Use English `intent.action` enum values, original and translated `intent.targetHints`, explicit `target` when known, and structured `values` for edits. The backend does not translate the full prompt; deterministic orchestration treats structured intent as a routing hint while preserving all safety checks. @@ -37,6 +37,8 @@ Supported modes: Mutation rule: `preview_update` never applies workbook edits. It can preview scoped range value writes, grouped multi-range value patches, raw value clears, row/column insert/delete operations, plain range column reorders, merge/unmerge operations, formula writes, style updates, data-validation dropdowns, formula-based conditional formatting, template style copy/repair, sheet create/copy/rename/hide/unhide/tab-color updates, local snapshot/backup creation, workbook backup/config/close actions, template-sheet cleanup, template registry mutations, template-backed sheet repair, explicit table-row appends, and composed booking/OCR table replacement through the default agent tool. `auto` may preview and apply a clearly authorized, scoped value edit when safety checks pass; structural, destructive, broad, sparse, stale, ambiguous, style, formula, template, sheet, backup, workbook, or table append edits stop before mutation and return `nextAction`. Manual applying requires a second `excel.agent.run` call with `mode: "apply_update"`, the previewed `operationId`, and the matching `confirmationToken`. Pending previews store workbook and target-specific fingerprints; apply returns `STALE_CONTEXT` when sheet, table, named-range, formula-region, selection, or target metadata has drifted after preview. If a grouped or composed preview succeeds, agents should call `apply_update` once and should not split these related patches unless apply returns a hard failure with actionable details. +Styling workflows are first-class agent actions. Use `style_overview` for one low-read styling inspection, `improve_visual_readability` for broad safe visual styling, and `grouped_header` for structural two-level header bands. `improve_visual_readability` keeps grouped headers suggest-only, preserves grouped/header bands by default, and applies safe body styling, widths, alignment, date/money formats, filters, and highlights through one preview/apply lifecycle; `layout`, `validation`, and `freeze_panes` remain explicit opt-in buckets. If a visual preview reports `operationCount: 0` or `nextAction: "answer_now"`, agents must not call `apply_update` or decompose the task into primitive style calls. `grouped_header` accepts group payloads shaped as `{ label, startColumn, endColumn }`, `{ label, columns: [...] }`, or `{ label, range: "A:B" }`, and agents must create a fresh grouped-header preview rather than reusing a visual-readability `operationId`. + For booking images, OCR output, or client screenshots that need headers rotated into a value table while preserving workbook style, use `intent.action: "replace_range_with_styled_table"` with explicit target, values, optional `values.clearRange`, and optional `values.headerStyleSource`/`values.bodyStyleSource`. This compiles clear/write/autofit/style-copy steps into one previewed operation and one apply. Do not issue separate clear, write, autofit, and style calls for this workflow. Agent results include `structuredContent`, a text fallback, compact proof ranges, reusable candidate ids, resource links such as `excel://agent/contexts/{workbook_context_id}`, `excel://agent/contexts/{workbook_context_id}/semantic-index`, `excel://agent/operations/{operation_id}`, and `excel://agent/results/{result_id}`, plus telemetry for payload bytes, estimated tokens, elapsed time, cache reuse, route decision, workflow route, metadata/read policy, semantic index status, caller intent source/action/acceptance, target hint count/usage, matched action handler, operation risk, target fingerprint status, metadata cache status, auto-apply decisions, internal read count, full-read cell count, candidate count, resource-link count, and estimated token savings. `budget.maxPayloadBytes`, `budget.maxEstimatedTokens`, and `budget.maxExamples` bound inline agent output; larger context is summarized or moved behind resource links. Reuse `workbookContextId` or `continuation` on follow-up calls. `excel://` handles are MCP/Open Workbook handles, not HTTP URLs; retrieve result resources through MCP resources or another `excel.agent.run` call, only when the user explicitly needs full detail. @@ -52,7 +54,7 @@ The backend also tracks whether a capability is the public agent entrypoint, cur - Runtime: `excel.runtime.get_status`, `excel.runtime.connect_addin`, `excel.runtime.disconnect_addin`, `excel.runtime.ping_addin`, `excel.runtime.get_capabilities`, `excel.runtime.get_active_context`, `excel.runtime.get_selection`, `excel.runtime.set_active_workbook`, `excel.runtime.set_active_sheet` - Workbook: `excel.workbook.list_open_workbooks`, `excel.workbook.get_workbook_info`, `excel.workbook.get_workbook_map`, `excel.workbook.get_summary`, `excel.workbook.get_used_range_summary`, `excel.workbook.snapshot`, `excel.workbook.refresh_snapshot`, `excel.workbook.get_snapshot`, `excel.workbook.detect_external_changes`, `excel.workbook.calculate`, `excel.workbook.save`, `excel.workbook.save_as`, `excel.workbook.create_backup`, `excel.workbook.restore_backup`, `excel.workbook.export_copy`, `excel.workbook.export_local_config`, `excel.workbook.import_local_config`, `excel.workbook.embed_local_config`, `excel.workbook.read_embedded_local_config`, `excel.workbook.import_embedded_local_config`, `excel.workbook.close` - File backups: `excel.backup.create_file`, `excel.backup.list`, `excel.backup.get`, `excel.backup.verify`, `excel.backup.restore_file`, `excel.backup.delete`, `excel.backup.prune`, `excel.backup.pin`, `excel.backup.unpin` -- Sheet: `excel.sheet.list`, `excel.sheet.get_info`, `excel.sheet.get_summary`, `excel.sheet.create`, `excel.sheet.copy`, `excel.sheet.copy_clean_data_regions`, `excel.sheet.rename`, `excel.sheet.delete`, `excel.sheet.hide`, `excel.sheet.unhide`, `excel.sheet.protect`, `excel.sheet.unprotect`, `excel.sheet.clear`, `excel.sheet.get_used_range`, `excel.sheet.set_tab_color` +- Sheet: `excel.sheet.list`, `excel.sheet.get_info`, `excel.sheet.get_summary`, `excel.sheet.create`, `excel.sheet.copy`, `excel.sheet.copy_clean_data_regions`, `excel.sheet.rename`, `excel.sheet.delete`, `excel.sheet.hide`, `excel.sheet.unhide`, `excel.sheet.protect`, `excel.sheet.unprotect`, `excel.sheet.clear`, `excel.sheet.get_used_range`, `excel.sheet.set_tab_color`, `excel.sheet.freeze_panes` - Range: `excel.range.read_compact`, `excel.range.get_summary`, `excel.range.read_hyperlinks`, `excel.range.read_comments`, `excel.range.read_notes`, `excel.range.read_merged_cells`, `excel.range.read_data_validation`, `excel.range.read_conditional_formatting`, `excel.range.search`, `excel.range.find_blank_cells`, `excel.range.find_errors`, `excel.range.write_values`, `excel.range.write_values_many`, `excel.range.write_formulas`, `excel.range.write_number_formats`, `excel.range.write_number_formats_many`, `excel.range.write_styles`, `excel.range.write_styles_many`, `excel.range.write_data_validation`, `excel.range.write_conditional_formatting`, `excel.range.clear_style_dimensions`, `excel.range.clear_style_dimensions_many`, `excel.range.clear`, `excel.range.clear_many`, `excel.range.clear_values`, `excel.range.clear_formats`, `excel.range.clear_formats_many`, `excel.range.clear_values_keep_format`, `excel.range.copy`, `excel.range.move`, `excel.range.reorder_columns`, `excel.range.insert_rows`, `excel.range.delete_rows`, `excel.range.insert_columns`, `excel.range.delete_columns`, `excel.range.hide_columns`, `excel.range.unhide_columns`, `excel.range.autofit_columns`, `excel.range.autofit_rows`, `excel.range.autofit_many`, `excel.range.merge`, `excel.range.unmerge` - Lookup: `excel.lookup.search_workbook`, `excel.lookup.resolve_range`, `excel.lookup.inspect_match` - Batch: `excel.batch.apply`, `excel.batch.submit`, `excel.batch.submit_chunked`, `excel.batch.preflight`, `excel.batch.validate`, `excel.batch.dry_run` diff --git a/llms-full.txt b/llms-full.txt index 5f430ee..5a74aca 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -197,6 +197,41 @@ Agents call `excel.agent.run` with natural language plus optional structured fie The backend keeps verbose workbook context local and returns compact proof, resource links, telemetry, warnings, and next actions. Caller LLMs may provide canonical `intent.action`, `intent.targetHints`, explicit `target`, and structured `values`, but the backend still owns ambiguity checks, stale-context checks, permissions, locks, backups, validation, and rollback metadata. +For styling review, agents should use `intent.action: "style_overview"` or `detailLevel: "style_overview"` with `mode: "answer"` to get current style context, column groups, grouped-header suggestions, and workflow hints without full data reads. For workbook design review, such as deciding which columns should be free text, dates, money, ID/text codes, dropdowns, or lookups/references from related sheets, agents should use `intent.action: "workbook_design_overview"` with `mode: "answer"` once before reading related sheets manually. It returns column-by-column recommendations, related-sheet hints, and next workflows without broad-reading empty data rows. For broad styling/readability work, agents should use `intent.action: "improve_visual_readability"` with `mode: "preview_update"` rather than issuing many primitive style calls. Options live under `values.visualReadability`; standard mode compiles safe column-first layout/formatting/highlight rules, comprehensive mode can include preview-only validation/formula suggestions, `stylePreservationMode` defaults to `protected_regions` so summary/template areas and grouped header bands stay guarded while ordinary table body styling, widths, alignment, and date/money formats can still be intentionally improved, `strict` preserves every detected existing style, `none` allows an explicit redesign, `referenceStyle` can preview adaptation from another sheet, and `presentationMode` can preview print/export suggestions. Apply still requires `apply_update` with the returned operation token, `nextAction: "call_apply_update"`, and `operationCount > 0`; if a preview reports `operationCount: 0` or `nextAction: "answer_now"`, agents should explain the skipped reasons instead of applying or decomposing the work into primitive style calls. Use `intent.action: "grouped_header"` for the separate structural preview that inserts a visual group row, merges group labels, and restyles the shifted table header. Grouped-header groups should use `{ "label": "...", "startColumn": "A", "endColumn": "B" }`; `{ "columns": ["A", "B"] }` and `{ "range": "A:B" }` are also accepted. Do not reuse an `operationId` from visual readability when creating a grouped-header preview. + +Grouped headers are structure-level styling. If apply is blocked by `DESTRUCTIVE_ACTION_BLOCKED` or `PERMISSION_DENIED`, the public agent path can enable the required policy with `intent.action: "set_permissions"` and `values.permissions` such as `{ "allowWrites": true, "allowDestructiveActions": true, "scopeToWorkbook": true, "requireConfirmationFor": [] }`; after that, create and apply a fresh grouped-header preview. + +Example OpenCode prompts: + +```text +Use open-workbook. Inspect the active sheet with a style overview first, without reading every data cell. Suggest visual readability improvements including grouped headers, one consistent palette, safe widths, alignment, filters, number formats, and highlights. Do not apply yet. +``` + +```text +Preview a grouped_header workflow for this sheet. Add a higher-level grouped header row above the existing column headers, merge group labels, and use matching group colors. Wait for approval before apply_update. +``` + +```json +{ + "mode": "preview_update", + "intent": { "action": "grouped_header" }, + "target": { "sheetName": "Invoices", "tableName": "InvoicesTable" }, + "values": { + "stylePreservationMode": "none", + "groupedHeader": { + "groups": [ + { "label": "สถานะ", "startColumn": "A", "endColumn": "B" }, + { "label": "ข้อมูลงาน", "startColumn": "C", "endColumn": "E" } + ] + } + } +} +``` + +```text +Apply the safe visual readability preview in one apply_update. Include opt-in buckets layout, validation, and freeze_panes only if they were present in the preview. +``` + With the shared daemon, multiple MCP sessions get distinct trusted agent identities. `status` and `prepare` include compact collaboration summaries for active agents, open tasks, locks, queued/applying transactions, conflicts, and recent events. ## Common Commands @@ -311,6 +346,26 @@ The format is based on Keep a Changelog, and this project follows semantic versi ## [Unreleased] +## [0.1.25] - 2026-06-26 + +### Added + +- Added first-class grouped-header summary answers so agents can inspect row-1 header spans, labels, and merged/unmerged status without chasing broad workbook overviews or stored result handles. +- Added explicit row and column structure operations, including insert/delete row support and richer delete-column guidance, so authorized destructive requests route to real structural changes instead of style or row-height workarounds. +- Added merge-aware multi-range previews for grouped headers so merge operations and center alignment can be batched and applied together. + +### Fixed + +- Fixed grouped-header color routing so row 1 grouped headers stay visually distinct from row 2 column headers, including exact `target.address` handling and darker grouped-header defaults. +- Fixed freeze-pane workflows so agents can apply, unfreeze, and answer frozen row/column status through live workbook state instead of cached style summaries. +- Fixed batched column width updates and column reorder/swap operations so widths, formulas, values, and formatting move with the affected columns. +- Fixed merged-header alignment normalization so center/middle alignment requests are translated to Office.js-compatible alignment values. + +### Changed + +- Improved `excel.agent.run` guidance, capability metadata, packaged skill docs, and generated MCP surface docs for batched updates, merge operations, freeze panes, grouped headers, and structural worksheet edits. +- Improved preview/apply regression coverage for OpenCode Excel workflows, including header styling, width preservation, merge batching, row/column deletion, freeze panes, and grouped-header summaries. + ## [0.1.24] - 2026-06-25 ### Fixed @@ -788,7 +843,13 @@ For reads, start with a bounded `answer` call on the selected range. For mutatio - Create from template: use template workflows to preserve structure, formulas, styles, validation, and layout while clearing old data regions for fresh entry. - Apply style from template: use `copy_style_from_template`; the source/template sheet is a style source only and must not be duplicated or mutated. - Replace styled table: use `replace_range_with_styled_table` to clear stale layout, write headers/rows, copy header/body style samples, and autofit in one preview/apply workflow. -- Inspect current styling: call `excel.agent.run` with `mode: "answer"`, `intent.action: "read_style_summary"`, and an exact range or current selection. Use `read_style_fingerprint` for template comparison, not normal user-facing style inspection. When the user asks for a style/template reference from another sheet or month, call `intent.action: "find_style_references"` so Open Workbook returns bounded source candidates instead of reading whole sheets. +- Inspect current styling: call `excel.agent.run` with `mode: "answer"`, `intent.action: "read_style_summary"`, and an exact range or current selection. For styling recommendations or best-practice review, call `intent.action: "style_overview"` or `detailLevel: "style_overview"` once; it returns current style context, column groups, grouped-header suggestions, and safe next workflow hints without reading full data rows. Use `read_style_fingerprint` for template comparison, not normal user-facing style inspection. When the user asks for a style/template reference from another sheet or month, call `intent.action: "find_style_references"` so Open Workbook returns bounded source candidates instead of reading whole sheets. +- Grouped-header summary: when the user asks to look at, examine, or summarize grouped headers such as row 1 grouped headers, call `excel.agent.run` once with `mode: "answer"` and a target sheet/range if known. The backend returns `answer.kind: "grouped_header_summary"` with spans, labels, merged count, and unmerged labels. Do not call `workbook_design_overview`, `semantic_index`, full result resources, or broad row value reads for the same summary. +- Workbook design review: when the user asks for a column-by-column recommendation such as whether each column should be free text, date, money/number, ID/text code, dropdown, or lookup/reference from another sheet, call `excel.agent.run` once with `mode: "answer"` and `intent.action: "workbook_design_overview"`. It returns column recommendations, related-sheet hints, format/dropdown/lookup suggestions, and next workflows from cached metadata. Do not manually read Customer, Bookings, Drivers, or empty data rows first; use targeted `read_data_validation`, `write_data_validation`, or lookup/formula previews only after the user chooses a recommendation. +- Visual readability: when the user asks to make a sheet cleaner, easier to read, office-ready, visually grouped, highlighted, or formatted broadly, call `excel.agent.run` with `mode: "preview_update"` and `intent.action: "improve_visual_readability"` instead of issuing many low-level style operations. Default to `values.visualReadability.styleDepth: "standard"`, preserve formulas, and apply only after the returned `operationId` and `confirmationToken` when `nextAction` is `call_apply_update` and metrics show `operationCount > 0`. If the preview reports `operationCount: 0` or `nextAction: "answer_now"`, do not call `apply_update` and do not decompose the styling into primitive `format_range` calls; explain the skipped reasons and ask for the supported next workflow. Style preservation defaults to `stylePreservationMode: "protected_regions"` so summary/template areas and grouped header bands stay guarded while ordinary table body styling, widths, alignment, and date/money formats can still be improved. Use `"strict"` only when the user asks to preserve every existing style, and `"none"` for an explicit redesign. Use `styleDepth: "basic"` for fast low-risk layout/format cleanup, `styleDepth: "comprehensive"` for deeper suggestions, and `values.visualReadability.applySuggestionBuckets` for explicit actionable suggestions: `["layout"]` for wrap/row-height styling, `["validation"]` for dropdown writes, and `["freeze_panes"]` for freeze rows/columns. Grouped headers are suggestions for wide tables by default; to apply inserted group rows, merged labels, and matching header colors, use `mode: "preview_update"` with `intent.action: "grouped_header"` and optional `values.groupedHeader.groups`. Prefer grouped-header groups as `{ "label": "...", "startColumn": "A", "endColumn": "B" }`; `{ "columns": ["A", "B"] }` and `{ "range": "A:B" }` are accepted. Never continue grouped_header with an old visual-readability `operationId`. For freeze column requests, use `freezePanes: { "columns": 1 }` or a clear request like "freeze first column". Keep formulas, summary blocks, reference-style layout cues, and print settings as separate confirmed workflows or preview-only when the host capability is unavailable. +- Structural styling permission: grouped headers insert rows and merge ranges, so they require structure/destructive permission. If `apply_update` returns `DESTRUCTIVE_ACTION_BLOCKED` or `PERMISSION_DENIED`, do not retry the stale preview. Ask the user for permission, then call `excel.agent.run` with `intent.action: "set_permissions"` and `values.permissions: { "allowWrites": true, "allowDestructiveActions": true, "scopeToWorkbook": true, "requireConfirmationFor": [] }`; after success, create a fresh grouped_header preview and apply that fresh operation. +- Multi-range merge and alignment: when the user asks to merge several grouped-header spans and center them, send one `preview_update` with `values.merges` or `values.entries` containing each `sheetName`/`range` and optional `style`. The preview must include `range.merge` operations before the `range.write_styles_many` operation. Do not claim a range was merged if the preview/apply only contains style updates. +- Visual styling safety: comprehensive validation/formula suggestions remain preview-only unless the user chooses an explicit bucket or separate workflow; do not apply dropdowns, formulas, inserted rows/columns, or summary blocks through the visual styling apply path. - Formatting errors: use `format_diagnostics` before mutating. It returns raw value, displayed text, formulas, number formats, style summary, likely issues, and suggested fix actions. - Historical labels and similar rows: when the user asks how something was labeled/classified before, or asks to look at another month/sheet for a data reference, call `intent.action: "find_similar_rows"` on the current row/range/table or requested prior sheet. Let Open Workbook search related sheets and return exact matched rows with proof; do not manually read broad prior-sheet ranges, fetch fullResultUri, or chunk rows looking for matches. - Formulas: for exact checks such as “is this a formula?”, “raw formula”, “show formula”, or “formula in I165”, call `intent.action: "read_formulas"` with the exact target. Use `read_formula_patterns`, `validate_formula_against_template`, or formula dependency/trace actions for repeated layouts and reference comparisons. Never infer formula existence from displayed values or numbers alone. Formula writes, formula repairs, and broad formula-like derivations are preview/apply workflows with validation. @@ -884,6 +945,63 @@ Use `preview_update` for non-trivial mutations, broad edits, table appends, temp Formula writes, formula repairs, and broad formula-like derivations are preview/apply workflows with validation. Use `derive_values` with `formula_like` for row-aware calculations such as Payment Variance = Actual Amount - Cash Amount so the backend scans source/target columns and returns bounded source/before/after examples. When a full range should receive the same relative A1 formula pattern, use `write_formulas` with the full target range and one `values.formula`, for example `=H2-G2` on `I2:I244`; do not create a row-per-formula array unless the backend asks for it. +For styling review or "what would make this easier to read?" requests, use `mode: "answer"` with `intent.action: "style_overview"` or `detailLevel: "style_overview"` first. It returns current style context, column-role groups, grouped-header suggestions, and safe next workflow hints without full data-row reads. + +For workbook design review requests such as "for each column decide free text/date/money/ID/dropdown/lookup" or "look at other sheets and recommend how this table should behave", use one `mode: "answer"` call with `intent.action: "workbook_design_overview"`. It returns the target shape, column-by-column behavior recommendations, date/money/text formats, dropdown candidates, lookup/reference candidates, related-sheet hints, and next workflows from cached metadata. Do not read Customer, Bookings, Drivers, or empty table rows manually before this overview. After the user chooses a recommendation, use the targeted workflow it names, such as `write_data_validation`, `derive_values`, or `improve_visual_readability`. + +For broad readability/styling requests such as "make this cleaner", "make this easier to read", "office-ready", "highlight important issues", or "format this table", use `mode: "preview_update"` with `intent.action: "improve_visual_readability"`. Put options under `values.visualReadability`; use `styleDepth: "standard"` by default, `basic` for fast low-risk cleanup, and `comprehensive` only when the user wants deeper suggestions. The backend compiles column-first layout, width, alignment, number-format, grouping, formula/error highlight, and optional validation/formula suggestions. Style preservation defaults to `stylePreservationMode: "protected_regions"` so summary/template areas and grouped header bands stay guarded while ordinary table body styling, widths, alignment, and date/money formats can still be improved; use `"strict"` only when the user asks to preserve every existing style, and `"none"` for explicit redesign. Use `applySuggestionBuckets` to opt into actionable suggestions: `["layout"]` for wrap/row-height style writes, `["validation"]` for dropdown validation writes, and `["freeze_panes"]` for freeze rows/columns. Grouped headers are suggested for wide tables by default; to apply inserted group rows, merged labels, and matching header colors, use `mode: "preview_update"` with `intent.action: "grouped_header"` and optional `values.groupedHeader.groups`. Prefer groups shaped as `{ "label": "...", "startColumn": "A", "endColumn": "B" }`; `{ "columns": ["A", "B"] }` and `{ "range": "A:B" }` are accepted. For freeze column requests, include `freezePanes: { "columns": 1 }` or use a clear request like "freeze first column". Use `referenceStyle` for "make this look like that sheet" previews and `presentationMode` for print/export suggestions. Apply only when `nextAction` is `call_apply_update`, `operationCount > 0`, and the returned `operationId`/`confirmationToken` match the preview. If `operationCount` is `0` or `nextAction` is `answer_now`, do not call `apply_update` and do not decompose the styling into primitive `format_range` calls; explain the skipped reasons and ask for the supported next workflow. Never continue a new grouped-header preview with an `operationId` from an older visual-readability preview. Formula helpers, structure changes, reference-style layout cues, and print settings remain separate confirmed workflows or preview-only until their host capability is available. + +Grouped headers are structural. If apply returns `DESTRUCTIVE_ACTION_BLOCKED` or `PERMISSION_DENIED`, ask for user approval to allow structure changes, then call: + +```json +{ + "intent": { "action": "set_permissions" }, + "values": { + "permissions": { + "allowWrites": true, + "allowDestructiveActions": true, + "scopeToWorkbook": true, + "requireConfirmationFor": [] + } + } +} +``` + +After the permission update succeeds, create a fresh grouped_header preview and apply that fresh operation; do not retry a stale failed preview. + +Visual styling safety: comprehensive validation/formula suggestions remain preview-only unless the user chooses an explicit bucket or separate workflow; do not apply dropdowns, formulas, inserted rows/columns, or summary blocks through the visual styling apply path. + +OpenCode prompt examples: + +```text +Use open-workbook. Inspect the active sheet with a style overview first, without reading every data cell. Suggest visual readability improvements including grouped headers, one consistent palette, safe widths, alignment, filters, number formats, and highlights. Do not apply yet. +``` + +```text +Preview a grouped_header workflow for this sheet. Add a higher-level grouped header row above the existing column headers, merge group labels, and use matching group colors. Wait for approval before apply_update. +``` + +```json +{ + "mode": "preview_update", + "intent": { "action": "grouped_header" }, + "target": { "sheetName": "Invoices", "tableName": "InvoicesTable" }, + "values": { + "stylePreservationMode": "none", + "groupedHeader": { + "groups": [ + { "label": "สถานะ", "startColumn": "A", "endColumn": "B" }, + { "label": "ข้อมูลงาน", "startColumn": "C", "endColumn": "E" } + ] + } + } +} +``` + +```text +Apply the safe visual readability preview in one apply_update. Include opt-in buckets layout, validation, and freeze_panes only if they were present in the preview. +``` + Only call `apply_update` with the returned `operationId` and `confirmationToken`. If the backend reports stale context, target drift, ambiguity, missing permission, an active lock, or validation failure, stop and create a fresh preview or ask the user for direction. If `auto` returns `taskOutcome: "apply_complete"`, stop and report the applied change. If `auto` returns `taskOutcome: "preview_ready"` or `nextAction: "call_apply_update"`, ask the user once unless the user's configuration/instruction already permits applying previews, then call one `apply_update` with the returned `operationId` and `confirmationToken`. @@ -1767,7 +1885,7 @@ The full protocol catalog remains available to backend/runtime tests and interna Default public tool: -- `excel.agent.run`: send workbook intent through `request` and optional `mode`, `intent`, `workbookContextId`, `target`, `values`, `operationId`, and `confirmationToken`. The backend performs deterministic orchestration over internal Excel capabilities and returns structured compact output. Natural-language targets are resolved against cached sheets, tables, headers, named ranges, registered regions, summary blocks, formula regions, and the derived semantic workbook index; close matches return `AMBIGUOUS_TARGET` with candidates instead of guessing. Candidates include retry hints when budget allows; retry with `target.candidateId` from a returned candidate to force that target in a follow-up call. Caller LLMs may provide canonical English `intent.action` hints from the protocol schema, including workbook lifecycle, range read/metadata/mutation, formula, names, regions, style, repair, cleaning, table, sheet, template, snapshot, backup, validation, calculate, and save actions. These hints improve routing but never bypass target resolution, preview/apply confirmation, risk policy, stale checks, or validation. Caller LLMs may also provide `intent.targetHints`; those hints are a bounded scoring signal for candidate ranking, not an exact target override, so explicit targets and ambiguity checks still win. Workbook overview, semantic-index, sheet-count, table-list, named-range, and vague `.xlsx` review questions answer from lightweight structure metadata before deeper reads. Schema/header-only requests can answer from cached metadata; formula pattern/dependency/trace/error/explanation requests use formula-native runtime reads; range metadata requests can inspect hyperlinks, comments, notes, merged cells, data validation, conditional formatting, blanks, search hits, and errors; row/value/sample/A1-range requests perform live targeted reads. Explicit two-sheet comparison requests read both targets in one agent call. Raw monthly sheets without Excel Tables can resolve exact sheet/range references and generic semantic header blocks. Related range value edits can be grouped with `values.patches`, where each patch has `target.sheetName`, `target.range`, and a 2D `values` matrix; the backend returns one preview operation that should be applied once. +- `excel.agent.run`: send workbook intent through `request` and optional `mode`, `intent`, `workbookContextId`, `target`, `values`, `operationId`, and `confirmationToken`. The backend performs deterministic orchestration over internal Excel capabilities and returns structured compact output. Natural-language targets are resolved against cached sheets, tables, headers, named ranges, registered regions, summary blocks, formula regions, and the derived semantic workbook index; close matches return `AMBIGUOUS_TARGET` with candidates instead of guessing. Candidates include retry hints when budget allows; retry with `target.candidateId` from a returned candidate to force that target in a follow-up call. Caller LLMs may provide canonical English `intent.action` hints from the protocol schema, including workbook lifecycle, range read/metadata/mutation, formula, names, regions, style, repair, cleaning, table, sheet, template, snapshot, backup, validation, calculate, and save actions. These hints improve routing but never bypass target resolution, preview/apply confirmation, risk policy, stale checks, or validation. Caller LLMs may also provide `intent.targetHints`; those hints are a bounded scoring signal for candidate ranking, not an exact target override, so explicit targets and ambiguity checks still win. Workbook overview, semantic-index, style-overview, sheet-count, table-list, named-range, and vague `.xlsx` review questions answer from lightweight structure metadata before deeper reads. Schema/header-only requests can answer from cached metadata; style-overview requests combine cached metadata with bounded style fingerprints to return current style context, column groups, grouped-header suggestions, and workflow hints without full data reads; formula pattern/dependency/trace/error/explanation requests use formula-native runtime reads; range metadata requests can inspect hyperlinks, comments, notes, merged cells, data validation, conditional formatting, blanks, search hits, and errors; row/value/sample/A1-range requests perform live targeted reads. Explicit two-sheet comparison requests read both targets in one agent call. Raw monthly sheets without Excel Tables can resolve exact sheet/range references and generic semantic header blocks. Related range value edits can be grouped with `values.patches`, where each patch has `target.sheetName`, `target.range`, and a 2D `values` matrix; the backend returns one preview operation that should be applied once. For multilingual prompts, the caller should keep `request` in the user's original language and normalize machine-routing fields into the canonical schema. Use English `intent.action` enum values, original and translated `intent.targetHints`, explicit `target` when known, and structured `values` for edits. The backend does not translate the full prompt; deterministic orchestration treats structured intent as a routing hint while preserving all safety checks. @@ -1787,6 +1905,8 @@ Supported modes: Mutation rule: `preview_update` never applies workbook edits. It can preview scoped range value writes, grouped multi-range value patches, raw value clears, row/column insert/delete operations, plain range column reorders, merge/unmerge operations, formula writes, style updates, data-validation dropdowns, formula-based conditional formatting, template style copy/repair, sheet create/copy/rename/hide/unhide/tab-color updates, local snapshot/backup creation, workbook backup/config/close actions, template-sheet cleanup, template registry mutations, template-backed sheet repair, explicit table-row appends, and composed booking/OCR table replacement through the default agent tool. `auto` may preview and apply a clearly authorized, scoped value edit when safety checks pass; structural, destructive, broad, sparse, stale, ambiguous, style, formula, template, sheet, backup, workbook, or table append edits stop before mutation and return `nextAction`. Manual applying requires a second `excel.agent.run` call with `mode: "apply_update"`, the previewed `operationId`, and the matching `confirmationToken`. Pending previews store workbook and target-specific fingerprints; apply returns `STALE_CONTEXT` when sheet, table, named-range, formula-region, selection, or target metadata has drifted after preview. If a grouped or composed preview succeeds, agents should call `apply_update` once and should not split these related patches unless apply returns a hard failure with actionable details. +Styling workflows are first-class agent actions. Use `style_overview` for one low-read styling inspection, `improve_visual_readability` for broad safe visual styling, and `grouped_header` for structural two-level header bands. `improve_visual_readability` keeps grouped headers suggest-only, preserves grouped/header bands by default, and applies safe body styling, widths, alignment, date/money formats, filters, and highlights through one preview/apply lifecycle; `layout`, `validation`, and `freeze_panes` remain explicit opt-in buckets. If a visual preview reports `operationCount: 0` or `nextAction: "answer_now"`, agents must not call `apply_update` or decompose the task into primitive style calls. `grouped_header` accepts group payloads shaped as `{ label, startColumn, endColumn }`, `{ label, columns: [...] }`, or `{ label, range: "A:B" }`, and agents must create a fresh grouped-header preview rather than reusing a visual-readability `operationId`. + For booking images, OCR output, or client screenshots that need headers rotated into a value table while preserving workbook style, use `intent.action: "replace_range_with_styled_table"` with explicit target, values, optional `values.clearRange`, and optional `values.headerStyleSource`/`values.bodyStyleSource`. This compiles clear/write/autofit/style-copy steps into one previewed operation and one apply. Do not issue separate clear, write, autofit, and style calls for this workflow. Agent results include `structuredContent`, a text fallback, compact proof ranges, reusable candidate ids, resource links such as `excel://agent/contexts/{workbook_context_id}`, `excel://agent/contexts/{workbook_context_id}/semantic-index`, `excel://agent/operations/{operation_id}`, and `excel://agent/results/{result_id}`, plus telemetry for payload bytes, estimated tokens, elapsed time, cache reuse, route decision, workflow route, metadata/read policy, semantic index status, caller intent source/action/acceptance, target hint count/usage, matched action handler, operation risk, target fingerprint status, metadata cache status, auto-apply decisions, internal read count, full-read cell count, candidate count, resource-link count, and estimated token savings. `budget.maxPayloadBytes`, `budget.maxEstimatedTokens`, and `budget.maxExamples` bound inline agent output; larger context is summarized or moved behind resource links. Reuse `workbookContextId` or `continuation` on follow-up calls. `excel://` handles are MCP/Open Workbook handles, not HTTP URLs; retrieve result resources through MCP resources or another `excel.agent.run` call, only when the user explicitly needs full detail. @@ -1802,7 +1922,7 @@ The backend also tracks whether a capability is the public agent entrypoint, cur - Runtime: `excel.runtime.get_status`, `excel.runtime.connect_addin`, `excel.runtime.disconnect_addin`, `excel.runtime.ping_addin`, `excel.runtime.get_capabilities`, `excel.runtime.get_active_context`, `excel.runtime.get_selection`, `excel.runtime.set_active_workbook`, `excel.runtime.set_active_sheet` - Workbook: `excel.workbook.list_open_workbooks`, `excel.workbook.get_workbook_info`, `excel.workbook.get_workbook_map`, `excel.workbook.get_summary`, `excel.workbook.get_used_range_summary`, `excel.workbook.snapshot`, `excel.workbook.refresh_snapshot`, `excel.workbook.get_snapshot`, `excel.workbook.detect_external_changes`, `excel.workbook.calculate`, `excel.workbook.save`, `excel.workbook.save_as`, `excel.workbook.create_backup`, `excel.workbook.restore_backup`, `excel.workbook.export_copy`, `excel.workbook.export_local_config`, `excel.workbook.import_local_config`, `excel.workbook.embed_local_config`, `excel.workbook.read_embedded_local_config`, `excel.workbook.import_embedded_local_config`, `excel.workbook.close` - File backups: `excel.backup.create_file`, `excel.backup.list`, `excel.backup.get`, `excel.backup.verify`, `excel.backup.restore_file`, `excel.backup.delete`, `excel.backup.prune`, `excel.backup.pin`, `excel.backup.unpin` -- Sheet: `excel.sheet.list`, `excel.sheet.get_info`, `excel.sheet.get_summary`, `excel.sheet.create`, `excel.sheet.copy`, `excel.sheet.copy_clean_data_regions`, `excel.sheet.rename`, `excel.sheet.delete`, `excel.sheet.hide`, `excel.sheet.unhide`, `excel.sheet.protect`, `excel.sheet.unprotect`, `excel.sheet.clear`, `excel.sheet.get_used_range`, `excel.sheet.set_tab_color` +- Sheet: `excel.sheet.list`, `excel.sheet.get_info`, `excel.sheet.get_summary`, `excel.sheet.create`, `excel.sheet.copy`, `excel.sheet.copy_clean_data_regions`, `excel.sheet.rename`, `excel.sheet.delete`, `excel.sheet.hide`, `excel.sheet.unhide`, `excel.sheet.protect`, `excel.sheet.unprotect`, `excel.sheet.clear`, `excel.sheet.get_used_range`, `excel.sheet.set_tab_color`, `excel.sheet.freeze_panes` - Range: `excel.range.read_compact`, `excel.range.get_summary`, `excel.range.read_hyperlinks`, `excel.range.read_comments`, `excel.range.read_notes`, `excel.range.read_merged_cells`, `excel.range.read_data_validation`, `excel.range.read_conditional_formatting`, `excel.range.search`, `excel.range.find_blank_cells`, `excel.range.find_errors`, `excel.range.write_values`, `excel.range.write_values_many`, `excel.range.write_formulas`, `excel.range.write_number_formats`, `excel.range.write_number_formats_many`, `excel.range.write_styles`, `excel.range.write_styles_many`, `excel.range.write_data_validation`, `excel.range.write_conditional_formatting`, `excel.range.clear_style_dimensions`, `excel.range.clear_style_dimensions_many`, `excel.range.clear`, `excel.range.clear_many`, `excel.range.clear_values`, `excel.range.clear_formats`, `excel.range.clear_formats_many`, `excel.range.clear_values_keep_format`, `excel.range.copy`, `excel.range.move`, `excel.range.reorder_columns`, `excel.range.insert_rows`, `excel.range.delete_rows`, `excel.range.insert_columns`, `excel.range.delete_columns`, `excel.range.hide_columns`, `excel.range.unhide_columns`, `excel.range.autofit_columns`, `excel.range.autofit_rows`, `excel.range.autofit_many`, `excel.range.merge`, `excel.range.unmerge` - Lookup: `excel.lookup.search_workbook`, `excel.lookup.resolve_range`, `excel.lookup.inspect_match` - Batch: `excel.batch.apply`, `excel.batch.submit`, `excel.batch.submit_chunked`, `excel.batch.preflight`, `excel.batch.validate`, `excel.batch.dry_run` diff --git a/package.json b/package.json index 38230f1..1b4f2c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-workbook", - "version": "0.1.24", + "version": "0.1.25", "private": true, "description": "Local-first MCP runtime for fast, reversible, template-aware Excel automation.", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 241d92a..c61c1b9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@components-kit/open-workbook", - "version": "0.1.24", + "version": "0.1.25", "description": "CLI for installing, sideloading, and running Open Workbook locally.", "license": "MIT", "type": "module", diff --git a/packages/excel-core/package.json b/packages/excel-core/package.json index 6e0c031..6344cb4 100644 --- a/packages/excel-core/package.json +++ b/packages/excel-core/package.json @@ -1,6 +1,6 @@ { "name": "@components-kit/open-workbook-excel-core", - "version": "0.1.24", + "version": "0.1.25", "description": "Core planning, backup, snapshot, template, permission, and range utilities for Open Workbook.", "license": "MIT", "type": "module", diff --git a/packages/excel-core/src/range/batch-compiler.ts b/packages/excel-core/src/range/batch-compiler.ts index e262405..1cfb884 100644 --- a/packages/excel-core/src/range/batch-compiler.ts +++ b/packages/excel-core/src/range/batch-compiler.ts @@ -106,7 +106,11 @@ function getOperationTargets(operation: ExcelOperation): A1Range[] { case "range.unmerge": case "range.restore_snapshot": case "range.reorder_columns": - return [operation.target]; + return operation.kind === "range.write_data_validation" && operation.entries?.length + ? operation.entries.map((entry) => entry.target) + : operation.target + ? [operation.target] + : []; case "range.write_values_many": return operation.entries.map((entry) => entry.target); case "range.write_number_formats_many": @@ -148,6 +152,7 @@ function getOperationTargets(operation: ExcelOperation): A1Range[] { case "sheet.unprotect": case "sheet.clear": case "sheet.set_tab_color": + case "sheet.freeze_panes": return []; default: return []; diff --git a/packages/office-js-engine/package.json b/packages/office-js-engine/package.json index 856381e..5ebfb88 100644 --- a/packages/office-js-engine/package.json +++ b/packages/office-js-engine/package.json @@ -1,6 +1,6 @@ { "name": "@components-kit/open-workbook-office-js-engine", - "version": "0.1.24", + "version": "0.1.25", "description": "Office.js engine interfaces and defaults for Open Workbook Excel execution.", "license": "MIT", "type": "module", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index edfdad1..d929519 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@components-kit/open-workbook-protocol", - "version": "0.1.24", + "version": "0.1.25", "description": "Shared Open Workbook protocol types, public agent tool contract, internal capability catalog, resources, prompts, and JSON-RPC contracts.", "license": "MIT", "type": "module", diff --git a/packages/protocol/src/agent.ts b/packages/protocol/src/agent.ts index df8f724..d1ed7d6 100644 --- a/packages/protocol/src/agent.ts +++ b/packages/protocol/src/agent.ts @@ -64,6 +64,8 @@ export const AGENT_DETAIL_LEVELS = [ "workbook_summary", "semantic_index", "sheet_summary", + "style_overview", + "workbook_design_overview", "table_sample", "full_table" ] as const; @@ -77,6 +79,7 @@ export interface AgentRunTarget { sheetName?: string; tableName?: string; range?: string; + address?: string; row?: number; column?: string; entity?: string; @@ -96,6 +99,9 @@ export const AGENT_INTENT_ACTIONS = [ "embed_local_config", "read_embedded_local_config", "import_embedded_local_config", + "get_permissions", + "set_permissions", + "allow_destructive_actions", "close_workbook", "prepare_session", "create_formula_sheet", @@ -132,6 +138,10 @@ export const AGENT_INTENT_ACTIONS = [ "find_similar_rows", "analyze_reference_sheet", "find_style_references", + "style_overview", + "workbook_design_overview", + "grouped_header", + "improve_visual_readability", "transform_values", "derive_values", "settle_reconciliation", @@ -223,6 +233,7 @@ export const AGENT_INTENT_ACTIONS = [ "unprotect_sheet", "clear_sheet", "set_sheet_tab_color", + "freeze_panes", "autofit", "autofit_rows", "copy_template_sheet", @@ -293,6 +304,7 @@ export interface AgentRunInput { target: AgentRunTarget; values?: unknown[][]; rows?: unknown[][]; + style?: Record; reason?: string; }>; }; diff --git a/packages/protocol/src/catalog/index.ts b/packages/protocol/src/catalog/index.ts index d43746b..f00af8a 100644 --- a/packages/protocol/src/catalog/index.ts +++ b/packages/protocol/src/catalog/index.ts @@ -115,6 +115,7 @@ const STABLE_TOOLS = new Set([ "excel.sheet.clear", "excel.sheet.get_used_range", "excel.sheet.set_tab_color", + "excel.sheet.freeze_panes", "excel.range.read_compact", "excel.range.get_summary", "excel.range.read_hyperlinks", @@ -428,6 +429,7 @@ const TOOL_NAMES = [ "excel.sheet.clear", "excel.sheet.get_used_range", "excel.sheet.set_tab_color", + "excel.sheet.freeze_panes", "excel.range.read_compact", "excel.range.get_summary", "excel.range.read_hyperlinks", diff --git a/packages/protocol/src/operations.ts b/packages/protocol/src/operations.ts index d62cdf6..54c4f1e 100644 --- a/packages/protocol/src/operations.ts +++ b/packages/protocol/src/operations.ts @@ -119,7 +119,11 @@ export interface WriteStylesManyOperation extends OperationBase { export interface WriteDataValidationOperation extends OperationBase { kind: "range.write_data_validation"; - target: A1Range; + target?: A1Range; + entries?: Array<{ + target: A1Range; + validation: WriteDataValidationOperation["validation"]; + }>; validation: { type: "list"; source: string | string[]; @@ -387,6 +391,13 @@ export interface SetSheetTabColorOperation extends OperationBase { color: string; } +export interface FreezePanesOperation extends OperationBase { + kind: "sheet.freeze_panes"; + sheetName: string; + rows?: number; + columns?: number; +} + export interface WorkbookCalculateOperation extends OperationBase { kind: "workbook.calculate"; calculationType?: "full" | "recalculate"; @@ -453,6 +464,7 @@ export type ExcelOperation = | UnprotectSheetOperation | ClearSheetOperation | SetSheetTabColorOperation + | FreezePanesOperation | WorkbookCalculateOperation | WorkbookSaveOperation | CreateSheetFromTemplateOperation; @@ -588,6 +600,7 @@ export interface RangeSnapshot { verticalAlignment?: string; rowHeight?: number; columnWidth?: number; + wrapText?: boolean; borders?: RangeBorderStyle; }; } diff --git a/packages/protocol/src/version.ts b/packages/protocol/src/version.ts index a850ff3..4353c6f 100644 --- a/packages/protocol/src/version.ts +++ b/packages/protocol/src/version.ts @@ -1 +1 @@ -export const OPEN_WORKBOOK_VERSION = "0.1.24"; +export const OPEN_WORKBOOK_VERSION = "0.1.25"; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ff5faa..2db7764 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ packages: - "apps/*" - "packages/*" +allowBuilds: + esbuild: set this to true or false diff --git a/scripts/validate/mcp-surface.mjs b/scripts/validate/mcp-surface.mjs index eae0b04..790eb4d 100644 --- a/scripts/validate/mcp-surface.mjs +++ b/scripts/validate/mcp-surface.mjs @@ -18,7 +18,7 @@ const deprecatedInternalSurfaceEnv = "OPEN_WORKBOOK_" + "INTERNAL_TOOL_SURFACE"; const deprecatedInternalSurfaceFlag = "expose" + "InternalToolSurface"; const forbiddenPrimitiveRegistrations = ["Runtime", "Workbook", "Range", "Batch", "Workflow", "Table", "Chart", "Pivot"] .map((name) => `register${name}Tools(server)`); -const expectedInternalCapabilityCount = 308; +const expectedInternalCapabilityCount = 309; const requiredInternalCapabilities = [ "excel.workflow.prepare_session", diff --git a/skills/open-workbook-skills/SKILL.md b/skills/open-workbook-skills/SKILL.md index 4cf177a..7b50d47 100644 --- a/skills/open-workbook-skills/SKILL.md +++ b/skills/open-workbook-skills/SKILL.md @@ -97,7 +97,13 @@ For reads, start with a bounded `answer` call on the selected range. For mutatio - Create from template: use template workflows to preserve structure, formulas, styles, validation, and layout while clearing old data regions for fresh entry. - Apply style from template: use `copy_style_from_template`; the source/template sheet is a style source only and must not be duplicated or mutated. - Replace styled table: use `replace_range_with_styled_table` to clear stale layout, write headers/rows, copy header/body style samples, and autofit in one preview/apply workflow. -- Inspect current styling: call `excel.agent.run` with `mode: "answer"`, `intent.action: "read_style_summary"`, and an exact range or current selection. Use `read_style_fingerprint` for template comparison, not normal user-facing style inspection. When the user asks for a style/template reference from another sheet or month, call `intent.action: "find_style_references"` so Open Workbook returns bounded source candidates instead of reading whole sheets. +- Inspect current styling: call `excel.agent.run` with `mode: "answer"`, `intent.action: "read_style_summary"`, and an exact range or current selection. For styling recommendations or best-practice review, call `intent.action: "style_overview"` or `detailLevel: "style_overview"` once; it returns current style context, column groups, grouped-header suggestions, and safe next workflow hints without reading full data rows. Use `read_style_fingerprint` for template comparison, not normal user-facing style inspection. When the user asks for a style/template reference from another sheet or month, call `intent.action: "find_style_references"` so Open Workbook returns bounded source candidates instead of reading whole sheets. +- Grouped-header summary: when the user asks to look at, examine, or summarize grouped headers such as row 1 grouped headers, call `excel.agent.run` once with `mode: "answer"` and a target sheet/range if known. The backend returns `answer.kind: "grouped_header_summary"` with spans, labels, merged count, and unmerged labels. Do not call `workbook_design_overview`, `semantic_index`, full result resources, or broad row value reads for the same summary. +- Workbook design review: when the user asks for a column-by-column recommendation such as whether each column should be free text, date, money/number, ID/text code, dropdown, or lookup/reference from another sheet, call `excel.agent.run` once with `mode: "answer"` and `intent.action: "workbook_design_overview"`. It returns column recommendations, related-sheet hints, format/dropdown/lookup suggestions, and next workflows from cached metadata. Do not manually read Customer, Bookings, Drivers, or empty data rows first; use targeted `read_data_validation`, `write_data_validation`, or lookup/formula previews only after the user chooses a recommendation. +- Visual readability: when the user asks to make a sheet cleaner, easier to read, office-ready, visually grouped, highlighted, or formatted broadly, call `excel.agent.run` with `mode: "preview_update"` and `intent.action: "improve_visual_readability"` instead of issuing many low-level style operations. Default to `values.visualReadability.styleDepth: "standard"`, preserve formulas, and apply only after the returned `operationId` and `confirmationToken` when `nextAction` is `call_apply_update` and metrics show `operationCount > 0`. If the preview reports `operationCount: 0` or `nextAction: "answer_now"`, do not call `apply_update` and do not decompose the styling into primitive `format_range` calls; explain the skipped reasons and ask for the supported next workflow. Style preservation defaults to `stylePreservationMode: "protected_regions"` so summary/template areas and grouped header bands stay guarded while ordinary table body styling, widths, alignment, and date/money formats can still be improved. Use `"strict"` only when the user asks to preserve every existing style, and `"none"` for an explicit redesign. Use `styleDepth: "basic"` for fast low-risk layout/format cleanup, `styleDepth: "comprehensive"` for deeper suggestions, and `values.visualReadability.applySuggestionBuckets` for explicit actionable suggestions: `["layout"]` for wrap/row-height styling, `["validation"]` for dropdown writes, and `["freeze_panes"]` for freeze rows/columns. Grouped headers are suggestions for wide tables by default; to apply inserted group rows, merged labels, and matching header colors, use `mode: "preview_update"` with `intent.action: "grouped_header"` and optional `values.groupedHeader.groups`. Prefer grouped-header groups as `{ "label": "...", "startColumn": "A", "endColumn": "B" }`; `{ "columns": ["A", "B"] }` and `{ "range": "A:B" }` are accepted. Never continue grouped_header with an old visual-readability `operationId`. For freeze column requests, use `freezePanes: { "columns": 1 }` or a clear request like "freeze first column". Keep formulas, summary blocks, reference-style layout cues, and print settings as separate confirmed workflows or preview-only when the host capability is unavailable. +- Structural styling permission: grouped headers insert rows and merge ranges, so they require structure/destructive permission. If `apply_update` returns `DESTRUCTIVE_ACTION_BLOCKED` or `PERMISSION_DENIED`, do not retry the stale preview. Ask the user for permission, then call `excel.agent.run` with `intent.action: "set_permissions"` and `values.permissions: { "allowWrites": true, "allowDestructiveActions": true, "scopeToWorkbook": true, "requireConfirmationFor": [] }`; after success, create a fresh grouped_header preview and apply that fresh operation. +- Multi-range merge and alignment: when the user asks to merge several grouped-header spans and center them, send one `preview_update` with `values.merges` or `values.entries` containing each `sheetName`/`range` and optional `style`. The preview must include `range.merge` operations before the `range.write_styles_many` operation. Do not claim a range was merged if the preview/apply only contains style updates. +- Visual styling safety: comprehensive validation/formula suggestions remain preview-only unless the user chooses an explicit bucket or separate workflow; do not apply dropdowns, formulas, inserted rows/columns, or summary blocks through the visual styling apply path. - Formatting errors: use `format_diagnostics` before mutating. It returns raw value, displayed text, formulas, number formats, style summary, likely issues, and suggested fix actions. - Historical labels and similar rows: when the user asks how something was labeled/classified before, or asks to look at another month/sheet for a data reference, call `intent.action: "find_similar_rows"` on the current row/range/table or requested prior sheet. Let Open Workbook search related sheets and return exact matched rows with proof; do not manually read broad prior-sheet ranges, fetch fullResultUri, or chunk rows looking for matches. - Formulas: for exact checks such as “is this a formula?”, “raw formula”, “show formula”, or “formula in I165”, call `intent.action: "read_formulas"` with the exact target. Use `read_formula_patterns`, `validate_formula_against_template`, or formula dependency/trace actions for repeated layouts and reference comparisons. Never infer formula existence from displayed values or numbers alone. Formula writes, formula repairs, and broad formula-like derivations are preview/apply workflows with validation. diff --git a/skills/open-workbook-skills/references/agent-run.md b/skills/open-workbook-skills/references/agent-run.md index e109e75..4b8c810 100644 --- a/skills/open-workbook-skills/references/agent-run.md +++ b/skills/open-workbook-skills/references/agent-run.md @@ -51,6 +51,63 @@ Use `preview_update` for non-trivial mutations, broad edits, table appends, temp Formula writes, formula repairs, and broad formula-like derivations are preview/apply workflows with validation. Use `derive_values` with `formula_like` for row-aware calculations such as Payment Variance = Actual Amount - Cash Amount so the backend scans source/target columns and returns bounded source/before/after examples. When a full range should receive the same relative A1 formula pattern, use `write_formulas` with the full target range and one `values.formula`, for example `=H2-G2` on `I2:I244`; do not create a row-per-formula array unless the backend asks for it. +For styling review or "what would make this easier to read?" requests, use `mode: "answer"` with `intent.action: "style_overview"` or `detailLevel: "style_overview"` first. It returns current style context, column-role groups, grouped-header suggestions, and safe next workflow hints without full data-row reads. + +For workbook design review requests such as "for each column decide free text/date/money/ID/dropdown/lookup" or "look at other sheets and recommend how this table should behave", use one `mode: "answer"` call with `intent.action: "workbook_design_overview"`. It returns the target shape, column-by-column behavior recommendations, date/money/text formats, dropdown candidates, lookup/reference candidates, related-sheet hints, and next workflows from cached metadata. Do not read Customer, Bookings, Drivers, or empty table rows manually before this overview. After the user chooses a recommendation, use the targeted workflow it names, such as `write_data_validation`, `derive_values`, or `improve_visual_readability`. + +For broad readability/styling requests such as "make this cleaner", "make this easier to read", "office-ready", "highlight important issues", or "format this table", use `mode: "preview_update"` with `intent.action: "improve_visual_readability"`. Put options under `values.visualReadability`; use `styleDepth: "standard"` by default, `basic` for fast low-risk cleanup, and `comprehensive` only when the user wants deeper suggestions. The backend compiles column-first layout, width, alignment, number-format, grouping, formula/error highlight, and optional validation/formula suggestions. Style preservation defaults to `stylePreservationMode: "protected_regions"` so summary/template areas and grouped header bands stay guarded while ordinary table body styling, widths, alignment, and date/money formats can still be improved; use `"strict"` only when the user asks to preserve every existing style, and `"none"` for explicit redesign. Use `applySuggestionBuckets` to opt into actionable suggestions: `["layout"]` for wrap/row-height style writes, `["validation"]` for dropdown validation writes, and `["freeze_panes"]` for freeze rows/columns. Grouped headers are suggested for wide tables by default; to apply inserted group rows, merged labels, and matching header colors, use `mode: "preview_update"` with `intent.action: "grouped_header"` and optional `values.groupedHeader.groups`. Prefer groups shaped as `{ "label": "...", "startColumn": "A", "endColumn": "B" }`; `{ "columns": ["A", "B"] }` and `{ "range": "A:B" }` are accepted. For freeze column requests, include `freezePanes: { "columns": 1 }` or use a clear request like "freeze first column". Use `referenceStyle` for "make this look like that sheet" previews and `presentationMode` for print/export suggestions. Apply only when `nextAction` is `call_apply_update`, `operationCount > 0`, and the returned `operationId`/`confirmationToken` match the preview. If `operationCount` is `0` or `nextAction` is `answer_now`, do not call `apply_update` and do not decompose the styling into primitive `format_range` calls; explain the skipped reasons and ask for the supported next workflow. Never continue a new grouped-header preview with an `operationId` from an older visual-readability preview. Formula helpers, structure changes, reference-style layout cues, and print settings remain separate confirmed workflows or preview-only until their host capability is available. + +Grouped headers are structural. If apply returns `DESTRUCTIVE_ACTION_BLOCKED` or `PERMISSION_DENIED`, ask for user approval to allow structure changes, then call: + +```json +{ + "intent": { "action": "set_permissions" }, + "values": { + "permissions": { + "allowWrites": true, + "allowDestructiveActions": true, + "scopeToWorkbook": true, + "requireConfirmationFor": [] + } + } +} +``` + +After the permission update succeeds, create a fresh grouped_header preview and apply that fresh operation; do not retry a stale failed preview. + +Visual styling safety: comprehensive validation/formula suggestions remain preview-only unless the user chooses an explicit bucket or separate workflow; do not apply dropdowns, formulas, inserted rows/columns, or summary blocks through the visual styling apply path. + +OpenCode prompt examples: + +```text +Use open-workbook. Inspect the active sheet with a style overview first, without reading every data cell. Suggest visual readability improvements including grouped headers, one consistent palette, safe widths, alignment, filters, number formats, and highlights. Do not apply yet. +``` + +```text +Preview a grouped_header workflow for this sheet. Add a higher-level grouped header row above the existing column headers, merge group labels, and use matching group colors. Wait for approval before apply_update. +``` + +```json +{ + "mode": "preview_update", + "intent": { "action": "grouped_header" }, + "target": { "sheetName": "Invoices", "tableName": "InvoicesTable" }, + "values": { + "stylePreservationMode": "none", + "groupedHeader": { + "groups": [ + { "label": "สถานะ", "startColumn": "A", "endColumn": "B" }, + { "label": "ข้อมูลงาน", "startColumn": "C", "endColumn": "E" } + ] + } + } +} +``` + +```text +Apply the safe visual readability preview in one apply_update. Include opt-in buckets layout, validation, and freeze_panes only if they were present in the preview. +``` + Only call `apply_update` with the returned `operationId` and `confirmationToken`. If the backend reports stale context, target drift, ambiguity, missing permission, an active lock, or validation failure, stop and create a fresh preview or ask the user for direction. If `auto` returns `taskOutcome: "apply_complete"`, stop and report the applied change. If `auto` returns `taskOutcome: "preview_ready"` or `nextAction: "call_apply_update"`, ask the user once unless the user's configuration/instruction already permits applying previews, then call one `apply_update` with the returned `operationId` and `confirmationToken`. diff --git a/tests/e2e/agent-workflow.mjs b/tests/e2e/agent-workflow.mjs index 2824908..e515a11 100644 --- a/tests/e2e/agent-workflow.mjs +++ b/tests/e2e/agent-workflow.mjs @@ -156,6 +156,35 @@ async function main() { assert(appendApplied.status === "SUCCESS", "table append apply should succeed"); assert(addin.workbook.table("TransactionsArchive").info().rowCount === 4, "fake add-in table should have one appended row"); + const visualValuesBefore = addin.workbook.sheet("Data").snapshot({ workbookId, sheetName: "Data", address: "A1:D4" }).values; + const visualPreview = await agentRun(mcp, { + request: "Make the Data sheet easier to read", + mode: "preview_update", + workbookContextId: refreshed.workbookContextId, + intent: { action: "improve_visual_readability" }, + target: { sheetName: "Data" }, + values: { visualReadability: { styleDepth: "standard" } } + }, agentOutputSchema); + assert(visualPreview.status === "PREVIEW_READY", "visual readability should return a preview"); + assert(visualPreview.answer?.kind === "visual_readability_preview", "visual readability should return compact preview answer"); + assert(visualPreview.metrics?.operationCount > 0, "visual readability preview should compile safe operations"); + assert(visualPreview.metrics?.groupedOperationCount > 0, "visual readability preview should report grouped rule counts"); + assert(visualPreview.changes?.some((change) => change.after === "role-based alignment"), "visual readability preview should include compact column-rule examples"); + const visualApplied = await agentRun(mcp, { + request: "Apply visual readability preview", + mode: "apply_update", + operationId: visualPreview.operationId, + confirmationToken: visualPreview.confirmationToken + }, agentOutputSchema); + assert(visualApplied.status === "SUCCESS", "visual readability apply should succeed"); + const visualBatch = [...addin.calls].reverse().find((call) => call.method === "operation.execute_batch" && call.params?.request?.idempotencyKey?.includes("visual_readability")); + assert(visualBatch, "visual readability apply should execute one batch through the add-in"); + const visualKinds = visualBatch.params.request.operations.map((operation) => operation.kind); + assert(visualKinds.some((kind) => kind === "range.write_styles_many"), "visual readability batch should include grouped style writes"); + assert(visualKinds.every((kind) => kind !== "range.write_values" && kind !== "range.write_formulas"), "visual readability batch must not write values or formulas"); + const visualValuesAfter = addin.workbook.sheet("Data").snapshot({ workbookId, sheetName: "Data", address: "A1:D4" }).values; + assert(JSON.stringify(visualValuesAfter) === JSON.stringify(visualValuesBefore), "visual readability apply must not change cell values"); + const preview = await agentRun(mcp, { request: "Update Data B2", mode: "preview_update", diff --git a/tests/e2e/fixtures/office-agent-department-scenarios.json b/tests/e2e/fixtures/office-agent-department-scenarios.json index 84b3838..ef5a685 100644 --- a/tests/e2e/fixtures/office-agent-department-scenarios.json +++ b/tests/e2e/fixtures/office-agent-department-scenarios.json @@ -574,7 +574,7 @@ "xlsxAssertions": true, "shouldMutateWorkbook": true, "mustNotReadFullWorkbook": true, - "previewAnswerKind": "formula_preview", + "previewAnswerKind": "formula_update_preview", "hostMethods": ["operation.execute_batch"], "operationKinds": ["range.write_formulas"], "cellFormulas": [