wip: Campaign LA LA2 probe mode + idle policy — INCOMPLETE, stopped mid-task
Agent was stopped for token budget. Landed here: probe flag through LiveSessionConnectOptions + the StartCore short-circuit, the mode field with JsonRequired-to-semantic-validation move, host exit-code mapping, and 34 passing tests including 3 new probe tests (agent last reported green before the stop). NOT DONE: the idle-policy unit tests (next step), full-suite verification, and the WSL run. Build/test state UNVERIFIED at this commit. Next session: finish idle policy tests, run Runtime+Headless Release suites Windows and WSL, then dispatch the Opus dual-lens review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
498f1c1182
commit
c601942467
14 changed files with 830 additions and 35 deletions
|
|
@ -208,6 +208,208 @@ public sealed class HeadlessConfigurationLoaderTests
|
|||
Assert.Empty(declared!);
|
||||
}
|
||||
|
||||
// ── Campaign LA slice LA2: probe-mode `mode` field shape validation ──
|
||||
|
||||
[Fact]
|
||||
public void ProbeSessionOmittingCharacterAndPolicyLoads()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "probe-session",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"mode": "probe",
|
||||
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfiguration configuration =
|
||||
HeadlessConfigurationLoader.Load(file.Path);
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
|
||||
Assert.Null(session.Character);
|
||||
Assert.Null(session.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "probe-with-character",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"mode": "probe",
|
||||
"character": { "index": 0 },
|
||||
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfigurationException exception = Assert.Throws<
|
||||
HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
|
||||
Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("character", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeSessionDeclaringPolicyFailsLoadNamingTheField()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "probe-with-policy",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"mode": "probe",
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfigurationException exception = Assert.Throws<
|
||||
HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
|
||||
Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("policy", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A play session (mode absent) missing `character` must still fail —
|
||||
/// the LA2 change moved this requiredness from `[JsonRequired]` (a raw
|
||||
/// <see cref="System.Text.Json.JsonException"/> at deserialize time) to
|
||||
/// <see cref="HeadlessConfigurationLoader.ValidateSession"/>'s semantic
|
||||
/// check (a <see cref="HeadlessConfigurationException"/> naming the
|
||||
/// missing field). Exit-code parity (both map to
|
||||
/// <c>HeadlessExitCode.ConfigurationError</c>) is proven at
|
||||
/// <c>HeadlessEntryPointTests</c>; this test pins the loader-level
|
||||
/// exception type/message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PlaySessionMissingCharacterStillFailsLoad()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "play-missing-character",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "PLAY_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfigurationException exception = Assert.Throws<
|
||||
HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
|
||||
Assert.Contains(
|
||||
"requires a character selector",
|
||||
exception.Message,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Same parity claim as
|
||||
/// <see cref="PlaySessionMissingCharacterStillFailsLoad"/> for the
|
||||
/// `policy` field.</summary>
|
||||
[Fact]
|
||||
public void PlaySessionMissingPolicyStillFailsLoad()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "play-missing-policy",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"credential": { "provider": "environment", "reference": "PLAY_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfigurationException exception = Assert.Throws<
|
||||
HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
|
||||
Assert.Contains(
|
||||
"requires a non-empty policy id",
|
||||
exception.Message,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaySessionKeepsTodaysStrictCharacterSelectorAndPolicyValidation()
|
||||
{
|
||||
// Unrelated to `mode` — proves the LA2 refactor of ValidateSession
|
||||
// did not loosen the existing selector-shape/policy-id checks for
|
||||
// ordinary play sessions (mode absent).
|
||||
using TemporaryConfiguration badSelector = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-selector",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0, "name": "Two" },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "A" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
using TemporaryConfiguration blankPolicy = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "blank-policy",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "" },
|
||||
"credential": { "provider": "environment", "reference": "B" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(badSelector.Path));
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(blankPolicy.Path));
|
||||
}
|
||||
|
||||
private static string ConfigurationWith(params string[] sessions) =>
|
||||
$$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}""";
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,76 @@ public sealed class HeadlessEntryPointTests
|
|||
Assert.Contains(expected, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: before this change, an omitted `character` or
|
||||
/// `policy` field failed deserialization itself with a raw
|
||||
/// <see cref="System.Text.Json.JsonException"/> ("missing required
|
||||
/// properties") — this test pins that the LA2 move to semantic
|
||||
/// validation (<see cref="AcDream.Headless.Configuration.HeadlessConfigurationException"/>
|
||||
/// naming the exact missing field) preserves the SAME exit code
|
||||
/// (3, <c>HeadlessExitCode.ConfigurationError</c>) end to end through
|
||||
/// <see cref="HeadlessEntryPoint.Run(IReadOnlyList{string}, TextWriter, TextWriter)"/>.
|
||||
/// The message text change (generic → field-naming) is a deliberate,
|
||||
/// accepted improvement, not a contract break.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(
|
||||
"""
|
||||
{"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","policy":{"id":"idle"},"credential":{"provider":"environment","reference":"X"}}]}
|
||||
""",
|
||||
"character selector")]
|
||||
[InlineData(
|
||||
"""
|
||||
{"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","character":{"index":0},"credential":{"provider":"environment","reference":"X"}}]}
|
||||
""",
|
||||
"policy id")]
|
||||
public void PlaySessionMissingCharacterOrPolicyKeepsConfigurationErrorExitCode(
|
||||
string json,
|
||||
string expectedMessageFragment)
|
||||
{
|
||||
using var file = TemporaryConfiguration.Create(json);
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
int exitCode = HeadlessEntryPoint.Run(
|
||||
["validate", "--config", file.Path],
|
||||
output,
|
||||
error);
|
||||
|
||||
Assert.Equal((int)HeadlessExitCode.ConfigurationError, exitCode);
|
||||
Assert.Contains(
|
||||
expectedMessageFragment,
|
||||
error.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(string.Empty, output.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: a valid probe-mode session (mode "probe",
|
||||
/// character/policy both omitted) passes `validate` — the launcher's
|
||||
/// "refresh characters" flow only needs the process to accept the
|
||||
/// document, not to run it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ValidateAcceptsProbeSessionOmittingCharacterAndPolicy()
|
||||
{
|
||||
using var file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{"version":1,"sessions":[{"id":"probe","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","mode":"probe","credential":{"provider":"environment","reference":"X"}}]}
|
||||
""");
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
int exitCode = HeadlessEntryPoint.Run(
|
||||
["validate", "--config", file.Path],
|
||||
output,
|
||||
error);
|
||||
|
||||
Assert.Equal((int)HeadlessExitCode.Success, exitCode);
|
||||
Assert.Contains("1 session(s)", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownCommandReturnsUsageError()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -151,6 +151,170 @@ public sealed class HeadlessSessionHostTests
|
|||
// writer is a permanent no-op with no configured path.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: a probe-mode session's status stream reports
|
||||
/// started/connected/characterList and then converges straight to
|
||||
/// exited(reason:"probe", code:0) — never enteredWorld — and the
|
||||
/// underlying operations fake proves EnterWorld was literally never
|
||||
/// called (not merely that no wire message happened to arrive).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProbeSessionEmitsRosterThenExitsSuccessfullyWithoutEnteringWorld()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-probe-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
ProbeDescriptor(statusFile: statusPath),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
RuntimeSessionStartResult started = host.Start();
|
||||
Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, started.Status);
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.False(host.Runtime.Session.IsInWorld);
|
||||
|
||||
host.Dispose();
|
||||
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
string[] eventNames = lines
|
||||
.Select(line => JsonDocument.Parse(line)
|
||||
.RootElement.GetProperty("e").GetString()!)
|
||||
.ToArray();
|
||||
Assert.DoesNotContain("enteredWorld", eventNames);
|
||||
Assert.Contains("characterList", eventNames);
|
||||
Assert.Contains("exited", eventNames);
|
||||
Assert.True(
|
||||
Array.IndexOf(eventNames, "characterList")
|
||||
< Array.IndexOf(eventNames, "exited"),
|
||||
"characterList must land before the terminal exited event.");
|
||||
|
||||
using JsonDocument exitedDoc = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "exited")]);
|
||||
Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
|
||||
Assert.Equal(
|
||||
"probe",
|
||||
exitedDoc.RootElement.GetProperty("reason").GetString());
|
||||
|
||||
string contents = File.ReadAllText(statusPath);
|
||||
Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: <see cref="HeadlessProcessHost.RunOnUpdateThread"/>
|
||||
/// maps a ProbeComplete start to <see cref="HeadlessExitCode.Success"/>
|
||||
/// (0) rather than <see cref="HeadlessExitCode.ConnectionError"/> — a
|
||||
/// single-session probe-only process must exit cleanly and promptly
|
||||
/// without ever needing SIGINT/cancellation, because
|
||||
/// ProbeHeadlessBotPolicy reports IsComplete immediately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProcessHostMapsProbeCompleteStartToSuccessExitCode()
|
||||
{
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
ProbeDescriptor(
|
||||
provider: HeadlessCredentialProviderKind.StandardInput,
|
||||
credentialReference: "probe-password"),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
new HeadlessPathOverrides());
|
||||
using var diagnostics = new StringWriter();
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
new System.IO.StringReader("probe-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
// Deliberately NOT cancelled — a probe-only process must return on
|
||||
// its own; a hang here would mean the scheduler never recognized
|
||||
// the probe session as already complete.
|
||||
using var cancellation = new CancellationTokenSource(
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
HeadlessExitCode result = await host.RunAsync(cancellation.Token);
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.False(cancellation.IsCancellationRequested);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: a probe session completing must not tear down
|
||||
/// a sibling play session sharing the same process — the process exit
|
||||
/// code is 0 only once every configured session has succeeded (the
|
||||
/// probe counts as success the instant it completes; the play session
|
||||
/// keeps running until cancellation).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProbeSessionSharingAProcessDoesNotTearDownASiblingPlaySession()
|
||||
{
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
ProbeDescriptor(
|
||||
"probe-sibling",
|
||||
provider: HeadlessCredentialProviderKind.StandardInput,
|
||||
credentialReference: "probe-password"),
|
||||
Descriptor(
|
||||
HeadlessCredentialProviderKind.StandardInput,
|
||||
"play-password"),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
new HeadlessPathOverrides());
|
||||
using var diagnostics = new StringWriter();
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
new System.IO.StringReader(
|
||||
"probe-password" + Environment.NewLine
|
||||
+ "play-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
HeadlessExitCode result = await host.RunAsync(cancellation.Token);
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
Assert.Equal(2, host.Sessions.Count);
|
||||
HeadlessSessionHost probeSession = Assert.Single(
|
||||
host.Sessions,
|
||||
s => s.SessionId == "probe-sibling");
|
||||
HeadlessSessionHost playSession = Assert.Single(
|
||||
host.Sessions,
|
||||
s => s.SessionId == "bot");
|
||||
Assert.False(probeSession.Runtime.Session.IsInWorld);
|
||||
Assert.True(playSession.Runtime.Session.IsInWorld);
|
||||
Assert.False(playSession.IsFaulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
|
||||
{
|
||||
|
|
@ -2086,6 +2250,32 @@ public sealed class HeadlessSessionHostTests
|
|||
StatusFile = statusFile,
|
||||
};
|
||||
|
||||
/// <summary>Campaign LA slice LA2: a probe-mode descriptor — mode
|
||||
/// "probe", <c>Character</c>/<c>Policy</c> both omitted per the pinned
|
||||
/// contract shape <see cref="HeadlessConfigurationLoader"/> enforces.</summary>
|
||||
private static HeadlessSessionDescriptor ProbeDescriptor(
|
||||
string id = "probe-bot",
|
||||
HeadlessCredentialProviderKind provider =
|
||||
HeadlessCredentialProviderKind.Environment,
|
||||
string credentialReference = "PROBE_PASSWORD",
|
||||
string? statusFile = null) => new()
|
||||
{
|
||||
Id = id,
|
||||
Endpoint = new HeadlessEndpointDescriptor
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 9000,
|
||||
},
|
||||
Account = "account",
|
||||
Mode = HeadlessSessionMode.Probe,
|
||||
Credential = new HeadlessCredentialReference
|
||||
{
|
||||
Provider = provider,
|
||||
Reference = credentialReference,
|
||||
},
|
||||
StatusFile = statusFile,
|
||||
};
|
||||
|
||||
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
||||
{
|
||||
const uint player = 0x50000002u;
|
||||
|
|
@ -2773,10 +2963,15 @@ public sealed class HeadlessSessionHostTests
|
|||
true,
|
||||
true);
|
||||
|
||||
/// <summary>Campaign LA slice LA2: lets a probe test assert the
|
||||
/// live-session controller never reached EnterWorld.</summary>
|
||||
public int EnterWorldCallCount { get; private set; }
|
||||
|
||||
public void EnterWorld(
|
||||
WorldSession session,
|
||||
int activeCharacterIndex)
|
||||
{
|
||||
EnterWorldCallCount++;
|
||||
}
|
||||
|
||||
public void Tick(WorldSession session)
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ public sealed class SessionConfigurationSharedFixtureTests
|
|||
Assert.Equal("127.0.0.1", session.Endpoint.Host);
|
||||
Assert.Equal(9000, session.Endpoint.Port);
|
||||
Assert.Equal("sharedaccount", session.Account);
|
||||
Assert.Equal("SharedToon", session.Character.Name);
|
||||
Assert.Equal("idle", session.Policy.Id);
|
||||
Assert.Equal("SharedToon", session.Character!.Name);
|
||||
Assert.Equal("idle", session.Policy!.Id);
|
||||
Assert.Equal(
|
||||
HeadlessCredentialProviderKind.Environment,
|
||||
session.Credential.Provider);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue