Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions backend/src/Taskdeck.Application/Services/ChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,38 +217,46 @@ await _quotaService.RecordUsageAsync(
messageType = "degraded";
}

if (llmResult.IsActionable && dto.RequestProposal)
var shouldAttemptProposal = llmResult.IsActionable || (dto.RequestProposal && session.BoardId.HasValue);

if (shouldAttemptProposal)
{
if (!session.BoardId.HasValue)
{
messageType = "error";
assistantContent = "Actionable instructions require a board-scoped chat session. Create a session with BoardId and retry.";
// Surface a hint so the user knows why no proposal was created
assistantContent = $"{llmResult.Content}\n\n(To act on this, open a board-scoped chat session.)";
messageType = "status";
}
else
{
var proposalResult = await _automationPlanner.ParseInstructionAsync(
dto.Content,
userId,
session.BoardId,
ct);
ct,
sourceType: ProposalSourceType.Chat,
sourceReferenceId: session.Id.ToString());

if (proposalResult.IsSuccess)
{
messageType = "proposal-reference";
proposalId = proposalResult.Value.Id;
assistantContent = $"{llmResult.Content}\n\nProposal created: {proposalResult.Value.Id}";
assistantContent = $"{llmResult.Content}\n\nProposal created for review: {proposalResult.Value.Id}";
}
else if (llmResult.IsActionable)
{
// Planner could not parse an auto-detected actionable message — hint the user
assistantContent = $"{llmResult.Content}\n\n(I detected a task request but could not parse it into a proposal: {proposalResult.ErrorMessage})";
messageType = "status";
}
else
{
messageType = "error";
assistantContent = $"I could not create a proposal: {proposalResult.ErrorMessage}";
// User explicitly requested proposal but planner failed — surface the error
assistantContent = $"{llmResult.Content}\n\n(Could not create the requested proposal: {proposalResult.ErrorMessage})";
messageType = "status";
}
}
}
else if (llmResult.IsActionable)
{
messageType = "status";
}
}

var persistedDegradedReason = string.IsNullOrWhiteSpace(degradedReason)
Expand Down
26 changes: 21 additions & 5 deletions backend/src/Taskdeck.Application/Services/LlmIntentClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,33 @@ public static (bool IsActionable, string? ActionIntent) Classify(string message)
{
var lower = message.ToLowerInvariant();

if (lower.Contains("create card") || lower.Contains("add card"))
// Card creation — explicit commands and natural language
if (lower.Contains("create card") || lower.Contains("add card")
|| lower.Contains("create a card") || lower.Contains("add a card")
|| lower.Contains("create task") || lower.Contains("add task")
|| lower.Contains("create a task") || lower.Contains("add a task")
|| lower.Contains("new card") || lower.Contains("new task")
|| lower.Contains("make a card") || lower.Contains("make a task")
|| lower.Contains("make card") || lower.Contains("make task"))
return (true, "card.create");

if (lower.Contains("move card"))
return (true, "card.move");
if (lower.Contains("archive card") || lower.Contains("delete card"))
if (lower.Contains("archive card") || lower.Contains("delete card")
|| lower.Contains("remove card"))
return (true, "card.archive");
if (lower.Contains("update card") || lower.Contains("edit card"))
if (lower.Contains("update card") || lower.Contains("edit card")
|| lower.Contains("rename card"))
return (true, "card.update");
if (lower.Contains("create board") || lower.Contains("add board"))
if (lower.Contains("create board") || lower.Contains("add board")
|| lower.Contains("new board"))
return (true, "board.create");
if (lower.Contains("reorder") || lower.Contains("sort"))
if (lower.Contains("rename board"))
return (true, "board.update");
if (lower.Contains("reorder cards") || lower.Contains("reorder column")
|| lower.Contains("reorder columns") || lower.Contains("reorder board")
|| lower.Contains("sort cards") || lower.Contains("sort column")
|| lower.Contains("sort columns") || lower.Contains("sort board"))
return (true, "column.reorder");

return (false, null);
Expand Down
7 changes: 5 additions & 2 deletions backend/tests/Taskdeck.Api.Tests/ChatApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,21 @@ public async Task CreateSession_And_SendActionableMessage_ShouldReturnProposalRe
var session = await createSessionResponse.Content.ReadFromJsonAsync<ChatSessionDto>();
session.Should().NotBeNull();

// Actionable messages now auto-create proposals without needing RequestProposal
var sendMessageResponse = await _client.PostAsJsonAsync(
$"/api/llm/chat/sessions/{session!.Id}/messages",
new SendChatMessageDto("create card \"Backend task\""));

sendMessageResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var assistant = await sendMessageResponse.Content.ReadFromJsonAsync<ChatMessageDto>();
assistant.Should().NotBeNull();
assistant!.MessageType.Should().Be("status");
assistant!.MessageType.Should().Be("proposal-reference");
assistant.ProposalId.Should().NotBeNull();

// Explicitly requesting proposal should also work
var actionableResponse = await _client.PostAsJsonAsync(
$"/api/llm/chat/sessions/{session.Id}/messages",
new SendChatMessageDto("create card \"Backend task\"", RequestProposal: true));
new SendChatMessageDto("create card \"Backend task 2\"", RequestProposal: true));

actionableResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var actionableAssistant = await actionableResponse.Content.ReadFromJsonAsync<ChatMessageDto>();
Expand Down
210 changes: 210 additions & 0 deletions backend/tests/Taskdeck.Application.Tests/Services/ChatServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,216 @@ public async Task SendMessageAsync_ShouldCreateProposalReference_WhenActionableA
result.Value.ProposalId.Should().Be(proposalId);
}

[Fact]
public async Task SendMessageAsync_ShouldAutoCreateProposal_WhenActionableIntentDetected_WithoutExplicitRequestProposal()
{
var userId = Guid.NewGuid();
var boardId = Guid.NewGuid();
var proposalId = Guid.NewGuid();
var session = new ChatSession(userId, "Auto-proposal session", boardId);

_chatSessionRepoMock
.Setup(r => r.GetByIdWithMessagesAsync(session.Id, default))
.ReturnsAsync(session);
_llmProviderMock
.Setup(p => p.CompleteAsync(It.IsAny<ChatCompletionRequest>(), default))
.ReturnsAsync(new LlmCompletionResult("I'll create that card for you.", 12, true, "card.create"));
_plannerMock
.Setup(p => p.ParseInstructionAsync(
It.IsAny<string>(),
userId,
boardId,
It.IsAny<CancellationToken>(),
It.IsAny<ProposalSourceType>(),
It.IsAny<string?>(),
It.IsAny<string?>()))
.ReturnsAsync(Result.Success(new ProposalDto(
proposalId,
ProposalSourceType.Chat,
null,
boardId,
userId,
ProposalStatus.PendingReview,
RiskLevel.Low,
"create card",
null,
null,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
DateTime.UtcNow.AddHours(1),
Comment on lines +215 to +217
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using DateTimeOffset.UtcNow and DateTime.UtcNow directly in tests can lead to non-deterministic behavior, especially if tests are run near midnight or across time zone changes. While these specific values aren't asserted on here, it's a best practice to use a fixed date/time or a time provider abstraction (like TimeProvider in .NET 8) to ensure tests are fully deterministic and repeatable.

null,
null,
null,
null,
"corr",
new List<ProposalOperationDto>())));

// RequestProposal defaults to false — proposals should still be created
var result = await _service.SendMessageAsync(
session.Id,
userId,
new SendChatMessageDto("create card \"My New Task\""),
default);

result.IsSuccess.Should().BeTrue();
result.Value.MessageType.Should().Be("proposal-reference");
result.Value.ProposalId.Should().Be(proposalId);
result.Value.Content.Should().Contain("Proposal created for review");
_plannerMock.Verify(
p => p.ParseInstructionAsync(
It.IsAny<string>(),
userId,
boardId,
It.IsAny<CancellationToken>(),
ProposalSourceType.Chat,
session.Id.ToString(),
It.IsAny<string?>()),
Times.Once);
}

[Fact]
public async Task SendMessageAsync_ShouldReturnStatusWithHint_WhenActionableButNoBoardScope()
{
var userId = Guid.NewGuid();
var session = new ChatSession(userId, "No board session");

_chatSessionRepoMock
.Setup(r => r.GetByIdWithMessagesAsync(session.Id, default))
.ReturnsAsync(session);
_llmProviderMock
.Setup(p => p.CompleteAsync(It.IsAny<ChatCompletionRequest>(), default))
.ReturnsAsync(new LlmCompletionResult("I can create that card.", 12, true, "card.create"));

var result = await _service.SendMessageAsync(
session.Id,
userId,
new SendChatMessageDto("create card \"Test\""),
default);

result.IsSuccess.Should().BeTrue();
result.Value.MessageType.Should().Be("status");
result.Value.Content.Should().Contain("board-scoped chat session");
_plannerMock.Verify(
p => p.ParseInstructionAsync(
It.IsAny<string>(), It.IsAny<Guid>(), It.IsAny<Guid?>(),
It.IsAny<CancellationToken>(), It.IsAny<ProposalSourceType>(),
It.IsAny<string?>(), It.IsAny<string?>()),
Times.Never);
}

