fix(worker): transport ConPTY task brief via file, not CLI argument

BuildForTaskAsync's fresh-session path flattened the task title+description
into one positional CLI argument, which the ConPTY host joins into a single
command line and claude re-splits on whitespace -- any dash-leading token in
the description (e.g. "->", "--abort") was misread as an unknown option, and
a raw multi-line prompt truncated at its first newline regardless. Now the
brief is written to ~/.todo-app/task-sessions/<taskId>/brief.md and exposed
via --add-dir, with a single-line kickoff pointing claude at it -- the same
pattern BuildForMergeHelperAsync and the planning launcher already use.
This commit is contained in:
mika kuns
2026-08-05 10:54:48 +02:00
parent 334cf1e1d2
commit 3972ce50a6
3 changed files with 129 additions and 18 deletions
@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -25,6 +26,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
private readonly FakeSessionSkillSeeder _seeder = new();
private readonly FakeSessionSkillRegistry _registry = new();
private readonly List<GitRepoFixture> _gitFixtures = new();
private readonly List<string> _seededTaskIds = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
@@ -54,6 +56,8 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
foreach (var d in _mergeHelperSessionDirs)
try { Directory.Delete(d, true); } catch { /* best effort */ }
foreach (var id in _seededTaskIds)
try { Directory.Delete(Path.Combine(Paths.AppDataRoot(), "task-sessions", id), true); } catch { /* best effort */ }
}
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
@@ -92,6 +96,8 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Id = taskId, ListId = listId, Title = title, Status = status,
CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson,
});
_seededTaskIds.Add(taskId); // a fresh-task ConPTY spec may write a real brief.md under
// ~/.todo-app/task-sessions/<taskId> -- clean it up on dispose
}
private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? path = null)
@@ -185,8 +191,11 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.True(Directory.Exists(wtRow.Path));
Assert.Equal(wtRow.Path, spec.Cwd);
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh session seeds the task title as the prompt
Assert.Equal(_claudeStubPath, spec.Exe);
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec);
Assert.Contains(briefPath, kickoff); // fresh session points claude at the brief file, not a raw prompt
Assert.Equal("T", File.ReadAllText(briefPath)); // brief file holds the task title
}
[Theory]
@@ -262,7 +271,9 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); // fresh: no --resume, brief file holds the title
Assert.Contains(briefPath, kickoff);
Assert.Equal("T", File.ReadAllText(briefPath));
Assert.Equal(_worktreeDir, spec.Cwd);
}
@@ -278,7 +289,9 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec); // fresh: brief file holds the title
Assert.Contains(briefPath, kickoff);
Assert.Equal("T", File.ReadAllText(briefPath));
}
[Fact]
@@ -298,7 +311,59 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "T\n\nDo the thing" }, ArgsAfterEffort(spec));
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec);
Assert.Contains(briefPath, kickoff);
Assert.Equal("T\n\nDo the thing", File.ReadAllText(briefPath));
}
[Fact]
public async Task BuildForTaskAsync_FreshTask_DashInDescription_DoesNotAppearInArgs()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle, title: "Worker: OAuth-Usage-Client");
await SeedWorktreeAsync(taskId, WorktreeState.Active);
var description = "WorkerHub.PreviewMerge(taskId, targetBranch) -> MergePreviewDto\n" +
"run with --abort -Seconds 5 -m -A and `backticks` \"quotes\"";
using (var ctx = _db.CreateContext())
{
var t = await ctx.Tasks.FindAsync(taskId);
t!.Description = description;
await ctx.SaveChangesAsync();
}
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
var (_, briefPath, kickoff) = ParseFreshTaskArgs(spec);
Assert.Contains(briefPath, kickoff);
Assert.DoesNotContain('\n', kickoff);
// No arg element carries any fragment of the title/description -- it only ever
// reaches the session via the brief file.
foreach (var arg in spec.Args)
{
Assert.DoesNotContain("->", arg);
Assert.DoesNotContain("--abort", arg);
Assert.DoesNotContain("OAuth-Usage-Client", arg);
}
var briefContent = File.ReadAllText(briefPath);
Assert.Equal($"Worker: OAuth-Usage-Client\n\n{description}", briefContent);
}
[Fact]
public async Task BuildForTaskAsync_FreshTask_NoTitleNoDescription_NoPositionalArg()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle, title: "");
await SeedWorktreeAsync(taskId, WorktreeState.Active);
var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Empty(ArgsAfterEffort(spec)); // no brief -> no --add-dir, no positional arg
}
[Fact]
@@ -360,6 +425,25 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
return args.Skip(2).ToArray();
}
/// A fresh (never-resumed) task with a title/description produces
/// `--add-dir <sessionDir> --effort <level> <kickoff>` -- the brief travels via the file at
/// <sessionDir>/brief.md, never as a raw CLI argument. Asserts that exact shape and returns
/// (sessionDir, briefPath, kickoff) for the test's own checks. Cleanup happens via
/// `_seededTaskIds` in Dispose (the session dir is keyed by task id).
private static (string SessionDir, string BriefPath, string Kickoff) ParseFreshTaskArgs(LaunchSpec spec)
{
var args = spec.Args.ToList();
Assert.Equal(5, args.Count);
Assert.Equal("--add-dir", args[0]);
var sessionDir = args[1];
Assert.Equal("--effort", args[2]);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[3]);
var kickoff = args[4];
var briefPath = Path.Combine(sessionDir, "brief.md");
return (sessionDir, briefPath, kickoff);
}
private readonly List<string> _mergeHelperSessionDirs = new();
/// The session dir is the value right after --add-dir; register it for cleanup