diff --git a/CHANGELOG.md b/CHANGELOG.md index 552e22c83c..47e27f111d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 1.20.1 (2026-11-05) + +Roomote 1.20.1 restores reliable Copilot task helpers, keeps Slack history reads useful in busy channels, and clarifies how agents carry out Slack replies and GitHub actions. + +### Highlights + +- Restore GitHub Copilot GPT-5.x task helpers that failed on an unsupported endpoint. +- Keep available Slack history when a busy channel cannot be read completely in one request. +- Avoid false lost-access reports for private Slack conversations and false refusals of authorized GitHub actions. + +### Patch changes + +- GitHub Copilot GPT-5.x task helpers use the correct inference endpoint instead of failing on requests sent to an unsupported endpoint. +- Coding tasks recognize their authorized GitHub write path when integration tools are read-only, avoiding false refusals to carry out requested pull request updates, comments, or workflow actions. +- Slack channel history reads return accumulated messages with explicit partial-result and continuation information instead of timing out and losing all results in busy channels. +- Slack destination guidance distinguishes unlisted private channels from lost access and directs replies, including automation results, to the current conversation without requiring standalone channel discovery. + ## 1.20.0 (2026-11-04) Roomote 1.20.0 adds MCP connection controls and testing, stops runaway agent activity, and includes security updates for redirects and static-file serving. diff --git a/apps/api/src/handlers/mcp/__tests__/communication-channel-discovery.test.ts b/apps/api/src/handlers/mcp/__tests__/communication-channel-discovery.test.ts index a52d08be8e..2c68323c68 100644 --- a/apps/api/src/handlers/mcp/__tests__/communication-channel-discovery.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/communication-channel-discovery.test.ts @@ -397,6 +397,30 @@ describe('listCommunicationChannels', () => { expect(findSlackUserMappingsMock).not.toHaveBeenCalled(); }); + it('explains that private Slack channels are not listed and points to send_chat_reply', async () => { + findSlackInstallationsMock.mockResolvedValue([ + { botAccessToken: 'token-1', teamId: 'T1', teamName: 'One' }, + ]); + listPublicChannelsMock.mockResolvedValue([ + { id: 'CPRIV', name: 'team-private', isPrivate: true, isMember: true }, + ]); + + const result = await listCommunicationDestinations({ + actingUserId: 'user-1', + provider: 'slack', + kind: 'channel', + query: 'team-private', + }); + + expect(result.destinations).toEqual([]); + expect(result.limitations).toEqual([ + expect.objectContaining({ + provider: 'slack', + reason: expect.stringContaining('use send_chat_reply'), + }), + ]); + }); + it('paginates targeted linked-person search inside one Slack workspace', async () => { findSlackInstallationsMock.mockResolvedValue([ { botAccessToken: 'token-1', teamId: 'T1', teamName: 'One' }, diff --git a/apps/api/src/handlers/mcp/__tests__/communication-channel-messages-budget.test.ts b/apps/api/src/handlers/mcp/__tests__/communication-channel-messages-budget.test.ts index 769a1a5005..e4a253d14b 100644 --- a/apps/api/src/handlers/mcp/__tests__/communication-channel-messages-budget.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/communication-channel-messages-budget.test.ts @@ -57,7 +57,7 @@ describe('applyChannelMessagesResultBudget', () => { expect(result.messages.map(({ id }) => id)).not.toContain( result.nextLatest, ); - expect(result.note).toContain('nextLatest'); + expect(result.note).toContain('sizeContinuation'); expect(JSON.stringify(result, null, 2).length).toBeLessThanOrEqual( CHANNEL_MESSAGES_RESULT_BUDGET_CHARS, ); @@ -75,6 +75,37 @@ describe('applyChannelMessagesResultBudget', () => { expect(result.messages.at(-1)).toEqual(messages.at(-1)); }); + it('retains independent history and size continuations with their original bounds', () => { + const messages = Array.from({ length: 20 }, (_, index) => + message(index, 600), + ); + const historyContinuation = { + oldest: '100.000', + latest: '300.000', + historyLatest: '150.000', + }; + const result = applyChannelMessagesResultBudget( + { + ...payload(messages), + requestedOldest: '100.000', + requestedLatest: '300.000', + requestedHistoryLatest: '200.000', + truncated: true, + historyContinuation, + note: 'Partial provider read.', + }, + 3000, + ); + expect(result.historyContinuation).toEqual(historyContinuation); + expect(result.sizeContinuation).toEqual({ + oldest: '100.000', + latest: result.nextLatest, + historyLatest: '200.000', + }); + expect(result.note).toContain('Partial provider read.'); + expect(JSON.stringify(result, null, 2).length).toBeLessThanOrEqual(3000); + }); + it('never drops below one message', () => { const result = applyChannelMessagesResultBudget( payload([message(1, 5_000), message(2, 5_000)]), diff --git a/apps/api/src/handlers/mcp/__tests__/communication-message-lookup.test.ts b/apps/api/src/handlers/mcp/__tests__/communication-message-lookup.test.ts index 1f54aed462..4b6eb35480 100644 --- a/apps/api/src/handlers/mcp/__tests__/communication-message-lookup.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/communication-message-lookup.test.ts @@ -240,6 +240,59 @@ describe('lookupCommunicationMessageContext', () => { describe('lookupCommunicationChannelMessages', () => { afterEach(() => vi.clearAllMocks()); + it('carries Slack root continuations through the communication strategy and preserves their reply bound', async () => { + const historyContinuation = { + oldest: '100.000', + latest: '300.000', + historyLatest: '200.000', + }; + lookupSlackChannelMessagesMock.mockResolvedValueOnce({ + slackTeamId: 'T123', + channelId: 'C123', + requestedOldest: '100.000', + requestedLatest: '300.000', + requestedHistoryLatest: '300.000', + truncated: true, + historyContinuation, + messageCount: 0, + messages: [], + }); + const first = await lookupCommunicationChannelMessages({ + provider: 'slack', + channel: 'C123', + oldest: '100.000', + latest: '300.000', + }); + expect(first.historyContinuation).toEqual(historyContinuation); + lookupSlackChannelMessagesMock.mockResolvedValueOnce({ + slackTeamId: 'T123', + channelId: 'C123', + messageCount: 1, + messages: [ + { + ts: '250.000', + threadTs: '150.000', + user: 'U1', + text: 'new reply on old root', + fileCount: 0, + }, + ], + }); + const next = await lookupCommunicationChannelMessages({ + provider: 'slack', + channel: 'C123', + ...first.historyContinuation, + }); + expect(lookupSlackChannelMessagesMock).toHaveBeenLastCalledWith({ + channel: 'C123', + ...historyContinuation, + }); + expect(next.messages[0]).toMatchObject({ + id: '250.000', + threadId: '150.000', + }); + }); + it('uses the task communication channel when channel is omitted', async () => { lookupDiscordChannelMessagesMock.mockResolvedValueOnce({ channelId: '456', diff --git a/apps/api/src/handlers/mcp/__tests__/slack-channel-messages.test.ts b/apps/api/src/handlers/mcp/__tests__/slack-channel-messages.test.ts index 657ae6e18f..5dcf63d799 100644 --- a/apps/api/src/handlers/mcp/__tests__/slack-channel-messages.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/slack-channel-messages.test.ts @@ -6,6 +6,7 @@ import type { Variables } from '../../../types'; const { findActiveSlackInstallationForChannelMock, fetchChannelMessagesMock, + fetchIncompleteMock, resolveChannelIdMock, isAppInChannelMock, isUserInChannelMock, @@ -13,6 +14,7 @@ const { } = vi.hoisted(() => ({ findActiveSlackInstallationForChannelMock: vi.fn(), fetchChannelMessagesMock: vi.fn(), + fetchIncompleteMock: vi.fn(), resolveChannelIdMock: vi.fn(), isAppInChannelMock: vi.fn(), isUserInChannelMock: vi.fn(), @@ -47,6 +49,16 @@ vi.mock('@roomote/slack', () => ({ SlackNotifier: vi.fn( class { fetchChannelMessages = fetchChannelMessagesMock; + fetchChannelMessagesWithinBudget = async ({ + deadlineAt: _deadlineAt, + ...params + }: Record) => { + const incomplete = fetchIncompleteMock(); + return { + messages: await fetchChannelMessagesMock(params), + ...(incomplete ? { incomplete } : {}), + }; + }; resolveChannelId = resolveChannelIdMock; isAppInChannel = isAppInChannelMock; isUserInChannel = isUserInChannelMock; @@ -245,6 +257,7 @@ describe('slack channel messages MCP endpoint', () => { channelId: 'CENG', requestedOldest: '2026-04-01T00:00:00Z', requestedLatest: '2026-04-02T00:00:00Z', + requestedHistoryLatest: '1775088000.000000', messageCount: 2, messages: [ { @@ -272,6 +285,98 @@ describe('slack channel messages MCP endpoint', () => { channel: 'CENG', oldest: '1775001600.000000', latest: '1775088000.000000', + historyLatest: '1775088000.000000', + }); + }); + + it('returns a partial result with paging hints when Slack is too slow', async () => { + vi.mocked(db.query.taskRuns.findFirst).mockResolvedValue( + mockSlackTaskRun({ + type: 'standard', + slackThreadTs: null, + payload: {}, + }) as never, + ); + fetchChannelMessagesMock.mockResolvedValue([ + { + ts: '1711929900.000000', + user: 'U234', + username: 'Bob', + text: 'newest', + type: 'message', + }, + ]); + fetchIncompleteMock.mockReturnValueOnce({ + historyStoppedAtTs: '1711929900.000000', + unexpandedThreadCount: 3, + }); + + const response = await postChannelMessages(runToken, { + channel: 'eng', + oldest: '2026-04-01T00:00:00Z', + latest: '2026-04-02T00:00:00Z', + }); + const body = (await response.json()) as JsonBody; + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + truncated: true, + historyContinuation: { + oldest: '1775001600.000000', + latest: '1775088000.000000', + historyLatest: '1711929900.000000', + }, + messageCount: 1, + }); + expect(String((body as { note?: unknown }).note)).toContain( + 'historyContinuation', + ); + expect(String((body as { note?: unknown }).note)).toContain( + 'Replies in 3 thread(s)', + ); + }); + + it('marks an empty primary result partial when stale-root discovery was skipped', async () => { + fetchChannelMessagesMock.mockResolvedValue([]); + fetchIncompleteMock.mockReturnValueOnce({ + staleRootDiscoveryIncomplete: true, + }); + const response = await postChannelMessages(runToken, { + channel: 'eng', + oldest: '100.000', + latest: '300.000', + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + truncated: true, + messageCount: 0, + messages: [], + note: expect.stringContaining( + 'Stale-thread discovery was skipped or incomplete', + ), + }); + }); + + it('accepts the returned history continuation without narrowing the reply window', async () => { + fetchChannelMessagesMock.mockResolvedValue([]); + fetchIncompleteMock.mockReturnValueOnce({ historyStoppedAtTs: '200.000' }); + const response = await postChannelMessages(runToken, { + channel: 'eng', + oldest: '100.000', + latest: '300.000', + }); + const first = (await response.json()) as { + historyContinuation: Record; + }; + await postChannelMessages(runToken, { + channel: 'eng', + ...first.historyContinuation, + }); + expect(fetchChannelMessagesMock).toHaveBeenLastCalledWith({ + channel: 'CENG', + oldest: '100.000', + latest: '300.000', + historyLatest: '200.000', }); }); @@ -297,6 +402,8 @@ describe('slack channel messages MCP endpoint', () => { expect(fetchChannelMessagesMock).toHaveBeenCalledWith({ channel: 'C123', oldest: '1711929600.000000', + latest: expect.any(String), + historyLatest: expect.any(String), }); expect(resolveChannelIdMock).toHaveBeenCalledWith('#c123'); expect(isAppInChannelMock).toHaveBeenCalledWith('C123'); @@ -335,7 +442,11 @@ describe('slack channel messages MCP endpoint', () => { expect(response.status).toBe(200); expect(body.channelId).toBe('CENG'); expect(isUserInChannelMock).not.toHaveBeenCalled(); - expect(fetchChannelMessagesMock).toHaveBeenCalledWith({ channel: 'CENG' }); + expect(fetchChannelMessagesMock).toHaveBeenCalledWith({ + channel: 'CENG', + latest: expect.any(String), + historyLatest: expect.any(String), + }); }); it('allows public channel lookup when the acting user has no linked Slack account', async () => { @@ -360,7 +471,11 @@ describe('slack channel messages MCP endpoint', () => { expect(body.channelId).toBe('CENG'); expect(isPublicChannelMock).toHaveBeenCalledWith('CENG'); expect(isUserInChannelMock).not.toHaveBeenCalled(); - expect(fetchChannelMessagesMock).toHaveBeenCalledWith({ channel: 'CENG' }); + expect(fetchChannelMessagesMock).toHaveBeenCalledWith({ + channel: 'CENG', + latest: expect.any(String), + historyLatest: expect.any(String), + }); }); it('rejects public channel lookup when the Slack app is not a member', async () => { @@ -407,7 +522,11 @@ describe('slack channel messages MCP endpoint', () => { channelId: 'CENG', userId: 'UACTOR', }); - expect(fetchChannelMessagesMock).toHaveBeenCalledWith({ channel: 'CENG' }); + expect(fetchChannelMessagesMock).toHaveBeenCalledWith({ + channel: 'CENG', + latest: expect.any(String), + historyLatest: expect.any(String), + }); }); it('rejects private channel lookup when the acting Slack user is not a member', async () => { diff --git a/apps/api/src/handlers/mcp/communication-channel-discovery.ts b/apps/api/src/handlers/mcp/communication-channel-discovery.ts index 833ed792bc..a451f2a9c9 100644 --- a/apps/api/src/handlers/mcp/communication-channel-discovery.ts +++ b/apps/api/src/handlers/mcp/communication-channel-discovery.ts @@ -450,5 +450,18 @@ export async function listCommunicationDestinations( destinations: filtered, offset: input.offset, limit: input.limit, + ...(input.kind === 'channel' + ? { limitations: [SLACK_CHANNEL_LISTING_LIMITATION] } + : {}), }); } + +/** + * An agent that cannot find its automation's private target channel here + * should not conclude that Slack access was lost. + */ +const SLACK_CHANNEL_LISTING_LIMITATION = { + provider: 'slack' as const, + reason: + 'Only public Slack channels Roomote has joined are listed; private channels are never standalone destinations, and a missing channel does not mean Slack access was lost. To post in the current conversation, including a custom automation result for its configured channel, use send_chat_reply.', +}; diff --git a/apps/api/src/handlers/mcp/communication-channel-messages-budget.ts b/apps/api/src/handlers/mcp/communication-channel-messages-budget.ts index e38ef84f08..d870c98699 100644 --- a/apps/api/src/handlers/mcp/communication-channel-messages-budget.ts +++ b/apps/api/src/handlers/mcp/communication-channel-messages-budget.ts @@ -76,7 +76,29 @@ export function applyChannelMessagesResultBudget( truncated: true, omittedMessageCount: omitted, ...(newestOmitted ? { nextLatest: newestOmitted.id } : {}), - note: `Result truncated to the newest ${subset.length} of ${messages.length} messages in the requested range. To read the older messages, call again with latest set to nextLatest and the same oldest; that page will not repeat any message returned here.`, + ...(newestOmitted + ? { + sizeContinuation: { + ...(payload.requestedOldest + ? { oldest: payload.requestedOldest } + : {}), + latest: newestOmitted.id, + ...(payload.provider === 'slack' + ? { + historyLatest: + payload.requestedHistoryLatest ?? payload.requestedLatest, + } + : {}), + }, + } + : {}), + note: [ + `Result truncated to the newest ${subset.length} of ${messages.length} messages. Call again with sizeContinuation and the same channel/provider. Retain historyContinuation independently; process all further continuations with their own bounds and deduplicate by ID.`, + // A provider read that already stopped early keeps its explanation. + payload.note, + ] + .filter(Boolean) + .join(' '), }; }; diff --git a/apps/api/src/handlers/mcp/communication-message-lookup-strategies.ts b/apps/api/src/handlers/mcp/communication-message-lookup-strategies.ts index d750763316..e5542fdfa8 100644 --- a/apps/api/src/handlers/mcp/communication-message-lookup-strategies.ts +++ b/apps/api/src/handlers/mcp/communication-message-lookup-strategies.ts @@ -166,6 +166,9 @@ const slackLookupStrategy: CommunicationLookupStrategy = { ...(options.channel ? { channel: options.channel } : {}), ...(options.oldest ? { oldest: options.oldest } : {}), ...(options.latest ? { latest: options.latest } : {}), + ...(options.historyLatest + ? { historyLatest: options.historyLatest } + : {}), ...(options.workspaceId ? { slackTeamId: options.workspaceId } : {}), ...(options.workspaceDomain ? { slackTeamDomain: options.workspaceDomain } @@ -188,6 +191,15 @@ const slackLookupStrategy: CommunicationLookupStrategy = { ...(result.requestedLatest ? { requestedLatest: result.requestedLatest } : {}), + ...(result.truncated ? { truncated: result.truncated } : {}), + ...(result.nextLatest ? { nextLatest: result.nextLatest } : {}), + ...(result.requestedHistoryLatest + ? { requestedHistoryLatest: result.requestedHistoryLatest } + : {}), + ...(result.historyContinuation + ? { historyContinuation: result.historyContinuation } + : {}), + ...(result.note ? { note: result.note } : {}), messageCount: result.messageCount, messages: result.messages.map((message) => ({ provider: 'slack', diff --git a/apps/api/src/handlers/mcp/communication-message-lookup-types.ts b/apps/api/src/handlers/mcp/communication-message-lookup-types.ts index 3e29b7d631..23964c6bf7 100644 --- a/apps/api/src/handlers/mcp/communication-message-lookup-types.ts +++ b/apps/api/src/handlers/mcp/communication-message-lookup-types.ts @@ -56,10 +56,24 @@ export type CommunicationChannelMessagesPayload = { channelId: string; requestedOldest?: string; requestedLatest?: string; + requestedHistoryLatest?: string; + /** Independent pending reads; process both and deduplicate messages by ID. */ + historyContinuation?: ChannelMessagesContinuation; + sizeContinuation?: ChannelMessagesContinuation; + /** Set for a partial read; see `note` and the independent continuations. */ + truncated?: true; + nextLatest?: string; + note?: string; messageCount: number; messages: CommunicationLookupMessage[]; }; +export type ChannelMessagesContinuation = { + oldest?: string; + latest?: string; + historyLatest?: string; +}; + export type CommunicationLookupStrategy = { provider: SupportedCommunicationLookupProvider; parseReference( @@ -79,6 +93,7 @@ export type CommunicationLookupStrategy = { workspaceDomain?: string; oldest?: string; latest?: string; + historyLatest?: string; taskRun?: CommunicationLookupTaskRun | null; actingUserId?: string | null; }): Promise; diff --git a/apps/api/src/handlers/mcp/communication-message-lookup.ts b/apps/api/src/handlers/mcp/communication-message-lookup.ts index 527f0bfc54..a5af04ab50 100644 --- a/apps/api/src/handlers/mcp/communication-message-lookup.ts +++ b/apps/api/src/handlers/mcp/communication-message-lookup.ts @@ -170,6 +170,7 @@ export async function lookupCommunicationChannelMessages(options: { channel?: string; oldest?: string; latest?: string; + historyLatest?: string; provider?: SupportedCommunicationLookupProvider; taskRun?: CommunicationLookupTaskRun | null; actingUserId?: string | null; @@ -198,6 +199,7 @@ export async function lookupCommunicationChannelMessages(options: { : {}), ...(options.oldest ? { oldest: options.oldest } : {}), ...(options.latest ? { latest: options.latest } : {}), + ...(options.historyLatest ? { historyLatest: options.historyLatest } : {}), ...(reference?.workspaceId ? { workspaceId: reference.workspaceId } : {}), ...(reference?.workspaceDomain ? { workspaceDomain: reference.workspaceDomain } diff --git a/apps/api/src/handlers/mcp/communication-message-send.ts b/apps/api/src/handlers/mcp/communication-message-send.ts index faa55173fe..0a91c3cdf7 100644 --- a/apps/api/src/handlers/mcp/communication-message-send.ts +++ b/apps/api/src/handlers/mcp/communication-message-send.ts @@ -84,7 +84,7 @@ export async function sendCommunicationMessage(params: { { code: 'invalid_destination', error: - 'Use an exact destination returned by list_chat_destinations or a trusted current-destination reference.', + 'Use an exact destination returned by list_chat_destinations or a trusted current-destination reference. To post in the current conversation, including a custom automation result for its configured channel, use send_chat_reply instead.', }, 400, ); diff --git a/apps/api/src/handlers/mcp/communication.ts b/apps/api/src/handlers/mcp/communication.ts index 13b875ac57..f750ba706f 100644 --- a/apps/api/src/handlers/mcp/communication.ts +++ b/apps/api/src/handlers/mcp/communication.ts @@ -54,11 +54,16 @@ function parseChannelMessagesRequestBody(body: unknown): { channel?: string; oldest?: string; latest?: string; + historyLatest?: string; } { if (!body || typeof body !== 'object') { throw new Error('Request body must be a JSON object'); } const record = body as Record; + const historyLatest = + typeof record.historyLatest === 'string' + ? record.historyLatest.trim() + : undefined; const channel = typeof record.channel === 'string' ? record.channel.trim() : undefined; const oldest = @@ -69,6 +74,7 @@ function parseChannelMessagesRequestBody(body: unknown): { ...(channel ? { channel } : {}), ...(oldest ? { oldest } : {}), ...(latest ? { latest } : {}), + ...(historyLatest ? { historyLatest } : {}), }; } diff --git a/apps/api/src/handlers/mcp/roomote.ts b/apps/api/src/handlers/mcp/roomote.ts index 6947567c2c..edf50c6bd5 100644 --- a/apps/api/src/handlers/mcp/roomote.ts +++ b/apps/api/src/handlers/mcp/roomote.ts @@ -366,6 +366,7 @@ async function buildCommunicationChannelMessagesPayload(options: { channel?: string; oldest?: string; latest?: string; + historyLatest?: string; provider?: 'slack' | 'discord'; }) { let taskRun: CommunicationLookupTaskRun | undefined; @@ -382,6 +383,7 @@ async function buildCommunicationChannelMessagesPayload(options: { } return lookupCommunicationChannelMessages({ + ...(options.historyLatest ? { historyLatest: options.historyLatest } : {}), ...(typeof options.channel === 'string' && options.channel.length > 0 ? { channel: options.channel } : {}), @@ -477,6 +479,10 @@ async function createRoomoteMcpServer( .string() .optional() .describe(CHAT_CHANNEL_MESSAGES_TOOL.inputDescriptions.latest), + historyLatest: z + .string() + .optional() + .describe(CHAT_CHANNEL_MESSAGES_TOOL.inputDescriptions.historyLatest), provider: z .enum(['slack', 'discord']) .optional() @@ -492,10 +498,11 @@ async function createRoomoteMcpServer( openWorldHint: false, }, }, - async ({ channel, oldest, latest, provider }) => { + async ({ channel, oldest, latest, historyLatest, provider }) => { const payload = await buildCommunicationChannelMessagesPayload({ auth, actingUserId, + ...(historyLatest ? { historyLatest: historyLatest.trim() } : {}), ...(typeof channel === 'string' && channel.trim().length > 0 ? { channel: channel.trim() } : {}), diff --git a/apps/api/src/handlers/mcp/slack-thread-lookup.ts b/apps/api/src/handlers/mcp/slack-thread-lookup.ts index 9803e37d6b..0179fefda9 100644 --- a/apps/api/src/handlers/mcp/slack-thread-lookup.ts +++ b/apps/api/src/handlers/mcp/slack-thread-lookup.ts @@ -70,6 +70,15 @@ type SlackChannelMessagesPayload = { channelId: string; requestedOldest?: string; requestedLatest?: string; + truncated?: true; + nextLatest?: string; + requestedHistoryLatest?: string; + historyContinuation?: { + oldest?: string; + latest?: string; + historyLatest?: string; + }; + note?: string; messageCount: number; messages: Array<{ ts: string; @@ -627,11 +636,19 @@ export async function lookupSlackChannelMessages(options: { slackTeamDomain?: string; oldest?: string; latest?: string; + historyLatest?: string; taskRun?: SlackReplyTargetTaskRun | null; actingSlackMembershipUserId?: string | null; }): Promise { const oldestBoundary = normalizeSlackTimeBoundary(options.oldest, 'oldest'); const latestBoundary = normalizeSlackTimeBoundary(options.latest, 'latest'); + const historyBoundary = normalizeSlackTimeBoundary( + options.historyLatest, + 'latest', + ); + // Freeze an open-ended window so continuation never changes its reply bound. + const replyLatest = latestBoundary?.slackTs ?? (Date.now() / 1000).toFixed(6); + const historyLatest = historyBoundary?.slackTs ?? replyLatest; const channel = options.channel ?? (options.taskRun @@ -657,12 +674,22 @@ export async function lookupSlackChannelMessages(options: { }); let messages: SlackThreadMessage[]; + let incomplete: + | { + historyStoppedAtTs?: string; + unexpandedThreadCount?: number; + staleRootDiscoveryIncomplete?: boolean; + historyIncomplete?: boolean; + } + | undefined; try { - messages = await slack.fetchChannelMessages({ + ({ messages, incomplete } = await slack.fetchChannelMessagesWithinBudget({ channel: channelId, ...(oldestBoundary ? { oldest: oldestBoundary.slackTs } : {}), - ...(latestBoundary ? { latest: latestBoundary.slackTs } : {}), - }); + latest: replyLatest, + historyLatest, + deadlineAt: Date.now() + SLACK_CHANNEL_MESSAGES_TIME_BUDGET_MS, + })); } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new McpProxyError( @@ -677,8 +704,57 @@ export async function lookupSlackChannelMessages(options: { slackTeamId, channelId, ...(oldestBoundary ? { requestedOldest: oldestBoundary.requested } : {}), - ...(latestBoundary ? { requestedLatest: latestBoundary.requested } : {}), + requestedLatest: latestBoundary?.requested ?? replyLatest, + requestedHistoryLatest: historyLatest, + ...(incomplete + ? { + truncated: true as const, + ...(incomplete.historyStoppedAtTs || incomplete.historyIncomplete + ? { + historyContinuation: { + ...(oldestBoundary ? { oldest: oldestBoundary.slackTs } : {}), + latest: replyLatest, + historyLatest: incomplete.historyStoppedAtTs ?? historyLatest, + }, + } + : {}), + note: describeIncompleteSlackChannelRead(incomplete), + } + : {}), messageCount: normalizedMessages.length, messages: normalizedMessages, }; } + +/** + * Stay well inside the 60s integration-call timeout so a slow read returns + * partial results instead of nothing. + */ +const SLACK_CHANNEL_MESSAGES_TIME_BUDGET_MS = 40_000; + +function describeIncompleteSlackChannelRead(incomplete: { + historyStoppedAtTs?: string; + unexpandedThreadCount?: number; + staleRootDiscoveryIncomplete?: boolean; + historyIncomplete?: boolean; +}): string { + const parts = [ + 'Slack was too slow to read this whole window in one call, so this result is partial.', + ]; + if (incomplete.historyStoppedAtTs || incomplete.historyIncomplete) { + parts.push( + 'Older top-level messages were not read: call again with the fields in historyContinuation and the same channel. This advances only the root-history bound and preserves the original reply window. Deduplicate overlapping messages by ID.', + ); + } + if (incomplete.staleRootDiscoveryIncomplete) { + parts.push( + 'Stale-thread discovery was skipped or incomplete; replies on pre-window roots may be missing. Retry with a narrower window or fetch specific threads.', + ); + } + if (incomplete.unexpandedThreadCount) { + parts.push( + `Replies in ${incomplete.unexpandedThreadCount} thread(s) were not read; use a narrower window or fetch a specific thread.`, + ); + } + return parts.join(' '); +} diff --git a/apps/api/src/handlers/mcp/slack.ts b/apps/api/src/handlers/mcp/slack.ts index 146d3d8ac9..af4b7cc192 100644 --- a/apps/api/src/handlers/mcp/slack.ts +++ b/apps/api/src/handlers/mcp/slack.ts @@ -679,6 +679,7 @@ function parseChannelMessagesRequestBody(body: unknown): { channel?: string; oldest?: string; latest?: string; + historyLatest?: string; } { if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new Error('Invalid request body'); @@ -687,6 +688,10 @@ function parseChannelMessagesRequestBody(body: unknown): { const record = body as Record; const rawChannel = typeof record.channel === 'string' ? record.channel.trim() : ''; + const historyLatest = + typeof record.historyLatest === 'string' + ? record.historyLatest.trim() + : undefined; const oldest = typeof record.oldest === 'string' && record.oldest.trim().length > 0 ? record.oldest.trim() @@ -697,12 +702,20 @@ function parseChannelMessagesRequestBody(body: unknown): { : undefined; if (!rawChannel) { - return { ...(oldest ? { oldest } : {}), ...(latest ? { latest } : {}) }; + return { + ...(oldest ? { oldest } : {}), + ...(latest ? { latest } : {}), + ...(historyLatest ? { historyLatest } : {}), + }; } const channelTarget = normalizeSlackChannelTarget(rawChannel); if (!channelTarget) { - return { ...(oldest ? { oldest } : {}), ...(latest ? { latest } : {}) }; + return { + ...(oldest ? { oldest } : {}), + ...(latest ? { latest } : {}), + ...(historyLatest ? { historyLatest } : {}), + }; } if ('error' in channelTarget) { throw new Error(channelTarget.error); @@ -710,6 +723,7 @@ function parseChannelMessagesRequestBody(body: unknown): { return { channel: channelTarget.value, + ...(historyLatest ? { historyLatest } : {}), ...(oldest ? { oldest } : {}), ...(latest ? { latest } : {}), }; @@ -1969,6 +1983,7 @@ slackMcp.post('/channel_messages', async (c) => { channel?: string; oldest?: string; latest?: string; + historyLatest?: string; }; try { parsedBody = parseChannelMessagesRequestBody(await c.req.json()); @@ -1981,6 +1996,9 @@ slackMcp.post('/channel_messages', async (c) => { try { const payload = await lookupSlackChannelMessages({ + ...(typeof parsedBody.historyLatest === 'string' + ? { historyLatest: parsedBody.historyLatest } + : {}), ...(typeof parsedBody.channel === 'string' ? { channel: parsedBody.channel } : {}), diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 0c4d65cbfd..a8620a8638 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -380,7 +380,11 @@ channel-access checks. It does not infer access from the originating chat. Roomote uses one destination lookup and one send action for explicitly requested standalone messages. The lookup can return your linked Slack or Telegram account, -linked people in shared Slack workspaces, and accessible Slack channels. Each +linked people in shared Slack workspaces, and public Slack channels the Roomote +app has joined. Private channels are not listed, so a missing channel does not +mean Slack access was lost. Replies in the current conversation, including an +automation's configured report channel, do not depend on finding that channel +in the standalone destination lookup. Each lookup names one provider and destination kind. Self lookup resolves only your authenticated identity; people and channel lookup requires a targeted search or an exact destination reference. Results are bounded and include a continuation diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 1ec7d8fee8..3aef3e4729 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -232,9 +232,12 @@ for example `@Roomote summarize this thread` or `@Roomote fix the failing CI job`. Replies in a Roomote-owned Slack thread continue that session; replies in an existing task-only thread continue the task. -Roomote can read a bounded history from the current Slack channel, discover other -channels in the same workspace, and post standalone updates to channels where -the Roomote app is authorized. From another session surface, such as Telegram, you +Roomote can read a bounded history from the current Slack channel, discover public +channels the app has joined in the same workspace, and post standalone updates to +authorized destinations. Private channels are not listed in the standalone +destination lookup; their absence does not mean Slack access was lost. Replies +in the current conversation, including an automation's configured report channel, +do not depend on that lookup. From another session surface, such as Telegram, you can provide a Slack link or explicitly name Slack to read authorized context, then ask Roomote to send an update to an explicit workspace and destination. Roomote uses the same destination lookup and send action for your own linked Slack diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/get-chat-channel-messages-registration.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/get-chat-channel-messages-registration.test.ts new file mode 100644 index 0000000000..d625217d97 --- /dev/null +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/get-chat-channel-messages-registration.test.ts @@ -0,0 +1,132 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; + +describe('sandbox channel history continuation', () => { + const fetchMock = vi.fn(); + let client: Client; + let server: typeof import('../index.js').roomoteMcpServer; + + beforeEach(async () => { + vi.resetModules(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('ROOMOTE_CLOUD_TOKEN', 'test-task-token'); + vi.stubEnv('ROOMOTE_FAST_AGENT_CHILD', 'false'); + vi.stubEnv('ROOMOTE_PLATFORM_API_URL', 'https://api.example.com'); + vi.stubEnv('ROOMOTE_AUTH_BYPASS_HEADER_NAME', ''); + vi.stubEnv('ROOMOTE_AUTH_BYPASS_VALUE', ''); + ({ roomoteMcpServer: server } = await import('../index.js')); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + client = new Client({ name: 'channel-history-test', version: '1.0.0' }); + await server.connect(serverTransport); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('advertises the root-history bound on the actual sandbox tool', async () => { + const { tools } = await client.listTools(); + const tool = tools.find(({ name }) => name === 'get_chat_channel_messages'); + expect(tool?.inputSchema.properties).toHaveProperty('historyLatest'); + }); + + it('round-trips a history continuation to the API without narrowing the reply upper bound', async () => { + const firstPage = { + provider: 'slack', + channelId: 'C123', + messageCount: 0, + messages: [], + truncated: true, + historyContinuation: { + oldest: '100', + latest: '300', + historyLatest: '200', + }, + note: 'Continue reading older roots with the original reply window.', + }; + const nextPage = { + provider: 'slack', + channelId: 'C123', + messageCount: 1, + messages: [ + { + provider: 'slack', + id: '250', + threadId: '150', + user: 'U1', + text: 'reply on an older root', + channelId: 'C123', + fileCount: 0, + }, + ], + }; + fetchMock + .mockResolvedValueOnce(Response.json(firstPage)) + .mockResolvedValueOnce(Response.json(nextPage)); + + const first = await client.callTool({ + name: 'get_chat_channel_messages', + arguments: { channel: 'C123', oldest: '100', latest: '300' }, + }); + expect(first.isError).not.toBe(true); + expect(first.structuredContent).toEqual(firstPage); + const continuation = (first.structuredContent as typeof firstPage) + .historyContinuation; + const next = await client.callTool({ + name: 'get_chat_channel_messages', + arguments: { channel: ' C123 ', ...continuation }, + }); + + expect(next.isError, JSON.stringify(next.content)).not.toBe(true); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.example.com/api/mcp/communication/channel_messages', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + channel: 'C123', + oldest: '100', + latest: '300', + historyLatest: '200', + }), + }), + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(next.structuredContent).toEqual(nextPage); + }); + + it.each([' 200 ', '', null])( + 'normalizes optional historyLatest=%j before the HTTP boundary', + async (historyLatest) => { + fetchMock.mockResolvedValueOnce( + Response.json({ + provider: 'slack', + channelId: 'C123', + messageCount: 0, + messages: [], + }), + ); + const result = await client.callTool({ + name: 'get_chat_channel_messages', + arguments: { oldest: '100', latest: '300', historyLatest }, + }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + 'https://api.example.com/api/mcp/communication/channel_messages', + expect.objectContaining({ + body: JSON.stringify({ + oldest: '100', + latest: '300', + ...(historyLatest?.trim() ? { historyLatest: '200' } : {}), + }), + }), + ); + }, + ); +}); diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations.test.ts index 3747f55197..cc3432d548 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations.test.ts @@ -208,6 +208,39 @@ describe('on-demand integration tools', () => { expect(warn.mock.calls.flat().join(' ')).not.toContain('customer'); }); + it('returns an integration note with every lookup that covers it, even an empty one', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const noted: OnDemandMcpCatalog = { + servers: catalog.servers.map((server) => + server.name === 'github' + ? { ...server, note: 'Writes go through gh.' } + : server, + ), + }; + + const empty = parse( + await findOnDemandIntegrationTools( + noted, + { integrationId: 'github', toolName: 'run_workflow' }, + listTools, + ), + ); + expect(empty).toMatchObject({ + tools: [], + emptyReason: 'no_filter_match', + integrationNotes: { github: 'Writes go through gh.' }, + }); + + const other = parse( + await findOnDemandIntegrationTools( + noted, + { integrationId: 'linear' }, + listTools, + ), + ); + expect(other).not.toHaveProperty('integrationNotes'); + }); + it('lists every scoped server at once so one slow server does not serialize the lookup', async () => { let releaseSlow!: () => void; const slowListing = new Promise((resolve) => { diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts index fea06c5eaf..50e544c792 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts @@ -9,6 +9,8 @@ import { SHOW_WIDGET_THEME_GUIDANCE, } from '@roomote/cloud-agents/show-widget'; import { + CHAT_CHANNEL_MESSAGES_TOOL, + CHAT_MESSAGE_SEND_TOOL, CREATE_CUSTOM_SKILL_TOOL, CUSTOM_AUTOMATION_PROMPT_MAX_LENGTH, MANAGE_CUSTOM_AUTOMATIONS_TOOL, @@ -1017,15 +1019,26 @@ describe('roomote MCP tool descriptions', () => { const channelField = getInputSchemaField(lookupTool, 'channel'); const oldestField = getInputSchemaField(lookupTool, 'oldest'); const latestField = getInputSchemaField(lookupTool, 'latest'); + const historyLatestField = getInputSchemaField(lookupTool, 'historyLatest'); expect(lookupTool.config.description).toBe( - 'Fetch readable history from the task communication channel. When the task has no communication channel, or when another channel is needed, provide a Slack or Discord channel/message link. Provider-specific access checks still apply. Large results are cut to the newest messages: when the response has truncated set, call again with latest set to nextLatest (and the same oldest) to read older messages.', + CHAT_CHANNEL_MESSAGES_TOOL.description, + ); + expect(lookupTool.config.description).toContain('sizeContinuation'); + expect(lookupTool.config.description).toContain('historyContinuation'); + expect(lookupTool.config.description).toContain( + 'Retain both independently', ); expect(channelField.description).toBe( 'Optional channel ID, name, mention, or Slack/Discord channel/message link. Omit it to use the task communication channel.', ); expect(oldestField.description).toContain('Slack timestamp'); expect(latestField.description).toContain('message snowflake'); + expect(historyLatestField.description).toBe( + CHAT_CHANNEL_MESSAGES_TOOL.inputDescriptions.historyLatest, + ); + expect(historyLatestField.description).toContain('root-history'); + expect(historyLatestField.description).toContain('Keep latest'); }); it('gives delegated coding tasks only the private parent Session report tool', async () => { @@ -1285,7 +1298,12 @@ describe('roomote MCP tool descriptions', () => { expect(messageField.description).toBe('Markdown message to send.'); expect(postTool.config.description).toBe( - 'Send a new standalone Markdown message to an authorized destination. First use list_chat_destinations and pass its exact destination reference unchanged. Self destinations resolve only from the authenticated member; Slack people and channels retain workspace linkage and access checks. A trusted Slack channel reference may append :thread:. Use send_chat_reply for the current conversation. Never infer or alter a destination reference. Worker tasks may also attach image paths or already-uploaded artifact IDs; attachment ownership is verified against the current task run.', + `${CHAT_MESSAGE_SEND_TOOL.description} Worker tasks may also attach image paths or already-uploaded artifact IDs; attachment ownership is verified against the current task run.`, + ); + expect(postTool.config.description).toContain('list_chat_destinations'); + expect(postTool.config.description).toContain('Use send_chat_reply'); + expect(postTool.config.description).toContain( + 'Never infer or alter a destination reference.', ); expect(postTool.config.description).not.toContain( '', diff --git a/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts b/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts index 1864a92d2d..cc3d7d5e4d 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts @@ -231,6 +231,7 @@ export async function getChatChannelMessages( channel?: string; oldest?: string; latest?: string; + historyLatest?: string; }, ): Promise { return postToCommunicationLookupEndpoint( diff --git a/apps/worker/src/mcp/roomote-mcp-server/get-chat-channel-messages.ts b/apps/worker/src/mcp/roomote-mcp-server/get-chat-channel-messages.ts index 96b11bb2de..38a8f4868e 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/get-chat-channel-messages.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/get-chat-channel-messages.ts @@ -7,6 +7,7 @@ export async function handleGetChatChannelMessages( channel?: string; oldest?: string; latest?: string; + historyLatest?: string; }, roomoteConfig: RoomoteConfig, ): Promise { @@ -14,12 +15,14 @@ export async function handleGetChatChannelMessages( const channel = input.channel?.trim(); const oldest = input.oldest?.trim(); const latest = input.latest?.trim(); + const historyLatest = input.historyLatest?.trim(); return jsonResult( await getChatChannelMessages(roomoteConfig, { ...(channel ? { channel } : {}), ...(oldest ? { oldest } : {}), ...(latest ? { latest } : {}), + ...(historyLatest ? { historyLatest } : {}), }), ); } catch (error) { diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index 4333b4a7b4..461c4b20e4 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -1639,6 +1639,10 @@ if (!isFastAgentChild()) { .string() .optional() .describe(CHAT_CHANNEL_MESSAGES_TOOL.inputDescriptions.latest), + historyLatest: z + .string() + .optional() + .describe(CHAT_CHANNEL_MESSAGES_TOOL.inputDescriptions.historyLatest), }, annotations: { readOnlyHint: true, @@ -1658,6 +1662,7 @@ if (!isFastAgentChild()) { channel: params.channel, oldest: params.oldest, latest: params.latest, + historyLatest: params.historyLatest, }, roomoteConfig, ); diff --git a/apps/worker/src/mcp/roomote-mcp-server/on-demand-integrations.ts b/apps/worker/src/mcp/roomote-mcp-server/on-demand-integrations.ts index 1fe60e8f67..d91819c16a 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/on-demand-integrations.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/on-demand-integrations.ts @@ -32,6 +32,8 @@ const onDemandMcpServerSchema = z.object({ name: z.string().min(1), displayName: z.string().min(1), description: z.string().optional(), + /** Returned with every lookup that covers this integration. */ + note: z.string().optional(), url: z.string().url(), headers: z.record(z.string(), z.string()).optional(), }); @@ -190,10 +192,16 @@ export async function findOnDemandIntegrationTools( ), ); } + const integrationNotes = Object.fromEntries( + scoped.flatMap((server) => + server.note ? [[server.name, server.note] as const] : [], + ), + ); return jsonResult({ success: true, tools, availableToolCount, + ...(Object.keys(integrationNotes).length > 0 ? { integrationNotes } : {}), ...(emptyReason ? { emptyReason } : {}), ...(truncated ? { guidance: INTEGRATION_TOOL_LOOKUP_TRUNCATED_GUIDANCE } diff --git a/apps/worker/src/run-task/agent-home.test.ts b/apps/worker/src/run-task/agent-home.test.ts index 4ca6f5fbec..5720cd1abe 100644 --- a/apps/worker/src/run-task/agent-home.test.ts +++ b/apps/worker/src/run-task/agent-home.test.ts @@ -1776,6 +1776,7 @@ describe('generateOpenCodeConfig provider support', () => { { name: 'github', displayName: 'github', + note: expect.stringContaining('use `git` and `gh` in this checkout'), url: 'https://api.example.com/api/mcp-routing/github', headers: { Authorization: 'Bearer run-token' }, }, @@ -1851,7 +1852,7 @@ describe('generateOpenCodeConfig provider support', () => { 'An eligible deployment GitHub App installation with an active connected repository is required', ); expect(integrationInstructions).toContain( - 'This task MCP path is read-only', + 'use `git` and `gh` in this checkout', ); expect(integrationInstructions).not.toContain('gist'); }); diff --git a/apps/worker/src/run-task/agent-home.ts b/apps/worker/src/run-task/agent-home.ts index 1151d19fb1..4c878444f5 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -182,6 +182,14 @@ const ROOMOTE_OPENCODE_SLACK_SILENCE_HOOK_FILE_NAME = const ROOMOTE_OPENCODE_PLUGINS_DIR_NAME = 'plugins'; const ROOMOTE_OPENCODE_ON_DEMAND_MCP_CATALOG_FILE_NAME = 'on-demand-mcp-servers.json'; +/** + * The task's GitHub tools are read-only while its checkout's `git` and `gh` + * carry the same installation credentials with write access. Say so wherever + * the task meets the GitHub tools, or a missing write tool reads as a denial. + */ +const GITHUB_TASK_WRITE_PATH_NOTE = + "These GitHub tools are read-only. For writes (pushes, pull requests, comments, reviews, labels, workflow dispatches, and other GitHub actions), use `git` and `gh` in this checkout: they carry this task's GitHub credentials and are its authorized write path, not a workaround."; + const ROOMOTE_ON_DEMAND_MCP_CATALOG_PATH_ENV_VAR = 'ROOMOTE_ON_DEMAND_MCP_CATALOG_PATH'; @@ -782,6 +790,9 @@ function writeOnDemandMcpCatalog( ...(integration?.description ? { description: integration.description } : {}), + ...(mcpServer.name === 'github' + ? { note: GITHUB_TASK_WRITE_PATH_NOTE } + : {}), url: mcpServer.url, ...(mcpServer.headers ? { @@ -842,7 +853,7 @@ export function createIntegrationMcpInstructions( if (mcpServer.name === 'github') { return [ - '# GitHub reads\n\nDiscover GitHub tools through roomote_find_integration_tools with integrationId github. An eligible deployment GitHub App installation with an active connected repository is required. Public github.com repositories do not themselves need to be connected. Use the existing native tools and their discovered schemas for source reads, code search, issues, and pull requests. Searches can span the connected repositories; add a repo:owner/name or org: qualifier when the scope is known. Private reads retain connected-repository authorization. Respect upstream pagination and search-index limits; disclose incomplete results. Never retry an authorization denial anonymously. This task MCP path is read-only; use the authorized coding-task source-control workflow for writes.', + `# GitHub reads\n\nDiscover GitHub tools through roomote_find_integration_tools with integrationId github. An eligible deployment GitHub App installation with an active connected repository is required. Public github.com repositories do not themselves need to be connected. Use the existing native tools and their discovered schemas for source reads, code search, issues, and pull requests. Searches can span the connected repositories; add a repo:owner/name or org: qualifier when the scope is known. Private reads retain connected-repository authorization. Respect upstream pagination and search-index limits; disclose incomplete results. Never retry an authorization denial anonymously. ${GITHUB_TASK_WRITE_PATH_NOTE}`, ]; } if (isMemoryMcpServer(mcpServer.name)) { diff --git a/package.json b/package.json index 18bfc4a6a3..b6ee9eb998 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "1.20.0", + "version": "1.20.1", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { diff --git a/packages/cloud-agents/src/server/__tests__/task-helper-subscription-transport.test.ts b/packages/cloud-agents/src/server/__tests__/task-helper-subscription-transport.test.ts index 30a295846f..4e5cb542f5 100644 --- a/packages/cloud-agents/src/server/__tests__/task-helper-subscription-transport.test.ts +++ b/packages/cloud-agents/src/server/__tests__/task-helper-subscription-transport.test.ts @@ -143,8 +143,10 @@ describe.skipIf(process.env.ROOMOTE_TEST_OPENCODE_SUBSCRIPTIONS !== '1')( expect(requests[0]).toMatchObject({ authorization: 'Bearer synthetic-run-token', }); - expect(requests[0]?.url).toContain( - `/api/inference/${provider === 'openai' ? 'openai-chatgpt' : provider}/`, + expect(requests[0]?.url).toBe( + provider === 'openai' + ? '/api/inference/openai-chatgpt/v1/responses' + : '/api/inference/github-copilot/responses', ); expect( isAgentSafetyStop(result.error ?? result.data?.info.error), diff --git a/packages/cloud-agents/src/server/opencode-runtime.ts b/packages/cloud-agents/src/server/opencode-runtime.ts index 224606be73..6a04898104 100644 --- a/packages/cloud-agents/src/server/opencode-runtime.ts +++ b/packages/cloud-agents/src/server/opencode-runtime.ts @@ -751,6 +751,11 @@ export function rebaseTaskHelperInference( const existing = asRecord(providers[id]) ?? {}; providers[id] = { ...existing, + // As in the sandbox provider config, activate Copilot before OpenCode's + // custom-loader pass so GPT-5+ models use /responses, not /chat/completions. + ...(id === 'github-copilot' + ? { env: ['ROOMOTE_TASK_INFERENCE_TOKEN'] } + : {}), options: { ...asRecord(existing.options), baseURL: buildInferenceGatewayOpenCodeBaseUrl( diff --git a/packages/slack/src/__tests__/slack-notifier.test.ts b/packages/slack/src/__tests__/slack-notifier.test.ts index e85a536f24..d3881efcdd 100644 --- a/packages/slack/src/__tests__/slack-notifier.test.ts +++ b/packages/slack/src/__tests__/slack-notifier.test.ts @@ -3164,6 +3164,270 @@ describe('SlackNotifier', () => { }); describe('fetchChannelMessages', () => { + afterEach(() => vi.restoreAllMocks()); + const root = { + bot_id: 'B123', + text: 'root', + ts: '200.000', + reply_count: 1, + latest_reply: '250.000', + type: 'message', + }; + + it('returns accumulated roots without sleeping when Retry-After exceeds the remaining budget', async () => { + vi.useFakeTimers(); + try { + const started = Date.now(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ ok: true, messages: [root] })) + .mockResolvedValueOnce( + new Response('', { status: 429, headers: { 'Retry-After': '60' } }), + ); + getGlobalWithFetch().fetch = fetchMock; + const result = await notifier.fetchChannelMessagesWithinBudget({ + channel: 'C123', + deadlineAt: started + 1000, + }); + expect(result.messages.map(({ ts }) => ts)).toEqual(['200.000']); + expect(result.incomplete).toEqual({ unexpandedThreadCount: 1 }); + expect(Date.now()).toBe(started); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('checks the deadline between stale-root lookback pages', async () => { + const started = Date.now(); + const now = vi.spyOn(Date, 'now').mockReturnValue(started); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ ok: true, messages: [{ ...root, reply_count: 0 }] }), + ) + .mockImplementationOnce(async () => { + now.mockReturnValue(started + 1000); + return Response.json({ + ok: true, + messages: [], + response_metadata: { next_cursor: 'more' }, + }); + }); + getGlobalWithFetch().fetch = fetchMock; + const result = await notifier.fetchChannelMessagesWithinBudget({ + channel: 'C123', + oldest: '100.000', + deadlineAt: started + 1000, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.messages).toHaveLength(1); + expect(result.incomplete).toEqual({ staleRootDiscoveryIncomplete: true }); + }); + + it('does not join an unbudgeted thread retry sleep and leaves its retry behavior unchanged', async () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0); + try { + const rateLimited = () => + new Response('', { status: 429, headers: { 'Retry-After': '60' } }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(rateLimited()) + .mockResolvedValueOnce(Response.json({ ok: true, messages: [root] })) + .mockResolvedValueOnce(rateLimited()) + .mockResolvedValueOnce(Response.json({ ok: true, messages: [] })); + getGlobalWithFetch().fetch = fetchMock; + const unbudgeted = notifier.fetchThreadMessages({ + channel: 'C123', + threadTs: root.ts, + }); + await vi.advanceTimersByTimeAsync(0); + const partial = await notifier.fetchChannelMessagesWithinBudget({ + channel: 'C123', + deadlineAt: Date.now() + 1000, + }); + expect(partial.incomplete).toEqual({ unexpandedThreadCount: 1 }); + expect(fetchMock).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(60_000); + await expect(unbudgeted).resolves.toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(4); + } finally { + vi.useRealTimers(); + } + }); + + it.each([{ messages: [] }, { messages: [{ ...root, reply_count: 0 }] }])( + 'marks skipped lookback after a final primary page, including empty results ($messages)', + async ({ messages }) => { + const started = Date.now(); + const now = vi.spyOn(Date, 'now').mockReturnValue(started); + getGlobalWithFetch().fetch = vi.fn().mockImplementation(async () => { + now.mockReturnValue(started + 1000); + return Response.json({ ok: true, messages }); + }); + const result = await notifier.fetchChannelMessagesWithinBudget({ + channel: 'C123', + oldest: '100.000', + deadlineAt: started + 1000, + }); + expect(result.messages).toHaveLength(messages.length); + expect(result.incomplete).toEqual({ + staleRootDiscoveryIncomplete: true, + }); + expect(getGlobalWithFetch().fetch).toHaveBeenCalledTimes(1); + }, + ); + + it('aborts an in-flight history request at the deadline and retains previous pages', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + ok: true, + messages: [{ ...root, reply_count: 0 }], + response_metadata: { next_cursor: 'more' }, + }), + ) + .mockImplementationOnce( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal!.addEventListener( + 'abort', + () => reject(init.signal!.reason), + { once: true }, + ); + }), + ); + getGlobalWithFetch().fetch = fetchMock; + const result = await notifier.fetchChannelMessagesWithinBudget({ + channel: 'C123', + deadlineAt: Date.now() + 50, + }); + expect(result.messages).toHaveLength(1); + expect(result.incomplete).toMatchObject({ + historyIncomplete: true, + historyStoppedAtTs: '200.000', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('aborts an in-flight reply request at the deadline without losing channel messages', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ ok: true, messages: [root] })) + .mockImplementationOnce( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal!.addEventListener( + 'abort', + () => reject(init.signal!.reason), + { once: true }, + ); + }), + ); + getGlobalWithFetch().fetch = fetchMock; + const result = await notifier.fetchChannelMessagesWithinBudget({ + channel: 'C123', + deadlineAt: Date.now() + 50, + }); + expect(result.messages).toHaveLength(1); + expect(result.incomplete).toEqual({ unexpandedThreadCount: 1 }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('keeps replies newer than the root-history continuation bound', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ ok: true, messages: [{ ...root, ts: '150.000' }] }), + ) + .mockResolvedValueOnce(Response.json({ ok: true, messages: [] })) + .mockResolvedValueOnce( + Response.json({ + ok: true, + messages: [ + { ...root, ts: '250.000', thread_ts: '150.000', text: 'reply' }, + ], + }), + ); + getGlobalWithFetch().fetch = fetchMock; + const result = await notifier.fetchChannelMessagesWithinBudget({ + channel: 'C123', + oldest: '100.000', + latest: '300.000', + historyLatest: '200.000', + deadlineAt: Date.now() + 1000, + }); + expect(result.messages.map(({ ts }) => ts)).toEqual([ + '150.000', + '250.000', + ]); + expect( + new URL(fetchMock.mock.calls[0]![0] as string).searchParams.get( + 'latest', + ), + ).toBe('200.000'); + expect( + new URL(fetchMock.mock.calls[2]![0] as string).searchParams.get( + 'latest', + ), + ).toBe('300.000'); + }); + + it('stops at the deadline and reports what it did not read', async () => { + const started = Date.now(); + const now = vi.spyOn(Date, 'now').mockReturnValue(started); + vi.spyOn( + SlackNotifier.prototype as unknown as { + getUsersInfo(userIds: string[]): Promise>; + }, + 'getUsersInfo', + ).mockResolvedValue(new Map([['U123', 'Alice']])); + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => { + now.mockReturnValue(started + 1000); + return { + ok: true, + messages: [ + { + user: 'U123', + text: 'root', + ts: '112.000', + thread_ts: '112.000', + reply_count: 2, + latest_reply: '112.500', + type: 'message', + }, + { user: 'U123', text: 'newer', ts: '113.000', type: 'message' }, + ], + response_metadata: { next_cursor: 'cursor-1' }, + }; + }, + }); + getGlobalWithFetch().fetch = fetchMock; + + const result = await notifier.fetchChannelMessagesWithinBudget({ + channel: 'C123', + oldest: '100.000', + latest: '113.000', + deadlineAt: started + 1000, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.messages.map((message) => message.ts)).toEqual([ + '112.000', + '113.000', + ]); + expect(result.incomplete).toEqual({ + historyStoppedAtTs: '112.000', + unexpandedThreadCount: 1, + staleRootDiscoveryIncomplete: true, + }); + }); + it('paginates channel history and expands thread replies', async () => { const getUsersInfoSpy = vi .spyOn( diff --git a/packages/slack/src/slack-notifier.ts b/packages/slack/src/slack-notifier.ts index 5fb2cf6a69..af49de5b33 100644 --- a/packages/slack/src/slack-notifier.ts +++ b/packages/slack/src/slack-notifier.ts @@ -22,6 +22,7 @@ import { fetchSlackGetJson, getSlackRetryAfterMs, slackFetch, + SLACK_REQUEST_TIMEOUT_MS, SLACK_DOWNLOAD_TIMEOUT_MS, } from './slack-api-fetch'; import type { @@ -36,6 +37,19 @@ import { formatSlackAttachmentContext, } from './forwarded-message-context'; +class SlackChannelDeadlineError extends Error {} + +function slackDeadlineOptions(deadlineAt?: number): { timeoutMs?: number } { + return deadlineAt === undefined + ? {} + : { + timeoutMs: Math.min( + SLACK_REQUEST_TIMEOUT_MS, + Math.max(1, deadlineAt - Date.now()), + ), + }; +} + type SlackApiThreadMessage = { user?: string; username?: string; @@ -421,56 +435,64 @@ export class SlackNotifier { return this.channelDiscovery; } - private async getOwnBotIdentity(): Promise<{ + private async getOwnBotIdentity(deadlineAt?: number): Promise<{ userId?: string; botId?: string; teamId?: string; teamDomain?: string; } | null> { - if (!this.ownBotIdentityPromise) { - this.ownBotIdentityPromise = (async () => { - try { - const response = await slackFetch(buildSlackApiUrl('auth.test'), { + const loadIdentity = async () => { + if (deadlineAt !== undefined && Date.now() >= deadlineAt) return null; + try { + const response = await slackFetch( + buildSlackApiUrl('auth.test'), + { method: 'POST', headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/x-www-form-urlencoded', }, - }); - - if (!response.ok) { - console.error( - `[getOwnBotIdentity] Slack auth.test failed: ${response.status} ${response.statusText}`, - ); - return null; - } - - const result: SlackAuthTestResponse = await response.json(); + }, + slackDeadlineOptions(deadlineAt), + ); - if (!result.ok) { - console.error( - `[getOwnBotIdentity] Slack auth.test error: ${result.error || 'Unknown error'}`, - ); - return null; - } + if (!response.ok) { + console.error( + `[getOwnBotIdentity] Slack auth.test failed: ${response.status} ${response.statusText}`, + ); + return null; + } - if (!result.user_id && !result.bot_id) { - return null; - } + const result: SlackAuthTestResponse = await response.json(); - return { - userId: result.user_id, - botId: result.bot_id, - teamId: result.team_id, - teamDomain: getSlackWorkspaceDomain(result.url) ?? undefined, - }; - } catch (error) { + if (!result.ok) { console.error( - `[getOwnBotIdentity] Failed to fetch Slack bot identity: ${error instanceof Error ? error.message : String(error)}`, + `[getOwnBotIdentity] Slack auth.test error: ${result.error || 'Unknown error'}`, ); return null; } - })(); + + if (!result.user_id && !result.bot_id) { + return null; + } + + return { + userId: result.user_id, + botId: result.bot_id, + teamId: result.team_id, + teamDomain: getSlackWorkspaceDomain(result.url) ?? undefined, + }; + } catch (error) { + console.error( + `[getOwnBotIdentity] Failed to fetch Slack bot identity: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + }; + // A budgeted read cannot join an identity request with a longer lifetime. + if (deadlineAt !== undefined) return loadIdentity(); + if (!this.ownBotIdentityPromise) { + this.ownBotIdentityPromise = loadIdentity(); } const ownBotIdentity = await this.ownBotIdentityPromise; @@ -502,6 +524,7 @@ export class SlackNotifier { messages: SlackApiThreadMessage[], options: { excludeOwnBot?: boolean; + deadlineAt?: number; } = {}, ): Promise { const needsOwnBotId = @@ -510,7 +533,7 @@ export class SlackNotifier { SUGGESTION_REACTION_START_NOTICE_REGEX.test(msg.text ?? ''), ); const ownBotIdentity = needsOwnBotId - ? await this.getOwnBotIdentity() + ? await this.getOwnBotIdentity(options.deadlineAt) : null; const normalizedInput = messages @@ -551,7 +574,9 @@ export class SlackNotifier { const usernameMap = userIds.length > 0 - ? await this.getUsersInfo(userIds) + ? await (options.deadlineAt === undefined + ? this.getUsersInfo(userIds) + : this.getUsersInfo(userIds, options.deadlineAt)) : new Map(); return filteredMessages.map((msg) => ({ @@ -2168,7 +2193,10 @@ export class SlackNotifier { * @param userIds - Array of Slack user IDs to fetch information for * @returns Map of user IDs to display names */ - private async getUsersInfo(userIds: string[]): Promise> { + private async getUsersInfo( + userIds: string[], + deadlineAt?: number, + ): Promise> { const usernameMap = new Map(); // Fetch user info for each unique user ID @@ -2176,6 +2204,7 @@ export class SlackNotifier { await Promise.all( uniqueUserIds.map(async (userId) => { + if (deadlineAt !== undefined && Date.now() >= deadlineAt) return; try { const response = await slackFetch( `${buildSlackApiUrl('users.info')}?user=${encodeURIComponent(userId)}`, @@ -2186,6 +2215,7 @@ export class SlackNotifier { 'Content-Type': 'application/x-www-form-urlencoded', }, }, + slackDeadlineOptions(deadlineAt), ); if (!response.ok) { @@ -2244,12 +2274,14 @@ export class SlackNotifier { oldest, latest, excludeOwnBot = false, + deadlineAt, }: { channel: string; threadTs: string; oldest?: string; latest?: string; excludeOwnBot?: boolean; + deadlineAt?: number; }): Promise { try { const params = new URLSearchParams({ @@ -2272,11 +2304,16 @@ export class SlackNotifier { const result = await this.fetchThreadResponse( params, 'fetchThreadMessages', + deadlineAt, ); - if (!result) return []; + if (!result) { + if (deadlineAt !== undefined) throw new SlackChannelDeadlineError(); + return []; + } if (!result.ok) { + if (deadlineAt !== undefined) throw new SlackChannelDeadlineError(); console.error( `[fetchThreadMessages] Slack conversations.replies error: ${result.error || 'Unknown error'}`, ); @@ -2285,8 +2322,10 @@ export class SlackNotifier { return await this.normalizeFetchedMessages(result.messages, { excludeOwnBot, + deadlineAt, }); } catch (error) { + if (error instanceof SlackChannelDeadlineError) throw error; console.error( `[fetchThreadMessages] Failed to fetch Slack thread messages: ${error instanceof Error ? error.message : String(error)}`, ); @@ -2309,6 +2348,7 @@ export class SlackNotifier { | 'fetchMessageBlocks' | 'getMessageMetadata' | 'getMessage', + deadlineAt?: number, ): Promise { // Full thread snapshots tolerate a short cache. Existence/identity checks // must see current state, and block reads protect read-modify-write callers @@ -2321,7 +2361,8 @@ export class SlackNotifier { context === 'fetchMessageBlocks' ? 1 : MAX_SLACK_CONVERSATIONS_REPLIES_RATE_LIMIT_RETRIES; - const requestKey = `${cacheKey}:retries=${maxRateLimitRetries}`; + // Budgeted reads must never join an unbudgeted request/retry sleep. + const requestKey = `${cacheKey}:retries=${maxRateLimitRetries}:deadline=${deadlineAt ?? 'none'}`; const getCachedResponse = (): SlackApiThreadResponse | null => { if (!cacheable) return null; const cached = this.threadResponseCache.get(cacheKey); @@ -2346,6 +2387,8 @@ export class SlackNotifier { retryCount += 1 ) { try { + if (deadlineAt !== undefined && Date.now() >= deadlineAt) + throw new SlackChannelDeadlineError(); if (retryCount > 0) { const cached = getCachedResponse(); if (cached) return cached; @@ -2360,6 +2403,7 @@ export class SlackNotifier { 'Content-Type': 'application/x-www-form-urlencoded', }, }, + slackDeadlineOptions(deadlineAt), ); if (response.status === 429) { @@ -2380,15 +2424,14 @@ export class SlackNotifier { ? getSlackRetryAfterMs(retryAfterHeader) : DEFAULT_SLACK_CONVERSATIONS_REPLIES_BACKOFF_MS * 2 ** retryCount; - await new Promise((resolve) => - setTimeout( - resolve, - Math.min( - retryAfterMs + jitterMs, - MAX_SLACK_CONVERSATIONS_REPLIES_RATE_LIMIT_WAIT_MS, - ), - ), + const waitMs = Math.min( + retryAfterMs + jitterMs, + MAX_SLACK_CONVERSATIONS_REPLIES_RATE_LIMIT_WAIT_MS, ); + if (deadlineAt !== undefined && waitMs >= deadlineAt - Date.now()) { + throw new SlackChannelDeadlineError(); + } + await new Promise((resolve) => setTimeout(resolve, waitMs)); continue; } @@ -2420,6 +2463,12 @@ export class SlackNotifier { } return result; } catch (error) { + if ( + deadlineAt !== undefined && + (error instanceof SlackChannelDeadlineError || + Date.now() >= deadlineAt) + ) + throw new SlackChannelDeadlineError(); console.error( `[${context}] Failed to fetch Slack thread messages: ${error instanceof Error ? error.message : String(error)}`, ); @@ -2445,6 +2494,7 @@ export class SlackNotifier { oldest?: string; latest?: string; cursor?: string; + deadlineAt?: number; }): Promise { const query = new URLSearchParams({ channel: params.channel, @@ -2473,6 +2523,7 @@ export class SlackNotifier { 'Content-Type': 'application/x-www-form-urlencoded', }, }, + slackDeadlineOptions(params.deadlineAt), ); if (!response.ok) { @@ -2497,15 +2548,51 @@ export class SlackNotifier { * time window. Thread replies are expanded and included alongside top-level * channel messages. */ - public async fetchChannelMessages({ + public async fetchChannelMessages(params: { + channel: string; + oldest?: string; + latest?: string; + }): Promise { + return (await this.fetchChannelMessagesWithinBudget(params)).messages; + } + + /** + * Like `fetchChannelMessages`, but stops issuing Slack requests once + * `deadlineAt` (epoch ms) passes and returns what it has, marked incomplete. + * Busy channels with many threads, or Slack tiers that allow one history + * request per minute, can otherwise outlast the caller's timeout and return + * nothing at all. + */ + public async fetchChannelMessagesWithinBudget({ channel, oldest, latest, + deadlineAt, + historyLatest, }: { channel: string; oldest?: string; latest?: string; - }): Promise { + deadlineAt?: number; + /** Root pagination bound; replies retain the original latest bound. */ + historyLatest?: string; + }): Promise<{ + messages: SlackChannelMessage[]; + incomplete?: { + /** Oldest top-level message fetched when history paging stopped early. */ + historyStoppedAtTs?: string; + /** Threads whose replies in the window were not fetched. */ + unexpandedThreadCount?: number; + staleRootDiscoveryIncomplete?: boolean; + historyIncomplete?: boolean; + }; + }> { + const pastDeadline = () => + deadlineAt !== undefined && Date.now() >= deadlineAt; + let historyStoppedAtTs: string | undefined; + let unexpandedThreadCount = 0; + let staleRootDiscoveryIncomplete = false; + let historyIncomplete = false; const historyMessages: SlackApiThreadMessage[] = []; const threadRootTimestamps = new Set(); const oldestTs = parseSlackTimestamp(oldest); @@ -2515,14 +2602,30 @@ export class SlackNotifier { try { do { + if (pastDeadline()) { + historyIncomplete = true; + break; + } historyPageCount += 1; const result = await this.fetchChannelHistoryPage({ channel, ...(oldest ? { oldest } : {}), - ...(latest ? { latest } : {}), + ...((historyLatest ?? latest) + ? { latest: historyLatest ?? latest } + : {}), ...(cursor ? { cursor } : {}), + deadlineAt, + }).catch((error) => { + if ( + !pastDeadline() && + !(deadlineAt !== undefined && historyMessages.length > 0) + ) + throw error; + historyIncomplete = true; + return null; }); + if (!result) break; historyMessages.push(...result.messages); for (const message of result.messages) { @@ -2533,6 +2636,19 @@ export class SlackNotifier { cursor = result.response_metadata?.next_cursor || undefined; + if (cursor && pastDeadline() && historyMessages.length > 0) { + // History pages arrive newest first, so the oldest part of the + // window is what remains unread. + historyStoppedAtTs = historyMessages.reduce( + (oldestSeen, message) => + Number.parseFloat(message.ts) < Number.parseFloat(oldestSeen) + ? message.ts + : oldestSeen, + historyMessages[0]!.ts, + ); + break; + } + if ( oldestTs !== null && cursor && @@ -2544,7 +2660,24 @@ export class SlackNotifier { } } while (cursor); - if (oldest && oldestTs !== null) { + if (historyIncomplete && historyMessages.length > 0) { + historyStoppedAtTs = historyMessages.reduce( + (ts, message) => (Number(message.ts) < Number(ts) ? message.ts : ts), + historyMessages[0]!.ts, + ); + } + staleRootDiscoveryIncomplete = Boolean( + oldest && + oldestTs !== null && + (pastDeadline() || historyIncomplete || historyStoppedAtTs), + ); + + if ( + oldest && + oldestTs !== null && + !pastDeadline() && + !historyStoppedAtTs + ) { // Best effort: the bounded read above is the primary result. Some // Slack tiers only allow one history request per minute, so a failed // lookback must not turn an otherwise successful read into an error. @@ -2553,12 +2686,17 @@ export class SlackNotifier { let lookbackPageCount = 0; do { + if (pastDeadline()) { + staleRootDiscoveryIncomplete = true; + break; + } lookbackPageCount += 1; const result = await this.fetchChannelHistoryPage({ channel, latest: oldest, ...(lookbackCursor ? { cursor: lookbackCursor } : {}), + deadlineAt, }); for (const message of result.messages) { @@ -2573,23 +2711,36 @@ export class SlackNotifier { lookbackPageCount < MAX_STALE_THREAD_ROOT_LOOKBACK_PAGES ); } catch (error) { + if (deadlineAt !== undefined) staleRootDiscoveryIncomplete = true; console.warn( `[fetchChannelMessages] Skipping stale thread root lookback: ${error instanceof Error ? error.message : String(error)}`, ); } } - const rootMessages = await this.normalizeFetchedMessages(historyMessages); + const rootMessages = await this.normalizeFetchedMessages( + historyMessages, + { deadlineAt }, + ); const messagesByTs = new Map( rootMessages.map((message) => [message.ts, message]), ); for (const threadTs of threadRootTimestamps) { + if (pastDeadline()) { + unexpandedThreadCount += 1; + continue; + } const threadMessages = await this.fetchThreadMessages({ channel, threadTs, ...(oldest ? { oldest } : {}), ...(latest ? { latest } : {}), + deadlineAt, + }).catch((error) => { + if (!(error instanceof SlackChannelDeadlineError)) throw error; + unexpandedThreadCount += 1; + return []; }); for (const message of threadMessages) { @@ -2604,7 +2755,7 @@ export class SlackNotifier { } } - return [...messagesByTs.values()] + const messages = [...messagesByTs.values()] .sort((a, b) => Number.parseFloat(a.ts) - Number.parseFloat(b.ts)) .filter((message) => { const numericTs = Number.parseFloat(message.ts); @@ -2616,6 +2767,23 @@ export class SlackNotifier { } return true; }); + + return historyIncomplete || + historyStoppedAtTs || + unexpandedThreadCount > 0 || + staleRootDiscoveryIncomplete + ? { + messages, + incomplete: { + ...(historyStoppedAtTs ? { historyStoppedAtTs } : {}), + ...(unexpandedThreadCount > 0 ? { unexpandedThreadCount } : {}), + ...(historyIncomplete ? { historyIncomplete: true } : {}), + ...(staleRootDiscoveryIncomplete + ? { staleRootDiscoveryIncomplete: true } + : {}), + }, + } + : { messages }; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error( diff --git a/packages/types/src/communication.ts b/packages/types/src/communication.ts index 30b2658c32..8449e03d82 100644 --- a/packages/types/src/communication.ts +++ b/packages/types/src/communication.ts @@ -164,7 +164,7 @@ export const CHAT_MESSAGE_SEND_TOOL = { name: 'send_chat_message', title: 'Send Chat Message', description: - 'Send a new standalone Markdown message to an authorized destination. First use list_chat_destinations and pass its exact destination reference unchanged. Self destinations resolve only from the authenticated member; Slack people and channels retain workspace linkage and access checks. A trusted Slack channel reference may append :thread:. Use send_chat_reply for the current conversation. Never infer or alter a destination reference.', + 'Send a new standalone Markdown message to an authorized destination. First use list_chat_destinations and pass its exact destination reference unchanged. Self destinations resolve only from the authenticated member; Slack people and channels retain workspace linkage and access checks. A trusted Slack channel reference may append :thread:. Use send_chat_reply for the current conversation, including a custom automation result for its configured channel. Never infer or alter a destination reference.', inputDescriptions: { destination: 'Exact destination reference from list_chat_destinations, or a trusted Slack channel reference with an optional :thread: suffix.', @@ -203,7 +203,7 @@ export const CHAT_CHANNEL_MESSAGES_TOOL = { name: 'get_chat_channel_messages', title: 'Get Chat Channel Messages', description: - 'Fetch readable history from the task communication channel. When the task has no communication channel, or when another channel is needed, provide a Slack or Discord channel/message link. Provider-specific access checks still apply. Large results are cut to the newest messages: when the response has truncated set, call again with latest set to nextLatest (and the same oldest) to read older messages.', + 'Fetch readable history from the task communication channel. When the task has no communication channel, or when another channel is needed, provide a Slack or Discord channel/message link. Provider-specific access checks still apply. Partial results can include sizeContinuation and historyContinuation: call again with each object as input fields and the same channel/provider. Retain both independently, process any further continuations, and deduplicate messages by ID. Follow the note for unread thread replies or stale-thread discovery.', inputDescriptions: { channel: 'Optional channel ID, name, mention, or Slack/Discord channel/message link. Omit it to use the task communication channel.', @@ -211,6 +211,8 @@ export const CHAT_CHANNEL_MESSAGES_TOOL = { 'Optional inclusive lower message bound. Use a Slack timestamp or ISO 8601 date for Slack, or a message snowflake for Discord.', latest: 'Optional inclusive upper message bound. Use a Slack timestamp or ISO 8601 date for Slack, or a message snowflake for Discord.', + historyLatest: + 'Optional Slack root-history pagination bound from a continuation. Keep latest from that continuation unchanged so newer replies on older roots remain visible.', }, } as const;