diff --git a/apps/worker/src/run-task/__tests__/run-task.test.ts b/apps/worker/src/run-task/__tests__/run-task.test.ts index 76e42d9bf..fc0d5aacc 100644 --- a/apps/worker/src/run-task/__tests__/run-task.test.ts +++ b/apps/worker/src/run-task/__tests__/run-task.test.ts @@ -3233,7 +3233,12 @@ describe('runTask', () => { taskId: 'task-151', payloadKind: TaskPayloadKind.StandardTask, harness: 'opencode-server', - payload: {}, + payload: { + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], + }, result: null, } as never, envVars: {}, @@ -3282,7 +3287,10 @@ describe('runTask', () => { const harnessManager = harnessManagerInstances.at(0); expect(harnessManager?.startNewTask).toHaveBeenCalledWith({ prompt: 'Fix the failing test', - images: undefined, + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], visibleInTranscript: false, }); expect(harnessManager?.initializeWithoutPrompt).not.toHaveBeenCalled(); diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-bridge.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-bridge.test.ts index a9abb10b8..40b466ca4 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-bridge.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-bridge.test.ts @@ -80,6 +80,10 @@ describe('Fast native OpenCode tool bridge', () => { join(toolsDirectory, 'launch_task.js'), 'utf8', ); + const sendTaskMessageSource = await readFile( + join(toolsDirectory, 'send_task_message.js'), + 'utf8', + ); const showWidgetSource = await readFile( join(toolsDirectory, 'show_widget.js'), 'utf8', @@ -112,6 +116,11 @@ describe('Fast native OpenCode tool bridge', () => { expect(replySource).toContain('Launchable follow-ups'); expect(launchTaskSource).toContain('model: z.string().min(1)'); expect(launchTaskSource).toContain('deployment-enabled model ID'); + expect(launchTaskSource).toContain('includeImages: z.boolean().optional()'); + expect(launchTaskSource).toContain( + 'Current-turn images are attached only when includeImages is true', + ); + expect(launchTaskSource).toContain('defaults to false'); expect(launchTaskSource).toContain( 'Brief user-facing description of the work now underway', ); @@ -125,6 +134,13 @@ describe('Fast native OpenCode tool bridge', () => { expect(launchTaskSource).toContain( 'to run against all active repositories', ); + expect(sendTaskMessageSource).toContain( + 'includeImages: z.boolean().optional()', + ); + expect(sendTaskMessageSource).toContain( + 'Current-turn images are attached only when includeImages is true', + ); + expect(sendTaskMessageSource).toContain('defaults to false'); expect(showWidgetSource).toContain('invoke("show_widget"'); expect(showWidgetSource).toContain('textFallback: z.string().max(4000)'); expect(showWidgetSource).toContain( 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 e9f074fef..eecc9bd85 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 @@ -127,6 +127,11 @@ describe('buildFastAgentSystemPrompt', () => { 'Call it immediately, before an acknowledgement or other user-visible response', ); expect(prompt).toContain('kickoffMessage'); + expect(prompt).toContain('"includeImages"'); + expect(prompt).toContain('images are not attached by default'); + expect(prompt).toContain( + 'supported images from the active conversation turn are relevant to that instruction', + ); expect(prompt).toContain("describing the user's work now underway"); expect(prompt).toContain( 'The kickoff acknowledges the request, but it is not the only communication expected while longer work continues', 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 ce25ce75f..f2306719a 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 @@ -1836,6 +1836,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { prompt: 'Fix checkout.', environmentId: 'env-1', model: 'anthropic/claude-sonnet-5', + includeImages: true, kickoffMessage: 'I’m delegating the checkout fix.', }); expect(result).toEqual( @@ -1848,6 +1849,10 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { const result = await answerFastAgentQuestion({ ...baseParams, question: 'Fix checkout.', + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/gif;base64,c2NyZWVuc2hvdC0y', + ], adapter, }); @@ -1860,6 +1865,10 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { ); expect(launchTask).toHaveBeenCalledWith( expect.objectContaining({ + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/gif;base64,c2NyZWVuc2hvdC0y', + ], model: 'anthropic/claude-sonnet-5', prompt: 'Fix checkout.', }), @@ -1912,11 +1921,16 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }, ); - await answerFastAgentQuestion({ ...baseParams, adapter }); + await answerFastAgentQuestion({ + ...baseParams, + images: ['data:image/png;base64,bm90LWZvcndhcmRlZA=='], + adapter, + }); expect(launchTask).toHaveBeenCalledWith( expect.objectContaining({ environmentId: ALL_REPOSITORIES }), ); + expect(launchTask.mock.calls[0]?.[0]).not.toHaveProperty('images'); }); it.each(['slack', 'discord', 'teams', 'telegram'] as const)( @@ -2214,6 +2228,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { invokeTool(nativeToolNames.sendTaskMessage, { taskId: 'task-1', message: 'Include the failing test.', + includeImages: true, }), ).resolves.toEqual({ success: true }); await invokeTool(nativeToolNames.sendChatReply, { @@ -2229,18 +2244,63 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }), }); - await answerFastAgentQuestion({ ...baseParams, adapter }); + await answerFastAgentQuestion({ + ...baseParams, + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], + adapter, + }); expect(mocks.sendTaskMessage).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1' }), { taskId: 'task-1', message: 'Include the failing test.', + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], }, ); expect(order).toEqual(['steer', 'reply']); }); + it('does not attach current-turn images to a task message without opt-in', async () => { + mocks.getActiveTasks.mockResolvedValue([ + { taskId: 'task-1', title: 'Checkout', status: 'running' }, + ]); + mocks.generateText.mockImplementation( + async (_params, _session, options) => { + await options.onSessionReady('opencode-session-1'); + await invokeTool(nativeToolNames.sendTaskMessage, { + taskId: 'task-1', + message: 'Include the failing test.', + }); + await invokeTool(nativeToolNames.sendChatReply, { + purpose: 'closeout', + message: 'The task was updated.', + }); + return ''; + }, + ); + + await answerFastAgentQuestion({ + ...baseParams, + images: ['data:image/png;base64,bm90LWZvcndhcmRlZA=='], + adapter: callbacks(), + }); + + expect(mocks.sendTaskMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + { + taskId: 'task-1', + message: 'Include the failing test.', + }, + ); + }); + it('still requires an acknowledgement before canceling a task', async () => { mocks.getActiveTasks.mockResolvedValue([ { taskId: 'task-1', title: 'Checkout', status: 'running' }, 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 index ee400820e..54c629590 100644 --- 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 @@ -116,6 +116,9 @@ describe('createFastAgentSlackTaskLauncher', () => { taskId: 'task-1', taskUrl: 'https://roomote.example/task/task-1', }); + expect( + mocks.enqueueTask.mock.calls[0]?.[0]?.task.payload, + ).not.toHaveProperty('images'); expect(order).toEqual(['kickoff', 'queued']); }); @@ -163,6 +166,31 @@ describe('createFastAgentSlackTaskLauncher', () => { expect(task.payload).not.toHaveProperty('environmentId'); }); + it('retains multiple Fast turn images in the child task payload', async () => { + const images = [ + 'data:image/png;base64,cG5nLWJ5dGVz', + 'data:image/webp;base64,d2VicC1ieXRlcw==', + ]; + const launchTask = createFastAgentSlackTaskLauncher({ + userId: 'user-1', + teamId: 'T123', + channelId: 'C123', + threadTs: '100.001', + }); + + await launchTask({ + prompt: 'Implement the UI shown in these screenshots', + images, + environmentId: null, + parentSessionId: '11111111-1111-4111-8111-111111111111', + postKickoff: vi.fn(), + }); + + expect(mocks.enqueueTask.mock.calls[0]?.[0]?.task.payload.images).toEqual( + images, + ); + }); + it('runs afterKickoff inside the launch gate', async () => { const order: string[] = []; const afterKickoff = vi.fn(async () => { diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tasks.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tasks.test.ts index d0f8974b1..2b09adb3d 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tasks.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tasks.test.ts @@ -5,7 +5,7 @@ describe('fast-agent task operations', () => { vi.unstubAllGlobals(); }); - it('steers messages to active tasks through a reverse-proxy pathname', async () => { + it('steers messages with images through a reverse-proxy pathname', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true }), { status: 200, @@ -23,6 +23,10 @@ describe('fast-agent task operations', () => { { taskId: 'task-42', message: 'Also add a test.', + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], }, ); @@ -36,6 +40,10 @@ describe('fast-agent task operations', () => { }), body: JSON.stringify({ message: 'Also add a test.', + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], senderMode: 'fast_agent', }), }), diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts index 0c6c4eafa..3bf9bb36d 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts @@ -53,6 +53,7 @@ export type FastAgentReaction = { export type LaunchFastAgentTask = (params: { prompt: string; + images?: string[]; environmentId: string | null; model?: string | null; parentSessionId: string; diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts index 4e87c67ec..d0f85b63e 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts @@ -281,11 +281,12 @@ import { z } from "zod" import { invoke } from "../roomote-fast-tool-bridge.js" export default { - description: "Delegate new repository or workspace execution work to a Roomote task, optionally using an exact deployment-enabled model ID from the system prompt.", + description: "Delegate new repository or workspace execution work to a Roomote task, optionally using an exact deployment-enabled model ID. Current-turn images are attached only when includeImages is true.", args: { prompt: z.string().min(1).describe("Complete task instruction"), environmentId: z.string().nullable().optional().describe(${JSON.stringify(`Exact environment ID from the system prompt; omit, pass null, or pass "${ALL_REPOSITORIES}" to run against all active repositories`)}), model: z.string().min(1).nullable().optional().describe("Exact deployment-enabled model ID; omit or pass null to use the deployment default"), + includeImages: z.boolean().optional().describe("Set true to attach supported images from the active conversation turn; defaults to false"), kickoffMessage: z.string().min(1).describe("Brief user-facing description of the work now underway; do not mention delegation, launching, or queue state"), }, execute: (args, context) => invoke("launch_task", args, context), @@ -297,10 +298,11 @@ import { z } from "zod" import { invoke } from "../roomote-fast-tool-bridge.js" export default { - description: "Send a new instruction to an active or resumable task delegated by this Fast conversation.", + description: "Send a new instruction to an active or resumable task delegated by this Fast conversation. Current-turn images are attached only when includeImages is true.", args: { taskId: z.string().nullable().optional(), message: z.string().min(1), + includeImages: z.boolean().optional().describe("Set true to attach supported images from the active conversation turn; defaults to false"), }, execute: (args, context) => invoke("send_task_message", args, context), } 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 72a0e9467..8dea4e604 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 @@ -175,6 +175,7 @@ ${formatIntegrationsForPrompt(availableIntegrations)} - An acknowledgement or progress update does not end the turn. Continue using native tools, then post a closeout or clarification. - Before calling a deployment MCP tool other than Roomote custom automation management, or canceling a task on a human-authored turn, first post a brief acknowledgement. The runtime rejects those calls until an acknowledgement or progress update has been delivered. Platform events are exempt. Sending a task message is also exempt so steering is not delayed behind a user-visible reply. - "launch_task" behaves like a normal tool. Do not send a separate acknowledgement before it. Include a brief "kickoffMessage" describing the user's work now underway; the runtime automatically posts that kickoff and task link as a progress artifact for each launch. The kickoff acknowledges the request, but it is not the only communication expected while longer work continues. +- Set "includeImages" on "launch_task" to true only when supported images from the active conversation turn are relevant to the coding task. Omit it otherwise; images are not attached by default. - If the answer is immediate, call the closeout tool directly. ${reactionGuidance} - Prefer one direct closeout over an acknowledgement followed immediately by the same answer. @@ -219,7 +220,7 @@ ${reactionGuidance} - Use "launch_task" for new independent repository or workspace work when external inspection, editing, execution, or validation is required, regardless of whether the message is phrased as a question, request, or declarative feedback. Existing active tasks do not block a new independent task. - You may launch multiple independent tasks in one turn. Each successful launch posts its own kickoff automatically, and the turn remains open for more tools. - Set "model" on "launch_task" only to an exact ID from Available Delegated Task Models when a specific model is useful or requested. Omit it to use the deployment default. Never invent or abbreviate model IDs. -- Use "send_task_message" when an active or resumable task is listed above and the user clearly gives that task a new instruction. Call it immediately, before an acknowledgement or other user-visible response, so the instruction reaches the task without an extra inference round. A resumable settled task continues under the same task identity. Set "taskId" when needed; with exactly one listed task, omit it or use null. Afterward, post a concise closeout confirming the outcome when useful. +- Use "send_task_message" when an active or resumable task is listed above and the user clearly gives that task a new instruction. Call it immediately, before an acknowledgement or other user-visible response, so the instruction reaches the task without an extra inference round. Set "includeImages" to true only when supported images from the active conversation turn are relevant to that instruction; omit it otherwise. A resumable settled task continues under the same task identity. Set "taskId" when needed; with exactly one listed task, omit it or use null. Afterward, post a concise closeout confirming the outcome when useful. - Use \`roomote_manage_tasks\` to inspect tasks in this deployment. Use "get_summary" for current status and failures, "get_messages" for transcript details, and "get_compute_logs" for runtime output when supported. Keep using "launch_task", "send_task_message", or "cancel_task" for task changes so Fast conversation kickoff and follow-up behavior is preserved. - Use \`roomote_get_chat_message_context\` or \`roomote_get_chat_channel_messages\` for additional chat context. Pass the target channel or message reference required by the native tool schema. Slack channel history defaults to the previous 24 hours when \`oldest\` is omitted. - Never send conversational acknowledgements to a task. "Okay", "cool", "thanks", status questions, and similar conversation are addressed to you. Use a user-visible chat tool. 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 33118bd5f..916ba3216 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 @@ -215,11 +215,13 @@ const launchTaskArgsSchema = z.object({ prompt: z.string().trim().min(1), environmentId: z.string().trim().min(1).nullable().optional(), model: z.string().trim().min(1).nullable().optional(), + includeImages: z.boolean().optional().default(false), kickoffMessage: z.string().trim().min(1), }); const taskMessageArgsSchema = z.object({ taskId: z.string().trim().min(1).nullable().optional(), message: z.string().trim().min(1), + includeImages: z.boolean().optional().default(false), }); const taskIdArgsSchema = z.object({ taskId: z.string().trim().min(1).nullable().optional(), @@ -1601,6 +1603,7 @@ export async function answerFastAgentQuestion({ args.prompt, args.environmentId ?? null, args.model ?? null, + args.includeImages, ])}`; if (completedTaskActions.has(signature)) { return { @@ -1635,6 +1638,7 @@ export async function answerFastAgentQuestion({ throwIfTurnCancelled(); const result = await adapter.launchTask({ prompt: args.prompt, + ...(args.includeImages && images.length > 0 ? { images } : {}), environmentId: args.environmentId ?? null, model: args.model ?? null, parentSessionId: session.id, @@ -1667,7 +1671,11 @@ export async function answerFastAgentQuestion({ throwIfTurnCancelled(); const result = await sendFastAgentTaskMessage( { userId, apiBaseUrl }, - { taskId: target.taskId, message: args.message }, + { + taskId: target.taskId, + message: args.message, + ...(args.includeImages && images.length > 0 ? { images } : {}), + }, ); return result; } 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 index ccbf50880..ca8b5bba1 100644 --- 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 @@ -46,17 +46,27 @@ export function createFastAgentTaskLauncher( ): LaunchFastAgentTask { return async ({ prompt, + images, environmentId, model, parentSessionId, postKickoff, }) => { - const task = await params.buildTask({ + const builtTask = await params.buildTask({ prompt, environmentId, model, parentSessionId, }); + const task = images?.length + ? { + ...builtTask, + payload: { + ...builtTask.payload, + images, + }, + } + : builtTask; let taskUrl: string | undefined; let preparedTaskRun: { id: number; taskId: string } | undefined; diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-tasks.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-tasks.ts index 99b7dd30f..e2151d8ba 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-tasks.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-tasks.ts @@ -188,13 +188,17 @@ async function callFastAgentTaskApi({ export async function sendFastAgentTaskMessage( context: FastAgentTaskApiContext, - params: { taskId: string; message: string }, + params: { taskId: string; message: string; images?: string[] }, ): Promise { return callFastAgentTaskApi({ ...context, method: 'POST', path: `${FAST_AGENT_TASKS_API_PATH}/${params.taskId}/steer_message`, - body: { message: params.message, senderMode: 'fast_agent' }, + body: { + message: params.message, + ...(params.images?.length ? { images: params.images } : {}), + senderMode: 'fast_agent', + }, }); } diff --git a/packages/slack/src/__tests__/slack-notifier.test.ts b/packages/slack/src/__tests__/slack-notifier.test.ts index f3f57152a..a000f642a 100644 --- a/packages/slack/src/__tests__/slack-notifier.test.ts +++ b/packages/slack/src/__tests__/slack-notifier.test.ts @@ -1168,6 +1168,14 @@ describe('SlackNotifier', () => { filetype: 'svg', }; + const misleadingFilename: SlackFile = { + ...smallImage, + id: 'F5', + name: 'document.png', + mimetype: 'application/pdf', + filetype: 'pdf', + }; + getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new TextEncoder().encode('fake-image').buffer, @@ -1178,6 +1186,7 @@ describe('SlackNotifier', () => { largeImage, textFile, svgFile, + misleadingFilename, ]); expect(getGlobalWithFetch().fetch).toHaveBeenCalledTimes(1); diff --git a/packages/slack/src/thread-image-utils.ts b/packages/slack/src/thread-image-utils.ts index f9d3bd404..0f23f2179 100644 --- a/packages/slack/src/thread-image-utils.ts +++ b/packages/slack/src/thread-image-utils.ts @@ -14,7 +14,6 @@ export const MAX_THREAD_ATTACHMENT_FILES = 20; export function isSlackImageFile(file: SlackFile): boolean { return ( isRoomoteImageAttachment({ - filename: file.name, mimeType: file.mimetype, }) && file.size < MAX_SLACK_IMAGE_FILE_SIZE_BYTES );