Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
3206753
Harden board dialog keyboard safety
Chris0Jeky Sep 2, 2026
d727852
Serialize Paper column reorder paths
Chris0Jeky Sep 2, 2026
8184d9a
Merge remote-tracking branch 'origin/main' into issue-1975/paper-dial…
Chris0Jeky Sep 2, 2026
186dac4
Merge remote-tracking branch 'origin/issue-1975/paper-dialog-residual…
Chris0Jeky Sep 3, 2026
6d205d6
Merge remote-tracking branch 'origin/main' into issue-1975/paper-dial…
Chris0Jeky Sep 3, 2026
db9fd5d
Preserve untouched dialog fields during realtime refresh
Chris0Jeky Sep 3, 2026
391967d
Restore focus for card keyboard activation
Chris0Jeky Sep 3, 2026
56b40b7
Preserve dialog drafts across busy realtime refreshes
Chris0Jeky Sep 3, 2026
1031d44
Restore card keyboard activation after drag-handle clicks
Chris0Jeky Sep 3, 2026
0b5eb8a
Merge remote-tracking branch 'origin/main' into issue-1975/paper-dial…
Chris0Jeky Sep 3, 2026
b40df49
Align card keyboard coverage with Enter event contract
Chris0Jeky Sep 3, 2026
98057cb
deps(npm): bump @humanfs/node in /frontend/taskdeck-web
dependabot[bot] Sep 3, 2026
ec0166b
deps(npm): bump fast-uri from 3.1.5 to 3.1.7 in /frontend/taskdeck-web
dependabot[bot] Sep 3, 2026
42cf8f9
Merge remote-tracking branch 'origin/main' into issue-1975/dialog-bus…
Chris0Jeky Sep 3, 2026
28c3cbb
Resume dialog draft reconciliation after busy
Chris0Jeky Sep 3, 2026
cc646c4
Assert resumed dialog drafts save safely
Chris0Jeky Sep 3, 2026
a8fcbdd
Merge remote-tracking branch 'origin/main' into issue-1975/dialog-bus…
Chris0Jeky Sep 3, 2026
80f7402
Fix duplicate card Enter activation
Chris0Jeky Sep 3, 2026
1804858
Merge current main into issue-1975 dialog hardening
Chris0Jeky Sep 3, 2026
80e4811
Report unresolved batch triage polling expiry
Chris0Jeky Sep 3, 2026
3ec891e
Render polling expiry as warning
Chris0Jeky Sep 3, 2026
7cff3b5
Merge remote-tracking branch 'origin/main' into issue-1975/dialog-bus…
Chris0Jeky Sep 3, 2026
a6577da
Merge remote-tracking branch 'origin/main' into issue-2230/inbox-poll…
Chris0Jeky Sep 3, 2026
5165271
Protect revision focus across edit sessions
Chris0Jeky Sep 3, 2026
ca9012f
Merge remote-tracking branch 'origin/main' into issue-2230/inbox-poll…
Chris0Jeky Sep 3, 2026
744a81b
Record the 2026-09-03 wave, and retract what it made false
Chris0Jeky Sep 3, 2026
aefed25
Merge pull request #2407 from Chris0Jeky/dependabot/npm_and_yarn/fron…
Chris0Jeky Sep 3, 2026
3738014
Merge branch 'main' into dependabot/npm_and_yarn/frontend/taskdeck-we…
Chris0Jeky Sep 3, 2026
d29ca86
Correct three claims the adversarial pass overturned
Chris0Jeky Sep 3, 2026
051fb02
Merge pull request #2423 from Chris0Jeky/issue-2215/paper-revision-focus
Chris0Jeky Sep 3, 2026
d1d786b
Add a close action for stalled history detail
Chris0Jeky Sep 3, 2026
cf54383
Fix my own off-by-one in the STATUS line count
Chris0Jeky Sep 3, 2026
b110cc9
Merge current main into PR 2397 repair
Chris0Jeky Sep 3, 2026
a546502
Preserve dialog dirty baselines
Chris0Jeky Sep 3, 2026
6837bf1
Merge pull request #2406 from Chris0Jeky/dependabot/npm_and_yarn/fron…
Chris0Jeky Sep 3, 2026
f04981d
Merge branch 'main' into coord/status-sync-2026-09-03
Chris0Jeky Sep 3, 2026
35a1902
Merge current main into issue 1999
Chris0Jeky Sep 3, 2026
992a4c4
Keep card Enter activation in place
Chris0Jeky Sep 3, 2026
ea2ab3e
Merge pull request #2426 from Chris0Jeky/issue-1999/paper-history-loa…
Chris0Jeky Sep 3, 2026
fa0253e
Merge current main into PR 2397 repair
Chris0Jeky Sep 3, 2026
751a4c3
Merge pull request #2397 from Chris0Jeky/issue-1975/paper-dialog-resi…
Chris0Jeky Sep 3, 2026
a89b07b
Add retryable board load errors
Chris0Jeky Sep 3, 2026
2836c9d
Prove Paper board retry recovery
Chris0Jeky Sep 3, 2026
dabf19d
Sanitize agent runtime failures
Chris0Jeky Sep 3, 2026
8ff2ab6
Keep board Retry provenance-specific
Chris0Jeky Sep 3, 2026
02691ec
Preserve empty-board mutation errors
Chris0Jeky Sep 3, 2026
bd84891
Stop board loads after view unmount
Chris0Jeky Sep 3, 2026
1eb71be
Merge pull request #2428 from Chris0Jeky/issue-2351/agent-runtime-une…
Chris0Jeky Sep 3, 2026
49d261f
Hide cached boards after route failures
Chris0Jeky Sep 3, 2026
7fb98e1
Merge origin/main into issue-1721/board-load-retry
Chris0Jeky Sep 3, 2026
a127dee
Merge pull request #2429 from Chris0Jeky/issue-1721/board-load-retry
Chris0Jeky Sep 3, 2026
2862249
Requalify inbox polling timeout on current main
Chris0Jeky Sep 3, 2026
436ed3b
Record the second half of the 2026-09-03 wave and close the two Codex…
Chris0Jeky Sep 3, 2026
68d6dc5
Merge remote-tracking branch 'origin/main' into coord/status-sync-202…
Chris0Jeky Sep 3, 2026
f9e1a93
Sanitize unexpected MCP proposal errors
Chris0Jeky Sep 3, 2026
2647c3a
Merge pull request #2424 from Chris0Jeky/coord/status-sync-2026-09-03
Chris0Jeky Sep 3, 2026
83603a7
Merge remote-tracking branch 'origin/main' into issue-2351/mcp-propos…
Chris0Jeky Sep 3, 2026
3bb0681
Test board refresh arbitration races
Chris0Jeky Sep 3, 2026
30a748e
Arbitrate board detail refreshes
Chris0Jeky Sep 3, 2026
e8186ca
Update degraded board refresh expectations
Chris0Jeky Sep 3, 2026
d3a3f7a
Merge pull request #2432 from Chris0Jeky/issue-2351/mcp-proposal-erro…
Chris0Jeky Sep 3, 2026
2c86528
Test retained realtime refreshes
Chris0Jeky Sep 3, 2026
a253c4b
Retain realtime refreshes during active fetches
Chris0Jeky Sep 3, 2026
a2d632c
Classify board refresh cancellation
Chris0Jeky Sep 3, 2026
86abaeb
Cover successful realtime refresh follow-up
Chris0Jeky Sep 3, 2026
e96ba77
Merge current main into issue 1736
Chris0Jeky Sep 3, 2026
ca51801
Protect proposal resource failure details
Chris0Jeky Sep 3, 2026
a3e5925
Surface current background board revocation
Chris0Jeky Sep 3, 2026
a3d0263
Honor configured API paths in recovery E2E
Chris0Jeky Sep 3, 2026
ebf68e0
Merge pull request #2433 from Chris0Jeky/issue-1736/board-refresh-arb…
Chris0Jeky Sep 3, 2026
f3945be
Merge remote-tracking branch 'origin/main' into issue-2431/api-base-p…
Chris0Jeky Sep 3, 2026
2fcc9ae
Merge current main into issue 2351
Chris0Jeky Sep 3, 2026
f59b854
Merge pull request #2436 from Chris0Jeky/issue-2351/mcp-proposal-reso…
Chris0Jeky Sep 3, 2026
dc7c549
Merge remote-tracking branch 'origin/main' into issue-2431/api-base-p…
Chris0Jeky Sep 3, 2026
98f3fbd
Merge pull request #2437 from Chris0Jeky/issue-2431/api-base-path-rec…
Chris0Jeky Sep 3, 2026
390a311
Merge remote-tracking branch 'origin/issue-2230/inbox-poll-timeout' i…
Chris0Jeky Sep 3, 2026
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
2 changes: 1 addition & 1 deletion .codex/memories/00_ACTIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Taskdeck ships as a free open beta: the local-first, review-first action-item en

