// Copyright 2026 OfficeCLI (https://OfficeCLI.AI) // SPDX-License-Identifier: Apache-2.0 using System.Text.Json; using System.Text.Json.Serialization; namespace OfficeCli; internal class LenientStringDictionaryConverter : JsonConverter> { public override Dictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) return null; var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); // Array form: ["key=value", ...]. This mirrors the single-command MCP // `props` argument and the CLI `--prop key=value` flag, so an agent that // learned props from `set`/`add` produces the same shape inside a batch // item. Before this, batch props was object-only and every array-form // batch failed with "Expected object for props" — observed as a 100% // batch-failure for models that (correctly) reused the single-command // props shape. Lenient split on the first '=' matches McpServer.ParseProps. if (reader.TokenType == JsonTokenType.StartArray) { while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndArray) return dict; if (reader.TokenType != JsonTokenType.String) throw new JsonException("Expected \"key=value\" string in props array"); var kv = reader.GetString()!; var eq = kv.IndexOf('='); if (eq > 0) dict[kv[..eq]] = kv[(eq + 1)..]; // skip malformed, as ParseProps does } throw new JsonException("Unexpected end of JSON"); } if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException("Expected object or [\"key=value\"] array for props"); while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) return dict; if (reader.TokenType != JsonTokenType.PropertyName) throw new JsonException("Expected property name"); var key = reader.GetString()!; reader.Read(); var value = reader.TokenType switch { JsonTokenType.String => reader.GetString()!, JsonTokenType.Number => reader.TryGetInt64(out var l) ? l.ToString() : reader.GetDouble().ToString(), JsonTokenType.True => "true", JsonTokenType.False => "false", JsonTokenType.Null => "", _ => throw new JsonException($"Unexpected token {reader.TokenType} for prop value '{key}'") }; dict[key] = value; } throw new JsonException("Unexpected end of JSON"); } public override void Write(Utf8JsonWriter writer, Dictionary value, JsonSerializerOptions options) { writer.WriteStartObject(); foreach (var kv in value) writer.WriteString(kv.Key, kv.Value); writer.WriteEndObject(); } } internal class BatchItemConverter : JsonConverter { private static readonly LenientStringDictionaryConverter PropsConverter = new(); public override BatchItem? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException("Expected StartObject for BatchItem"); var item = new BatchItem(); while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) return item; if (reader.TokenType != JsonTokenType.PropertyName) throw new JsonException("Expected PropertyName"); var prop = reader.GetString()!; reader.Read(); switch (prop.ToLowerInvariant()) { case "command": case "op": item.Command = reader.GetString() ?? ""; break; case "path": item.Path = reader.GetString(); break; case "parent": item.Parent = reader.GetString(); break; case "type": item.Type = reader.GetString(); break; case "from": item.From = reader.GetString(); break; case "index": item.Index = reader.TokenType == JsonTokenType.Null ? null : reader.GetInt32(); break; case "after": item.After = reader.GetString(); break; case "before": item.Before = reader.GetString(); break; case "to": item.To = reader.GetString(); break; case "path2": item.Path2 = reader.GetString(); break; case "props": item.Props = PropsConverter.Read(ref reader, typeof(Dictionary), options); break; case "selector": item.Selector = reader.GetString(); break; case "text": item.Text = reader.GetString(); break; case "mode": item.Mode = reader.GetString(); break; case "depth": item.Depth = reader.TokenType == JsonTokenType.Null ? null : reader.GetInt32(); break; case "part": item.Part = reader.GetString(); break; case "xpath": item.Xpath = reader.GetString(); break; case "action": item.Action = reader.GetString(); break; case "xml": item.Xml = reader.GetString(); break; default: reader.Skip(); break; } } throw new JsonException("Unexpected end of JSON for BatchItem"); } public override void Write(Utf8JsonWriter writer, BatchItem value, JsonSerializerOptions options) { writer.WriteStartObject(); if (!string.IsNullOrEmpty(value.Command)) writer.WriteString("command", value.Command); if (value.Path != null) writer.WriteString("path", value.Path); if (value.Parent != null) writer.WriteString("parent", value.Parent); if (value.Type != null) writer.WriteString("type", value.Type); if (value.From != null) writer.WriteString("from", value.From); if (value.Index.HasValue) writer.WriteNumber("index", value.Index.Value); if (value.After != null) writer.WriteString("after", value.After); if (value.Before != null) writer.WriteString("before", value.Before); if (value.To != null) writer.WriteString("to", value.To); if (value.Path2 != null) writer.WriteString("path2", value.Path2); if (value.Props != null) { writer.WritePropertyName("props"); PropsConverter.Write(writer, value.Props, options); } if (value.Selector != null) writer.WriteString("selector", value.Selector); if (value.Text != null) writer.WriteString("text", value.Text); if (value.Mode != null) writer.WriteString("mode", value.Mode); if (value.Depth.HasValue) writer.WriteNumber("depth", value.Depth.Value); if (value.Part != null) writer.WriteString("part", value.Part); if (value.Xpath != null) writer.WriteString("xpath", value.Xpath); if (value.Action != null) writer.WriteString("action", value.Action); if (value.Xml != null) writer.WriteString("xml", value.Xml); writer.WriteEndObject(); } } [JsonConverter(typeof(BatchItemConverter))] public class BatchItem { public string Command { get; set; } = ""; public string? Path { get; set; } public string? Parent { get; set; } public string? Type { get; set; } public string? From { get; set; } public int? Index { get; set; } public string? After { get; set; } public string? Before { get; set; } public string? To { get; set; } // swap's second element. Canonical name across the single-command MCP tool // and the CLI (`swap path1 path2`); a batch swap that reused that name was // previously dropped (BatchItem had no path2), so swap only worked via the // off-name `to`. Accept both — see the swap case in ExecuteBatchItem. public string? Path2 { get; set; } public Dictionary? Props { get; set; } public string? Selector { get; set; } public string? Text { get; set; } public string? Mode { get; set; } public int? Depth { get; set; } public string? Part { get; set; } public string? Xpath { get; set; } public string? Action { get; set; } public string? Xml { get; set; } internal static readonly HashSet KnownFields = new(StringComparer.OrdinalIgnoreCase) { "command", "op", "path", "parent", "type", "from", "index", "after", "before", "to", "path2", "props", "selector", "text", "mode", "depth", "part", "xpath", "action", "xml" }; public ResidentRequest ToResidentRequest() { var req = new ResidentRequest { Command = Command }; if (Path != null) req.Args["path"] = Path; if (Parent != null) req.Args["parent"] = Parent; if (Type != null) req.Args["type"] = Type; if (From != null) req.Args["from"] = From; if (Index.HasValue) req.Args["index"] = Index.Value.ToString(); if (After != null) req.Args["after"] = After; if (Before != null) req.Args["before"] = Before; if (To != null) req.Args["to"] = To; if (Path2 != null) req.Args["path2"] = Path2; if (Selector != null) req.Args["selector"] = Selector; if (Text != null) req.Args["text"] = Text; if (Mode != null) req.Args["mode"] = Mode; if (Depth.HasValue) req.Args["depth"] = Depth.Value.ToString(); if (Part != null) req.Args["part"] = Part; if (Xpath != null) req.Args["xpath"] = Xpath; if (Action != null) req.Args["action"] = Action; if (Xml != null) req.Args["xml"] = Xml; if (Props != null) req.Props = Props; return req; } } [JsonConverter(typeof(BatchResultConverter))] public class BatchResult { public int Index { get; set; } public bool Success { get; set; } public string? Output { get; set; } public string? Error { get; set; } /// The original batch item, included when the command fails so the agent can inspect/retry. public BatchItem? Item { get; set; } } /// /// Custom converter for BatchResult that writes Output as raw JSON (not double-encoded) /// when the Output string is valid JSON. /// internal class BatchResultConverter : JsonConverter { public override BatchResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { using var doc = JsonDocument.ParseValue(ref reader); var root = doc.RootElement; var result = new BatchResult(); if (root.TryGetProperty("index", out var idx)) result.Index = idx.GetInt32(); if (root.TryGetProperty("success", out var suc)) result.Success = suc.GetBoolean(); if (root.TryGetProperty("output", out var outp)) result.Output = outp.ValueKind == JsonValueKind.String ? outp.GetString() : outp.GetRawText(); if (root.TryGetProperty("error", out var err)) result.Error = err.GetString(); if (root.TryGetProperty("item", out var itm)) result.Item = JsonSerializer.Deserialize(itm.GetRawText(), BatchJsonContext.Default.BatchItem); return result; } public override void Write(Utf8JsonWriter writer, BatchResult value, JsonSerializerOptions options) { writer.WriteStartObject(); writer.WriteNumber("index", value.Index); writer.WriteBoolean("success", value.Success); if (value.Output != null) { // If Output is valid JSON (object or array), write it as raw JSON to avoid double-encoding if (IsJsonObjectOrArray(value.Output)) { writer.WritePropertyName("output"); using var doc = JsonDocument.Parse(value.Output); doc.RootElement.WriteTo(writer); } else { writer.WriteString("output", value.Output); } } if (value.Error != null) { writer.WriteString("error", value.Error); if (value.Item != null) { writer.WritePropertyName("item"); JsonSerializer.Serialize(writer, value.Item, BatchJsonContext.Default.BatchItem); } } writer.WriteEndObject(); } private static bool IsJsonObjectOrArray(string s) { if (string.IsNullOrWhiteSpace(s)) return false; var trimmed = s.TrimStart(); if (trimmed.Length == 0) return false; if (trimmed[0] != '{' && trimmed[0] != '[') return false; try { using var doc = JsonDocument.Parse(s); return doc.RootElement.ValueKind is JsonValueKind.Object or JsonValueKind.Array; } catch { return false; } } } [JsonSourceGenerationOptions] [JsonSerializable(typeof(BatchItem))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] internal partial class BatchJsonContext : JsonSerializerContext;