chore(claude-do): Mission Control: allgemeiner Session-Handoff an eine frische
## Ziel Jede Mission-Control-Session (Ad-hoc UND Task-Session) soll ihre Arbeit an eine frische Session uebergeben koennen: "gib das ab an eine neue Session". Die alte Kachel wird geschlossen, an derselben Position oeffnet eine frische Session im selben cwd, die mit einem Brief startet, den die abgebende Session selbst geschrieben hat. Motivation: eine lange Session mit vollem/verrottetem Kontex ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93
This commit is contained in:
@@ -60,6 +60,8 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
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 */ }
|
||||
foreach (var id in _paneIds)
|
||||
try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(id), true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
|
||||
@@ -576,7 +578,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec()
|
||||
{
|
||||
var svc = BuildService();
|
||||
var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None);
|
||||
var spec = await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(_tempDir, spec.Cwd);
|
||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||
@@ -590,7 +592,134 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
{
|
||||
var svc = BuildService();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None));
|
||||
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), null, CancellationToken.None));
|
||||
}
|
||||
|
||||
// ── Mission Control session handoff ──
|
||||
|
||||
private readonly List<string> _paneIds = new();
|
||||
|
||||
private string NewPaneId()
|
||||
{
|
||||
var id = Guid.NewGuid().ToString("n");
|
||||
_paneIds.Add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Asserts the spec leads with the per-tile MCP entry and the handoff hint: the entry's headers
|
||||
/// name this pane and the session id the spec actually launches/resumes, and the hint names no
|
||||
/// id at all (the model would guess it).
|
||||
private static void AssertHandoffHint(LaunchSpec spec, string paneId, string sessionId)
|
||||
{
|
||||
Assert.Equal("--mcp-config", spec.Args[0]);
|
||||
Assert.Equal("--append-system-prompt-file", spec.Args[2]);
|
||||
|
||||
using var mcp = System.Text.Json.JsonDocument.Parse(File.ReadAllText(spec.Args[1]));
|
||||
var server = mcp.RootElement.GetProperty("mcpServers").GetProperty(InteractiveLaunchSpecService.PaneMcpServerName);
|
||||
Assert.Equal($"http://127.0.0.1:{new WorkerConfig().ExternalMcpPort}/mcp", server.GetProperty("url").GetString());
|
||||
var headers = server.GetProperty("headers");
|
||||
Assert.Equal(paneId, headers.GetProperty(SessionHandoffMcpTools.PaneHeader).GetString());
|
||||
Assert.Equal(sessionId, headers.GetProperty(SessionHandoffMcpTools.SessionHeader).GetString());
|
||||
|
||||
var hint = File.ReadAllText(spec.Args[3]);
|
||||
Assert.Contains("mcp__claudedo_pane__handoff_session", hint);
|
||||
Assert.DoesNotContain(paneId, hint);
|
||||
Assert.DoesNotContain(sessionId, hint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForDirectoryAsync_ExternalMcpDisabled_NoPaneEntry()
|
||||
{
|
||||
var svc = new InteractiveLaunchSpecService(_db.CreateFactory(), _seeder, _registry,
|
||||
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
|
||||
new GitService(), new WorkerConfig { ClaudeBin = _claudeStubPath, ExternalMcpPort = 0 },
|
||||
new HandoffRoundTracker(), NewRoundState());
|
||||
|
||||
var spec = await svc.BuildForDirectoryAsync(_tempDir, NewPaneId(), CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain("--mcp-config", spec.Args);
|
||||
}
|
||||
|
||||
private static string ArgAfter(LaunchSpec spec, string flag) => spec.Args[spec.Args.ToList().IndexOf(flag) + 1];
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForDirectoryAsync_WithPaneId_PreAssignsSessionIdAndAppendsHint()
|
||||
{
|
||||
var paneId = NewPaneId();
|
||||
var spec = await BuildService().BuildForDirectoryAsync(_tempDir, paneId, CancellationToken.None);
|
||||
|
||||
var sessionId = ArgAfter(spec, "--session-id");
|
||||
Assert.True(Guid.TryParse(sessionId, out _));
|
||||
AssertHandoffHint(spec, paneId, sessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForTaskAsync_WithPaneId_ResumedSession_HintNamesResumedId()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||
var svc = BuildService();
|
||||
var (_, _, _, firstSessionId) = ParseFreshTaskArgs(await svc.BuildForTaskAsync(taskId, CancellationToken.None));
|
||||
|
||||
var paneId = NewPaneId();
|
||||
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None, paneId);
|
||||
|
||||
AssertHandoffHint(spec, paneId, firstSessionId);
|
||||
Assert.Equal(firstSessionId, ArgAfter(spec, "--resume"));
|
||||
}
|
||||
|
||||
private static string WriteBrief(string paneId)
|
||||
{
|
||||
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", "h1");
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "brief.md");
|
||||
File.WriteAllText(path, "brief");
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildHandoffAsync_AdHoc_KicksOffFromBriefInSameCwd()
|
||||
{
|
||||
var paneId = NewPaneId();
|
||||
var briefPath = WriteBrief(paneId);
|
||||
|
||||
var spec = await BuildService().BuildHandoffAsync(paneId, _tempDir + Path.DirectorySeparatorChar, briefPath, null, CancellationToken.None);
|
||||
|
||||
// A trailing separator on the cwd must not survive into the spec (ConPTY quoting trap).
|
||||
Assert.Equal(_tempDir, spec.Cwd);
|
||||
var sessionId = ArgAfter(spec, "--session-id");
|
||||
AssertHandoffHint(spec, paneId, sessionId);
|
||||
Assert.Equal(Path.GetDirectoryName(briefPath), ArgAfter(spec, "--add-dir"));
|
||||
var kickoff = spec.Args[^1];
|
||||
Assert.Contains(briefPath, kickoff);
|
||||
Assert.DoesNotContain('\n', kickoff);
|
||||
Assert.Equal("--session-id", spec.Args[^3]);
|
||||
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildHandoffAsync_Task_PersistsNewSessionIdForResume()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||
var briefPath = WriteBrief(taskId);
|
||||
_paneIds.Add(taskId);
|
||||
|
||||
var spec = await BuildService().BuildHandoffAsync(taskId, _tempDir, briefPath, taskId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ArgAfter(spec, "--session-id"), await ReadInteractiveSessionIdAsync(taskId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildHandoffAsync_BriefOutsidePaneDir_Throws()
|
||||
{
|
||||
var outside = Path.Combine(_tempDir, "brief.md");
|
||||
File.WriteAllText(outside, "x");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => BuildService().BuildHandoffAsync(NewPaneId(), _tempDir, outside, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
// ── Merge helper ──
|
||||
@@ -1482,7 +1611,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var specs = new List<LaunchSpec>
|
||||
{
|
||||
await svc.BuildForTaskAsync(taskId, CancellationToken.None),
|
||||
await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None),
|
||||
await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None),
|
||||
mergeHelperSpec,
|
||||
handoffSpec,
|
||||
svc.BuildPlanningStart(planningStartCtx),
|
||||
|
||||
Reference in New Issue
Block a user