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
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,43 @@ public async Task Excludes_bot_below_root_even_when_root_is_also_bot()
Assert.DoesNotContain(result, r => r.Contents.OfType<TextContent>().Any(t => t.Text == "agent's reply turn (in transcript)"));
}

[Fact]
public async Task Hydrated_channel_input_carries_resolved_historical_audience()
{
// Locks in the invariant that the Slack twin regressed on: the fetcher
// MUST propagate the resolved historical audience to the produced
// ChannelInput so hydration-driven backfill doesn't silently fall back
// to the channel pipeline's DefaultAudience.
var options = new DiscordChannelOptions
{
AllowDirectMessages = true,
ChannelAudiences = new Dictionary<string, string>(StringComparer.Ordinal)
{
["dm"] = "personal"
}
};

var fetcher = CreateFetcher(
(_, _) => Task.FromResult<IReadOnlyList<DiscordThreadHistoryFetcher.HistoricalMessage>>(
[
new DiscordThreadHistoryFetcher.HistoricalMessage(
MessageId: "100000000000000010",
SenderId: "user-10",
IsBot: false,
Text: "first DM message",
Timestamp: TimeProvider.System.GetUtcNow(),
Attachments: [])
]),
options: options);

var result = await fetcher.FetchThreadHistoryAsync(
new SessionId("100000000000000010/100000000000000010"),
TestContext.Current.CancellationToken);

var item = Assert.Single(result);
Assert.Equal(TrustAudience.Personal, item.Audience);
}

private static DiscordThreadHistoryFetcher CreateFetcher(
DiscordThreadHistoryFetcher.MessageFetcher? messageFetcher = null,
HttpMessageHandler? handler = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ public async Task Fetches_text_messages_from_thread()
Assert.Contains(result, r => r.Contents.OfType<TextContent>().Any(t => t.Text == "thread root"));
}

[Fact]
public async Task Hydrated_channel_input_carries_resolved_historical_audience()
{
// Regression: the fetcher previously omitted Audience on the produced
// ChannelInput, so hydration-driven backfill on a fresh DM fell back to
// the channel pipeline's DefaultAudience (Public) and silently denied
// shell_execute with tool_not_allowed_for_audience_profile even when
// the operator had `dm: personal` configured.
_options.ChannelAudiences["dm"] = "personal";

_replies.Set("D1", "1000.0", null, new ConversationMessagesResponse
{
Messages = [new MessageEvent { Ts = "1000.0", User = "U1", Text = "first DM message" }]
});

var result = await CreateFetcher().FetchThreadHistoryAsync(new SessionId("D1/1000.0"), TestContext.Current.CancellationToken);

var item = Assert.Single(result);
Assert.Equal(TrustAudience.Personal, item.Audience);
}

[Fact]
public async Task Includes_bot_authored_root_for_proactive_post_bootstrap()
{
Expand Down
1 change: 1 addition & 0 deletions src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ private async Task<IReadOnlyList<ChannelInput>> FetchRepliesAsync(
SenderId = senderId,
ChannelId = channelId.Value,
MessageId = $"{channelId.Value}:{message.Ts ?? string.Empty}",
Audience = audience,
Contents = contents,
ReceivedAt = receivedAt
};
Expand Down
Loading