feat(ui): answer a running task's question inline in Mission Control
TaskMonitorViewModel surfaces a pending AskUser question (TaskQuestionAsked / TaskQuestionResolved events) with an AnswerDraft + SubmitAnswerCommand that calls the new IWorkerClient.AnswerTaskQuestionAsync; MonitorPaneView shows an accent question banner with an input box above the terminal. Pending question is cleared on answer/resolve/finish and re-hydrated on attach via GetPendingQuestionAsync. en/de localization for missionControl.question.*; test fakes updated.
This commit is contained in:
@@ -243,7 +243,12 @@
|
|||||||
"empty": "Keine laufenden Aufgaben",
|
"empty": "Keine laufenden Aufgaben",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"queue": "Warteschlange",
|
"queue": "Warteschlange",
|
||||||
"blocked": "Blockiert"
|
"blocked": "Blockiert",
|
||||||
|
"question": {
|
||||||
|
"title": "Claude fragt nach",
|
||||||
|
"placeholder": "Antwort eingeben…",
|
||||||
|
"send": "Senden"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"logVisualizer": {
|
"logVisualizer": {
|
||||||
|
|||||||
@@ -243,7 +243,12 @@
|
|||||||
"empty": "No running tasks",
|
"empty": "No running tasks",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"blocked": "Blocked"
|
"blocked": "Blocked",
|
||||||
|
"question": {
|
||||||
|
"title": "Claude is asking",
|
||||||
|
"placeholder": "Type your answer…",
|
||||||
|
"send": "Send"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"logVisualizer": {
|
"logVisualizer": {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
event Action<string, string>? TaskMessageEvent;
|
event Action<string, string>? TaskMessageEvent;
|
||||||
event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||||
|
|
||||||
|
/// <summary>A running task raised a question via AskUser: (taskId, questionId, question).</summary>
|
||||||
|
event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||||
|
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
|
||||||
|
event Action<string, string>? TaskQuestionResolvedEvent;
|
||||||
|
|
||||||
event Action? PrepStartedEvent;
|
event Action? PrepStartedEvent;
|
||||||
event Action<string>? PrepLineEvent;
|
event Action<string>? PrepLineEvent;
|
||||||
event Action<bool>? PrepFinishedEvent;
|
event Action<bool>? PrepFinishedEvent;
|
||||||
@@ -39,6 +44,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task WakeQueueAsync();
|
Task WakeQueueAsync();
|
||||||
Task RunNowAsync(string taskId);
|
Task RunNowAsync(string taskId);
|
||||||
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
||||||
|
/// <summary>Answer a question a running task raised via AskUser.</summary>
|
||||||
|
Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer);
|
||||||
|
/// <summary>The question a running task is currently blocked on, if any (for re-attach).</summary>
|
||||||
|
Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId);
|
||||||
Task ResetTaskAsync(string taskId);
|
Task ResetTaskAsync(string taskId);
|
||||||
Task CancelTaskAsync(string taskId);
|
Task CancelTaskAsync(string taskId);
|
||||||
Task<List<AgentInfo>> GetAgentsAsync();
|
Task<List<AgentInfo>> GetAgentsAsync();
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||||
|
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||||
public event Action? ConnectionRestoredEvent;
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string>? ListUpdatedEvent;
|
public event Action<string>? ListUpdatedEvent;
|
||||||
@@ -136,6 +138,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId));
|
Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_hub.On<string, string, string>("TaskQuestionAsked", (taskId, questionId, question) =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question));
|
||||||
|
});
|
||||||
|
|
||||||
|
_hub.On<string, string>("TaskQuestionResolved", (taskId, questionId) =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
|
||||||
|
});
|
||||||
|
|
||||||
_hub.On<string>("WorktreeUpdated", taskId =>
|
_hub.On<string>("WorktreeUpdated", taskId =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||||
@@ -261,6 +273,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
|
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer)
|
||||||
|
{
|
||||||
|
try { await _hub.InvokeAsync<bool>("AnswerTaskQuestion", taskId, questionId, answer); }
|
||||||
|
catch { /* offline or already resolved — the UI clears optimistically */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId)
|
||||||
|
=> TryInvokeAsync<PendingQuestionDto>("GetPendingQuestion", taskId);
|
||||||
|
|
||||||
public async Task ResetTaskAsync(string taskId)
|
public async Task ResetTaskAsync(string taskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("ResetTask", taskId);
|
await _hub.InvokeAsync("ResetTask", taskId);
|
||||||
@@ -586,6 +607,7 @@ public sealed record WorktreeOverviewDto(
|
|||||||
bool PathExistsOnDisk);
|
bool PathExistsOnDisk);
|
||||||
|
|
||||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||||
|
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||||
|
|
||||||
public sealed record OnlineInboxStateDto(
|
public sealed record OnlineInboxStateDto(
|
||||||
bool Enabled,
|
bool Enabled,
|
||||||
|
|||||||
@@ -75,6 +75,23 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||||
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
||||||
private readonly Action<string> _onTaskUpdated;
|
private readonly Action<string> _onTaskUpdated;
|
||||||
|
private readonly Action<string, string, string> _onTaskQuestionAsked;
|
||||||
|
private readonly Action<string, string> _onTaskQuestionResolved;
|
||||||
|
|
||||||
|
// A question the running task raised via AskUser and is blocking on, plus the answer
|
||||||
|
// the user is typing. Ephemeral (in-memory + live events) — the task is still Running.
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasPendingQuestion))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(SubmitAnswerCommand))]
|
||||||
|
private string? _pendingQuestionId;
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _pendingQuestion;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(SubmitAnswerCommand))]
|
||||||
|
private string _answerDraft = string.Empty;
|
||||||
|
|
||||||
|
public bool HasPendingQuestion => PendingQuestionId is not null;
|
||||||
|
|
||||||
public TaskMonitorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
public TaskMonitorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
||||||
{
|
{
|
||||||
@@ -101,6 +118,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
|
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
|
||||||
});
|
});
|
||||||
AgentState = FinishedStatusToStateKey(status);
|
AgentState = FinishedStatusToStateKey(status);
|
||||||
|
ClearPendingQuestion();
|
||||||
_ = RefreshOutcomeAsync(taskId);
|
_ = RefreshOutcomeAsync(taskId);
|
||||||
};
|
};
|
||||||
_worker.TaskFinishedEvent += _onTaskFinished;
|
_worker.TaskFinishedEvent += _onTaskFinished;
|
||||||
@@ -111,8 +129,50 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
_ = RefreshStatusAsync(taskId);
|
_ = RefreshStatusAsync(taskId);
|
||||||
};
|
};
|
||||||
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
||||||
|
|
||||||
|
_onTaskQuestionAsked = (taskId, questionId, question) =>
|
||||||
|
{
|
||||||
|
if (taskId != _subscribedTaskId) return;
|
||||||
|
PendingQuestionId = questionId;
|
||||||
|
PendingQuestion = question;
|
||||||
|
};
|
||||||
|
_worker.TaskQuestionAskedEvent += _onTaskQuestionAsked;
|
||||||
|
|
||||||
|
_onTaskQuestionResolved = (taskId, questionId) =>
|
||||||
|
{
|
||||||
|
if (taskId == _subscribedTaskId && PendingQuestionId == questionId)
|
||||||
|
ClearPendingQuestion();
|
||||||
|
};
|
||||||
|
_worker.TaskQuestionResolvedEvent += _onTaskQuestionResolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surface a pending question (used by live event + re-attach hydration).
|
||||||
|
public void SetPendingQuestion(string questionId, string question)
|
||||||
|
{
|
||||||
|
PendingQuestionId = questionId;
|
||||||
|
PendingQuestion = question;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearPendingQuestion()
|
||||||
|
{
|
||||||
|
PendingQuestionId = null;
|
||||||
|
PendingQuestion = null;
|
||||||
|
AnswerDraft = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanSubmitAnswer))]
|
||||||
|
private async System.Threading.Tasks.Task SubmitAnswer()
|
||||||
|
{
|
||||||
|
var questionId = PendingQuestionId;
|
||||||
|
if (questionId is null || string.IsNullOrEmpty(_subscribedTaskId)) return;
|
||||||
|
var answer = AnswerDraft;
|
||||||
|
if (string.IsNullOrWhiteSpace(answer)) return;
|
||||||
|
ClearPendingQuestion(); // optimistic; the resolved event also clears
|
||||||
|
await _worker.AnswerTaskQuestionAsync(_subscribedTaskId, questionId, answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanSubmitAnswer() => HasPendingQuestion && !string.IsNullOrWhiteSpace(AnswerDraft);
|
||||||
|
|
||||||
partial void OnAgentStateChanged(string value)
|
partial void OnAgentStateChanged(string value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(AgentStatusLabel));
|
OnPropertyChanged(nameof(AgentStatusLabel));
|
||||||
@@ -140,6 +200,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
AgentState = "idle";
|
AgentState = "idle";
|
||||||
SessionOutcome = null;
|
SessionOutcome = null;
|
||||||
Roadblocks = null;
|
Roadblocks = null;
|
||||||
|
ClearPendingQuestion();
|
||||||
}
|
}
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -361,5 +422,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.TaskStartedEvent -= _onTaskStarted;
|
_worker.TaskStartedEvent -= _onTaskStarted;
|
||||||
_worker.TaskFinishedEvent -= _onTaskFinished;
|
_worker.TaskFinishedEvent -= _onTaskFinished;
|
||||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||||
|
_worker.TaskQuestionAskedEvent -= _onTaskQuestionAsked;
|
||||||
|
_worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
|
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
|
||||||
monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
||||||
await monitor.ReplayLogFileAsync(entity.LogPath, CancellationToken.None);
|
await monitor.ReplayLogFileAsync(entity.LogPath, CancellationToken.None);
|
||||||
|
|
||||||
|
// Re-attach: if the task is blocked on an AskUser question right now, surface it.
|
||||||
|
var pending = await _worker.GetPendingQuestionAsync(taskId);
|
||||||
|
if (pending is not null && monitor.SubscribedTaskId == taskId)
|
||||||
|
monitor.SetPendingQuestion(pending.QuestionId, pending.Question);
|
||||||
}
|
}
|
||||||
catch { /* best-effort hydrate */ }
|
catch { /* best-effort hydrate */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,34 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Question prompt (AskUser): answer a running task's question inline -->
|
||||||
|
<Border DockPanel.Dock="Top"
|
||||||
|
IsVisible="{Binding HasPendingQuestion}"
|
||||||
|
Background="{DynamicResource AccentSoftBrush}"
|
||||||
|
BorderBrush="{DynamicResource AccentBrush}"
|
||||||
|
BorderThickness="0,0,0,1" Padding="12,8">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="meta"
|
||||||
|
Text="{loc:Tr missionControl.question.title}"
|
||||||
|
Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding PendingQuestion}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextBrush}" />
|
||||||
|
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
Text="{Binding AnswerDraft, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
PlaceholderText="{loc:Tr missionControl.question.placeholder}"
|
||||||
|
AcceptsReturn="False">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SubmitAnswerCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="{loc:Tr missionControl.question.send}"
|
||||||
|
Command="{Binding SubmitAnswerCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Console body: reuse SessionTerminalView -->
|
<!-- Console body: reuse SessionTerminalView -->
|
||||||
<islands:SessionTerminalView
|
<islands:SessionTerminalView
|
||||||
Entries="{Binding Log}"
|
Entries="{Binding Log}"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public event Action<string>? ListUpdatedEvent;
|
public event Action<string>? ListUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||||
|
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||||
|
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||||
public event Action? PrepStartedEvent;
|
public event Action? PrepStartedEvent;
|
||||||
public event Action<string>? PrepLineEvent;
|
public event Action<string>? PrepLineEvent;
|
||||||
public event Action<bool>? PrepFinishedEvent;
|
public event Action<bool>? PrepFinishedEvent;
|
||||||
@@ -46,6 +48,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
||||||
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
||||||
public void RaiseConnectionRestored() => ConnectionRestoredEvent?.Invoke();
|
public void RaiseConnectionRestored() => ConnectionRestoredEvent?.Invoke();
|
||||||
|
public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question);
|
||||||
|
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
||||||
|
|
||||||
public void RaisePrepStarted() => PrepStartedEvent?.Invoke();
|
public void RaisePrepStarted() => PrepStartedEvent?.Invoke();
|
||||||
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
|
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
|
||||||
@@ -58,6 +62,14 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public virtual Task WakeQueueAsync() => Task.CompletedTask;
|
public virtual Task WakeQueueAsync() => Task.CompletedTask;
|
||||||
public virtual Task RunNowAsync(string taskId) => Task.CompletedTask;
|
public virtual Task RunNowAsync(string taskId) => Task.CompletedTask;
|
||||||
public virtual Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
public virtual Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
||||||
|
public (string TaskId, string QuestionId, string Answer)? LastAnswer { get; private set; }
|
||||||
|
public virtual Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer)
|
||||||
|
{
|
||||||
|
LastAnswer = (taskId, questionId, answer);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
public PendingQuestionDto? PendingQuestion;
|
||||||
|
public virtual Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId) => Task.FromResult(PendingQuestion);
|
||||||
public virtual Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
public virtual Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
public virtual Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
public virtual Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
public virtual Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
public virtual Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||||
|
|||||||
@@ -120,4 +120,73 @@ public class TaskMonitorViewModelTests : IDisposable
|
|||||||
vm.DetachCommand.Execute(null);
|
vm.DetachCommand.Execute(null);
|
||||||
Assert.Same(vm, requested);
|
Assert.Same(vm, requested);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TaskQuestionAsked_SurfacesQuestion()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = Build(worker);
|
||||||
|
vm.SetTaskId("t1");
|
||||||
|
|
||||||
|
worker.RaiseTaskQuestionAsked("t1", "q1", "DPAPI or plaintext?");
|
||||||
|
|
||||||
|
Assert.True(vm.HasPendingQuestion);
|
||||||
|
Assert.Equal("DPAPI or plaintext?", vm.PendingQuestion);
|
||||||
|
Assert.True(vm.SubmitAnswerCommand.CanExecute(null) is false); // no draft yet
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TaskQuestionAsked_ForOtherTask_IsIgnored()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = Build(worker);
|
||||||
|
vm.SetTaskId("t1");
|
||||||
|
|
||||||
|
worker.RaiseTaskQuestionAsked("other", "q1", "not mine");
|
||||||
|
|
||||||
|
Assert.False(vm.HasPendingQuestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubmitAnswer_InvokesClient_AndClears()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = Build(worker);
|
||||||
|
vm.SetTaskId("t1");
|
||||||
|
worker.RaiseTaskQuestionAsked("t1", "q1", "DPAPI or plaintext?");
|
||||||
|
vm.AnswerDraft = "DPAPI please";
|
||||||
|
|
||||||
|
Assert.True(vm.SubmitAnswerCommand.CanExecute(null));
|
||||||
|
await vm.SubmitAnswerCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(("t1", "q1", "DPAPI please"), worker.LastAnswer);
|
||||||
|
Assert.False(vm.HasPendingQuestion);
|
||||||
|
Assert.Equal(string.Empty, vm.AnswerDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TaskQuestionResolved_ClearsMatchingQuestion()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = Build(worker);
|
||||||
|
vm.SetTaskId("t1");
|
||||||
|
worker.RaiseTaskQuestionAsked("t1", "q1", "?");
|
||||||
|
|
||||||
|
worker.RaiseTaskQuestionResolved("t1", "q1");
|
||||||
|
|
||||||
|
Assert.False(vm.HasPendingQuestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TaskFinished_ClearsPendingQuestion()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = Build(worker);
|
||||||
|
vm.SetTaskId("t1");
|
||||||
|
worker.RaiseTaskQuestionAsked("t1", "q1", "?");
|
||||||
|
|
||||||
|
worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow);
|
||||||
|
|
||||||
|
Assert.False(vm.HasPendingQuestion);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,16 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public event Action<string>? ListUpdatedEvent;
|
public event Action<string>? ListUpdatedEvent;
|
||||||
public event Action<string, string>? TaskMessageEvent;
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||||
|
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||||
|
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||||
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
||||||
public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);
|
public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);
|
||||||
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
||||||
|
|
||||||
public Task RunNowAsync(string taskId) => Task.CompletedTask;
|
public Task RunNowAsync(string taskId) => Task.CompletedTask;
|
||||||
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
||||||
|
public Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer) => Task.CompletedTask;
|
||||||
|
public Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId) => Task.FromResult<PendingQuestionDto?>(null);
|
||||||
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||||
|
|||||||
Reference in New Issue
Block a user