wip: Campaign LA LA1 fix round — INCOMPLETE, stopped mid-task
Agent was stopped for token budget partway through the LA1 review fix round. Landed here: F1 best-effort SessionStatusWriter, F2 App reader tolerance (paths/mode), F5 argument-parsing hardening, plus new tests. NOT DONE: F4 shared-fixture production shape (was the next step), F3 reconnect disconnected edge + recorded limitation, F6 exited idempotency/reasons, F7 structural redaction test, F8 platform-guard test + comment fix, optional RuntimeOptions PrintMembers redaction. Build/test state UNVERIFIED at this commit. Next session: finish the remaining findings, run the suites, then narrow re-review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c9fc7f4a66
commit
75a6724d5b
9 changed files with 628 additions and 52 deletions
|
|
@ -0,0 +1,97 @@
|
|||
using AcDream.App.Configuration;
|
||||
|
||||
namespace AcDream.App.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1 review fix (F5): pins
|
||||
/// <see cref="SessionConfigArgumentParsing"/>'s trailing-flag edge case —
|
||||
/// <c>--session-config</c> present as the LAST argument with nothing after
|
||||
/// it must be distinguishable from the flag being entirely absent, so
|
||||
/// <c>Program.cs</c> can turn it into a hard error instead of a silent
|
||||
/// fall-through to the env-var/positional dat-dir path.
|
||||
/// </summary>
|
||||
public sealed class SessionConfigArgumentParsingTests
|
||||
{
|
||||
private const string Flag = "--session-config";
|
||||
|
||||
[Fact]
|
||||
public void FlagWithAFollowingValueReturnsThatValueAndIsPresent()
|
||||
{
|
||||
string? value = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||
["D:\\dats", Flag, "session.json"],
|
||||
Flag,
|
||||
out bool present);
|
||||
|
||||
Assert.True(present);
|
||||
Assert.Equal("session.json", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlagAbsentReturnsNullAndIsNotPresent()
|
||||
{
|
||||
string? value = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||
["D:\\dats"],
|
||||
Flag,
|
||||
out bool present);
|
||||
|
||||
Assert.False(present);
|
||||
Assert.Null(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrailingFlagWithNoValueIsPresentWithANullValue()
|
||||
{
|
||||
string? value = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||
["D:\\dats", Flag],
|
||||
Flag,
|
||||
out bool present);
|
||||
|
||||
// This is the case Program.cs must turn into exit code 2 — present
|
||||
// but no value is categorically different from "not present at
|
||||
// all", even though both currently yield a null return value.
|
||||
Assert.True(present);
|
||||
Assert.Null(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlagAloneAsTheOnlyArgumentIsPresentWithANullValue()
|
||||
{
|
||||
string? value = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||
[Flag],
|
||||
Flag,
|
||||
out bool present);
|
||||
|
||||
Assert.True(present);
|
||||
Assert.Null(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutFlagAndValueDropsTheFlagAndItsValue()
|
||||
{
|
||||
string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue(
|
||||
["D:\\dats", Flag, "session.json", "extra"],
|
||||
Flag);
|
||||
|
||||
Assert.Equal(["D:\\dats", "extra"], positional);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutFlagAndValueTrailingFlagDropsOnlyTheFlagItself()
|
||||
{
|
||||
string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue(
|
||||
["D:\\dats", Flag],
|
||||
Flag);
|
||||
|
||||
Assert.Equal(["D:\\dats"], positional);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutFlagAndValueLeavesArgumentsUnchangedWhenFlagIsAbsent()
|
||||
{
|
||||
string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue(
|
||||
["D:\\dats"],
|
||||
Flag);
|
||||
|
||||
Assert.Equal(["D:\\dats"], positional);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
using AcDream.App.Configuration;
|
||||
|
||||
namespace AcDream.App.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1 review fix (F2): the App session-config reader
|
||||
/// must TOLERATE the two document shapes only the Headless side of the
|
||||
/// pinned contract currently defines meaning for — <c>process.paths</c>
|
||||
/// (<c>HeadlessPathOverrides</c>) and the per-session <c>mode</c>
|
||||
/// discriminator (LA2's probe flow) — so a launcher-composed document does
|
||||
/// not throw a raw unmapped-member <see cref="System.Text.Json.JsonException"/>
|
||||
/// on the App host. See <c>docs/plans/2026-08-14-launcher-campaign.md</c>
|
||||
/// LA1's pinned contract.
|
||||
/// </summary>
|
||||
public sealed class SessionConfigurationLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProcessPathsAreAcceptedButIgnored()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"process": {
|
||||
"paths": {
|
||||
"configDirectory": "/config",
|
||||
"dataDirectory": "/data",
|
||||
"cacheDirectory": "/cache"
|
||||
}
|
||||
},
|
||||
"sessions": [
|
||||
{
|
||||
"id": "paths-tolerant",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"credential": { "provider": "environment", "reference": "X" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
(SessionConfiguration configuration, SessionDescriptor session) =
|
||||
SessionConfigurationLoader.Load(file.Path);
|
||||
|
||||
Assert.Equal("paths-tolerant", session.Id);
|
||||
Assert.Equal("/config", configuration.Process?.Paths?.ConfigDirectory);
|
||||
Assert.Equal("/data", configuration.Process?.Paths?.DataDirectory);
|
||||
Assert.Equal("/cache", configuration.Process?.Paths?.CacheDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentModeIsTreatedAsPlay()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "no-mode",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"credential": { "provider": "environment", "reference": "X" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
(_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
|
||||
|
||||
Assert.Null(session.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeModeFailsLoadWithAnExplicitHeadlessOnlyMessage()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "probe-session",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"mode": "probe"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
SessionConfigurationException error = Assert.Throws<SessionConfigurationException>(
|
||||
() => SessionConfigurationLoader.Load(file.Path));
|
||||
Assert.Contains("mode", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("probe", error.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("headless-only", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrecognizedModeFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-mode",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"mode": "bogus"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<SessionConfigurationException>(
|
||||
() => SessionConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
private sealed class TemporaryFile : IDisposable
|
||||
{
|
||||
private TemporaryFile(string path) => Path = path;
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
internal static TemporaryFile Create(string json)
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-app-la1-loader-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, json);
|
||||
return new TemporaryFile(path);
|
||||
}
|
||||
|
||||
public void Dispose() => File.Delete(Path);
|
||||
}
|
||||
}
|
||||
|
|
@ -97,8 +97,22 @@ public sealed class SessionStatusWriterTests
|
|||
writer.Started("s1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F7 (Campaign LA LA1 review fix round): replaces the earlier
|
||||
/// "DoesNotContain 'hunter2'/'password'" assertion, which could never
|
||||
/// actually fail — no writer method below accepts a credential-shaped
|
||||
/// parameter in the first place, so the absence of those literal strings
|
||||
/// proved nothing about the SHAPE of what gets serialized. This test
|
||||
/// asserts the structural claim that actually backs the "never write
|
||||
/// credential material into this stream" contract: each event kind
|
||||
/// serializes EXACTLY its pinned property set — the shared envelope
|
||||
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own
|
||||
/// named fields, nothing else. An extra property (a smuggled password,
|
||||
/// or any other accidental field) fails this test by construction,
|
||||
/// regardless of what value it carries.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PasswordNeverAppearsInTheStatusStream()
|
||||
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create();
|
||||
var writer = new SessionStatusWriter(file.Path);
|
||||
|
|
@ -115,9 +129,110 @@ public sealed class SessionStatusWriterTests
|
|||
writer.Disconnected("bot", "stopped");
|
||||
writer.Exited("bot", 0, "disposed");
|
||||
|
||||
string contents = File.ReadAllText(file.Path);
|
||||
Assert.DoesNotContain("hunter2", contents, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("password", contents, StringComparison.OrdinalIgnoreCase);
|
||||
string[] lines = File.ReadAllLines(file.Path);
|
||||
Assert.Equal(6, lines.Length);
|
||||
|
||||
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
|
||||
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
|
||||
AssertExactProperties(
|
||||
lines[2],
|
||||
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
|
||||
AssertExactProperties(
|
||||
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
|
||||
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason");
|
||||
AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason");
|
||||
|
||||
// The nested characters[] entries are exact too — the exact shape a
|
||||
// password could otherwise be smuggled through.
|
||||
JsonElement character = Parse(lines[2]).GetProperty("characters")[0];
|
||||
AssertExactProperties(character, "id", "name", "secondsGreyedOut");
|
||||
}
|
||||
|
||||
private static void AssertExactProperties(string line, params string[] expected) =>
|
||||
AssertExactProperties(Parse(line), expected);
|
||||
|
||||
private static void AssertExactProperties(JsonElement element, params string[] expected)
|
||||
{
|
||||
string[] actual = element.EnumerateObject()
|
||||
.Select(static property => property.Name)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] sortedExpected = expected
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
Assert.Equal(sortedExpected, actual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F1 (Campaign LA LA1 review fix round): a status file whose parent
|
||||
/// directory does not exist yet — the expected first-run shape of
|
||||
/// <c>.../launcher/sessions/<id>/status.jsonl</c> on a fresh cache
|
||||
/// dir — must be created lazily rather than throwing
|
||||
/// <see cref="DirectoryNotFoundException"/> out of the transaction the
|
||||
/// writer is merely observing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MissingParentDirectoryIsCreatedAndEventsFlow()
|
||||
{
|
||||
string root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-status-root-{Guid.NewGuid():N}");
|
||||
string path = Path.Combine(root, "nested", "sessions", "s1", "status.jsonl");
|
||||
try
|
||||
{
|
||||
Assert.False(Directory.Exists(Path.GetDirectoryName(path)));
|
||||
var writer = new SessionStatusWriter(path);
|
||||
|
||||
writer.Started("s1");
|
||||
writer.Connected("s1");
|
||||
|
||||
Assert.True(writer.IsEnabled);
|
||||
string[] lines = File.ReadAllLines(path);
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.Contains("\"started\"", lines[0]);
|
||||
Assert.Contains("\"connected\"", lines[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F1: a path whose PARENT SEGMENT already exists as an ordinary file
|
||||
/// (so <see cref="Directory.CreateDirectory"/> cannot turn it into a
|
||||
/// directory) is exactly the "unwritable path" case the review asked
|
||||
/// for — the writer must latch itself off instead of throwing, and every
|
||||
/// subsequent call must stay a cheap no-op.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParentSegmentIsAFileLatchesTheWriterInsteadOfThrowing()
|
||||
{
|
||||
string blocker = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-status-blocker-{Guid.NewGuid():N}");
|
||||
File.WriteAllText(blocker, "not a directory");
|
||||
string path = Path.Combine(blocker, "status.jsonl");
|
||||
try
|
||||
{
|
||||
var writer = new SessionStatusWriter(path);
|
||||
Assert.True(writer.IsEnabled);
|
||||
|
||||
// Must not throw — the writer swallows its own I/O failure and
|
||||
// latches off instead of failing the caller's transaction.
|
||||
writer.Started("s1");
|
||||
Assert.False(writer.IsEnabled);
|
||||
|
||||
// Latched-off calls stay cheap no-ops — no exception, no retry.
|
||||
writer.Connected("s1");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(blocker))
|
||||
File.Delete(blocker);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
{
|
||||
"version": 1,
|
||||
"process": {
|
||||
"content": {
|
||||
"datDirectory": "shared-fixture-dats",
|
||||
"preparedAssetPath": "shared-fixture-dats/acdream.pak"
|
||||
}
|
||||
},
|
||||
"sessions": [
|
||||
{
|
||||
"id": "shared-fixture",
|
||||
|
|
@ -8,8 +14,8 @@
|
|||
"character": { "name": "SharedToon" },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": {
|
||||
"provider": "environment",
|
||||
"reference": "SHARED_FIXTURE_PASSWORD"
|
||||
"provider": "standardInput",
|
||||
"reference": "session"
|
||||
},
|
||||
"plugins": ["ExamplePlugin", "AnotherPlugin"],
|
||||
"loginCommands": ["/tell someone, hi", "/vt start"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue