8cb1f9f479
Publish SDK (PyPI) / publish (push) Waiting to run
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Waiting to run
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Waiting to run
Publish SDK (npm) / publish (@officecli/sdk) (push) Waiting to run
Publish SDK (npm) / publish (officecli-sdk) (push) Waiting to run
SDK smoke / smoke (windows-latest) (push) Waiting to run
SDK smoke / smoke (macos-latest) (push) Waiting to run
SDK smoke / smoke (ubuntu-latest) (push) Waiting to run
Skill parity / diff (push) Waiting to run
283 lines
13 KiB
C#
283 lines
13 KiB
C#
// 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<Dictionary<string, string>>
|
|
{
|
|
public override Dictionary<string, string>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
{
|
|
if (reader.TokenType == JsonTokenType.Null) return null;
|
|
var dict = new Dictionary<string, string>(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<string, string> value, JsonSerializerOptions options)
|
|
{
|
|
writer.WriteStartObject();
|
|
foreach (var kv in value)
|
|
writer.WriteString(kv.Key, kv.Value);
|
|
writer.WriteEndObject();
|
|
}
|
|
}
|
|
|
|
internal class BatchItemConverter : JsonConverter<BatchItem>
|
|
{
|
|
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<string, string>), 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<string, string>? 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<string> 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; }
|
|
/// <summary>The original batch item, included when the command fails so the agent can inspect/retry.</summary>
|
|
public BatchItem? Item { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Custom converter for BatchResult that writes Output as raw JSON (not double-encoded)
|
|
/// when the Output string is valid JSON.
|
|
/// </summary>
|
|
internal class BatchResultConverter : JsonConverter<BatchResult>
|
|
{
|
|
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<BatchItem>))]
|
|
[JsonSerializable(typeof(List<BatchResult>))]
|
|
internal partial class BatchJsonContext : JsonSerializerContext;
|