feat(worker): AskUser MCP tool so a running task can ask the user mid-run
A running task can call mcp__claudedo_run__AskUser(question) to block (up to 3 min) on a human answer. PendingQuestionRegistry holds the pending question + TaskCompletionSource; the tool broadcasts TaskQuestionAsked, awaits the answer (WorkerHub.AnswerTaskQuestion resolves it), and returns it as the tool result — or a 'proceed on your judgment' fallback on timeout. The run stays Running throughout (no status/schema change). ClaudeProcess raises MCP_TOOL_TIMEOUT so the 60s HTTP-MCP cap doesn't kill the wait; the run MCP is now wired for every task, not just standalone ones. System prompt updated to reconcile 'unattended'.
This commit is contained in:
126
tests/ClaudeDo.Worker.Tests/Runner/PendingQuestionTests.cs
Normal file
126
tests/ClaudeDo.Worker.Tests/Runner/PendingQuestionTests.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Runner;
|
||||
|
||||
public class PendingQuestionRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Register_ThenAnswer_CompletesTheWait()
|
||||
{
|
||||
var registry = new PendingQuestionRegistry();
|
||||
var (questionId, answer) = registry.Register("t1", "which?");
|
||||
|
||||
Assert.False(answer.IsCompleted);
|
||||
Assert.Equal("which?", registry.Get("t1")?.Question);
|
||||
|
||||
Assert.True(registry.TryAnswer("t1", questionId, "this one"));
|
||||
Assert.Equal("this one", await answer);
|
||||
Assert.Null(registry.Get("t1")); // cleared after answering
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAnswer_WrongQuestionId_DoesNothing()
|
||||
{
|
||||
var registry = new PendingQuestionRegistry();
|
||||
var (_, answer) = registry.Register("t1", "q?");
|
||||
|
||||
Assert.False(registry.TryAnswer("t1", "stale-id", "x"));
|
||||
Assert.False(answer.IsCompleted);
|
||||
Assert.NotNull(registry.Get("t1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAnswer_UnknownTask_ReturnsFalse()
|
||||
{
|
||||
var registry = new PendingQuestionRegistry();
|
||||
Assert.False(registry.TryAnswer("ghost", "q", "x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondRegister_OverwritesStaleEntry()
|
||||
{
|
||||
var registry = new PendingQuestionRegistry();
|
||||
var (firstId, _) = registry.Register("t1", "first");
|
||||
var (secondId, _) = registry.Register("t1", "second");
|
||||
|
||||
Assert.NotEqual(firstId, secondId);
|
||||
Assert.Equal("second", registry.Get("t1")?.Question);
|
||||
Assert.False(registry.TryAnswer("t1", firstId, "x")); // old id no longer valid
|
||||
Assert.True(registry.TryAnswer("t1", secondId, "ok"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_DropsPendingWithoutAnswering()
|
||||
{
|
||||
var registry = new PendingQuestionRegistry();
|
||||
var (questionId, answer) = registry.Register("t1", "q?");
|
||||
|
||||
registry.Remove("t1", questionId);
|
||||
|
||||
Assert.Null(registry.Get("t1"));
|
||||
Assert.False(answer.IsCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
public class AskUserWaitTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AwaitAnswer_ReturnsUserAnswer_WhenAnsweredInTime()
|
||||
{
|
||||
var registry = new PendingQuestionRegistry();
|
||||
string? askedQuestionId = null;
|
||||
var asked = new TaskCompletionSource();
|
||||
var resolved = 0;
|
||||
|
||||
var wait = TaskRunMcpService.AwaitAnswerAsync(
|
||||
registry, "t1", "DPAPI or plaintext?",
|
||||
onAsked: (qid, _) => { askedQuestionId = qid; asked.TrySetResult(); return Task.CompletedTask; },
|
||||
onResolved: _ => { resolved++; return Task.CompletedTask; },
|
||||
timeout: TimeSpan.FromSeconds(5),
|
||||
ct: CancellationToken.None);
|
||||
|
||||
await asked.Task; // registration + onAsked have run
|
||||
Assert.True(registry.TryAnswer("t1", askedQuestionId!, "DPAPI please"));
|
||||
|
||||
Assert.Equal("DPAPI please", await wait);
|
||||
Assert.Equal(1, resolved);
|
||||
Assert.Null(registry.Get("t1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AwaitAnswer_ReturnsFallback_OnTimeout()
|
||||
{
|
||||
var registry = new PendingQuestionRegistry();
|
||||
var resolved = 0;
|
||||
|
||||
var result = await TaskRunMcpService.AwaitAnswerAsync(
|
||||
registry, "t2", "q?",
|
||||
onAsked: (_, _) => Task.CompletedTask,
|
||||
onResolved: _ => { resolved++; return Task.CompletedTask; },
|
||||
timeout: TimeSpan.FromMilliseconds(40),
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskRunMcpService.TimeoutFallback, result);
|
||||
Assert.Equal(1, resolved);
|
||||
Assert.Null(registry.Get("t2")); // cleaned up after timeout
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AwaitAnswer_Rethrows_WhenRunCancelled()
|
||||
{
|
||||
var registry = new PendingQuestionRegistry();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
TaskRunMcpService.AwaitAnswerAsync(
|
||||
registry, "t3", "q?",
|
||||
onAsked: (_, _) => Task.CompletedTask,
|
||||
onResolved: _ => Task.CompletedTask,
|
||||
timeout: TimeSpan.FromMinutes(1),
|
||||
ct: cts.Token));
|
||||
|
||||
Assert.Null(registry.Get("t3")); // cleanup still ran
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user