[Fact]
public async Task SendMessageAsync_ShouldReturnStatusWithParseHint_WhenActionableButPlannerFails()
{
var userId = Guid.NewGuid();
var boardId = Guid.NewGuid();
var session = new ChatSession(userId, "Parse fail session", boardId);

_chatSessionRepoMock
.Setup(r => r.GetByIdWithMessagesAsync(session.Id, default))
.ReturnsAsync(session);
_llmProviderMock
.Setup(p => p.CompleteAsync(It.IsAny<ChatCompletionRequest>(), default))
.ReturnsAsync(new LlmCompletionResult("I can help with that.", 12, true, "card.create"));
_plannerMock
.Setup(p => p.ParseInstructionAsync(
It.IsAny<string>(), userId, boardId,
It.IsAny<CancellationToken>(), It.IsAny<ProposalSourceType>(),
It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(Result.Failure<ProposalDto>(ErrorCodes.ValidationError, "Could not parse instruction"));

var result = await _service.SendMessageAsync(
session.Id,
userId,
new SendChatMessageDto("do something with cards please"),
default);

result.IsSuccess.Should().BeTrue();
result.Value.MessageType.Should().Be("status");
result.Value.Content.Should().Contain("could not parse it into a proposal");
}

[Fact]
public async Task SendMessageAsync_ShouldTryPlanner_WhenRequestProposalExplicitButNotActionable()
{
var userId = Guid.NewGuid();
var boardId = Guid.NewGuid();
var proposalId = Guid.NewGuid();
var session = new ChatSession(userId, "Explicit request session", boardId);

_chatSessionRepoMock
.Setup(r => r.GetByIdWithMessagesAsync(session.Id, default))
.ReturnsAsync(session);
_llmProviderMock
.Setup(p => p.CompleteAsync(It.IsAny<ChatCompletionRequest>(), default))
.ReturnsAsync(new LlmCompletionResult("Here is some info.", 12, false, null));
_plannerMock
.Setup(p => p.ParseInstructionAsync(
It.IsAny<string>(), userId, boardId,
It.IsAny<CancellationToken>(), It.IsAny<ProposalSourceType>(),
It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(Result.Success(new ProposalDto(
proposalId,
ProposalSourceType.Chat,
null,
boardId,
userId,
ProposalStatus.PendingReview,
RiskLevel.Low,
"explicit request",
null,
null,
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
DateTime.UtcNow.AddHours(1),
null,
null,
null,
null,
"corr",
new List<ProposalOperationDto>())));

var result = await _service.SendMessageAsync(
session.Id,
userId,
new SendChatMessageDto("create card \"Explicit\"", RequestProposal: true),
default);

result.IsSuccess.Should().BeTrue();
result.Value.MessageType.Should().Be("proposal-reference");
result.Value.ProposalId.Should().Be(proposalId);
}

[Fact]
public async Task SendMessageAsync_ShouldReturnStatusWithHint_WhenRequestProposalExplicitButPlannerFails()
{
var userId = Guid.NewGuid();
var boardId = Guid.NewGuid();
var session = new ChatSession(userId, "Explicit request planner fail", boardId);

_chatSessionRepoMock
.Setup(r => r.GetByIdWithMessagesAsync(session.Id, default))
.ReturnsAsync(session);
_llmProviderMock
.Setup(p => p.CompleteAsync(It.IsAny<ChatCompletionRequest>(), default))
.ReturnsAsync(new LlmCompletionResult("Here is some info.", 12, false, null));
_plannerMock
.Setup(p => p.ParseInstructionAsync(
It.IsAny<string>(), userId, boardId,
It.IsAny<CancellationToken>(), It.IsAny<ProposalSourceType>(),
It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(Result.Failure<ProposalDto>(ErrorCodes.ValidationError, "Could not parse instruction"));

var result = await _service.SendMessageAsync(
session.Id,
userId,
new SendChatMessageDto("please do something with this", RequestProposal: true),
default);

result.IsSuccess.Should().BeTrue();
result.Value.MessageType.Should().Be("status");
result.Value.Content.Should().Contain("Could not create the requested proposal");
}

[Fact]
public async Task SendMessageAsync_ShouldPersistDegradedReason_OnAssistantMessage()
{
Expand Down
Loading