## Context Fabric pointer (ADR-0065, accepted under delegation; NOT part of the v0.3 lane)

The architecture for "speak, type, paste, or drop" is `docs/decisions/ADR-0065-context-fabric-capture-representation-processing.md`, mapped in `docs/architecture/CONTEXT_FABRIC.md`, tracked on CF-00 `#2254` (children `#2255`-`#2277`, label `context-fabric`, milestones v0.4 foundation / v0.5 payoff / v0.6 rules). PR `#2280` and the reconciliation pass PR `#2320` (SourceAsset foundation, three capture state axes, Worker Protocol v1-alpha, IBlobStore reference semantics, canonical `CaptureIntakeService`) are both merged, so the reconciled contracts are on `main`. Build on those, not on #2280's originals. Do not pull CF issues into the v0.3 lane; do not add `CaptureSource` values or request-type lane predicates anywhere; do not build CF-22 (delegated authority) without its own maintainer go. Review-first automation is unchanged.
The architecture for "speak, type, paste, or drop" is `docs/decisions/ADR-0065-context-fabric-capture-representation-processing.md`, mapped in `docs/architecture/CONTEXT_FABRIC.md`, tracked on CF-00 `#2254` (children `#2255`-`#2277`, label `context-fabric`, milestones v0.4 foundation / v0.5 payoff / v0.6 rules). The current build base is **PR `#2417`** (merge `eaa996fa2`, 2026-09-03), which reconciles capture text before a disposition stamp lands. It sits on PR `#2344` (merge `a6cc459c9`, CF-01 durable Capture: ID-preserving backfill, dual-write on, Inbox reads through `ICaptureStore`), which in turn sits on PR `#2280` and the reconciliation pass PR `#2320` (SourceAsset foundation, three capture state axes, Worker Protocol v1-alpha, IBlobStore reference semantics, canonical `CaptureIntakeService`). Build on `#2417`, not on `#2344`, `#2320` or `#2280`'s originals. Do not pull CF issues into the v0.3 lane; do not add `CaptureSource` values or request-type lane predicates anywhere; do not build CF-22 (delegated authority) without its own maintainer go. Review-first automation is unchanged.

