chore(claude-do): merge Mission Control: allgemeiner Session-Handoff an eine frische
ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93
This commit is contained in:
@@ -97,6 +97,33 @@ Net effect: reopening an interactive session for a task (pane closed, process ki
|
|||||||
resumes the same claude conversation, because the id was committed to the DB before the previous
|
resumes the same claude conversation, because the id was committed to the DB before the previous
|
||||||
launch even started.
|
launch even started.
|
||||||
|
|
||||||
|
## Session handoff (`handoff_session`)
|
||||||
|
|
||||||
|
Task and ad-hoc tiles can hand off to a fresh session in the same tile. The **paneId is the pane's
|
||||||
|
`IdentityKey`**: the task id for a task tile, a GUID minted by `OpenAdHocConPtySessionAsync` for an
|
||||||
|
ad-hoc one.
|
||||||
|
|
||||||
|
**The model never passes an id** (it guesses GUIDs). Instead, every such spec gets
|
||||||
|
`--mcp-config ~/.claudeDo/pane-sessions/<paneId>/mcp.json`, which adds a second entry,
|
||||||
|
`claudedo_pane`. It points at the same external MCP, with headers `X-ClaudeDo-Pane` and
|
||||||
|
`X-ClaudeDo-Session` (plus `X-ClaudeDo-Key` when one is configured). An `AddListToolsFilter`
|
||||||
|
(`SessionHandoffMcpTools.FilterByPane`) splits the tool list by that header:
|
||||||
|
- `claudedo_pane` lists only `handoff_session`;
|
||||||
|
- the plain `claudedo` entry lists everything except `handoff_session`.
|
||||||
|
|
||||||
|
The hint (`handoff-hint.md`, via `--append-system-prompt-file`) says when to call
|
||||||
|
`mcp__claudedo_pane__handoff_session(brief)` and what goes into the brief, and contains no ids. Ad-hoc
|
||||||
|
tiles get a pre-assigned `--session-id` so the header has one. With `ExternalMcpPort = 0` the tile
|
||||||
|
gets neither the entry nor the hint.
|
||||||
|
|
||||||
|
`handoff_session(brief)` writes `pane-sessions/<paneId>/handoffs/<guid>/brief.md` (first line:
|
||||||
|
`Handed off from session <id>`) and broadcasts `SessionHandoffRequested(paneId, briefPath)`. The
|
||||||
|
worker has no pane registry: the UI looks up the pane by `IdentityKey`, takes its `Cwd` (from the
|
||||||
|
resolved launch spec) and its `TaskId`, and closes it. It then reopens the tile at the same slot
|
||||||
|
with the same `IdentityKey` via `GetSessionHandoffLaunchSpec` → `BuildHandoffAsync`. For a task
|
||||||
|
tile, the new session id replaces `InteractiveSessionId`, so reopening the task resumes the
|
||||||
|
handed-off session. The old transcript remains under `~/.claude/projects/`.
|
||||||
|
|
||||||
## List handler ("Let Claude handle it")
|
## List handler ("Let Claude handle it")
|
||||||
|
|
||||||
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
|
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
|
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
|
||||||
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||||
|
|
||||||
|
/// <summary>A Mission Control session called handoff_session: (paneId, briefPath). The UI replaces
|
||||||
|
/// the tile whose IdentityKey is paneId with a fresh session starting from the brief.</summary>
|
||||||
|
event Action<string, string>? SessionHandoffRequestedEvent;
|
||||||
|
|
||||||
/// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI
|
/// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI
|
||||||
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
|
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
|
||||||
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||||
@@ -133,8 +137,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// in a task's worktree (same worktree prep as an autonomous run).</summary>
|
/// in a task's worktree (same worktree prep as an autonomous run).</summary>
|
||||||
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||||
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
|
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
|
||||||
/// no task, no worktree.</summary>
|
/// no task, no worktree. paneId (the tile's IdentityKey) is rendered into the handoff hint.</summary>
|
||||||
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
|
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default);
|
||||||
|
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
||||||
|
Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default);
|
||||||
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
|
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||||
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
|
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
|
||||||
/// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
|
/// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||||
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||||
|
public event Action<string, string>? SessionHandoffRequestedEvent;
|
||||||
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||||
public event Action<string, string>? HandlerUsageWarningEvent;
|
public event Action<string, string>? HandlerUsageWarningEvent;
|
||||||
public event Action? ConnectionRestoredEvent;
|
public event Action? ConnectionRestoredEvent;
|
||||||
@@ -160,6 +161,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
|
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_hub.On<string, string>("SessionHandoffRequested", (paneId, briefPath) =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath));
|
||||||
|
});
|
||||||
|
|
||||||
_hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
|
_hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
|
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
|
||||||
@@ -620,8 +626,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
|
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
|
||||||
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
|
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, paneId, ct));
|
||||||
|
|
||||||
|
public async Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
||||||
|
=> await InvokeTimedAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct));
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
||||||
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
|
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
|||||||
public event Action<string, int>? SessionDiedAfterStartup;
|
public event Action<string, int>? SessionDiedAfterStartup;
|
||||||
|
|
||||||
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
|
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
|
||||||
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
|
/// (no task, never deduped) — its host sets <see cref="IdentityKey"/>, the tile's paneId.
|
||||||
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
|
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
|
||||||
/// so the tile — and its starting spinner — is visible while the launch spec is still being
|
/// so the tile — and its starting spinner — is visible while the launch spec is still being
|
||||||
/// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary>
|
/// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary>
|
||||||
@@ -132,11 +132,17 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
|||||||
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
|
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
|
||||||
public void Start() => _ = StartAsync();
|
public void Start() => _ = StartAsync();
|
||||||
|
|
||||||
|
/// <summary>Working directory of the launched session, known once the launch spec resolved —
|
||||||
|
/// a handoff reopens the fresh session there.</summary>
|
||||||
|
public string? Cwd { get; private set; }
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task StartAsync()
|
private async System.Threading.Tasks.Task StartAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Terminal.Start(await _descriptorFactory());
|
var descriptor = await _descriptorFactory();
|
||||||
|
Cwd = descriptor.Cwd;
|
||||||
|
Terminal.Start(descriptor);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -144,12 +150,6 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
|
|
||||||
public static ConPtyPaneViewModel CreateAdHoc(
|
|
||||||
string displayTitle,
|
|
||||||
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
|
|
||||||
=> new(null, displayTitle, descriptorFactory);
|
|
||||||
|
|
||||||
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
|
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
|
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
|
||||||
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
||||||
|
_worker.SessionHandoffRequestedEvent += OpenSessionHandoff;
|
||||||
|
|
||||||
_onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
|
_onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
|
||||||
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
|
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
|
||||||
@@ -322,11 +323,31 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
||||||
if (string.IsNullOrEmpty(title)) title = directory;
|
if (string.IsNullOrEmpty(title)) title = directory;
|
||||||
|
|
||||||
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
|
// The key is minted here, not in the pane, because the launch spec needs it as the paneId.
|
||||||
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
|
var paneId = Guid.NewGuid().ToString("n");
|
||||||
|
AddConPtyPane(new ConPtyPaneViewModel(null, title,
|
||||||
|
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory, paneId))) { IdentityKey = paneId });
|
||||||
return System.Threading.Tasks.Task.CompletedTask;
|
return System.Threading.Tasks.Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A session called handoff_session: replaces its tile (same slot, same IdentityKey, so
|
||||||
|
/// same colour) with a fresh session in the same cwd that starts from the brief. No matching tile
|
||||||
|
/// (closed meanwhile, or another app instance) = nothing to replace.</summary>
|
||||||
|
internal void OpenSessionHandoff(string paneId, string briefPath)
|
||||||
|
{
|
||||||
|
if (ConPtySessions.FirstOrDefault(s => s.IdentityKey == paneId) is not { Cwd: { } cwd } outgoing) return;
|
||||||
|
OpenRequested?.Invoke();
|
||||||
|
|
||||||
|
var slot = ConPtySessions.IndexOf(outgoing);
|
||||||
|
var taskId = outgoing.TaskId;
|
||||||
|
var title = outgoing.DisplayTitle;
|
||||||
|
CloseConPtySession(outgoing);
|
||||||
|
|
||||||
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||||
|
() => DescribeAsync(() => _worker.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId)))
|
||||||
|
{ IdentityKey = paneId }, slot);
|
||||||
|
}
|
||||||
|
|
||||||
// List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
|
// List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
|
||||||
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
||||||
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
||||||
@@ -615,6 +636,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||||
_worker.HandoffRequestedEvent -= _onHandoffRequested;
|
_worker.HandoffRequestedEvent -= _onHandoffRequested;
|
||||||
|
_worker.SessionHandoffRequestedEvent -= OpenSessionHandoff;
|
||||||
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
|
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
|
||||||
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
|
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
|
||||||
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.External;
|
||||||
|
|
||||||
|
/// <summary>Confirmation of a handoff_session call: where the brief was written.</summary>
|
||||||
|
public sealed record SessionHandoffResult(bool Requested, string BriefPath);
|
||||||
|
|
||||||
|
/// <summary>Generic Mission Control handoff. Reached only through the per-tile <c>claudedo_pane</c>
|
||||||
|
/// MCP entry, whose headers identify the tile and session — the model never passes an id.</summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class SessionHandoffMcpTools
|
||||||
|
{
|
||||||
|
/// <summary>Header naming the calling tile (ConPtyPaneViewModel.IdentityKey).</summary>
|
||||||
|
public const string PaneHeader = "X-ClaudeDo-Pane";
|
||||||
|
|
||||||
|
/// <summary>Header naming the calling session's claude session id.</summary>
|
||||||
|
public const string SessionHeader = "X-ClaudeDo-Session";
|
||||||
|
|
||||||
|
/// <summary>The only tool the <c>claudedo_pane</c> entry lists, and the one the plain entry hides.</summary>
|
||||||
|
public const string ToolName = "handoff_session";
|
||||||
|
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly IHttpContextAccessor _http;
|
||||||
|
|
||||||
|
public SessionHandoffMcpTools(HubBroadcaster broadcaster, IHttpContextAccessor http)
|
||||||
|
{
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_http = http;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the brief under the tile's session dir and asks the UI to replace the tile.</summary>
|
||||||
|
[McpServerTool(Name = ToolName), Description(
|
||||||
|
"Hand this session off to a fresh session in the same Mission Control tile and directory, when the " +
|
||||||
|
"user asks for it (e.g. the context is full). The brief is the ONLY context the fresh session gets -- " +
|
||||||
|
"make it self-contained. This tile closes right after the call; end your turn immediately.")]
|
||||||
|
public async Task<SessionHandoffResult> HandoffSession(
|
||||||
|
[Description("Markdown brief for the fresh session: goal, done, decisions, current state, next steps, open questions, file paths.")] string brief,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var headers = _http.HttpContext?.Request.Headers;
|
||||||
|
var paneId = headers?[PaneHeader].ToString();
|
||||||
|
if (string.IsNullOrEmpty(paneId))
|
||||||
|
throw new InvalidOperationException("handoff_session only works from a ClaudeDo Mission Control tile.");
|
||||||
|
if (string.IsNullOrWhiteSpace(brief))
|
||||||
|
throw new InvalidOperationException("brief must not be empty.");
|
||||||
|
|
||||||
|
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", Guid.NewGuid().ToString("n"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var briefPath = Path.Combine(dir, "brief.md");
|
||||||
|
var fromSession = headers![SessionHeader].ToString();
|
||||||
|
var header = fromSession.Length == 0 ? "" : $"Handed off from session `{fromSession}`.\n\n";
|
||||||
|
await File.WriteAllTextAsync(briefPath, header + brief.Trim() + "\n", cancellationToken);
|
||||||
|
|
||||||
|
await _broadcaster.SessionHandoffRequested(paneId, briefPath);
|
||||||
|
return new SessionHandoffResult(true, briefPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>List-tools filter: a request carrying <see cref="PaneHeader"/> (the per-tile entry) sees
|
||||||
|
/// only handoff_session; every other request sees everything but it.</summary>
|
||||||
|
public static McpRequestHandler<ListToolsRequestParams, ListToolsResult> FilterByPane(
|
||||||
|
McpRequestHandler<ListToolsRequestParams, ListToolsResult> next) =>
|
||||||
|
async (request, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var result = await next(request, cancellationToken);
|
||||||
|
var http = request.Services?.GetService<IHttpContextAccessor>()?.HttpContext;
|
||||||
|
result.Tools = result.Tools.Where(t => IsVisible(t.Name, http)).ToList();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Visibility rule behind <see cref="FilterByPane"/>.</summary>
|
||||||
|
internal static bool IsVisible(string toolName, HttpContext? http) =>
|
||||||
|
(toolName == ToolName) == !string.IsNullOrEmpty(http?.Request.Headers[PaneHeader].ToString());
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
|||||||
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
|
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
|
||||||
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
|
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
|
||||||
|
|
||||||
|
/// <summary>A Mission Control session called handoff_session -- the UI replaces the tile whose
|
||||||
|
/// IdentityKey is paneId with a fresh session that starts from the brief.</summary>
|
||||||
|
public Task SessionHandoffRequested(string paneId, string briefPath) =>
|
||||||
|
_hub.Clients.All.SendAsync("SessionHandoffRequested", paneId, briefPath);
|
||||||
|
|
||||||
// start_list_handler (External MCP) -- the App creates the handler task and opens the triage
|
// start_list_handler (External MCP) -- the App creates the handler task and opens the triage
|
||||||
// tile, exactly like the "Let Claude handle it" button.
|
// tile, exactly like the "Let Claude handle it" button.
|
||||||
public Task ListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) =>
|
public Task ListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) =>
|
||||||
|
|||||||
@@ -776,16 +776,26 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
if (_interactiveLaunchSpec is null)
|
if (_interactiveLaunchSpec is null)
|
||||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||||
_broadcaster.WorktreeUpdated(taskId);
|
_broadcaster.WorktreeUpdated(taskId);
|
||||||
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
|
// A task tile's paneId is its task id (ConPtyPaneViewModel.IdentityKey).
|
||||||
|
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted, paneId: taskId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
|
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
|
||||||
// no task, no worktree, no session-skills seeding.
|
// no task, no worktree, no session-skills seeding. paneId is the tile's IdentityKey, rendered
|
||||||
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() =>
|
// into the handoff hint.
|
||||||
|
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory, string paneId) => HubGuard(() =>
|
||||||
{
|
{
|
||||||
if (_interactiveLaunchSpec is null)
|
if (_interactiveLaunchSpec is null)
|
||||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||||
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
|
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
||||||
|
public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() =>
|
||||||
|
{
|
||||||
|
if (_interactiveLaunchSpec is null)
|
||||||
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||||
|
return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||||
|
|||||||
@@ -375,6 +375,8 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<HandoffMcpTools>();
|
externalBuilder.Services.AddScoped<HandoffMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<SessionHandoffMcpTools>();
|
||||||
|
externalBuilder.Services.AddHttpContextAccessor();
|
||||||
externalBuilder.Services.AddScoped<ListHandlerMcpTools>();
|
externalBuilder.Services.AddScoped<ListHandlerMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
|
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
|
||||||
@@ -385,7 +387,9 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
externalBuilder.Services.AddScoped<NotesMcpTools>();
|
externalBuilder.Services.AddScoped<NotesMcpTools>();
|
||||||
externalBuilder.Services.AddMcpServer()
|
externalBuilder.Services.AddMcpServer()
|
||||||
.WithHttpTransport(o => o.Stateless = true)
|
.WithHttpTransport(o => o.Stateless = true)
|
||||||
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
|
.WithRequestFilters(f => f
|
||||||
|
.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)
|
||||||
|
.AddListToolsFilter(SessionHandoffMcpTools.FilterByPane))
|
||||||
.WithTools<ExternalMcpService>()
|
.WithTools<ExternalMcpService>()
|
||||||
.WithTools<MergeMcpTools>()
|
.WithTools<MergeMcpTools>()
|
||||||
.WithTools<WorktreeMcpTools>()
|
.WithTools<WorktreeMcpTools>()
|
||||||
@@ -396,6 +400,7 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
.WithTools<AgentMcpTools>()
|
.WithTools<AgentMcpTools>()
|
||||||
.WithTools<LifecycleMcpTools>()
|
.WithTools<LifecycleMcpTools>()
|
||||||
.WithTools<HandoffMcpTools>()
|
.WithTools<HandoffMcpTools>()
|
||||||
|
.WithTools<SessionHandoffMcpTools>()
|
||||||
.WithTools<ListHandlerMcpTools>()
|
.WithTools<ListHandlerMcpTools>()
|
||||||
.WithTools<AppSettingsMcpTools>()
|
.WithTools<AppSettingsMcpTools>()
|
||||||
.WithTools<TaskWaitMcpTools>()
|
.WithTools<TaskWaitMcpTools>()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
@@ -43,6 +44,7 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
private readonly WorktreeManager _wtManager;
|
private readonly WorktreeManager _wtManager;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly string _claudePath;
|
private readonly string _claudePath;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly HandoffRoundTracker _handoffRounds;
|
private readonly HandoffRoundTracker _handoffRounds;
|
||||||
private readonly HandlerRoundState _roundState;
|
private readonly HandlerRoundState _roundState;
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
_wtManager = wtManager;
|
_wtManager = wtManager;
|
||||||
_git = git;
|
_git = git;
|
||||||
_claudePath = cfg.ClaudeBin;
|
_claudePath = cfg.ClaudeBin;
|
||||||
|
_cfg = cfg;
|
||||||
_handoffRounds = handoffRounds;
|
_handoffRounds = handoffRounds;
|
||||||
_roundState = roundState;
|
_roundState = roundState;
|
||||||
}
|
}
|
||||||
@@ -81,8 +84,10 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
/// it has one, else the latest autonomous run's session; a task that has never run either
|
/// it has one, else the latest autonomous run's session; a task that has never run either
|
||||||
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
|
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
|
||||||
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before
|
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before
|
||||||
/// launch, so a closed/aborted session can be resumed next time.</summary>
|
/// launch, so a closed/aborted session can be resumed next time. A non-null
|
||||||
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
|
/// <paramref name="paneId"/> appends the Mission Control handoff hint (see
|
||||||
|
/// <see cref="PaneSessionArgsAsync"/>).</summary>
|
||||||
|
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct, string? paneId = null)
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
@@ -184,17 +189,22 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
// persisted HERE, before launch, so a closed/aborted session -- even one that never got
|
// persisted HERE, before launch, so a closed/aborted session -- even one that never got
|
||||||
// past startup -- still leaves an id the next open can resume.
|
// past startup -- still leaves an id the next open can resume.
|
||||||
IReadOnlyList<string> args;
|
IReadOnlyList<string> args;
|
||||||
|
string sessionId;
|
||||||
if (resumeSessionId is { Length: > 0 })
|
if (resumeSessionId is { Length: > 0 })
|
||||||
{
|
{
|
||||||
|
sessionId = resumeSessionId;
|
||||||
args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
|
args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid().ToString();
|
sessionId = Guid.NewGuid().ToString();
|
||||||
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct);
|
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct);
|
||||||
args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct);
|
args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (paneId is not null)
|
||||||
|
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args];
|
||||||
|
|
||||||
// Same run environment variable ClaudeProcess sets for every headless run: the
|
// Same run environment variable ClaudeProcess sets for every headless run: the
|
||||||
// AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting
|
// AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting
|
||||||
// it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT.
|
// it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT.
|
||||||
@@ -253,8 +263,9 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
|
|
||||||
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
|
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
|
||||||
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
|
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
|
||||||
/// directory doesn't exist.</summary>
|
/// directory doesn't exist. A non-null <paramref name="paneId"/> pre-assigns a session id and
|
||||||
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
|
/// appends the Mission Control handoff hint.</summary>
|
||||||
|
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, string? paneId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(directory))
|
if (!Directory.Exists(directory))
|
||||||
throw new InvalidOperationException($"Directory does not exist: {directory}");
|
throw new InvalidOperationException($"Directory does not exist: {directory}");
|
||||||
@@ -271,9 +282,117 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
};
|
};
|
||||||
|
|
||||||
// No task and no list here — the global default model's preset decides the effort.
|
// No task and no list here — the global default model's preset decides the effort.
|
||||||
return new LaunchSpec(
|
var args = WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel));
|
||||||
directory, resolvedClaude,
|
if (paneId is not null)
|
||||||
WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel)), env);
|
{
|
||||||
|
// Pre-assigned so the pane entry can carry it -- a handoff records it as the brief's origin.
|
||||||
|
var sessionId = Guid.NewGuid().ToString();
|
||||||
|
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args, "--session-id", sessionId];
|
||||||
|
}
|
||||||
|
return new LaunchSpec(directory, resolvedClaude, args, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the LaunchSpec for the fresh session a Mission Control tile hands off to via
|
||||||
|
/// handoff_session: same cwd, a new pre-assigned session id, the brief (written by the tool
|
||||||
|
/// under <see cref="PaneSessionDir"/>) as the single-line kickoff, and the handoff hint again so
|
||||||
|
/// the chain can continue. With a taskId the new session id becomes the task's
|
||||||
|
/// InteractiveSessionId, so reopening the task resumes the handed-off session. Throws
|
||||||
|
/// InvalidOperationException for a missing cwd or a brief outside the pane's own dir.</summary>
|
||||||
|
public async Task<LaunchSpec> BuildHandoffAsync(
|
||||||
|
string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(cwd))
|
||||||
|
throw new InvalidOperationException($"Directory does not exist: {cwd}");
|
||||||
|
briefPath = Path.GetFullPath(briefPath);
|
||||||
|
if (!briefPath.StartsWith(PaneSessionDir(paneId) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !File.Exists(briefPath))
|
||||||
|
throw new InvalidOperationException($"Handoff brief not found: {briefPath}");
|
||||||
|
|
||||||
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||||
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||||
|
var sessionId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
var model = settings.DefaultModel;
|
||||||
|
if (taskId is not null)
|
||||||
|
{
|
||||||
|
var taskRepo = new TaskRepository(ctx);
|
||||||
|
var task = await taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task not found: {taskId}");
|
||||||
|
model = task.Model ?? (await new ListRepository(ctx).GetConfigAsync(task.ListId, ct))?.Model;
|
||||||
|
await taskRepo.SetInteractiveSessionIdAsync(taskId, sessionId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --add-dir is variadic: the single-value flags after it end it, and the kickoff stays last.
|
||||||
|
var args = new List<string>(await PaneSessionArgsAsync(paneId, sessionId, ct))
|
||||||
|
{
|
||||||
|
"--add-dir", Path.GetDirectoryName(briefPath)!,
|
||||||
|
"--effort", EffortFor(settings, model),
|
||||||
|
"--session-id", sessionId,
|
||||||
|
$"Read the file {briefPath} first. It is the handoff brief from the session that worked here " +
|
||||||
|
"before you. After reading it, continue the work as it describes.",
|
||||||
|
};
|
||||||
|
|
||||||
|
var env = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new LaunchSpec(Paths.TrimTrailingSeparator(cwd)!, resolvedClaude, args, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-tile dir under the app data root holding the handoff hint and every brief the
|
||||||
|
/// tile's sessions handed off with. Throws InvalidOperationException unless paneId is a GUID —
|
||||||
|
/// it becomes a path segment.</summary>
|
||||||
|
public static string PaneSessionDir(string paneId)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(paneId, out _))
|
||||||
|
throw new InvalidOperationException($"Invalid paneId: {paneId}");
|
||||||
|
return Path.GetFullPath(Path.Combine(Paths.AppDataRoot(), "pane-sessions", paneId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Name of the per-tile MCP entry that carries the tile's identity in its headers.</summary>
|
||||||
|
public const string PaneMcpServerName = "claudedo_pane";
|
||||||
|
|
||||||
|
// `--mcp-config <file> --append-system-prompt-file <file>` giving the session a `claudedo_pane`
|
||||||
|
// entry whose headers name this tile and session, so handoff_session needs no id from the model.
|
||||||
|
// Empty without the external MCP -- there is nothing to hand off through.
|
||||||
|
private async Task<IReadOnlyList<string>> PaneSessionArgsAsync(string paneId, string sessionId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_cfg.ExternalMcpPort <= 0) return [];
|
||||||
|
|
||||||
|
var dir = PaneSessionDir(paneId);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
var headers = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[SessionHandoffMcpTools.PaneHeader] = paneId,
|
||||||
|
[SessionHandoffMcpTools.SessionHeader] = sessionId,
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrEmpty(_cfg.ExternalMcpApiKey))
|
||||||
|
headers["X-ClaudeDo-Key"] = _cfg.ExternalMcpApiKey;
|
||||||
|
var mcpConfigPath = Path.Combine(dir, "mcp.json");
|
||||||
|
await File.WriteAllTextAsync(mcpConfigPath, JsonSerializer.Serialize(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["mcpServers"] = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[PaneMcpServerName] = new { type = "http", url = $"http://127.0.0.1:{_cfg.ExternalMcpPort}/mcp", headers },
|
||||||
|
},
|
||||||
|
}), ct);
|
||||||
|
|
||||||
|
var hintPath = Path.Combine(dir, "handoff-hint.md");
|
||||||
|
await File.WriteAllTextAsync(hintPath,
|
||||||
|
$"""
|
||||||
|
You are running inside a ClaudeDo Mission Control tile. Only when the user asks you to hand off
|
||||||
|
to a fresh session, call mcp__{PaneMcpServerName}__{SessionHandoffMcpTools.ToolName} with a brief.
|
||||||
|
The brief is the ONLY context the fresh session gets, so make it self-contained: the goal, what
|
||||||
|
is done, decisions made and why, the current state (branch, uncommitted changes, failing tests),
|
||||||
|
what comes next, open questions, and the relevant file paths. This tile closes and a fresh
|
||||||
|
session opens in its place right after the call -- end your turn immediately.
|
||||||
|
""", ct);
|
||||||
|
|
||||||
|
return ["--mcp-config", mcpConfigPath, "--append-system-prompt-file", hintPath];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
|
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||||
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||||
|
public event Action<string, string>? SessionHandoffRequestedEvent;
|
||||||
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||||
public event Action<string, string>? HandlerUsageWarningEvent;
|
public event Action<string, string>? HandlerUsageWarningEvent;
|
||||||
public event Action? NotesUpdatedEvent;
|
public event Action? NotesUpdatedEvent;
|
||||||
@@ -59,6 +60,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
||||||
public void RaiseListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds);
|
public void RaiseListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds);
|
||||||
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
|
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
|
||||||
|
public void RaiseSessionHandoffRequested(string paneId, string briefPath) => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath);
|
||||||
public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message);
|
public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message);
|
||||||
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
||||||
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
||||||
@@ -124,8 +126,10 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public virtual Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public virtual Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
|
public virtual Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new LaunchSpec(cwd, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||||
public virtual Task<string> CreateMergeHelperTaskAsync(
|
public virtual Task<string> CreateMergeHelperTaskAsync(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class ConPtyPaneViewModelTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning()
|
public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning()
|
||||||
{
|
{
|
||||||
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
|
using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes);
|
||||||
SetRunning(pane);
|
SetRunning(pane);
|
||||||
|
|
||||||
Assert.False(pane.IsTaskBased);
|
Assert.False(pane.IsTaskBased);
|
||||||
@@ -192,7 +192,7 @@ public class ConPtyPaneViewModelTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SessionDiedAfterStartup_AdHocPane_NeverRaises()
|
public void SessionDiedAfterStartup_AdHocPane_NeverRaises()
|
||||||
{
|
{
|
||||||
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
|
using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes);
|
||||||
var raised = false;
|
var raised = false;
|
||||||
pane.SessionDiedAfterStartup += (_, _) => raised = true;
|
pane.SessionDiedAfterStartup += (_, _) => raised = true;
|
||||||
|
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
|
|
||||||
private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
|
private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
|
||||||
{
|
{
|
||||||
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
|
||||||
=> throw new InvalidOperationException("bad directory");
|
=> throw new InvalidOperationException("bad directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,6 +771,81 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
Assert.Equal(color, vm.ConPtySessions[1].IdentityColorKey);
|
Assert.Equal(color, vm.ConPtySessions[1].IdentityColorKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingSessionHandoffWorker : StubWorkerClient
|
||||||
|
{
|
||||||
|
public string? AdHocPaneId;
|
||||||
|
public readonly TaskCompletionSource<(string PaneId, string Cwd, string BriefPath, string? TaskId)> HandoffCall = new();
|
||||||
|
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
AdHocPaneId ??= paneId;
|
||||||
|
return base.GetAdHocLaunchSpecAsync(directory, paneId, ct);
|
||||||
|
}
|
||||||
|
public override Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
HandoffCall.TrySetResult((paneId, cwd, briefPath, taskId));
|
||||||
|
return base.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handoff_session replaces the tile in place: same slot, same identity colour, same cwd, and the
|
||||||
|
// outgoing pane is really closed rather than left running beside the new one.
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionHandoffRequested_ReplacesPaneInPlace_KeepingIdentityAndCwd()
|
||||||
|
{
|
||||||
|
var worker = new RecordingSessionHandoffWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
var dir = Path.Combine("C:", "Some", "Path");
|
||||||
|
await vm.OpenConPtySessionAsync("t1");
|
||||||
|
await vm.OpenAdHocConPtySessionAsync(dir);
|
||||||
|
await vm.OpenConPtySessionAsync("t2");
|
||||||
|
var outgoing = vm.ConPtySessions[1];
|
||||||
|
Assert.Equal(outgoing.IdentityKey, worker.AdHocPaneId);
|
||||||
|
var color = outgoing.IdentityColorKey;
|
||||||
|
var opened = 0;
|
||||||
|
vm.OpenRequested += () => opened++;
|
||||||
|
|
||||||
|
worker.RaiseSessionHandoffRequested(outgoing.IdentityKey, "brief.md");
|
||||||
|
|
||||||
|
Assert.Equal(3, vm.ConPtySessions.Count);
|
||||||
|
var incoming = vm.ConPtySessions[1];
|
||||||
|
Assert.NotSame(outgoing, incoming);
|
||||||
|
Assert.Null(incoming.TaskId);
|
||||||
|
Assert.Equal(outgoing.IdentityKey, incoming.IdentityKey);
|
||||||
|
Assert.Equal(color, incoming.IdentityColorKey);
|
||||||
|
Assert.Same(incoming, vm.Panes[1]);
|
||||||
|
Assert.Equal(1, opened);
|
||||||
|
Assert.Equal(0, SubscriberCount(outgoing, "SubmitForReviewRequested"));
|
||||||
|
|
||||||
|
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal((outgoing.IdentityKey, dir, "brief.md", (string?)null), call);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionHandoffRequested_TaskPane_KeepsTaskId()
|
||||||
|
{
|
||||||
|
var worker = new RecordingSessionHandoffWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
await vm.OpenConPtySessionAsync("t1");
|
||||||
|
|
||||||
|
worker.RaiseSessionHandoffRequested("t1", "brief.md");
|
||||||
|
|
||||||
|
Assert.Equal("t1", Assert.Single(vm.ConPtySessions).TaskId);
|
||||||
|
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal("t1", call.TaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionHandoffRequested_UnknownPane_DoesNothing()
|
||||||
|
{
|
||||||
|
var worker = new RecordingSessionHandoffWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
|
||||||
|
worker.RaiseSessionHandoffRequested("nope", "brief.md");
|
||||||
|
|
||||||
|
Assert.Empty(vm.ConPtySessions);
|
||||||
|
Assert.False(worker.HandoffCall.Task.IsCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
|
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests
|
|||||||
"list_runs", "get_run", "get_task_log",
|
"list_runs", "get_run", "get_task_log",
|
||||||
"list_agents",
|
"list_agents",
|
||||||
"reset_failed_task", "submit_task_for_review",
|
"reset_failed_task", "submit_task_for_review",
|
||||||
"handoff_list_handler", "get_handoff_status", "start_list_handler",
|
"handoff_list_handler", "get_handoff_status", "start_list_handler", "handoff_session",
|
||||||
"get_app_settings",
|
"get_app_settings",
|
||||||
"wait_for_task_change",
|
"wait_for_task_change",
|
||||||
"get_queue_state",
|
"get_queue_state",
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using ClaudeDo.Worker.External;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.External;
|
||||||
|
|
||||||
|
public sealed class SessionHandoffMcpToolsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly CapturingHubContext _hubContext = new();
|
||||||
|
private readonly string _paneId = Guid.NewGuid().ToString("n");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(_paneId), true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpContext Request(string? paneId, string? sessionId = null)
|
||||||
|
{
|
||||||
|
var http = new DefaultHttpContext();
|
||||||
|
if (paneId is not null) http.Request.Headers[SessionHandoffMcpTools.PaneHeader] = paneId;
|
||||||
|
if (sessionId is not null) http.Request.Headers[SessionHandoffMcpTools.SessionHeader] = sessionId;
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionHandoffMcpTools BuildSut(HttpContext http) =>
|
||||||
|
new(new HubBroadcaster(_hubContext), new HttpContextAccessor { HttpContext = http });
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandoffSession_IdentityFromHeaders_WritesBriefWithOrigin_AndBroadcasts()
|
||||||
|
{
|
||||||
|
var result = await BuildSut(Request(_paneId, "sess-1")).HandoffSession(" do the thing ", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Requested);
|
||||||
|
Assert.StartsWith(InteractiveLaunchSpecService.PaneSessionDir(_paneId), result.BriefPath);
|
||||||
|
Assert.Equal("Handed off from session `sess-1`.\n\ndo the thing\n", File.ReadAllText(result.BriefPath));
|
||||||
|
|
||||||
|
var call = Assert.Single(_hubContext.Proxy.Calls);
|
||||||
|
Assert.Equal("SessionHandoffRequested", call.Method);
|
||||||
|
Assert.Equal(new object?[] { _paneId, result.BriefPath }, call.Args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("not-a-guid")]
|
||||||
|
[InlineData(@"..\..\evil")]
|
||||||
|
public async Task HandoffSession_MissingOrInvalidPaneHeader_ThrowsWithoutBroadcast(string? paneId)
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
BuildSut(Request(paneId)).HandoffSession("brief", CancellationToken.None));
|
||||||
|
Assert.Empty(_hubContext.Proxy.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandoffSession_EmptyBrief_Throws()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
BuildSut(Request(_paneId)).HandoffSession(" ", CancellationToken.None));
|
||||||
|
Assert.Empty(_hubContext.Proxy.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The per-tile entry lists only handoff_session; the plain entry every session has hides it, so
|
||||||
|
// the tool is never duplicated and never offered where it can't identify the tile.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("handoff_session", true, true)]
|
||||||
|
[InlineData("handoff_session", false, false)]
|
||||||
|
[InlineData("get_task", true, false)]
|
||||||
|
[InlineData("get_task", false, true)]
|
||||||
|
public void IsVisible_SplitsToolsByPaneHeader(string tool, bool paneRequest, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, SessionHandoffMcpTools.IsVisible(tool, Request(paneRequest ? _paneId : null)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
try { Directory.Delete(d, true); } catch { /* best effort */ }
|
try { Directory.Delete(d, true); } catch { /* best effort */ }
|
||||||
foreach (var id in _seededTaskIds)
|
foreach (var id in _seededTaskIds)
|
||||||
try { Directory.Delete(Path.Combine(Paths.AppDataRoot(), "task-sessions", id), true); } catch { /* best effort */ }
|
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
|
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
|
||||||
@@ -576,7 +578,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec()
|
public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec()
|
||||||
{
|
{
|
||||||
var svc = BuildService();
|
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(_tempDir, spec.Cwd);
|
||||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||||
@@ -590,7 +592,134 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var svc = BuildService();
|
var svc = BuildService();
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
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 ──
|
// ── Merge helper ──
|
||||||
@@ -1482,7 +1611,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
var specs = new List<LaunchSpec>
|
var specs = new List<LaunchSpec>
|
||||||
{
|
{
|
||||||
await svc.BuildForTaskAsync(taskId, CancellationToken.None),
|
await svc.BuildForTaskAsync(taskId, CancellationToken.None),
|
||||||
await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None),
|
await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None),
|
||||||
mergeHelperSpec,
|
mergeHelperSpec,
|
||||||
handoffSpec,
|
handoffSpec,
|
||||||
svc.BuildPlanningStart(planningStartCtx),
|
svc.BuildPlanningStart(planningStartCtx),
|
||||||
|
|||||||
Reference in New Issue
Block a user