Skip to content
Open
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
39 changes: 39 additions & 0 deletions packages/daemon/src/mcp/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
threadKeyNeedsDmClassification
} from '../platforms/thread-keys.js'
import {
channelHistoryPlatformsFor,
directMessagePlatformFor,
directMessagePlatformList,
isAttachmentReadTool,
offersDirectMessages,
platformLabel
} from '../platforms/read-ports.js'
import type { PlatformChannelHistoryOptions, PlatformChannelHistoryPage } from '../platforms/contract.js'
import type {
ChannelAgentsReq,
ChannelAgentsOk,
Expand Down Expand Up @@ -83,6 +85,7 @@ export interface MessageGateway {
listMembers(channel: string): Promise<{ id: string; name?: string; isBot?: boolean }[]>
listChannels(): Promise<{ id: string; name?: string; isPrivate?: boolean }[]>
getUserProfile(user: string): Promise<{ id: string; name?: string; realName?: string; isBot?: boolean }>
getChannelHistory?(channel: string, options?: PlatformChannelHistoryOptions): Promise<PlatformChannelHistoryPage>
/** Download an auth-gated file (Slack url_private / Telegram file_id) with the
* bot credentials; null on failure / over-cap. Backs the `read*File` tools so
* the agent can read attachments without ever holding the token. */
Expand Down Expand Up @@ -1423,6 +1426,42 @@ export async function executeTool(
return { platform, users: deps.observedUsers?.(ctx.agentId, platform) ?? [] }
}

if (name === 'getChannelHistory') {
const platform = optionalString(args, 'platform') ?? ctx.platform
if (channelHistoryPlatformsFor([platform]).length === 0) {
throw new Error(platformLabel(platform) + ' channel history is unavailable')
}
const wantIntegrationId = optionalString(args, 'integrationId')
const { gw, sameConvo } = resolveGatewayForPlatform(ctx, deps, platform, wantIntegrationId)
if (!gw.getChannelHistory) {
throw new Error(platformLabel(platform) + ' channel history is unavailable on this connection')
}
const channel = optionalString(args, 'channel') ?? (sameConvo ? ctx.channel : undefined)
if (!channel) {
throw new Error(
'channel is required to read history on ' + platform + ' (a different platform than this session)'
)
}
const cursor = optionalString(args, 'cursor')
const limit = optionalBoundedInt(args, 'limit', 1, 200)
const oldest = optionalString(args, 'oldest')
const latest = optionalString(args, 'latest')
const options: PlatformChannelHistoryOptions = {
...(cursor ? { cursor } : {}),
...(limit !== undefined ? { limit } : {}),
...(oldest ? { oldest } : {}),
...(latest ? { latest } : {})
}
const page = await gw.getChannelHistory(channel, options)
return {
platform,
channel,
messages: page.messages,
hasMore: page.hasMore,
...(page.nextCursor ? { nextCursor: page.nextCursor } : {})
}
}

if (name === 'listChannels' || name === 'listChannelMembers' || name === 'getUserProfile') {
const platform = optionalString(args, 'platform') ?? ctx.platform
const wantIntegrationId = optionalString(args, 'integrationId')
Expand Down
41 changes: 39 additions & 2 deletions packages/daemon/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { Integration } from '../agents/agent-schema.js'
import type { MemoryPluginOperation } from '@agentconnect.md/protocol'
import type { JSONValue } from '@modelcontextprotocol/server'
import { SESSION_TITLE_TOOL_NAME } from './session-title-tool.js'
import { allAttachmentReadTools, attachmentReadToolsFor } from '../platforms/read-ports.js'
import {
allAttachmentReadTools,
allChannelHistoryPlatforms,
attachmentReadToolsFor,
channelHistoryPlatformsFor
} from '../platforms/read-ports.js'

/**
* A tool descriptor in MCP's `tools/list` shape: a name, a human/model-facing
Expand Down Expand Up @@ -342,6 +347,8 @@ function buildReadTools(platforms: string[]): ToolDescriptor[] {
type: 'string',
description: 'Optional. Pick a specific bot when the agent has multiple integrations on the target platform.'
}
const historyPlatforms = channelHistoryPlatformsFor(platforms)
const historyPlatform = { ...platform, enum: historyPlatforms }
return [
{
name: 'getCurrentChannel',
Expand All @@ -360,6 +367,36 @@ function buildReadTools(platforms: string[]): ToolDescriptor[] {
'has multiple bots on the platform (history is not attributable to one bot).',
inputSchema: obj({ platform, integrationId })
},
...(historyPlatforms.length > 0
? [
{
name: 'getChannelHistory',
description:
'Read one bounded page of channel/chat messages from a platform history API. Results are newest-first. ' +
'Pass the returned nextCursor as cursor to continue with older messages, and use oldest or latest to ' +
'bound the provider timestamp range. Omit channel to read the current conversation when targeting the ' +
'current platform. This returns channel messages, not replies inside a thread.',
inputSchema: obj({
platform: historyPlatform,
integrationId,
channel: {
type: 'string',
description:
'Channel/chat id. Required when platform differs from the current conversation; defaults to the current channel otherwise.'
},
cursor: { type: 'string', description: 'Cursor returned by the previous page.' },
limit: {
type: 'integer',
minimum: 1,
maximum: 200,
description: 'Number of messages to request, capped at 200 per page.'
},
oldest: { type: 'string', description: 'Oldest provider message timestamp to include in the range.' },
latest: { type: 'string', description: 'Latest provider message timestamp to include in the range.' }
})
}
]
: []),
{
name: 'listKnownUsers',
description:
Expand Down Expand Up @@ -843,7 +880,7 @@ export const ALL_TOOL_NAMES = [
// platform-neutral tools — descriptors are built per-agent, but the names
// are stable and belong in the permission auto-allow set.
buildSendMessageTool([]),
...buildReadTools([]),
...buildReadTools(allChannelHistoryPlatforms()),
// Every platform's credentialed attachment tool, whatever this agent has:
// the auto-allow set is about NAMES, and a name a platform can inject must
// be listed even for an agent that will never see it.
Expand Down
25 changes: 25 additions & 0 deletions packages/daemon/src/platforms/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,29 @@ export interface PlatformThreadWindow {
readState?: { truncated: boolean }
}

/** One bounded page of channel messages from a provider history read. */
export interface PlatformChannelHistoryMessage {
sender: string
ts: string
text: string
isBot: boolean
threadTs?: string
replyCount?: number
}

export interface PlatformChannelHistoryOptions {
cursor?: string
limit?: number
oldest?: string
latest?: string
}

export interface PlatformChannelHistoryPage {
messages: PlatformChannelHistoryMessage[]
hasMore: boolean
nextCursor?: string
}

/**
* The Layer-1 contract every chat-platform connection satisfies.
*
Expand Down Expand Up @@ -134,6 +157,8 @@ export interface PlatformConnection {
downloadFile(ref: string, maxBytes?: number): Promise<Buffer | null>

// ── optional facets: what core PROBES for today ──
/** Fetch one provider-paginated page of channel messages. */
getChannelHistory?(channel: string, options?: PlatformChannelHistoryOptions): Promise<PlatformChannelHistoryPage>
/** Provider thread history — backs mid-thread context recovery. Absent ⇒ the
* daemon degrades to observed-only transcript rows. */
getThreadReplies?(
Expand Down
18 changes: 16 additions & 2 deletions packages/daemon/src/platforms/read-ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ import { SLACK_ATTACHMENT_TOOL } from './slack/attachments.js'
import { TELEGRAM_ATTACHMENT_TOOL } from './telegram/attachments.js'

/** The optional {@link import('./contract.js').PlatformConnection} facets core
* branches on. Deliberately only the two this seam covers: a port earns a name
* branches on. A port earns a name
* here when a branch retires onto it, never speculatively. */
export type ReadPort = 'getThreadReplies' | 'openDirectMessage'
export type ReadPort = 'getThreadReplies' | 'openDirectMessage' | 'getChannelHistory'

/** Anything that MAY carry read ports: a real connection, the `MessageGateway`
* slice the MCP tools hold, or a duck-typed test fake. Deliberately `object`
Expand Down Expand Up @@ -72,6 +72,8 @@ export interface PlatformReadPorts {
* id to the app's own 1:1 conversation, so `sendMessage`'s `toUser` form has
* somewhere to post. */
readonly openDirectMessage?: boolean
/** The agent-facing channel history tool backed by this platform's cursor API. */
readonly channelHistory?: boolean
/** The agent-facing tool that surfaces this platform's CREDENTIALED attachment
* read. Present when the platform's file references cannot be fetched without
* the bot token, so the agent needs a tool instead of its own network access.
Expand Down Expand Up @@ -99,6 +101,7 @@ const READ_PORTS = new Map<string, PlatformReadPorts>([
platform: 'slack',
label: 'Slack',
openDirectMessage: true,
channelHistory: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: this capability declaration injects getChannelHistory into every Slack session, but VirtualSlackConnection does not implement the method. The existing Arena surface guard is failing on this head (missing concrete-connection members: getChannelHistory), and evaluation agents receive a tool that can only throw “unavailable on this connection.” Please add the bounded/paginated virtual read-port implementation (and world history support) so virtual Slack stays in parity with the daemon-consumed concrete surface.

attachmentReadTool: SLACK_ATTACHMENT_TOOL
}
],
Expand Down Expand Up @@ -169,6 +172,17 @@ export function attachmentReadToolsFor(platforms: Iterable<string>): ToolDescrip
)
}

/** The platforms in the input list that expose the bounded channel-history port. */
export function channelHistoryPlatformsFor(platforms: Iterable<string>): string[] {
const wanted = new Set(platforms)
return [...READ_PORTS.values()].filter((d) => d.channelHistory && wanted.has(d.platform)).map((d) => d.platform)
}

/** Every platform that exposes the bounded channel-history port, in registry order. */
export function allChannelHistoryPlatforms(): string[] {
return [...READ_PORTS.values()].filter((d) => d.channelHistory).map((d) => d.platform)
}

/** Is `name` some platform's attachment-read tool? The MCP dispatcher runs ONE
* shared body for all of them — the platform contributes only the descriptor,
* the download itself is the Layer-1 `downloadFile` every connection has. */
Expand Down
70 changes: 69 additions & 1 deletion packages/daemon/src/slack/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import {
type StatusBarInfo,
type StatusModalIdentity
} from './render.js'
import type { PlatformConnection } from '../platforms/contract.js'
import type {
PlatformChannelHistoryOptions,
PlatformChannelHistoryPage,
PlatformConnection
} from '../platforms/contract.js'

export interface ConsolidatedGroup {
appToken: string
Expand Down Expand Up @@ -393,6 +397,22 @@ type AppLike = {
has_more?: boolean
response_metadata?: { next_cursor?: string }
}>
history: (a: unknown) => Promise<{
messages?: {
user?: string
bot_id?: string
app_id?: string
bot_profile?: { app_id?: string }
ts?: string
text?: string
blocks?: unknown
attachments?: unknown
thread_ts?: string
reply_count?: number
}[]
has_more?: boolean
response_metadata?: { next_cursor?: string }
}>
}
users: {
info: (a: unknown) => Promise<{ user?: SlackUserResult }>
Expand Down Expand Up @@ -454,6 +474,8 @@ function isRoutableMessageEvent(ev: SlackMessageEvent): boolean {

/** Cap on members enriched per `listChannelMembers` call (bounds users.info fan-out). */
const MEMBER_ENRICH_CAP = 50
const SLACK_CHANNEL_HISTORY_DEFAULT_LIMIT = 100
const SLACK_CHANNEL_HISTORY_MAX_LIMIT = 200
const SLACK_FILE_ORIGIN = 'https://files.slack.com'

/**
Expand Down Expand Up @@ -1232,6 +1254,52 @@ export class SlackConnection implements PlatformConnection {
return out
}

/** Fetch one bounded, cursor-paginated page of Slack channel messages. */
async getChannelHistory(
channel: string,
options: PlatformChannelHistoryOptions = {}
): Promise<PlatformChannelHistoryPage> {
const limit = Math.min(
Math.max(options.limit ?? SLACK_CHANNEL_HISTORY_DEFAULT_LIMIT, 1),
SLACK_CHANNEL_HISTORY_MAX_LIMIT
)
try {
const res = await this.app.client.conversations.history({
channel,
limit,
...(options.cursor ? { cursor: options.cursor } : {}),
...(options.oldest ? { oldest: options.oldest } : {}),
...(options.latest ? { latest: options.latest } : {})
})
const nextCursor = res.response_metadata?.next_cursor?.trim() || undefined
const messages = (res.messages ?? []).flatMap((m) => {
if (!m.ts) return []
const appId = m.app_id ?? m.bot_profile?.app_id
const replyCount = typeof m.reply_count === 'number' && m.reply_count > 0 ? m.reply_count : undefined
return [
{
sender: m.bot_id ?? m.user ?? 'unknown',
ts: m.ts,
text: extractSlackMessageText(m),
isBot: Boolean(m.bot_id || appId),
...(m.thread_ts ? { threadTs: m.thread_ts } : {}),
...(replyCount !== undefined ? { replyCount } : {})
}
]
})
return {
messages,
hasMore: Boolean(res.has_more || nextCursor),
...(nextCursor ? { nextCursor } : {})
}
} catch (err) {
const code = slackApiErrorCode(err)
const safeCode = code && /^[a-z0-9._:-]{1,64}$/i.test(code) ? code : undefined
this.deps.log?.debug('slack: conversations.history failed (ch=' + channel + '): ' + (safeCode ?? 'unknown'))
throw new Error(safeCode ? 'Slack channel history failed: ' + safeCode : 'Slack channel history failed')
}
}

/**
* Download an auth-gated Slack file (url_private[_download]) with the bot token,
* up to `maxBytes` (bounds daemon RSS + the inlined prompt frame). Returns the
Expand Down
65 changes: 65 additions & 0 deletions packages/daemon/test/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,71 @@ describe('SlackConnection.getThreadReplies', () => {
})
})

describe('SlackConnection.getChannelHistory', () => {
it('forwards the official cursor and time bounds and returns one page', async () => {
const history = vi.fn(async () => ({
messages: [
{ ts: '100.5', user: 'U1', text: 'latest', thread_ts: '100.1', reply_count: 2 },
{ ts: '100.4', bot_id: 'B1', text: 'bot message' }
],
has_more: true,
response_metadata: { next_cursor: 'next-page' }
}))
const conn = new SlackConnection(
deps() as any,
() =>
({
message() {},
event() {},
action() {},
shortcut() {},
client: { auth: { test: async () => ({ user_id: 'UBOT' }) }, conversations: { history } },
start: async () => {},
stop: async () => {}
}) as any
)

await expect(
conn.getChannelHistory('C1', { cursor: 'previous-page', limit: 2, oldest: '100.0', latest: '100.5' })
).resolves.toEqual({
messages: [
{ sender: 'U1', ts: '100.5', text: 'latest', isBot: false, threadTs: '100.1', replyCount: 2 },
{ sender: 'B1', ts: '100.4', text: 'bot message', isBot: true }
],
hasMore: true,
nextCursor: 'next-page'
})
expect(history).toHaveBeenCalledWith({
channel: 'C1',
cursor: 'previous-page',
limit: 2,
oldest: '100.0',
latest: '100.5'
})
})

it('surfaces a bounded Slack API error code to the caller', async () => {
const history = vi.fn(async () => {
throw { data: { error: 'missing_scope' } }
})
const conn = new SlackConnection(
deps() as any,
() =>
({
message() {},
event() {},
action() {},
shortcut() {},
client: { auth: { test: async () => ({ user_id: 'UBOT' }) }, conversations: { history } },
start: async () => {},
stop: async () => {}
}) as any
)

await expect(conn.getChannelHistory('C1')).rejects.toThrow('Slack channel history failed: missing_scope')
})
})

describe('SlackConnection membership events', () => {
const fakeAppWithEvents = (
handlers: Map<string, (a: { event: unknown }) => unknown>,
Expand Down
Loading
Loading