diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index 1ac10ba1..65a5ea65 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -89,9 +89,12 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); + // Resume an existing session as-is; for a fresh session, seed the interactive TUI with + // the task's prompt (title + description) as claude's positional prompt so it starts on + // the task -- the user then supervises/answers rather than retyping it. var args = run?.SessionId is { Length: > 0 } sessionId ? WindowsTerminalLauncher.BuildResumeArgs(sessionId) - : Array.Empty(); + : BuildFreshPromptArgs(task); // Same run environment variable ClaudeProcess sets for every headless run: the // AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for @@ -120,6 +123,22 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty(), env)); } + // The positional prompt claude opens the interactive session on. Empty (no positional arg) + // if the task has neither a title nor a description. + private static IReadOnlyList BuildFreshPromptArgs(TaskEntity task) + { + var title = task.Title?.Trim(); + var description = task.Description?.Trim(); + var prompt = (string.IsNullOrEmpty(title), string.IsNullOrEmpty(description)) switch + { + (false, false) => $"{title}\n\n{description}", + (false, true) => title!, + (true, false) => description!, + _ => string.Empty, + }; + return string.IsNullOrEmpty(prompt) ? Array.Empty() : new[] { prompt }; + } + private async Task> FilterToInstalledSkillsAsync(IReadOnlyList requested, CancellationToken ct) { if (requested.Count == 0) return requested; diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index 132e338e..0d777388 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -181,7 +181,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.True(Directory.Exists(wtRow.Path)); Assert.Equal(wtRow.Path, spec.Cwd); - Assert.Empty(spec.Args); + Assert.Equal(new[] { "T" }, spec.Args); // fresh session seeds the task title as the prompt Assert.Equal(_claudeStubPath, spec.Exe); } @@ -258,7 +258,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Empty(spec.Args); + Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt Assert.Equal(_worktreeDir, spec.Cwd); } @@ -274,7 +274,27 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Empty(spec.Args); + Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt + } + + [Fact] + public async Task BuildForTaskAsync_FreshTask_WithDescription_SeedsTitleAndDescriptionPrompt() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId, listId, TaskStatus.Idle); + await SeedWorktreeAsync(taskId, WorktreeState.Active); + using (var ctx = _db.CreateContext()) + { + var t = await ctx.Tasks.FindAsync(taskId); + t!.Description = "Do the thing"; + await ctx.SaveChangesAsync(); + } + + var svc = BuildService(); + var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); + + Assert.Equal(new[] { "T\n\nDo the thing" }, spec.Args); } [Fact]