diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-failure.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-failure.test.ts index 446147ebb..a4959379f 100644 --- a/apps/api/src/handlers/slack/events/channel-auto-start-failure.test.ts +++ b/apps/api/src/handlers/slack/events/channel-auto-start-failure.test.ts @@ -33,6 +33,7 @@ vi.mock('@roomote/env', () => ({ vi.mock('@roomote/cloud-agents/server', () => ({ ROUTING_AUTO_CONFIRM_TIMEOUT_MS: 0, + createFastAgentSlackTaskLauncher: vi.fn(() => vi.fn()), })); vi.mock('@roomote/cloud-agents', () => ({ diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts index d823fdcb0..e9e89f450 100644 --- a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts +++ b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts @@ -28,6 +28,7 @@ vi.mock('@roomote/env', () => ({ vi.mock('@roomote/cloud-agents/server', () => ({ ROUTING_AUTO_CONFIRM_TIMEOUT_MS: 0, + createFastAgentSlackTaskLauncher: vi.fn(() => vi.fn()), })); vi.mock('@roomote/cloud-agents', () => ({ diff --git a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts index e31bfca7d..16dca98db 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts @@ -18,7 +18,15 @@ vi.mock('../helpers/thread-posting.js', () => ({ postSlackThreadMarkdownMessage: mocks.postThreadMessage, })); -import { processFastAgentMessage } from './fast-agent.js'; +import { processFastAgentMessage as processFastAgentMessageImpl } from './fast-agent.js'; + +type ProcessFastAgentMessageParams = Parameters< + typeof processFastAgentMessageImpl +>[0]; +const launchTask = vi.fn(); +const processFastAgentMessage = ( + params: Omit, +) => processFastAgentMessageImpl({ ...params, launchTask }); describe('processFastAgentMessage', () => { beforeEach(() => { @@ -72,6 +80,7 @@ describe('processFastAgentMessage', () => { expect.objectContaining({ question: 'investigate this', currentMessageAgentContext: 'Slack block text:\nState: New', + launchTask, activeTasks: [ { taskId: 'task-1', title: 'Fix API' }, { taskId: 'task-2', title: 'Update docs' }, diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index 91cfe0ef7..02596a5f6 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -53,7 +53,7 @@ export async function processFastAgentMessage(params: { usageText?: string; continuation?: boolean; activeTasks?: FastAgentActiveTask[]; - launchTask?: LaunchFastAgentSlackTask; + launchTask: LaunchFastAgentSlackTask; processingReactionName?: string; }): Promise { const { diff --git a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts index 089ebad6a..76329e062 100644 --- a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts +++ b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts @@ -26,6 +26,7 @@ vi.mock('@roomote/env', () => ({ vi.mock('@roomote/cloud-agents/server', () => ({ ROUTING_AUTO_CONFIRM_TIMEOUT_MS: 0, + createFastAgentSlackTaskLauncher: vi.fn(() => vi.fn()), hasFastAgentSession: hasFastAgentSessionMock, })); diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index 012e3dce9..c7a206a46 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -6,6 +6,7 @@ import { syncAutoStartChannelCacheBestEffort, } from '@roomote/redis'; import { + createFastAgentSlackTaskLauncher, hasFastAgentSession, ROUTING_AUTO_CONFIRM_TIMEOUT_MS, } from '@roomote/cloud-agents/server'; @@ -59,7 +60,6 @@ import { processFastAgentMessage, } from './fast-agent.js'; import { resolveFastAgentEntryMode } from '../../fast-agent-entry.js'; -import { createFastAgentTaskLauncher } from './fast-agent-task-launcher.js'; import { processSnapshotResume } from './snapshot-resume.js'; import { dispatchSlackThreadFollowUp, @@ -1606,7 +1606,16 @@ function startFastAgentResponse(params: { processFastAgentMessage({ ...fastAgentParams, apiBaseUrl: Env.TRPC_URL ?? Env.R_APP_URL, - launchTask: createFastAgentTaskLauncher(params), + launchTask: createFastAgentSlackTaskLauncher({ + userId: params.userId, + teamId: params.teamId, + ...(params.slackInstallation.teamDomain + ? { teamDomain: params.slackInstallation.teamDomain } + : {}), + channelId: params.event.channel, + threadTs: params.event.thread_ts || params.event.ts, + messageId: params.event.ts, + }), }).catch((error) => { console.error( errorLogPrefix, diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts index 6f463c944..d0c9051ce 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts @@ -134,12 +134,26 @@ describe('buildFastAgentSystemPrompt', () => { ); expect(prompt).toContain('Never use "ack" or "progress"'); expect(prompt).toContain('Use "ignore_event"'); + expect(prompt).toContain('The normal orchestration tools remain available'); + expect(prompt).toContain( + 'the event is context, not a new human instruction', + ); expect(prompt).toContain( 'includes the full secret-redacted error and its machine-readable errorCode', ); expect(prompt).toContain( 'Use "retry_task_start" only when the failure appears transient', ); + expect(prompt).toContain('"launch_task" creates a separate delegated task'); + expect(prompt).toContain( + 'creates a separate delegated task; it does not retry the task associated with this event', + ); + expect(prompt).not.toContain( + 'Do not use integrations, send messages to tasks, cancel tasks', + ); + expect(prompt).toContain( + 'a platform event has no incoming chat message to react to', + ); expect(prompt).toContain( 'Pull-request-opened events contain authoritative, user-presentable pull request metadata', ); @@ -163,6 +177,7 @@ describe('buildFastAgentSystemPrompt', () => { expect(prompt).not.toContain( 'Use "retry_task_start" only when the failure appears transient', ); + expect(prompt).toContain('"launch_task" creates a separate delegated task'); }); it('grounds first-person requests in current Slack message attributes', () => { diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index 1131a2801..1f7df5a7f 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -57,6 +57,8 @@ const baseParams = { currentMessageTs: '100.2', senderDisplayName: 'Matt', senderSlackUserId: 'U123', + launchTask: vi.fn(), + postSlackReply: vi.fn().mockResolvedValue(undefined), }; function decision(overrides: Record = {}) { @@ -116,6 +118,7 @@ describe('answerFastAgentQuestion', () => { }, ]); mocks.listIntegrations.mockResolvedValue([]); + mocks.callIntegration.mockResolvedValue({ status: 'ok' }); mocks.generateObject.mockResolvedValue({ object: decision() }); mocks.sendTaskMessage.mockResolvedValue({ success: true }); mocks.cancelTask.mockResolvedValue({ success: true }); @@ -408,6 +411,241 @@ describe('answerFastAgentQuestion', () => { expect(callbacks.postSlackReply).toHaveBeenCalledOnce(); }); + it('lets a delegated-task platform event launch a separate task', async () => { + mocks.generateObject + .mockResolvedValueOnce({ + object: decision({ + action: 'launch_task', + message: null, + purpose: null, + taskPrompt: 'Investigate the failure with a fresh approach.', + environmentId: 'env-1', + }), + }) + .mockResolvedValueOnce({ + object: decision({ + message: + 'I started a separate investigation. [Follow the task](https://roomote.example/task-2)', + }), + }); + const callbacks = chatCallbacks(); + const launchTask = successfulLaunchTask('task-2'); + + const result = await answerFastAgentQuestion({ + ...baseParams, + question: + '{"type":"task_settled","taskId":"task-1","status":"failed"}', + platformEvent: true, + launchTask, + ...callbacks, + }); + + expect(launchTask).toHaveBeenCalledWith({ + prompt: 'Investigate the failure with a fresh approach.', + environmentId: 'env-1', + parentSessionId: 'session-1', + postKickoff: expect.any(Function), + }); + expect(result).toContain('task-2'); + expect(callbacks.postSlackReply).toHaveBeenCalledOnce(); + }); + + it('keeps ordinary orchestration tools available for platform events', async () => { + mocks.listIntegrations.mockResolvedValueOnce([ + { + id: 'deployments', + name: 'Deployments', + description: 'Inspect deployments', + tools: [{ name: 'status', description: 'Get deployment status' }], + }, + ]); + mocks.generateObject + .mockResolvedValueOnce({ + object: decision({ + action: 'call_integration', + message: null, + purpose: null, + integrationId: 'deployments', + toolName: 'status', + toolArguments: '{}', + }), + }) + .mockResolvedValueOnce({ + object: decision({ + action: 'send_task_message', + message: null, + purpose: null, + taskId: 'task-2', + taskMessage: 'Keep working.', + }), + }) + .mockResolvedValueOnce({ + object: decision({ + action: 'cancel_task', + message: null, + purpose: null, + taskId: 'task-2', + }), + }) + .mockResolvedValueOnce({ + object: decision({ message: 'I handled the task update.' }), + }); + const callbacks = chatCallbacks(); + + const result = await answerFastAgentQuestion({ + ...baseParams, + question: + '{"type":"task_settled","taskId":"task-1","status":"failed"}', + platformEvent: true, + activeTasks: [{ taskId: 'task-2', title: 'Other work' }], + ...callbacks, + }); + + expect(mocks.callIntegration).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.any(Array), + { + integrationId: 'deployments', + toolName: 'status', + args: {}, + }, + ); + expect(mocks.sendTaskMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + { taskId: 'task-2', message: 'Keep working.' }, + ); + expect(mocks.cancelTask).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + 'task-2', + ); + expect(callbacks.postSlackReply).toHaveBeenCalledOnce(); + expect(result).toBe('I handled the task update.'); + }); + + it('keeps one conversation and action schema across user and task turns', async () => { + type PersistedMessage = { + role: 'user' | 'assistant'; + content: Array<{ type: 'text'; text: string }>; + }; + + const sessionMessages: PersistedMessage[] = []; + mocks.getSession.mockImplementation(async () => ({ + id: 'session-1', + messages: sessionMessages, + })); + mocks.appendSessionMessages.mockImplementation( + async ({ messages }: { messages: PersistedMessage[] }) => { + sessionMessages.push(...messages); + }, + ); + mocks.getActiveTasks + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { taskId: 'task-1', title: 'Fix checkout', status: 'running' }, + ]) + .mockResolvedValueOnce([ + { taskId: 'task-1', title: 'Fix checkout', status: 'running' }, + ]); + mocks.generateObject + .mockResolvedValueOnce({ + object: decision({ + action: 'launch_task', + message: null, + purpose: null, + taskPrompt: 'Fix checkout.', + environmentId: 'env-1', + }), + }) + .mockResolvedValueOnce({ + object: decision({ + message: + 'I delegated the checkout fix. [Follow the task](https://roomote.example/task-1)', + }), + }) + .mockResolvedValueOnce({ + object: decision({ + action: 'send_task_message', + message: null, + purpose: null, + taskId: 'task-1', + taskMessage: 'Please include the failing test name in your result.', + }), + }) + .mockResolvedValueOnce({ + object: decision({ message: 'The task is still working on checkout.' }), + }) + .mockResolvedValueOnce({ + object: decision({ message: 'I’ll stop that task.', purpose: 'ack' }), + }) + .mockResolvedValueOnce({ + object: decision({ + action: 'cancel_task', + message: null, + purpose: null, + taskId: 'task-1', + }), + }) + .mockResolvedValueOnce({ + object: decision({ message: 'The checkout task was canceled.' }), + }); + const callbacks = chatCallbacks(); + const launchTask = successfulLaunchTask(); + + await answerFastAgentQuestion({ + ...baseParams, + question: 'Fix checkout.', + launchTask, + ...callbacks, + }); + await answerFastAgentQuestion({ + ...baseParams, + question: + '{"type":"artifact_published","taskId":"task-1"}', + currentMessageTs: undefined, + platformEvent: true, + launchTask, + ...callbacks, + }); + await answerFastAgentQuestion({ + ...baseParams, + question: 'Cancel it.', + launchTask, + ...callbacks, + }); + + expect(launchTask).toHaveBeenCalledOnce(); + expect(mocks.sendTaskMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + { + taskId: 'task-1', + message: 'Please include the failing test name in your result.', + }, + ); + expect(mocks.cancelTask).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + 'task-1', + ); + expect( + new Set(mocks.generateObject.mock.calls.map(([call]) => call.schema)) + .size, + ).toBe(1); + expect(mocks.generateObject.mock.calls[4]?.[0]?.prompt).toContain( + 'artifact_published', + ); + expect(mocks.generateObject.mock.calls[4]?.[0]?.prompt).toContain( + 'Cancel it.', + ); + expect(sessionMessages).toEqual([ + expect.objectContaining({ role: 'user' }), + expect.objectContaining({ role: 'assistant' }), + expect.objectContaining({ role: 'user' }), + expect.objectContaining({ role: 'assistant' }), + expect.objectContaining({ role: 'user' }), + expect.objectContaining({ role: 'assistant' }), + expect.objectContaining({ role: 'assistant' }), + ]); + }); + it('can close out a lightweight turn with an emoji reaction', async () => { mocks.generateObject.mockResolvedValue({ object: decision({ diff --git a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts similarity index 76% rename from apps/api/src/handlers/slack/events/fast-agent-task-launcher.test.ts rename to packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts index ddddac4d5..63920a550 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts @@ -3,16 +3,19 @@ const mocks = vi.hoisted(() => ({ getTaskUrl: vi.fn(() => 'https://roomote.example/task/task-1'), })); -vi.mock('@roomote/cloud-agents/server', () => ({ +vi.mock('../../task-run-queue', () => ({ enqueueTask: mocks.enqueueTask, +})); + +vi.mock('../../task-url', () => ({ getTaskUrl: mocks.getTaskUrl, })); import { ALL_REPOSITORIES, TaskPayloadKind } from '@roomote/types'; -import { createFastAgentTaskLauncher } from './fast-agent-task-launcher.js'; +import { createFastAgentSlackTaskLauncher } from '../fast-agent-task-launcher'; -describe('createFastAgentTaskLauncher', () => { +describe('createFastAgentSlackTaskLauncher', () => { beforeEach(() => { vi.clearAllMocks(); mocks.enqueueTask.mockImplementation( @@ -29,24 +32,13 @@ describe('createFastAgentTaskLauncher', () => { }); it('launches a communication-isolated child owned by the Fast parent', async () => { - const launchTask = createFastAgentTaskLauncher({ - event: { - type: 'message', - channel: 'C123', - channel_type: 'channel', - thread_ts: '100.001', - user: 'U123', - text: 'Add a regression test', - ts: '100.002', - } as never, - slackInstallation: { - teamDomain: 'acme', - } as never, - userMapping: { - slackUserId: 'U123', - } as never, + const launchTask = createFastAgentSlackTaskLauncher({ userId: 'user-1', teamId: 'T123', + teamDomain: 'acme', + channelId: 'C123', + threadTs: '100.001', + messageId: '100.002', }); const order: string[] = []; const postKickoff = vi.fn(async () => { @@ -115,20 +107,32 @@ describe('createFastAgentTaskLauncher', () => { expect(order).toEqual(['kickoff', 'queued']); }); + it('supports platform-event launches without a human message ID', async () => { + const launchTask = createFastAgentSlackTaskLauncher({ + userId: 'user-1', + teamId: 'T123', + channelId: 'C123', + threadTs: '100.001', + }); + + await launchTask({ + prompt: 'Investigate separately', + environmentId: null, + parentSessionId: '11111111-1111-4111-8111-111111111111', + postKickoff: vi.fn(), + }); + + const task = mocks.enqueueTask.mock.calls[0]?.[0]?.task; + expect(task.payload).not.toHaveProperty('communicationMessageId'); + expect(task.payload).not.toHaveProperty('communicationTeamDomain'); + }); + it('does not make the child runnable when the parent kickoff fails', async () => { - const launchTask = createFastAgentTaskLauncher({ - event: { - type: 'message', - channel: 'C123', - channel_type: 'channel', - user: 'U123', - text: 'Add a regression test', - ts: '100.002', - } as never, - slackInstallation: {} as never, - userMapping: { slackUserId: 'U123' } as never, + const launchTask = createFastAgentSlackTaskLauncher({ userId: 'user-1', teamId: 'T123', + channelId: 'C123', + threadTs: '100.002', }); const postKickoff = vi.fn().mockRejectedValue(new Error('Slack failed')); let queued = false; diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index 546f58b92..fa27c5f23 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts @@ -107,7 +107,7 @@ ${ - "closeout": the answer, completed result, blocker, or handoff. This ends the turn. - "clarification": one concise question whose answer is needed next. This ends the turn. - An "ack" or "progress" does not end the turn. Continue using the tools you need, then send a "closeout". -- Before initiating an integration, sending a message to an active task, or canceling a task, first send a brief "ack". This requirement applies only to model-initiated tool use. The automatic Brain integration preflight is exempt because it runs before your first decision, when you cannot yet send an acknowledgement. +- Before initiating an integration, sending a message to an active task, or canceling a task, first send a brief "ack". This requirement applies only to model-initiated tool use on a human-authored turn. For a platform event, take useful actions without an acknowledgement and finish with its single chat-visible closeout. The automatic Brain integration preflight is exempt because it runs before your first decision, when you cannot yet send an acknowledgement. - For "launch_task", do not send a separate acknowledgement first. The runtime posts exactly one kickoff with the task link before making the child runnable, then ends this turn. Return the launch action directly and do not add another acknowledgement, progress update, or closeout. - If the answer is immediate and needs no model-initiated tool, skip the acknowledgement and send the "closeout" directly. ${reactionGuidance} @@ -134,12 +134,15 @@ ${ ## Delegated Task Platform Event - The current input is a trusted platform-generated event about a delegated task, not a human-authored request. - Decide whether the event is useful to the user now. Use "ignore_event" when it is routine, redundant, or not worth interrupting them for. -- When it is useful, emit exactly one "send_chat_reply" with purpose "closeout" and describe the outcome naturally in the context of the delegated work. Never use "ack" or "progress" for a platform event, and never copy a canned event sentence. +- The normal orchestration tools remain available. Use them only when the event and conversation context justify the action; the event is context, not a new human instruction. +- When the event or any action taken is useful to the user, emit exactly one "send_chat_reply" with purpose "closeout" and describe the outcome naturally in the context of the delegated work. Never use "ack" or "progress" for a platform event, and never copy a canned event sentence. ${ retryTaskStartAvailable - ? '- This failed task-settled event includes the full secret-redacted error and its machine-readable errorCode when available. Decide from that evidence whether another startup attempt is worthwhile. Use "retry_task_start" only when the failure appears transient; do not use it for clear configuration, authentication, permission, billing, quota, missing-resource, or other permanent failures.\n- After "retry_task_start", report its result with one closeout. Do not use integrations or any other task-control action for this event.' + ? '- This failed task-settled event includes the full secret-redacted error and its machine-readable errorCode when available. Decide from that evidence whether another startup attempt is worthwhile. Use "retry_task_start" only when the failure appears transient; do not use it for clear configuration, authentication, permission, billing, quota, missing-resource, or other permanent failures.\n- After "retry_task_start", report its result with one closeout.' : '- No failed-start retry action is available for this event. Report or ignore the event without attempting a retry.' } +- "launch_task" creates a separate delegated task; it does not retry the task associated with this event. +- Do not use "send_chat_reaction_emoji" because a platform event has no incoming chat message to react to. - Artifact events include stable artifact IDs and view URLs. When an image would help the user, include its ID in imageArtifactIds so it renders inline with the same reply. For non-image artifacts, link the supplied view URL when useful. - Pull-request-opened events contain authoritative, user-presentable pull request metadata and should be presented unless that exact pull request URL was already reported in this conversation. Briefly name and link the pull request, including its repository, number, title, and current status when available. - Task-settled events include the task's current pullRequests list. Use it in the closeout so a pull request produced by the task is named and linked even when its earlier open event was missed; do not describe the pull request as newly opened if the thread already received that update. diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index 8b9e1ea08..89d1a9e14 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -535,9 +535,9 @@ export async function answerFastAgentQuestion({ senderDisplayName?: string; senderSlackUserId?: string; activeTasks?: FastAgentActiveTask[]; - launchTask?: LaunchFastAgentSlackTask; + launchTask: LaunchFastAgentSlackTask; retryTaskStart?: RetryFastAgentTaskStart; - postSlackReply?: PostFastAgentSlackReply; + postSlackReply: PostFastAgentSlackReply; postSlackReaction?: PostFastAgentSlackReaction; surface?: FastAgentSurface; /** Platform-generated child lifecycle input, not a human-authored turn. */ @@ -625,7 +625,7 @@ export async function answerFastAgentQuestion({ const reply = pendingLifecycleReply; pendingLifecycleReply = null; - await postSlackReply?.(reply); + await postSlackReply(reply); turnSessionMessages.push(buildAssistantTextMessage(reply.message)); }; const brain = availableIntegrations.find( @@ -739,7 +739,7 @@ export async function answerFastAgentQuestion({ } pendingLifecycleReply = null; - await postSlackReply?.(reply); + await postSlackReply(reply); turnSessionMessages.push(buildAssistantTextMessage(message)); await persistFastAgentSessionMessages({ @@ -776,11 +776,6 @@ export async function answerFastAgentQuestion({ continue; } - if (platformEvent) { - prompt += `\n\n[PLATFORM EVENT ACTION REJECTED]\nA delegated-task platform event may only use send_chat_reply, ignore_event, or the offered retry_task_start action. Do not launch, message, or cancel tasks, react, or call integrations for this event.\n[END PLATFORM EVENT ACTION REJECTED]`; - continue; - } - if (decision.action === 'send_chat_reaction_emoji') { const name = decision.reactionName?.trim().replace(/^:+|:+$/g, ''); const purpose = decision.purpose; @@ -908,16 +903,11 @@ export async function answerFastAgentQuestion({ !validEnvironmentIds.has(decision.environmentId) ) { taskResult = { error: 'The selected environment was not found.' }; - } else if (!launchTask) { - taskResult = { error: 'Task delegation is unavailable.' }; } else { const deliverParentKickoff = async (task: { taskId: string; taskUrl?: string; }) => { - if (!postSlackReply) { - throw new Error('Parent chat delivery is unavailable.'); - } const message = await generateFastAgentKickoffMessage({ userId, system, @@ -1044,7 +1034,7 @@ export async function answerFastAgentQuestion({ const fallback = buildFastAgentTurnFallbackDecision(); const fallbackMessage = fallback.message ?? 'How can I help?'; - await postSlackReply?.({ + await postSlackReply({ purpose: 'closeout', slackChannel, slackThreadTs, @@ -1075,7 +1065,7 @@ export async function answerFastAgentQuestion({ : 'I hit an error while handling that request. Please try again in a moment.'; try { - await postSlackReply?.({ + await postSlackReply({ purpose: 'closeout', slackChannel, slackThreadTs, diff --git a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts similarity index 67% rename from apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts rename to packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts index 701071658..ce3d32f8b 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts @@ -1,28 +1,22 @@ -import { - enqueueTask, - getTaskUrl, - type LaunchFastAgentSlackTask, -} from '@roomote/cloud-agents/server'; -import { type SlackEvent } from '@roomote/slack'; -import { - type SlackInstallation, - type SlackUserMapping, -} from '@roomote/db/server'; import { ALL_REPOSITORIES, TaskPayloadKind, type StandardTask, } from '@roomote/types'; -export function createFastAgentTaskLauncher(params: { - event: SlackEvent; - slackInstallation: SlackInstallation; - userMapping: SlackUserMapping; +import { enqueueTask } from '../task-run-queue'; +import { getTaskUrl } from '../task-url'; +import type { LaunchFastAgentSlackTask } from './fast-agent-service'; + +export function createFastAgentSlackTaskLauncher(params: { userId: string; teamId: string; + teamDomain?: string; + channelId: string; + threadTs: string; + messageId?: string; }): LaunchFastAgentSlackTask { return async ({ prompt, environmentId, parentSessionId, postKickoff }) => { - const threadId = params.event.thread_ts || params.event.ts; const task: StandardTask = { type: TaskPayloadKind.StandardTask, payload: { @@ -30,18 +24,21 @@ export function createFastAgentTaskLauncher(params: { description: prompt, communicationProvider: 'slack', communicationTeamId: params.teamId, - communicationTeamDomain: - params.slackInstallation.teamDomain ?? undefined, - communicationChannelId: params.event.channel, - communicationThreadId: threadId, - communicationMessageId: params.event.ts, + ...(params.teamDomain + ? { communicationTeamDomain: params.teamDomain } + : {}), + communicationChannelId: params.channelId, + communicationThreadId: params.threadTs, + ...(params.messageId + ? { communicationMessageId: params.messageId } + : {}), communicationContextInherited: true, fastAgentSessionId: parentSessionId, fastAgentParent: { sessionId: parentSessionId, slackTeamId: params.teamId, - slackChannel: params.event.channel, - slackThreadTs: threadId, + slackChannel: params.channelId, + slackThreadTs: params.threadTs, }, ...(environmentId && environmentId !== ALL_REPOSITORIES ? { environmentId } diff --git a/packages/cloud-agents/src/server/fast-agent/index.ts b/packages/cloud-agents/src/server/fast-agent/index.ts index 72b3fdf9e..fe585c6b5 100644 --- a/packages/cloud-agents/src/server/fast-agent/index.ts +++ b/packages/cloud-agents/src/server/fast-agent/index.ts @@ -3,5 +3,6 @@ export * from './fast-agent-prompt'; export * from './fast-agent-service'; export * from './fast-agent-turn-lock'; export * from './fast-agent-session'; +export * from './fast-agent-task-launcher'; export * from './fast-agent-tasks'; export * from './onboarding-task-suggestions-service'; diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 958e30e2f..61a52491b 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -2,6 +2,8 @@ const mocks = vi.hoisted(() => ({ acquireTurnLock: vi.fn(), releaseTurnLock: vi.fn(), answerQuestion: vi.fn(), + createLauncher: vi.fn(), + launchTask: vi.fn(), findSession: vi.fn(), findInstallation: vi.fn(), findArtifacts: vi.fn(), @@ -12,6 +14,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@roomote/cloud-agents/server', () => ({ acquireFastAgentTurnLock: mocks.acquireTurnLock, answerFastAgentQuestion: mocks.answerQuestion, + createFastAgentSlackTaskLauncher: mocks.createLauncher, })); vi.mock('@roomote/db/server', () => ({ @@ -87,7 +90,11 @@ describe('deliverFastAgentParentEvent', () => { mocks.acquireTurnLock.mockResolvedValue(mocks.releaseTurnLock); mocks.releaseTurnLock.mockResolvedValue(undefined); mocks.findSession.mockResolvedValue({ id: parent.sessionId, userId: 'u1' }); - mocks.findInstallation.mockResolvedValue({ botAccessToken: 'xoxb-test' }); + mocks.findInstallation.mockResolvedValue({ + botAccessToken: 'xoxb-test', + teamDomain: 'acme', + }); + mocks.createLauncher.mockReturnValue(mocks.launchTask); mocks.findArtifacts.mockResolvedValue([ { id: 'artifact-1', @@ -125,8 +132,16 @@ describe('deliverFastAgentParentEvent', () => { expect(mocks.answerQuestion).toHaveBeenCalledWith( expect.objectContaining({ platformEvent: true, + launchTask: mocks.launchTask, }), ); + expect(mocks.createLauncher).toHaveBeenCalledWith({ + userId: 'u1', + teamId: 'T123', + teamDomain: 'acme', + channelId: 'C123', + threadTs: '100.001', + }); expect(mocks.postMessage).toHaveBeenCalledWith( expect.objectContaining({ channel: 'C123', diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index c9c833a8f..13caeb795 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -4,6 +4,7 @@ import { basename } from 'node:path'; import { acquireFastAgentTurnLock, answerFastAgentQuestion, + createFastAgentSlackTaskLauncher, } from '@roomote/cloud-agents/server'; import { asc, @@ -253,7 +254,7 @@ export async function deliverFastAgentParentEvent(params: { eq(slackInstallations.isActive, true), eq(slackInstallations.teamId, params.parent.slackTeamId), ), - columns: { botAccessToken: true }, + columns: { botAccessToken: true, teamDomain: true }, }), ]); @@ -265,6 +266,15 @@ export async function deliverFastAgentParentEvent(params: { } const slack = new SlackNotifier(installation.botAccessToken); + const launchTask = createFastAgentSlackTaskLauncher({ + userId: session.userId, + teamId: params.parent.slackTeamId, + ...(installation.teamDomain + ? { teamDomain: installation.teamDomain } + : {}), + channelId: params.parent.slackChannel, + threadTs: params.parent.slackThreadTs, + }); await answerFastAgentQuestion({ question: `${JSON.stringify(params.event)}`, userId: session.userId, @@ -272,6 +282,7 @@ export async function deliverFastAgentParentEvent(params: { slackChannel: params.parent.slackChannel, slackThreadTs: params.parent.slackThreadTs, platformEvent: true, + launchTask, ...(params.retryTaskStart ? { retryTaskStart: params.retryTaskStart } : {}),