feat: send text to named sessions with draft protection and listener isolation

- chat send and listener-smoke accept --session; defaults remain File Transfer Assistant.
- Reject existing drafts, reacquire the send control after input changes, and require a
  fresh message fingerprint for confirmation without retrying failed sends.
- Test-NamedTextSend sends the same text twice and verifies distinct confirmed fingerprints;
  Test-NamedListeners -SendMarkers verifies per-window own events exactly once with zero
  cross-session events. Note export now requires a unique fresh owned note window and keeps
  repeated paragraphs. 97 Core tests pass; real-machine validated.
This commit is contained in:
2026-09-06 14:35:55 +08:00
parent fb8c25c83c
commit ad22a89185
7 changed files with 166 additions and 33 deletions
+33 -5
View File
@@ -1,6 +1,7 @@
param(
[Parameter(Mandatory=$true)][string]$Executable,
[Parameter(Mandatory=$true)][string]$OutputDirectory
[Parameter(Mandatory=$true)][string]$OutputDirectory,
[switch]$SendMarkers
)
$ErrorActionPreference='Stop'
$exe=(Resolve-Path $Executable).Path
@@ -9,29 +10,56 @@ if (Test-Path $directory) { throw 'Use a new evidence directory.' }
[IO.Directory]::CreateDirectory($directory) | Out-Null
$targets=@('文件传输助手','消息测试专用群组')
$processes=@()
$sent=@()
$seconds=if($SendMarkers){45}else{8}
try {
for($i=0;$i -lt $targets.Count;$i++) {
$arguments=@('chat','monitor','--independent','--session',('"'+$targets[$i]+'"'),
'--seconds','8','--state-file',('"'+$directory+'\state-'+$i+'.json"'),'--timeout','20')
'--seconds',$seconds,'--state-file',('"'+$directory+'\state-'+$i+'.json"'),'--timeout','60')
$process=Start-Process $exe -ArgumentList $arguments -PassThru -NoNewWindow `
-RedirectStandardOutput "$directory\events-$i.jsonl" -RedirectStandardError "$directory\stderr-$i.txt"
$null=$process.Handle # Retain the process handle so PowerShell 5 can retrieve ExitCode after exit.
$processes+=$process
}
if($SendMarkers){
$readyDeadline=[DateTime]::UtcNow.AddSeconds(20)
while(!(Test-Path "$directory\state-0.json") -or !(Test-Path "$directory\state-1.json")){
if([DateTime]::UtcNow -ge $readyDeadline){throw 'Listeners did not establish both baselines; no messages sent.'}
Start-Sleep -Milliseconds 200
}
foreach($target in $targets){
$marker='wx-agent-isolation-'+[Guid]::NewGuid().ToString('N')
$message=(& $exe chat send --session $target --text $marker --timeout 30 | Out-String) | ConvertFrom-Json
$code=$LASTEXITCODE
$sent+=@{ExitCode=$code;Fingerprint=$message.Fingerprint}
if($code -ne 0){break} # Never retry a failed send.
}
}
$checks=@()
for($i=0;$i -lt $processes.Count;$i++) {
$process=$processes[$i]
if (!$process.WaitForExit(30000)) { throw 'Named listener exceeded its deadline.' }
if (!$process.WaitForExit(60000)) { throw 'Named listener exceeded its deadline.' }
$exitCode=$process.ExitCode
$statePath="$directory\state-$i.json"
$state=if(Test-Path $statePath){ Get-Content $statePath -Raw | ConvertFrom-Json }else{$null}
& $exe inspect-ui --window-title $targets[$i] --output "$directory\ui-$i.json" --timeout 20 | Out-Null
$inspectCode=$LASTEXITCODE
$checks+=@{Index=$i;ExitCode=$exitCode;CheckpointSaved=($null -ne $state);
$check=@{Index=$i;ExitCode=$exitCode;CheckpointSaved=($null -ne $state);
SessionMatches=($state.session -eq $targets[$i]);FingerprintCount=if($null -ne $state){@($state.fingerprints).Count}else{0};InspectExit=$inspectCode}
if($SendMarkers -and $sent.Count -eq 2){
$events=@(Get-Content "$directory\events-$i.jsonl" | Where-Object {$_} | ForEach-Object {$_ | ConvertFrom-Json})
$check.OwnEvents=@($events | Where-Object {$_.message.Fingerprint -eq $sent[$i].Fingerprint -and $_.Session -ceq $targets[$i]}).Count
$check.OtherSessionEvents=@($events | Where-Object {$_.message.Fingerprint -eq $sent[1-$i].Fingerprint}).Count
}
$checks+=$check
}
$passed=@($checks | Where-Object {$_.ExitCode -ne 0 -or !$_.CheckpointSaved -or !$_.SessionMatches -or $_.FingerprintCount -lt 1 -or $_.InspectExit -ne 0}).Count -eq 0
$result=@{Checks=$checks;Passed=$passed;MessagesSent=0;Scope='Parallel named-window baseline only; not incoming-message delivery or endurance.'}
if($SendMarkers){
$passed=$passed -and $sent.Count -eq 2 -and @($sent | Where-Object {$_.ExitCode -ne 0}).Count -eq 0 -and
@($checks | Where-Object {$_.OwnEvents -ne 1 -or $_.OtherSessionEvents -ne 0}).Count -eq 0
}
$result=@{Checks=$checks;Passed=$passed;AttemptedSends=$sent.Count;MessagesSent=@($sent | Where-Object {$_.ExitCode -eq 0}).Count;
Scope=if($SendMarkers){'Own sent-message events across two independent windows; not peer incoming delivery or endurance.'}else{'Parallel named-window baseline only; not incoming-message delivery or endurance.'}}
[IO.File]::WriteAllText("$directory\summary.json",($result | ConvertTo-Json -Depth 5),(New-Object Text.UTF8Encoding($false)))
if(!$passed){exit 2}
} finally {
+23
View File
@@ -0,0 +1,23 @@
param(
[Parameter(Mandatory=$true)][string]$Executable,
[Parameter(Mandatory=$true)][string]$OutputDirectory,
[string]$Session='消息测试专用群组'
)
$ErrorActionPreference='Stop'
$exe=(Resolve-Path $Executable).Path
if(Test-Path $OutputDirectory){throw 'Use a fresh evidence directory.'}
$directory=(New-Item -ItemType Directory $OutputDirectory).FullName
$marker='wx-agent-repeat-'+[Guid]::NewGuid().ToString('N')
$results=@()
for($i=0;$i -lt 2;$i++){
$result=(& $exe chat send --session $Session --text $marker --timeout 30 | Out-String) | ConvertFrom-Json
$exitCode=$LASTEXITCODE
$results+=@{ExitCode=$exitCode;Fingerprint=$result.Fingerprint;Error=$result.error;Message=$result.message}
if($exitCode -ne 0){break} # Never retry a failed send.
}
$success=($results.Count -eq 2 -and $results[0].ExitCode -eq 0 -and $results[1].ExitCode -eq 0 -and
$null -ne $results[0].Fingerprint -and $null -ne $results[1].Fingerprint -and
$results[0].Fingerprint -ne $results[1].Fingerprint)
@{Success=$success;Results=$results;AttemptedSends=$results.Count;BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash} |
ConvertTo-Json -Depth 5 | Set-Content "$directory\summary.json" -Encoding UTF8
if(!$success){exit 1}
+16
View File
@@ -0,0 +1,16 @@
namespace WxAgent.Core;
public sealed record WechatNoteWindowCandidate(long Handle, int ProcessId, string Title, bool IsVisible, bool HasChatSurface);
public static class WechatNoteWindow
{
public static long? FindOpened(IEnumerable<WechatNoteWindowCandidate> windows, int processId, IReadOnlySet<long> previousHandles)
{
var candidates = windows.Where(window => window.Handle != 0 && window.ProcessId == processId
&& !previousHandles.Contains(window.Handle) && window.IsVisible && !window.HasChatSurface
&& window.Title.Contains("笔记", StringComparison.Ordinal)).ToArray();
if (candidates.Length > 1)
throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Multiple new note windows appeared; refusing ambiguous content.");
return candidates.Length == 1 ? candidates[0].Handle : null;
}
}
+25 -14
View File
@@ -309,21 +309,32 @@ public static partial class WechatChatClient
var message = FindVisibleMessage(main, fingerprint);
if (message.Type != ChatMessageType.Note)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The selected message is not a note.");
var processId = main.Properties.ProcessId.ValueOrDefault;
var previousHandles = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window))
.Select(window => window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64()).ToHashSet();
FindVisibleMessageElement(main, fingerprint).DoubleClick();
await Task.Delay(400, cancellationToken).ConfigureAwait(false);
var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet();
var noteWindow = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window))
.FirstOrDefault(window => processIds.Contains(window.Properties.ProcessId.ValueOrDefault)
&& !window.Properties.IsOffscreen.ValueOrDefault
&& SafeName(window).Contains("笔记", StringComparison.Ordinal));
var root = noteWindow ?? main;
var parts = root.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
.Select(SafeName)
.Where(text => !string.IsNullOrWhiteSpace(text))
.Distinct(StringComparer.Ordinal)
.Select(text => new WechatNoteContentPart("text", text))
.ToArray();
return new WechatNoteContent(parts);
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(150, cancellationToken).ConfigureAwait(false);
var windows = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window))
.Where(window => window.Properties.ProcessId.ValueOrDefault == processId).ToArray();
var handle = WechatNoteWindow.FindOpened(windows.Select(window => new WechatNoteWindowCandidate(
window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64(), window.Properties.ProcessId.ValueOrDefault,
SafeName(window), !window.Properties.IsOffscreen.ValueOrDefault,
FindByAutomationId(window, WechatLocators.MessageList) is not null)), processId, previousHandles);
if (handle is null) continue;
var noteWindow = windows.Single(window => window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64() == handle);
var parts = noteWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))
.Select(SafeName)
.Where(text => !string.IsNullOrWhiteSpace(text))
.Select(text => new WechatNoteContentPart("text", text))
.ToArray();
if (parts.Length == 0) continue;
return new WechatNoteContent(parts);
}
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed,
"A unique new note window with readable content was not verified. The main chat and existing windows were not exported.");
}, cancellationToken);
public static async Task<string> VisibleNoteToMarkdownFileAsync(
+27 -10
View File
@@ -122,9 +122,11 @@ public static partial class WechatChatClient
}
}
public static async Task<ChatMessageSnapshot> SendTextAsync(string text, CancellationToken cancellationToken)
public static async Task<ChatMessageSnapshot> SendTextAsync(string text, CancellationToken cancellationToken,
string session = WechatLocators.FileTransferAssistant)
{
ArgumentException.ThrowIfNullOrWhiteSpace(text);
ArgumentException.ThrowIfNullOrWhiteSpace(session);
if (text.Length > 4000 || text.Contains('\r') || text.Contains('\n'))
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "M1 text messages must be one line and no longer than 4000 characters.");
@@ -135,26 +137,31 @@ public static partial class WechatChatClient
{
using var automation = new UIA3Automation();
var window = AttachWindow(automation, cancellationToken);
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false);
await Task.Delay(500, cancellationToken);
if (!IsListeningSession(window, session, independent: false))
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before input.");
var baseline = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal);
var input = FindByAutomationId(window, WechatLocators.ChatInput)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found.");
var send = window.FindAllDescendants().FirstOrDefault(element =>
SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found.");
var inputBox = input.AsTextBox();
if (!string.IsNullOrEmpty(inputBox.Text))
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten.");
ExecuteInputStep("set-input-value", () => inputBox.Text = text);
await Task.Delay(150, cancellationToken);
if (!string.Equals(inputBox.Text, text, StringComparison.Ordinal))
if (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text, text, StringComparison.Ordinal))
{
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted.");
}
// Setting input text can recreate the send control; acquire it only after the input mutation.
var send = window.FindAllDescendants().FirstOrDefault(element =>
SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after input.");
ExecuteInputStep("click-send", () => ClickCenter(send));
var confirmed = await WaitForMessageAsync(text, TimeSpan.FromSeconds(20), cancellationToken);
var confirmed = await WaitForMessageAsync(text, session, baseline, TimeSpan.FromSeconds(20), cancellationToken);
return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The text was entered but no matching visible message confirmed the send result.");
}
finally
@@ -667,6 +674,8 @@ public static partial class WechatChatClient
private static async Task<ChatMessageSnapshot?> WaitForMessageAsync(
string text,
string session,
IReadOnlySet<string> baseline,
TimeSpan timeout,
CancellationToken cancellationToken)
{
@@ -676,9 +685,12 @@ public static partial class WechatChatClient
cancellationToken.ThrowIfCancellationRequested();
using var freshAutomation = new UIA3Automation();
var window = WechatDoctor.FindWechatWindow(freshAutomation);
if (window is not null && !IsListeningSession(window, session, independent: false))
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed while confirming the send; no retry was attempted.");
var match = window is null
? null
: ReadVisible(window).LastOrDefault(message => string.Equals(message.Text, text, StringComparison.Ordinal));
: ReadVisible(window).LastOrDefault(message =>
string.Equals(message.Text, text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint));
if (match is not null)
{
return match;
@@ -833,9 +845,14 @@ public static partial class WechatChatClient
{
action();
}
catch (WxAgentException)
{
throw;
}
catch (Exception exception)
{
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"UI input failed at {step}.", exception);
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState,
$"UI input failed at {step} ({exception.GetType().Name}, HRESULT 0x{exception.HResult:X8}).", exception);
}
}
+5 -4
View File
@@ -7,7 +7,8 @@ public sealed record WechatListenerSmokeResult(bool Sent, int MatchingEvents, in
public static partial class WechatChatClient
{
public static async Task<WechatListenerSmokeResult> ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken, bool independentWindow = false)
public static async Task<WechatListenerSmokeResult> ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken, bool independentWindow = false,
string session = WechatLocators.FileTransferAssistant)
{
var directory = Path.GetFullPath(outputDirectory);
Directory.CreateDirectory(directory);
@@ -27,7 +28,7 @@ public static partial class WechatChatClient
{
await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(30), checkpoint, listening.Token,
snapshotObserved: () => { Interlocked.Increment(ref snapshots); ready.TrySetResult(); },
independentWindow: independentWindow))
session: session, independentWindow: independentWindow))
{
if (item.Message?.Text != marker) continue;
Interlocked.Increment(ref matches);
@@ -39,7 +40,7 @@ public static partial class WechatChatClient
try
{
await ready.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
await SendTextAsync(marker, cancellationToken).ConfigureAwait(false); // One send only, through the same command queue.
await SendTextAsync(marker, cancellationToken, session).ConfigureAwait(false); // One send only, through the same command queue.
await matched.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false);
}
@@ -50,7 +51,7 @@ public static partial class WechatChatClient
}
var afterRestart = 0;
await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(4), checkpoint, cancellationToken,
snapshotObserved: () => Interlocked.Increment(ref snapshots), independentWindow: independentWindow))
snapshotObserved: () => Interlocked.Increment(ref snapshots), session: session, independentWindow: independentWindow))
{
if (item.Message?.Text == marker) afterRestart++;
}
@@ -0,0 +1,37 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatNoteWindowTests
{
[Fact]
public void OnlyAFreshVisibleNoteInTheExpectedProcessIsSelected()
{
WechatNoteWindowCandidate[] windows = [
new(1, 10, "笔记", true, false), // Existing note must not be exported instead of the selected message.
new(2, 10, "笔记交流群", true, true),
new(3, 20, "笔记", true, false),
new(4, 10, "笔记", false, false),
new(5, 10, "微信", true, false),
new(6, 10, "笔记", true, false)];
Assert.Equal(6, WechatNoteWindow.FindOpened(windows, 10, new HashSet<long> { 1 }));
Assert.Null(WechatNoteWindow.FindOpened(windows[..5], 10, new HashSet<long> { 1 }));
}
[Fact]
public void AmbiguousNewNotesAreRejected()
{
var error = Assert.Throws<WxAgentException>(() => WechatNoteWindow.FindOpened([
new(1, 10, "笔记", true, false), new(2, 10, "笔记", true, false)], 10, new HashSet<long>()));
Assert.Equal(WxAgentErrorCode.UiStructureChanged, error.Code);
}
[Fact]
public void RepeatedNoteParagraphsMustRemainRepeated()
{
var content = new WechatNoteContent([
new("text", "重复段落"), new("text", "重复段落")]);
Assert.Equal("重复段落\n\n重复段落", content.ToMarkdown().ReplaceLineEndings("\n"));
}
}