## Standing constraints

Expand Down
2 changes: 1 addition & 1 deletion autodoc/AGENT_INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ It is the Taskdeck equivalent of the harness `AGENT_MAP.md` (grandfathered name)
(`.github/**`, `ci/**`, `scripts/ci/**`) and `.claude/rules/docs.md` (`docs/**`, root `*.md`).
Read those, not the whole repo.
- Current shipped state: `docs/STATUS.md` (source of truth) — read the relevant section, it is
935 lines; do not read it end-to-end. Roadmap: `docs/IMPLEMENTATION_MASTERPLAN.md` (2068
970 lines; do not read it end-to-end. Roadmap: `docs/IMPLEMENTATION_MASTERPLAN.md` (2068
lines — also section-read only, never bulk-read). Human-action file: `OUTSTANDING_TASKS.md`.
Strategy spine: `docs/strategy/PRODUCT_DIRECTION.md` → `docs/REVIVAL_PLAN.md`. Decisions:
`docs/decisions/INDEX.md`.
Expand Down
16 changes: 10 additions & 6 deletions backend/src/Taskdeck.Api/Mcp/ProposalResources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ public async Task<string> ListProposals()

var result = await _proposalService.GetProposalsAsync(new ProposalFilterDto(UserId: userId));
if (!result.IsSuccess)
throw new InvalidOperationException($"MCP: failed to list proposals: {result.ErrorMessage}");
throw new InvalidOperationException(
$"MCP: failed to list proposals: {PublicFailureMessage(result)}");

var proposals = result.Value.Select(p => new
{
Expand Down Expand Up @@ -77,7 +78,8 @@ public async Task<string> GetProposalDetail(string proposalId)

var result = await _proposalService.GetProposalByIdAsync(proposalGuid);
if (!result.IsSuccess)
throw new InvalidOperationException($"MCP: failed to get proposal: {result.ErrorMessage}");
throw new InvalidOperationException(
$"MCP: failed to get proposal: {PublicFailureMessage(result)}");

var p = result.Value;

Expand All @@ -99,11 +101,10 @@ public async Task<string> GetProposalDetail(string proposalId)
? await _proposalService.GetTerminalProposalStoredPreviewAsync(p.Id)
: await _proposalService.GetProposalDiffAsync(p.Id);

// Surface the service's own error message exactly as the GetProposalByIdAsync failure
// above and the MCP write tools do (WriteTools/ProposalTools raise result.ErrorMessage) —
// no new MCP error shape is invented for the gate denial.
// Keep known domain failures specific, but never carry an unexpected service failure's
// raw message across the public MCP boundary.
if (!previewResult.IsSuccess)
throw new InvalidOperationException($"MCP: {previewResult.ErrorMessage}");
throw new InvalidOperationException($"MCP: {PublicFailureMessage(previewResult)}");

var operations = p.Operations.Select(op => new
{
Expand Down Expand Up @@ -146,4 +147,7 @@ or ProposalStatus.Rejected
or ProposalStatus.Failed
or ProposalStatus.Expired
or ProposalStatus.Dismissed;

private static string PublicFailureMessage(Result result) =>
SensitiveDataRedactor.SanitizeLlmFailureMessage(result.ErrorCode, result.ErrorMessage);
}
22 changes: 18 additions & 4 deletions backend/src/Taskdeck.Api/Mcp/ProposalTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Common;
using Taskdeck.Domain.Entities;
using Taskdeck.Domain.Exceptions;

namespace Taskdeck.Api.Mcp;

Expand Down Expand Up @@ -46,7 +48,7 @@ public async Task<string> GetProposalStatus(

var result = await _proposalService.GetProposalByIdAsync(proposalGuid);
if (!result.IsSuccess)
return Error(result.ErrorMessage);
return Error(result);

var p = result.Value;

Expand Down Expand Up @@ -120,7 +122,7 @@ public async Task<string> ListProposals(

var result = await _proposalService.GetProposalsAsync(filter);
if (!result.IsSuccess)
return Error(result.ErrorMessage);
return Error(result);

var proposals = result.Value.Select(p => new
{
Expand Down Expand Up @@ -159,14 +161,14 @@ public async Task<string> DismissProposal(
// Verify the proposal belongs to the current user before dismissing
var getResult = await _proposalService.GetProposalByIdAsync(proposalGuid);
if (!getResult.IsSuccess)
return Error(getResult.ErrorMessage);
return Error(getResult);

if (getResult.Value.RequestedByUserId != userId)
return Error("Proposal not found or access denied");

var result = await _proposalService.DismissProposalsAsync(new List<Guid> { proposalGuid });
if (!result.IsSuccess)
return Error(result.ErrorMessage);
return Error(result);

return JsonSerializer.Serialize(new
{
Expand All @@ -181,4 +183,16 @@ private static string Error(string message)
{
return JsonSerializer.Serialize(new { error = message }, BoardResources.SerializerOptions);
}

private static string Error(Result result)
{
var message = string.Equals(
result.ErrorCode,
ErrorCodes.UnexpectedError,
StringComparison.Ordinal)
? SensitiveDataRedactor.GenericUnexpectedFailureMessage
: result.ErrorMessage;

return Error(message);
}
}
6 changes: 4 additions & 2 deletions backend/src/Taskdeck.Application/Services/AgentRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,13 +263,15 @@ public async Task<Result<AgentRunDto>> RunAsync(
catch (Exception ex)
{
_logger?.LogError(ex, "Agent run '{RunId}' failed unexpectedly", run.Id);
run.MarkFailed($"Unexpected error: {ex.Message}");
run.MarkFailed(SensitiveDataRedactor.GenericUnexpectedFailureMessage);
try { await _unitOfWork.SaveChangesAsync(CancellationToken.None); }
catch (Exception saveEx)
{
_logger?.LogError(saveEx, "Failed to persist failure state for run '{RunId}'", run.Id);
}
return Result.Failure<AgentRunDto>(ErrorCodes.UnexpectedError, $"Agent run failed: {ex.Message}");
return Result.Failure<AgentRunDto>(
ErrorCodes.UnexpectedError,
SensitiveDataRedactor.GenericUnexpectedFailureMessage);
}

if (run.Status == AgentRunStatus.Failed)
Expand Down
66 changes: 66 additions & 0 deletions backend/tests/Taskdeck.Api.Tests/McpResourcesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,62 @@ await proposalService.CreateProposalAsync(new CreateProposalDto(
first.TryGetProperty("riskLevel", out _).Should().BeTrue();
}

[Fact]
public async Task ProposalResources_ListProposals_IsolatesRequesters()
{
using var scope = _serviceProvider.CreateScope();
var userA = await CreateBoardScopedUserAsync(scope, "list-user-a");
var userB = await CreateBoardScopedUserAsync(scope, "list-user-b");
var (proposalA, _) = await CreateBoardScopedProposalAsync(scope, userA);
var (proposalB, _) = await CreateBoardScopedProposalAsync(scope, userB);
var proposalService = scope.ServiceProvider.GetRequiredService<IAutomationProposalService>();

var resourcesA = new ProposalResources(
proposalService,
new McpBoardResourcesTests.FixedUserContextProvider(userA));
var resourcesB = new ProposalResources(
proposalService,
new McpBoardResourcesTests.FixedUserContextProvider(userB));

var idsForA = ReadProposalIds(await resourcesA.ListProposals());
var idsForB = ReadProposalIds(await resourcesB.ListProposals());

idsForA.Should().Equal(proposalA);
idsForB.Should().Equal(proposalB);
}

[Fact]
public async Task ProposalResources_GetProposalDetail_IsolatesRequesters()
{
using var scope = _serviceProvider.CreateScope();
var userA = await CreateBoardScopedUserAsync(scope, "detail-user-a");
var userB = await CreateBoardScopedUserAsync(scope, "detail-user-b");
var (proposalA, _) = await CreateBoardScopedProposalAsync(scope, userA);
var (proposalB, _) = await CreateBoardScopedProposalAsync(scope, userB);
var proposalService = scope.ServiceProvider.GetRequiredService<IAutomationProposalService>();

var resourcesA = new ProposalResources(
proposalService,
new McpBoardResourcesTests.FixedUserContextProvider(userA));
var resourcesB = new ProposalResources(
proposalService,
new McpBoardResourcesTests.FixedUserContextProvider(userB));

using var ownDocumentA = JsonDocument.Parse(
await resourcesA.GetProposalDetail(proposalA.ToString()));
using var ownDocumentB = JsonDocument.Parse(
await resourcesB.GetProposalDetail(proposalB.ToString()));
ownDocumentA.RootElement.GetProperty("id").GetGuid().Should().Be(proposalA);
ownDocumentB.RootElement.GetProperty("id").GetGuid().Should().Be(proposalB);

var readAAsB = () => resourcesB.GetProposalDetail(proposalA.ToString());
var readBAsA = () => resourcesA.GetProposalDetail(proposalB.ToString());
(await readAAsB.Should().ThrowAsync<InvalidOperationException>()).Which.Message
.Should().Be("MCP: proposal not found or access denied");
(await readBAsA.Should().ThrowAsync<InvalidOperationException>()).Which.Message
.Should().Be("MCP: proposal not found or access denied");
}

[Fact]
public async Task ProposalResources_GetProposalDetail_ReturnsOperations()
{
Expand Down Expand Up @@ -230,6 +286,16 @@ private async Task<Guid> CreateBoardScopedUserAsync(IServiceScope scope, string
return (created.Value.Id, board.Value.Id);
}

private static Guid[] ReadProposalIds(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement
.GetProperty("proposals")
.EnumerateArray()
.Select(proposal => proposal.GetProperty("id").GetGuid())
.ToArray();
}

private async Task MakeTerminalWithStoredPreviewAsync(IServiceScope scope, Guid userId, Guid proposalId, string preview)
{
var uow = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
Expand Down
172 changes: 172 additions & 0 deletions backend/tests/Taskdeck.Api.Tests/ProposalResourcesErrorSafetyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using FluentAssertions;
using Moq;
using Taskdeck.Api.Mcp;
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Common;
using Taskdeck.Domain.Entities;
using Taskdeck.Domain.Exceptions;
using Xunit;

namespace Taskdeck.Api.Tests;

public class ProposalResourcesErrorSafetyTests
{
private const string HostileError =
"Bearer tdsk_test_secret C:\\Users\\alice\\taskdeck.db " +
"SQLite UNIQUE constraint failed: Users.Email https://provider.example/v1/internal";

private static readonly string[] HostileMarkers =
[
"tdsk_test_secret",
"C:\\Users\\alice\\taskdeck.db",
"UNIQUE constraint failed",
"https://provider.example/v1/internal"
];

[Fact]
public async Task ListProposals_UnexpectedFailure_ThrowsGenericErrorForCurrentUserFilter()
{
var userId = Guid.NewGuid();
var proposalService = new Mock<IAutomationProposalService>(MockBehavior.Strict);
proposalService
.Setup(service => service.GetProposalsAsync(
It.Is<ProposalFilterDto?>(filter => filter != null && filter.UserId == userId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure<IEnumerable<ProposalDto>>(
ErrorCodes.UnexpectedError,
HostileError));

var act = () => CreateResources(proposalService.Object, userId).ListProposals();

var exception = (await act.Should().ThrowAsync<InvalidOperationException>()).Which;
AssertGenericError(exception, "MCP: failed to list proposals: ");
proposalService.VerifyAll();
}

[Fact]
public async Task GetProposalDetail_UnexpectedLookupFailure_ThrowsGenericError()
{
var userId = Guid.NewGuid();
var proposalId = Guid.NewGuid();
var proposalService = new Mock<IAutomationProposalService>(MockBehavior.Strict);
proposalService
.Setup(service => service.GetProposalByIdAsync(
proposalId,
It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure<ProposalDto>(ErrorCodes.UnexpectedError, HostileError));

var act = () => CreateResources(proposalService.Object, userId)
.GetProposalDetail(proposalId.ToString());

var exception = (await act.Should().ThrowAsync<InvalidOperationException>()).Which;
AssertGenericError(exception, "MCP: failed to get proposal: ");
proposalService.VerifyAll();
}

[Theory]
[InlineData(ProposalStatus.PendingReview)]
[InlineData(ProposalStatus.Applied)]
public async Task GetProposalDetail_UnexpectedPreviewFailure_ThrowsGenericError(
ProposalStatus status)
{
var userId = Guid.NewGuid();
var proposalId = Guid.NewGuid();
var proposalService = new Mock<IAutomationProposalService>(MockBehavior.Strict);
proposalService
.Setup(service => service.GetProposalByIdAsync(
proposalId,
It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success(CreateProposal(proposalId, userId, status)));

if (status == ProposalStatus.Applied)
{
proposalService
.Setup(service => service.GetTerminalProposalStoredPreviewAsync(
proposalId,
It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure<string>(ErrorCodes.UnexpectedError, HostileError));
}
else
{
proposalService
.Setup(service => service.GetProposalDiffAsync(
proposalId,
It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure<string>(ErrorCodes.UnexpectedError, HostileError));
}

var act = () => CreateResources(proposalService.Object, userId)
.GetProposalDetail(proposalId.ToString());

var exception = (await act.Should().ThrowAsync<InvalidOperationException>()).Which;
AssertGenericError(exception, "MCP: ");
proposalService.VerifyAll();
}

[Fact]
public async Task GetProposalDetail_KnownDomainFailure_PreservesStableMessage()
{
const string stableMessage = "Proposal not found.";
var userId = Guid.NewGuid();
var proposalId = Guid.NewGuid();
var proposalService = new Mock<IAutomationProposalService>(MockBehavior.Strict);
proposalService
.Setup(service => service.GetProposalByIdAsync(
proposalId,
It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure<ProposalDto>(ErrorCodes.NotFound, stableMessage));

var act = () => CreateResources(proposalService.Object, userId)
.GetProposalDetail(proposalId.ToString());

var exception = (await act.Should().ThrowAsync<InvalidOperationException>()).Which;
exception.Message.Should().Be($"MCP: failed to get proposal: {stableMessage}");
proposalService.VerifyAll();
}

private static ProposalResources CreateResources(
IAutomationProposalService proposalService,
Guid userId)
{
return new ProposalResources(
proposalService,
new McpBoardResourcesTests.FixedUserContextProvider(userId));
}

private static ProposalDto CreateProposal(
Guid proposalId,
Guid userId,
ProposalStatus status)
{
var now = DateTimeOffset.UtcNow;
return new ProposalDto(
proposalId,
ProposalSourceType.Chat,
null,
null,
userId,
status,
RiskLevel.Low,
"Safe proposal",
null,
null,
now,
now,
now.UtcDateTime.AddHours(1),
status == ProposalStatus.Applied ? now.UtcDateTime : null,
status == ProposalStatus.Applied ? userId : null,
status == ProposalStatus.Applied ? now.UtcDateTime : null,
null,
"mcp-resource-error-safety-test",
[]);
}

private static void AssertGenericError(Exception exception, string prefix)
{
exception.Message.Should().Be(prefix + SensitiveDataRedactor.GenericUnexpectedFailureMessage);

foreach (var marker in HostileMarkers)
exception.Message.Should().NotContain(marker);
}
}
Loading