From 04955ac6318421ded26940ad16bd93bd86c0cde0 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 17 Aug 2026 15:08:32 -0500 Subject: [PATCH 1/5] Allow Fast task launches from platform events --- .../__tests__/fast-agent-prompt.test.ts | 9 +++ .../__tests__/fast-agent-service.test.ts | 71 +++++++++++++++++++ .../server/fast-agent/fast-agent-prompt.ts | 4 +- .../server/fast-agent/fast-agent-service.ts | 4 +- 4 files changed, 85 insertions(+), 3 deletions(-) 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..51657d35c 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 @@ -140,6 +140,12 @@ describe('buildFastAgentSystemPrompt', () => { expect(prompt).toContain( 'Use "retry_task_start" only when the failure appears transient', ); + expect(prompt).toContain( + '"launch_task" remains available under the normal orchestration policy', + ); + expect(prompt).toContain( + 'It creates a separate delegated task; it does not retry the task associated with this event', + ); expect(prompt).toContain( 'Pull-request-opened events contain authoritative, user-presentable pull request metadata', ); @@ -163,6 +169,9 @@ describe('buildFastAgentSystemPrompt', () => { expect(prompt).not.toContain( 'Use "retry_task_start" only when the failure appears transient', ); + expect(prompt).toContain( + '"launch_task" remains available under the normal orchestration policy', + ); }); 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..08dac96fd 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 @@ -408,6 +408,77 @@ 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('continues to reject other task controls for platform events', async () => { + mocks.generateObject + .mockResolvedValueOnce({ + object: decision({ + action: 'send_task_message', + message: null, + purpose: null, + taskId: 'task-2', + taskMessage: 'Keep working.', + }), + }) + .mockResolvedValueOnce({ + object: decision({ message: 'The original task failed.' }), + }); + 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.sendTaskMessage).not.toHaveBeenCalled(); + expect(mocks.generateObject.mock.calls[1]?.[0]?.prompt).toContain( + 'Do not message or cancel tasks', + ); + expect(result).toBe('The original task failed.'); + }); + it('can close out a lightweight turn with an emoji reaction', async () => { mocks.generateObject.mockResolvedValue({ object: decision({ 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..31045528a 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 @@ -137,9 +137,11 @@ ${ - 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. ${ 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" remains available under the normal orchestration policy. It creates a separate delegated task; it does not retry the task associated with this event. +- Do not use integrations, send messages to tasks, cancel tasks, or react for this event. - 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..3cf30edca 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 @@ -776,8 +776,8 @@ 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]`; + if (platformEvent && decision.action !== 'launch_task') { + prompt += `\n\n[PLATFORM EVENT ACTION REJECTED]\nA delegated-task platform event may only use send_chat_reply, ignore_event, launch_task, or the offered retry_task_start action. Do not message or cancel tasks, react, or call integrations for this event.\n[END PLATFORM EVENT ACTION REJECTED]`; continue; } From 6034ef5f490296dc2a800cbff7fe68fcec432d1a Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 17 Aug 2026 15:19:57 -0500 Subject: [PATCH 2/5] Wire platform event task launches end to end --- .../events/channel-auto-start-failure.test.ts | 1 + .../channel-auto-start-unlinked.test.ts | 1 + .../events/fast-agent-task-launcher.test.ts | 129 +------------- .../slack/events/fast-agent-task-launcher.ts | 79 ++------- .../message-entry-unmentioned-routing.test.ts | 1 + .../fast-agent-task-launcher.test.ts | 162 ++++++++++++++++++ .../fast-agent/fast-agent-task-launcher.ts | 81 +++++++++ .../src/server/fast-agent/index.ts | 1 + .../lib/fast-agent-parent-event.test.ts | 17 +- .../src/server/lib/fast-agent-parent-event.ts | 13 +- 10 files changed, 295 insertions(+), 190 deletions(-) create mode 100644 packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts create mode 100644 packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts 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-task-launcher.test.ts b/apps/api/src/handlers/slack/events/fast-agent-task-launcher.test.ts index ddddac4d5..0629ed1df 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.test.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-task-launcher.test.ts @@ -1,34 +1,19 @@ const mocks = vi.hoisted(() => ({ - enqueueTask: vi.fn(), - getTaskUrl: vi.fn(() => 'https://roomote.example/task/task-1'), + createLauncher: vi.fn(() => vi.fn()), })); vi.mock('@roomote/cloud-agents/server', () => ({ - enqueueTask: mocks.enqueueTask, - getTaskUrl: mocks.getTaskUrl, + createFastAgentSlackTaskLauncher: mocks.createLauncher, })); -import { ALL_REPOSITORIES, TaskPayloadKind } from '@roomote/types'; - import { createFastAgentTaskLauncher } from './fast-agent-task-launcher.js'; describe('createFastAgentTaskLauncher', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.enqueueTask.mockImplementation( - async ( - _input: unknown, - options: { - beforeEnqueue: (taskRun: { taskId: string }) => Promise; - }, - ) => { - await options.beforeEnqueue({ taskId: 'task-1' }); - return { taskId: 'task-1' }; - }, - ); }); - it('launches a communication-isolated child owned by the Fast parent', async () => { + it('maps the incoming Slack message to the shared Fast launcher', () => { const launchTask = createFastAgentTaskLauncher({ event: { type: 'message', @@ -48,111 +33,15 @@ describe('createFastAgentTaskLauncher', () => { userId: 'user-1', teamId: 'T123', }); - const order: string[] = []; - const postKickoff = vi.fn(async () => { - order.push('kickoff'); - }); - mocks.enqueueTask.mockImplementationOnce( - async ( - _input: unknown, - options: { - beforeEnqueue: (taskRun: { taskId: string }) => Promise; - }, - ) => { - await options.beforeEnqueue({ taskId: 'task-1' }); - order.push('queued'); - return { taskId: 'task-1' }; - }, - ); - - await expect( - launchTask({ - prompt: 'Add a regression test', - environmentId: 'env-1', - parentSessionId: '11111111-1111-4111-8111-111111111111', - postKickoff, - }), - ).resolves.toEqual({ - success: true, - taskId: 'task-1', - taskUrl: 'https://roomote.example/task/task-1', - }); - expect(mocks.enqueueTask).toHaveBeenCalledWith( - { - task: { - type: TaskPayloadKind.StandardTask, - payload: { - repo: ALL_REPOSITORIES, - description: 'Add a regression test', - communicationProvider: 'slack', - communicationTeamId: 'T123', - communicationTeamDomain: 'acme', - communicationChannelId: 'C123', - communicationThreadId: '100.001', - communicationMessageId: '100.002', - communicationContextInherited: true, - fastAgentSessionId: '11111111-1111-4111-8111-111111111111', - fastAgentParent: { - sessionId: '11111111-1111-4111-8111-111111111111', - slackTeamId: 'T123', - slackChannel: 'C123', - slackThreadTs: '100.001', - }, - environmentId: 'env-1', - }, - }, - initiator: { kind: 'user', userId: 'user-1' }, - workflow: 'standard', - surface: 'slack', - trigger: 'message', - }, - { beforeEnqueue: expect.any(Function) }, - ); - expect(postKickoff).toHaveBeenCalledWith({ - taskId: 'task-1', - taskUrl: 'https://roomote.example/task/task-1', - }); - expect(order).toEqual(['kickoff', 'queued']); - }); - 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, + expect(mocks.createLauncher).toHaveBeenCalledWith({ userId: 'user-1', teamId: 'T123', + teamDomain: 'acme', + channelId: 'C123', + threadTs: '100.001', + messageId: '100.002', }); - const postKickoff = vi.fn().mockRejectedValue(new Error('Slack failed')); - let queued = false; - mocks.enqueueTask.mockImplementationOnce( - async ( - _input: unknown, - options: { - beforeEnqueue: (taskRun: { taskId: string }) => Promise; - }, - ) => { - await options.beforeEnqueue({ taskId: 'task-1' }); - queued = true; - return { taskId: 'task-1' }; - }, - ); - - await expect( - launchTask({ - prompt: 'Add a regression test', - environmentId: null, - parentSessionId: '11111111-1111-4111-8111-111111111111', - postKickoff, - }), - ).rejects.toThrow('Slack failed'); - expect(queued).toBe(false); + expect(launchTask).toBe(mocks.createLauncher.mock.results[0]?.value); }); }); diff --git a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts b/apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts index 701071658..08ab41320 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts @@ -1,6 +1,5 @@ import { - enqueueTask, - getTaskUrl, + createFastAgentSlackTaskLauncher, type LaunchFastAgentSlackTask, } from '@roomote/cloud-agents/server'; import { type SlackEvent } from '@roomote/slack'; @@ -8,12 +7,6 @@ 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; @@ -21,64 +14,14 @@ export function createFastAgentTaskLauncher(params: { userId: string; teamId: string; }): LaunchFastAgentSlackTask { - return async ({ prompt, environmentId, parentSessionId, postKickoff }) => { - const threadId = params.event.thread_ts || params.event.ts; - const task: StandardTask = { - type: TaskPayloadKind.StandardTask, - payload: { - repo: ALL_REPOSITORIES, - description: prompt, - communicationProvider: 'slack', - communicationTeamId: params.teamId, - communicationTeamDomain: - params.slackInstallation.teamDomain ?? undefined, - communicationChannelId: params.event.channel, - communicationThreadId: threadId, - communicationMessageId: params.event.ts, - communicationContextInherited: true, - fastAgentSessionId: parentSessionId, - fastAgentParent: { - sessionId: parentSessionId, - slackTeamId: params.teamId, - slackChannel: params.event.channel, - slackThreadTs: threadId, - }, - ...(environmentId && environmentId !== ALL_REPOSITORIES - ? { environmentId } - : {}), - }, - }; - let taskUrl: string | undefined; - const launch = await enqueueTask( - { - task, - initiator: { kind: 'user', userId: params.userId }, - workflow: 'standard', - surface: 'slack', - trigger: 'message', - }, - { - beforeEnqueue: async (taskRun) => { - taskUrl = getTaskUrl({ - taskId: taskRun.taskId, - utm: { source: 'slack', campaign: 'fast-delegation' }, - }); - await postKickoff({ taskId: taskRun.taskId, taskUrl }); - }, - }, - ); - - if (!launch.taskId) { - return { - success: false, - error: 'The task launch did not return a task ID.', - }; - } - - return { - success: true, - taskId: launch.taskId, - taskUrl, - }; - }; + return 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, + }); } 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/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts new file mode 100644 index 000000000..63920a550 --- /dev/null +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts @@ -0,0 +1,162 @@ +const mocks = vi.hoisted(() => ({ + enqueueTask: vi.fn(), + getTaskUrl: vi.fn(() => 'https://roomote.example/task/task-1'), +})); + +vi.mock('../../task-run-queue', () => ({ + enqueueTask: mocks.enqueueTask, +})); + +vi.mock('../../task-url', () => ({ + getTaskUrl: mocks.getTaskUrl, +})); + +import { ALL_REPOSITORIES, TaskPayloadKind } from '@roomote/types'; + +import { createFastAgentSlackTaskLauncher } from '../fast-agent-task-launcher'; + +describe('createFastAgentSlackTaskLauncher', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.enqueueTask.mockImplementation( + async ( + _input: unknown, + options: { + beforeEnqueue: (taskRun: { taskId: string }) => Promise; + }, + ) => { + await options.beforeEnqueue({ taskId: 'task-1' }); + return { taskId: 'task-1' }; + }, + ); + }); + + it('launches a communication-isolated child owned by the Fast parent', async () => { + 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 () => { + order.push('kickoff'); + }); + mocks.enqueueTask.mockImplementationOnce( + async ( + _input: unknown, + options: { + beforeEnqueue: (taskRun: { taskId: string }) => Promise; + }, + ) => { + await options.beforeEnqueue({ taskId: 'task-1' }); + order.push('queued'); + return { taskId: 'task-1' }; + }, + ); + + await expect( + launchTask({ + prompt: 'Add a regression test', + environmentId: 'env-1', + parentSessionId: '11111111-1111-4111-8111-111111111111', + postKickoff, + }), + ).resolves.toEqual({ + success: true, + taskId: 'task-1', + taskUrl: 'https://roomote.example/task/task-1', + }); + expect(mocks.enqueueTask).toHaveBeenCalledWith( + { + task: { + type: TaskPayloadKind.StandardTask, + payload: { + repo: ALL_REPOSITORIES, + description: 'Add a regression test', + communicationProvider: 'slack', + communicationTeamId: 'T123', + communicationTeamDomain: 'acme', + communicationChannelId: 'C123', + communicationThreadId: '100.001', + communicationMessageId: '100.002', + communicationContextInherited: true, + fastAgentSessionId: '11111111-1111-4111-8111-111111111111', + fastAgentParent: { + sessionId: '11111111-1111-4111-8111-111111111111', + slackTeamId: 'T123', + slackChannel: 'C123', + slackThreadTs: '100.001', + }, + environmentId: 'env-1', + }, + }, + initiator: { kind: 'user', userId: 'user-1' }, + workflow: 'standard', + surface: 'slack', + trigger: 'message', + }, + { beforeEnqueue: expect.any(Function) }, + ); + expect(postKickoff).toHaveBeenCalledWith({ + taskId: 'task-1', + taskUrl: 'https://roomote.example/task/task-1', + }); + 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 = createFastAgentSlackTaskLauncher({ + userId: 'user-1', + teamId: 'T123', + channelId: 'C123', + threadTs: '100.002', + }); + const postKickoff = vi.fn().mockRejectedValue(new Error('Slack failed')); + let queued = false; + mocks.enqueueTask.mockImplementationOnce( + async ( + _input: unknown, + options: { + beforeEnqueue: (taskRun: { taskId: string }) => Promise; + }, + ) => { + await options.beforeEnqueue({ taskId: 'task-1' }); + queued = true; + return { taskId: 'task-1' }; + }, + ); + + await expect( + launchTask({ + prompt: 'Add a regression test', + environmentId: null, + parentSessionId: '11111111-1111-4111-8111-111111111111', + postKickoff, + }), + ).rejects.toThrow('Slack failed'); + expect(queued).toBe(false); + }); +}); diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts new file mode 100644 index 000000000..ce3d32f8b --- /dev/null +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts @@ -0,0 +1,81 @@ +import { + ALL_REPOSITORIES, + TaskPayloadKind, + type StandardTask, +} from '@roomote/types'; + +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 task: StandardTask = { + type: TaskPayloadKind.StandardTask, + payload: { + repo: ALL_REPOSITORIES, + description: prompt, + communicationProvider: 'slack', + communicationTeamId: params.teamId, + ...(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.channelId, + slackThreadTs: params.threadTs, + }, + ...(environmentId && environmentId !== ALL_REPOSITORIES + ? { environmentId } + : {}), + }, + }; + let taskUrl: string | undefined; + const launch = await enqueueTask( + { + task, + initiator: { kind: 'user', userId: params.userId }, + workflow: 'standard', + surface: 'slack', + trigger: 'message', + }, + { + beforeEnqueue: async (taskRun) => { + taskUrl = getTaskUrl({ + taskId: taskRun.taskId, + utm: { source: 'slack', campaign: 'fast-delegation' }, + }); + await postKickoff({ taskId: taskRun.taskId, taskUrl }); + }, + }, + ); + + if (!launch.taskId) { + return { + success: false, + error: 'The task launch did not return a task ID.', + }; + } + + return { + success: true, + taskId: launch.taskId, + taskUrl, + }; + }; +} 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 } : {}), From c3a14d50a5763cbbb66ca3c9fd73380e5c8c529f Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 17 Aug 2026 15:55:01 -0500 Subject: [PATCH 3/5] Keep Fast tools available for platform events --- .../events/fast-agent-processing.test.ts | 11 +++- .../events/fast-agent-task-launcher.test.ts | 47 ---------------- .../slack/events/fast-agent-task-launcher.ts | 27 ---------- .../src/handlers/slack/events/fast-agent.ts | 2 +- .../handlers/slack/events/message-entry.ts | 13 ++++- .../__tests__/fast-agent-prompt.test.ts | 16 ++++-- .../__tests__/fast-agent-service.test.ts | 54 ++++++++++++++++--- .../server/fast-agent/fast-agent-prompt.ts | 9 ++-- .../server/fast-agent/fast-agent-service.ts | 22 +++----- 9 files changed, 92 insertions(+), 109 deletions(-) delete mode 100644 apps/api/src/handlers/slack/events/fast-agent-task-launcher.test.ts delete mode 100644 apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts 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-task-launcher.test.ts b/apps/api/src/handlers/slack/events/fast-agent-task-launcher.test.ts deleted file mode 100644 index 0629ed1df..000000000 --- a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -const mocks = vi.hoisted(() => ({ - createLauncher: vi.fn(() => vi.fn()), -})); - -vi.mock('@roomote/cloud-agents/server', () => ({ - createFastAgentSlackTaskLauncher: mocks.createLauncher, -})); - -import { createFastAgentTaskLauncher } from './fast-agent-task-launcher.js'; - -describe('createFastAgentTaskLauncher', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('maps the incoming Slack message to the shared Fast launcher', () => { - 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, - userId: 'user-1', - teamId: 'T123', - }); - - expect(mocks.createLauncher).toHaveBeenCalledWith({ - userId: 'user-1', - teamId: 'T123', - teamDomain: 'acme', - channelId: 'C123', - threadTs: '100.001', - messageId: '100.002', - }); - expect(launchTask).toBe(mocks.createLauncher.mock.results[0]?.value); - }); -}); diff --git a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts b/apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts deleted file mode 100644 index 08ab41320..000000000 --- a/apps/api/src/handlers/slack/events/fast-agent-task-launcher.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - createFastAgentSlackTaskLauncher, - type LaunchFastAgentSlackTask, -} from '@roomote/cloud-agents/server'; -import { type SlackEvent } from '@roomote/slack'; -import { - type SlackInstallation, - type SlackUserMapping, -} from '@roomote/db/server'; -export function createFastAgentTaskLauncher(params: { - event: SlackEvent; - slackInstallation: SlackInstallation; - userMapping: SlackUserMapping; - userId: string; - teamId: string; -}): LaunchFastAgentSlackTask { - return 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, - }); -} 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.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 51657d35c..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,17 +134,25 @@ 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( - '"launch_task" remains available under the normal orchestration policy', + '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( - 'It creates a separate delegated task; it does not retry the task associated with this event', + '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', @@ -169,9 +177,7 @@ describe('buildFastAgentSystemPrompt', () => { expect(prompt).not.toContain( 'Use "retry_task_start" only when the failure appears transient', ); - expect(prompt).toContain( - '"launch_task" remains available under the normal orchestration policy', - ); + 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 08dac96fd..5832dc551 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 = {}) { @@ -447,8 +449,26 @@ describe('answerFastAgentQuestion', () => { expect(callbacks.postSlackReply).toHaveBeenCalledOnce(); }); - it('continues to reject other task controls for platform events', async () => { + 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', @@ -459,7 +479,15 @@ describe('answerFastAgentQuestion', () => { }), }) .mockResolvedValueOnce({ - object: decision({ message: 'The original task failed.' }), + object: decision({ + action: 'cancel_task', + message: null, + purpose: null, + taskId: 'task-2', + }), + }) + .mockResolvedValueOnce({ + object: decision({ message: 'I handled the task update.' }), }); const callbacks = chatCallbacks(); @@ -472,11 +500,25 @@ describe('answerFastAgentQuestion', () => { ...callbacks, }); - expect(mocks.sendTaskMessage).not.toHaveBeenCalled(); - expect(mocks.generateObject.mock.calls[1]?.[0]?.prompt).toContain( - 'Do not message or cancel tasks', + 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(result).toBe('The original task failed.'); + expect(mocks.cancelTask).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + 'task-2', + ); + expect(callbacks.postSlackReply).toHaveBeenCalledOnce(); + expect(result).toBe('I handled the task update.'); }); it('can close out a lightweight turn with an emoji reaction', async () => { 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 31045528a..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,14 +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.' : '- No failed-start retry action is available for this event. Report or ignore the event without attempting a retry.' } -- "launch_task" remains available under the normal orchestration policy. It creates a separate delegated task; it does not retry the task associated with this event. -- Do not use integrations, send messages to tasks, cancel tasks, or react for this event. +- "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 3cf30edca..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 && decision.action !== 'launch_task') { - prompt += `\n\n[PLATFORM EVENT ACTION REJECTED]\nA delegated-task platform event may only use send_chat_reply, ignore_event, launch_task, or the offered retry_task_start action. Do not 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, From d2bbdbdd4d1dcf342adadcece6e51c2fcfa3f6ac Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 18 Aug 2026 09:06:52 -0500 Subject: [PATCH 4/5] Make Fast platform event test independent --- .../src/server/fast-agent/__tests__/fast-agent-service.test.ts | 1 + 1 file changed, 1 insertion(+) 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 5832dc551..62aa9f07e 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 @@ -118,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 }); From 2ff414fd72e254badd5f175deaf3d46d32429a4a Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 18 Aug 2026 09:17:04 -0500 Subject: [PATCH 5/5] Test Fast conversation across task updates --- .../__tests__/fast-agent-service.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) 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 62aa9f07e..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 @@ -522,6 +522,130 @@ describe('answerFastAgentQuestion', () => { 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({