diff --git a/src/channels/lark/image-message.ts b/src/channels/lark/image-message.ts index c5855be..72e6ee8 100644 --- a/src/channels/lark/image-message.ts +++ b/src/channels/lark/image-message.ts @@ -11,15 +11,14 @@ export interface SendLarkImageMessageResult { openMessageId?: string; } -export async function sendLarkImageMessage(input: { +export async function uploadLarkImageMessageAsset(input: { installationId: string; chatId: string; - replyToMessageId?: string | null; imagePath: string; clients?: { getOrCreate(installationId: string): LarkSdkClient; }; -}): Promise { +}): Promise<{ imageKey: string }> { if (input.clients == null) { throw new Error("Lark image sending is not configured in this runtime."); } @@ -43,6 +42,24 @@ export async function sendLarkImageMessage(input: { imageKey, }); + return { imageKey }; +} + +export async function sendLarkImageMessage(input: { + installationId: string; + chatId: string; + replyToMessageId?: string | null; + imagePath: string; + clients?: { + getOrCreate(installationId: string): LarkSdkClient; + }; +}): Promise { + if (input.clients == null) { + throw new Error("Lark image sending is not configured in this runtime."); + } + + const client = input.clients.getOrCreate(input.installationId); + const { imageKey } = await uploadLarkImageMessageAsset(input); const content = JSON.stringify({ image_key: imageKey }); const response = input.replyToMessageId != null && input.replyToMessageId.length > 0 diff --git a/src/channels/lark/outbound.ts b/src/channels/lark/outbound.ts index c7dab2c..f532145 100644 --- a/src/channels/lark/outbound.ts +++ b/src/channels/lark/outbound.ts @@ -27,7 +27,10 @@ import { } from "@/src/channels/lark/cardkit-mutations.js"; import type { LarkSdkClient } from "@/src/channels/lark/client.js"; import { listLarkDeliveryTargets, readStringValue } from "@/src/channels/lark/delivery-targets.js"; -import { sendLarkImageMessage } from "@/src/channels/lark/image-message.js"; +import { + sendLarkImageMessage, + uploadLarkImageMessageAsset, +} from "@/src/channels/lark/image-message.js"; import { addLarkMessageReaction, removeLarkMessageReaction, @@ -41,6 +44,7 @@ import { describeTaskRunTerminal, getLarkTaskTerminalMessagePresentation, type LarkSubagentCreationRequestCardState, + type LarkTaskCardImage, } from "@/src/channels/lark/render.js"; import { type LarkRunState, @@ -1007,14 +1011,9 @@ export function createLarkOutboundRuntime( internalObjectId, }); const existingMetadata = parseBindingMetadata(existing?.metadataJson ?? null); - const rendered = buildLarkRenderedTaskCard(state, { - ...(taskTitle == null ? {} : { title: taskTitle }), - }); const terminalMessage = getLarkTaskTerminalMessagePresentation(state.terminalMessage); const snapshotKey = `${target.channelInstallationId}:${TASK_STATUS_DELIVERY_PREFIX}${taskRunId}`; const snapshot = deliverySnapshots.get(snapshotKey) ?? null; - const shouldRenderUpdate = - snapshot == null || snapshot.structureSignature !== rendered.structureSignature; let metadataJson = buildBindingMetadata( { sessionId: state.sessionId, @@ -1032,6 +1031,32 @@ export function createLarkOutboundRuntime( }, existing?.metadataJson ?? null, ); + let taskCardImages: { metadataJson: string; images: LarkTaskCardImage[] }; + try { + taskCardImages = await resolveTaskCardImages({ + channelInstallationId: target.channelInstallationId, + chatId, + client, + state, + metadataJson, + cacheMetadataJson: existing?.metadataJson ?? null, + }); + } catch (error) { + scheduleDependentRetry(`${TASK_STATUS_DELIVERY_PREFIX}${taskRunId}`, { + reason: "task_result_image_upload_failed", + taskRunId, + channelInstallationId: target.channelInstallationId, + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + metadataJson = taskCardImages.metadataJson; + const rendered = buildLarkRenderedTaskCard(state, { + ...(taskTitle == null ? {} : { title: taskTitle }), + ...(taskCardImages.images.length === 0 ? {} : { images: taskCardImages.images }), + }); + const shouldRenderUpdate = + snapshot == null || snapshot.structureSignature !== rendered.structureSignature; const status = state.terminal === "running" ? "active" : "finalized"; if (existing?.larkCardId == null || existing.larkMessageId == null) { @@ -1828,7 +1853,30 @@ export function createLarkOutboundRuntime( continue; } - const replyToMessageId = readStringValue(target.surfaceObject.reply_to_message_id); + const replyTarget = resolveOutboundAttachmentReplyToMessageId({ + bindingsRepo: new LarkObjectBindingsRepo(input.storage), + channelInstallationId: target.channelInstallationId, + surfaceObject: target.surfaceObject, + taskRunId: envelope.taskRun.taskRunId, + taskRunType: envelope.taskRun.runType, + }); + if (replyTarget.taskStatusCardMissing) { + logger.error( + "falling back to lark surface reply target because task status card is missing", + { + eventId: envelope.event.eventId, + conversationId: envelope.target.conversationId, + branchId: envelope.target.branchId, + taskRunId: envelope.taskRun.taskRunId, + taskRunType: envelope.taskRun.runType, + channelInstallationId: target.channelInstallationId, + chatId, + fallbackReplyToMessageId: replyTarget.replyToMessageId, + attachmentPath: envelope.event.attachmentPath, + }, + ); + } + const replyToMessageId = replyTarget.replyToMessageId; try { const result = await sendLarkImageMessage({ installationId: target.channelInstallationId, @@ -2134,6 +2182,142 @@ function parseSurfaceObject(raw: string): Record { return {}; } +function resolveOutboundAttachmentReplyToMessageId(input: { + bindingsRepo: LarkObjectBindingsRepo; + channelInstallationId: string; + surfaceObject: Record; + taskRunId: string | null; + taskRunType: string | null; +}): { replyToMessageId: string | null; taskStatusCardMissing: boolean } { + const surfaceReplyToMessageId = readStringValue(input.surfaceObject.reply_to_message_id); + if (input.taskRunId == null || !shouldRenderStandaloneTaskCard(input.taskRunType)) { + return { + replyToMessageId: surfaceReplyToMessageId, + taskStatusCardMissing: false, + }; + } + + const taskRootBinding = input.bindingsRepo.getByInternalObject({ + channelInstallationId: input.channelInstallationId, + internalObjectKind: RUN_CARD_OBJECT_KIND, + internalObjectId: buildTaskRunCardObjectId(input.taskRunId), + }); + + return { + replyToMessageId: taskRootBinding?.larkMessageId ?? surfaceReplyToMessageId, + taskStatusCardMissing: taskRootBinding?.larkMessageId == null, + }; +} + +interface CachedTaskResultImage { + path: string; + displayPath: string; + alt?: string; + imageKey: string; +} + +async function resolveTaskCardImages(input: { + channelInstallationId: string; + chatId: string; + client: LarkSdkClient; + state: LarkRunState; + metadataJson: string; + cacheMetadataJson?: string | null; +}): Promise<{ metadataJson: string; images: LarkTaskCardImage[] }> { + if (input.state.terminalImageAttachments.length === 0) { + return { + metadataJson: input.metadataJson, + images: [], + }; + } + + const metadata = parseBindingMetadata(input.metadataJson); + const cacheMetadata = parseBindingMetadata(input.cacheMetadataJson ?? input.metadataJson); + const cachedImages = readCachedTaskResultImages(cacheMetadata.taskResultImages); + const nextCache: CachedTaskResultImage[] = []; + const images: LarkTaskCardImage[] = []; + + for (const attachment of input.state.terminalImageAttachments) { + const cached = cachedImages.find((item) => item.path === attachment.path) ?? null; + let imageKey = cached?.imageKey ?? null; + if (imageKey == null) { + try { + const uploaded = await uploadLarkImageMessageAsset({ + installationId: input.channelInstallationId, + chatId: input.chatId, + imagePath: attachment.path, + clients: { + getOrCreate: (installationId) => { + if (installationId !== input.channelInstallationId) { + throw new Error(`Unexpected lark installation id ${installationId}`); + } + return input.client; + }, + }, + }); + imageKey = uploaded.imageKey; + } catch (error) { + logger.error("failed to upload lark task result image for task card", { + channelInstallationId: input.channelInstallationId, + taskRunId: input.state.taskRunId, + path: attachment.path, + displayPath: attachment.displayPath, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + const cachedImage: CachedTaskResultImage = { + path: attachment.path, + displayPath: attachment.displayPath, + ...(attachment.alt == null ? {} : { alt: attachment.alt }), + imageKey, + }; + nextCache.push(cachedImage); + images.push({ + imageKey, + displayPath: attachment.displayPath, + ...(attachment.alt == null ? {} : { alt: attachment.alt }), + }); + } + + return { + metadataJson: JSON.stringify({ + ...metadata, + taskResultImages: nextCache, + }), + images, + }; +} + +function readCachedTaskResultImages(value: unknown): CachedTaskResultImage[] { + if (!Array.isArray(value)) { + return []; + } + + const images: CachedTaskResultImage[] = []; + for (const item of value) { + if (!isRecord(item)) { + continue; + } + const path = readStringValue(item.path); + const displayPath = readStringValue(item.displayPath); + const imageKey = readStringValue(item.imageKey); + if (path == null || displayPath == null || imageKey == null) { + continue; + } + const alt = readStringValue(item.alt); + images.push({ + path, + displayPath, + imageKey, + ...(alt == null ? {} : { alt }), + }); + } + return images; +} + async function maybeSendFeishuDocPreviewMessages(input: { channelInstallationId: string; chatId: string; diff --git a/src/channels/lark/render.ts b/src/channels/lark/render.ts index b2b239a..c073754 100644 --- a/src/channels/lark/render.ts +++ b/src/channels/lark/render.ts @@ -27,6 +27,7 @@ export { describeTaskRunTerminal, getLarkTaskTerminalMessagePresentation, type LarkRenderedTaskCard, + type LarkTaskCardImage, type LarkTaskTerminalMessagePresentation, } from "@/src/channels/lark/render/task-card.js"; diff --git a/src/channels/lark/render/task-card.ts b/src/channels/lark/render/task-card.ts index cbbe5ab..d35ba8e 100644 --- a/src/channels/lark/render/task-card.ts +++ b/src/channels/lark/render/task-card.ts @@ -11,8 +11,15 @@ export interface LarkTaskTerminalMessagePresentation { truncated: boolean; } +export interface LarkTaskCardImage { + imageKey: string; + displayPath: string; + alt?: string; +} + export interface BuildLarkRenderedTaskCardOptions { title?: string | null; + images?: LarkTaskCardImage[]; } export function buildLarkRenderedTaskCard( @@ -45,7 +52,7 @@ export function buildLarkRenderedTaskCard( }, }, body: { - elements: buildTaskCardElements(state), + elements: buildTaskCardElements(state, options.images ?? []), }, }; @@ -119,52 +126,71 @@ export function describeTaskRunIcon(terminal: LarkRunState["terminal"]): string return "robot_outlined"; } -function buildTaskCardElements(state: LarkRunState): Array> { +function buildTaskCardElements( + state: LarkRunState, + images: LarkTaskCardImage[], +): Array> { const taskKind = describeTaskRunKind(state.taskRunType); const terminalMessage = getLarkTaskTerminalMessagePresentation(state.terminalMessage); if (state.terminal === "completed") { - return [ - { - tag: "markdown", - content: - terminalMessage.displayText == null - ? `✅ ${taskKind}已完成` - : terminalMessage.displayText, - }, - ]; + return appendTaskCardImageElements( + [ + { + tag: "markdown", + content: + terminalMessage.displayText == null + ? `✅ ${taskKind}已完成` + : terminalMessage.displayText, + }, + ], + images, + ); } if (state.terminal === "blocked") { - return [ - { - tag: "markdown", - content: - terminalMessage.displayText == null ? `⏸ ${taskKind}已阻塞` : terminalMessage.displayText, - }, - ]; + return appendTaskCardImageElements( + [ + { + tag: "markdown", + content: + terminalMessage.displayText == null + ? `⏸ ${taskKind}已阻塞` + : terminalMessage.displayText, + }, + ], + images, + ); } if (state.terminal === "failed") { - return [ - { - tag: "markdown", - content: - terminalMessage.displayText == null - ? `❌ ${taskKind}执行失败` - : terminalMessage.displayText, - }, - ]; + return appendTaskCardImageElements( + [ + { + tag: "markdown", + content: + terminalMessage.displayText == null + ? `❌ ${taskKind}执行失败` + : terminalMessage.displayText, + }, + ], + images, + ); } if (state.terminal === "cancelled") { - return [ - { - tag: "markdown", - content: - terminalMessage.displayText == null ? `⏹ ${taskKind}已停止` : terminalMessage.displayText, - }, - ]; + return appendTaskCardImageElements( + [ + { + tag: "markdown", + content: + terminalMessage.displayText == null + ? `⏹ ${taskKind}已停止` + : terminalMessage.displayText, + }, + ], + images, + ); } if (state.terminal === "awaiting_approval") { @@ -202,6 +228,29 @@ function buildTaskCardElements(state: LarkRunState): Array>, + images: LarkTaskCardImage[], +): Array> { + if (images.length === 0) { + return elements; + } + + return [ + ...elements, + ...images.map((image) => ({ + tag: "img", + img_key: image.imageKey, + alt: { + tag: "plain_text", + content: image.alt ?? image.displayPath, + }, + mode: "fit_horizontal", + preview: true, + })), + ]; +} + function summarizeTaskStatus(state: LarkRunState, taskTitle: string): string { if (state.terminal === "completed") { return `${taskTitle}已完成`; diff --git a/src/channels/lark/run-state.ts b/src/channels/lark/run-state.ts index 6493907..ce7dfe3 100644 --- a/src/channels/lark/run-state.ts +++ b/src/channels/lark/run-state.ts @@ -22,6 +22,7 @@ import { import type { OrchestratedRuntimeEventEnvelope, OrchestratedTaskRunEventEnvelope, + TaskRunResultImageAttachment, } from "@/src/orchestration/outbound-events.js"; import { appendCappedTextTail } from "@/src/shared/capped-text.js"; @@ -89,6 +90,7 @@ export interface LarkRunState { terminal: LarkRunTerminal; terminalErrorKind: string | null; terminalMessage: string | null; + terminalImageAttachments: TaskRunResultImageAttachment[]; } export const LARK_ASSISTANT_PLACEHOLDER_TEXT = "_正在思考..._"; @@ -130,15 +132,28 @@ export function reduceLarkRunState( case "task_run_started": return onTaskRunStarted(hydratedState, envelope.event); case "task_run_completed": - return finalizeTaskRun(hydratedState, "completed", envelope.event.resultSummary); + return finalizeTaskRun( + hydratedState, + "completed", + envelope.event.resultSummary, + null, + envelope.event.resultImages, + ); case "task_run_blocked": - return finalizeTaskRun(hydratedState, "blocked", envelope.event.resultSummary); + return finalizeTaskRun( + hydratedState, + "blocked", + envelope.event.resultSummary, + null, + envelope.event.resultImages, + ); case "task_run_failed": return finalizeTaskRun( hydratedState, "failed", envelope.event.resultSummary ?? envelope.event.errorText, envelope.event.errorText, + envelope.event.resultImages, ); case "task_run_cancelled": return finalizeTaskRun( @@ -202,6 +217,7 @@ function createInitialRunState(input: { terminal: "running", terminalErrorKind: null, terminalMessage: null, + terminalImageAttachments: [], }; } @@ -217,6 +233,7 @@ function onTaskRunStarted( terminal: "running", terminalErrorKind: null, terminalMessage: null, + terminalImageAttachments: [], }; } @@ -514,6 +531,7 @@ function finalizeRun( : event.type === "run_cancelled" ? event.reason : null, + terminalImageAttachments: [], }; } @@ -522,6 +540,7 @@ function finalizeTaskRun( terminal: Extract, terminalMessage: string | null, terminalErrorKind: string | null = null, + terminalImageAttachments: TaskRunResultImageAttachment[] | undefined = undefined, ): LarkRunState { return { ...state, @@ -539,6 +558,7 @@ function finalizeTaskRun( terminal, terminalErrorKind, terminalMessage, + terminalImageAttachments: terminalImageAttachments ?? [], }; } @@ -564,6 +584,7 @@ export function markLarkRunAwaitingApproval( terminal: input.approvalTarget === "main_agent" ? "running" : "awaiting_approval", terminalErrorKind: null, terminalMessage: null, + terminalImageAttachments: [], }; } @@ -595,6 +616,7 @@ export function markLarkRunApprovalResolved( : input.actor === "system:timeout" ? "授权请求已超时。" : "用户拒绝了这次授权请求。", + terminalImageAttachments: [], }; } diff --git a/src/orchestration/agent-manager.ts b/src/orchestration/agent-manager.ts index 51260a1..585e44a 100644 --- a/src/orchestration/agent-manager.ts +++ b/src/orchestration/agent-manager.ts @@ -71,6 +71,7 @@ import { parseBackgroundTaskPayload, } from "@/src/tasks/background-task-payload.js"; import { TaskExecutionRunner, type TaskExecutionRunResult } from "@/src/tasks/runner.js"; +import type { TaskCompletionImageAttachment } from "@/src/tasks/task-completion.js"; const logger = createSubsystemLogger("orchestration/agent-manager"); @@ -694,6 +695,7 @@ export class AgentManager { completeTaskExecution(input: { taskRunId: string; resultSummary?: string | null; + resultImages?: TaskCompletionImageAttachment[]; finishedAt?: Date; }): SettledTaskExecution { const settled = completeTaskExecution({ @@ -703,7 +705,9 @@ export class AgentManager { ...(input.finishedAt === undefined ? {} : { finishedAt: input.finishedAt }), }); logSettledTaskExecution("completed", settled); - this.publishTaskRunSettledEvent("task_run_completed", settled); + this.publishTaskRunSettledEvent("task_run_completed", settled, { + ...(input.resultImages === undefined ? {} : { resultImages: input.resultImages }), + }); this.appendBackgroundTaskCompletionNoticeIfNeeded(settled.taskRun); this.appendScheduledTaskRunSettledNoticeIfNeeded(settled.taskRun); return settled; @@ -712,6 +716,7 @@ export class AgentManager { blockTaskExecution(input: { taskRunId: string; resultSummary?: string | null; + resultImages?: TaskCompletionImageAttachment[]; finishedAt?: Date; }): SettledTaskExecution { const settled = blockTaskExecution({ @@ -721,7 +726,9 @@ export class AgentManager { ...(input.finishedAt === undefined ? {} : { finishedAt: input.finishedAt }), }); logSettledTaskExecution("blocked", settled); - this.publishTaskRunSettledEvent("task_run_blocked", settled); + this.publishTaskRunSettledEvent("task_run_blocked", settled, { + ...(input.resultImages === undefined ? {} : { resultImages: input.resultImages }), + }); this.appendBackgroundTaskCompletionNoticeIfNeeded(settled.taskRun); this.appendScheduledTaskRunSettledNoticeIfNeeded(settled.taskRun); return settled; @@ -731,6 +738,7 @@ export class AgentManager { taskRunId: string; errorText?: string | null; resultSummary?: string | null; + resultImages?: TaskCompletionImageAttachment[]; finishedAt?: Date; }): SettledTaskExecution { const settled = failTaskExecution({ @@ -741,7 +749,9 @@ export class AgentManager { ...(input.finishedAt === undefined ? {} : { finishedAt: input.finishedAt }), }); logSettledTaskExecution("failed", settled); - this.publishTaskRunSettledEvent("task_run_failed", settled); + this.publishTaskRunSettledEvent("task_run_failed", settled, { + ...(input.resultImages === undefined ? {} : { resultImages: input.resultImages }), + }); this.appendBackgroundTaskCompletionNoticeIfNeeded(settled.taskRun); this.appendScheduledTaskRunSettledNoticeIfNeeded(settled.taskRun); return settled; @@ -848,8 +858,12 @@ export class AgentManager { private publishTaskRunSettledEvent( eventType: "task_run_completed" | "task_run_blocked" | "task_run_failed" | "task_run_cancelled", settled: SettledTaskExecution, + options: { + resultImages?: TaskCompletionImageAttachment[]; + } = {}, ): void { const taskRun = settled.taskRun; + const resultImages = options.resultImages ?? []; const event = eventType === "task_run_completed" ? { @@ -861,6 +875,7 @@ export class AgentManager { finishedAt: taskRun.finishedAt, durationMs: taskRun.durationMs, resultSummary: taskRun.resultSummary, + ...(resultImages.length === 0 ? {} : { resultImages }), executionSessionId: taskRun.executionSessionId, } : eventType === "task_run_blocked" @@ -873,6 +888,7 @@ export class AgentManager { finishedAt: taskRun.finishedAt, durationMs: taskRun.durationMs, resultSummary: taskRun.resultSummary, + ...(resultImages.length === 0 ? {} : { resultImages }), executionSessionId: taskRun.executionSessionId, } : eventType === "task_run_failed" @@ -886,6 +902,7 @@ export class AgentManager { durationMs: taskRun.durationMs, resultSummary: taskRun.resultSummary, errorText: taskRun.errorText, + ...(resultImages.length === 0 ? {} : { resultImages }), executionSessionId: taskRun.executionSessionId, } : { diff --git a/src/orchestration/outbound-events.ts b/src/orchestration/outbound-events.ts index e27be73..e8b28eb 100644 --- a/src/orchestration/outbound-events.ts +++ b/src/orchestration/outbound-events.ts @@ -14,6 +14,7 @@ import { import type { AgentRuntimeRole } from "@/src/security/policy.js"; import type { StorageDb } from "@/src/storage/db/client.js"; import type { Session, SubagentCreationRequest, TaskRun } from "@/src/storage/schema/types.js"; +import type { TaskCompletionImageAttachment } from "@/src/tasks/task-completion.js"; export type OutboundAttachmentType = | "image" @@ -81,6 +82,7 @@ export interface TaskRunCompletedOutboundEvent { finishedAt: string | null; durationMs: number | null; resultSummary: string | null; + resultImages?: TaskRunResultImageAttachment[]; executionSessionId: string | null; } @@ -94,6 +96,7 @@ export interface TaskRunFailedOutboundEvent { durationMs: number | null; resultSummary: string | null; errorText: string | null; + resultImages?: TaskRunResultImageAttachment[]; executionSessionId: string | null; } @@ -106,6 +109,7 @@ export interface TaskRunBlockedOutboundEvent { finishedAt: string | null; durationMs: number | null; resultSummary: string | null; + resultImages?: TaskRunResultImageAttachment[]; executionSessionId: string | null; } @@ -129,6 +133,8 @@ export type TaskRunOutboundEvent = | TaskRunFailedOutboundEvent | TaskRunCancelledOutboundEvent; +export type TaskRunResultImageAttachment = TaskCompletionImageAttachment; + export interface OrchestratedTaskRunEventEnvelope extends OutboundEventContext { kind: "task_run_event"; event: TaskRunOutboundEvent; diff --git a/src/tasks/runner.ts b/src/tasks/runner.ts index 185f32c..e487d1d 100644 --- a/src/tasks/runner.ts +++ b/src/tasks/runner.ts @@ -8,6 +8,7 @@ import { resolveTaskCompletionResultSummary, TASK_COMPLETION_TOOL_NAME, type TaskCompletionDetails, + type TaskCompletionImageAttachment, type TaskCompletionSignal, } from "@/src/tasks/task-completion.js"; import { @@ -26,17 +27,20 @@ export interface TaskExecutionRunnerLifecycle { blockTaskExecution(input: { taskRunId: string; resultSummary?: string | null; + resultImages?: TaskCompletionImageAttachment[]; finishedAt?: Date; }): SettledTaskExecution; completeTaskExecution(input: { taskRunId: string; resultSummary?: string | null; + resultImages?: TaskCompletionImageAttachment[]; finishedAt?: Date; }): SettledTaskExecution; failTaskExecution(input: { taskRunId: string; errorText?: string | null; resultSummary?: string | null; + resultImages?: TaskCompletionImageAttachment[]; finishedAt?: Date; }): SettledTaskExecution; cancelTaskExecution(input: { @@ -324,11 +328,13 @@ export class TaskExecutionRunner { | Extract { const finishedAt = new Date(); const resultSummary = resolveTaskCompletionResultSummary(input.completion); + const resultImages = input.completion.images ?? []; if (input.completion.status === "completed") { const settled = this.deps.lifecycle.completeTaskExecution({ taskRunId: input.taskRunId, resultSummary, + ...(resultImages.length === 0 ? {} : { resultImages }), finishedAt, }); return { @@ -343,6 +349,7 @@ export class TaskExecutionRunner { const settled = this.deps.lifecycle.blockTaskExecution({ taskRunId: input.taskRunId, resultSummary, + ...(resultImages.length === 0 ? {} : { resultImages }), finishedAt, }); return { @@ -357,6 +364,7 @@ export class TaskExecutionRunner { taskRunId: input.taskRunId, errorText: input.completion.finalMessage, resultSummary, + ...(resultImages.length === 0 ? {} : { resultImages }), finishedAt, }); return { diff --git a/src/tasks/task-completion.ts b/src/tasks/task-completion.ts index 8fada33..db463b6 100644 --- a/src/tasks/task-completion.ts +++ b/src/tasks/task-completion.ts @@ -4,10 +4,17 @@ export const TASK_COMPLETION_TOOL_NAME = "finish_task"; export type TaskCompletionStatus = "completed" | "blocked" | "failed"; +export interface TaskCompletionImageAttachment { + path: string; + displayPath: string; + alt?: string; +} + export interface TaskCompletionSignal { status: TaskCompletionStatus; summary: string; finalMessage: string; + images?: TaskCompletionImageAttachment[]; } export interface TaskCompletionDetails { @@ -36,6 +43,7 @@ export function extractTaskCompletionSignal(input: { const status = normalizeCompletionStatus(details.taskCompletion.status); const summary = normalizeNonEmptyString(details.taskCompletion.summary); const finalMessage = normalizeNonEmptyString(details.taskCompletion.finalMessage); + const images = normalizeCompletionImages(details.taskCompletion.images); if (status == null || summary == null || finalMessage == null) { return null; } @@ -44,6 +52,7 @@ export function extractTaskCompletionSignal(input: { status, summary, finalMessage, + ...(images.length === 0 ? {} : { images }), }; } @@ -63,6 +72,31 @@ function normalizeNonEmptyString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeCompletionImages(value: unknown): TaskCompletionImageAttachment[] { + if (!Array.isArray(value)) { + return []; + } + + const images: TaskCompletionImageAttachment[] = []; + for (const item of value) { + if (!isRecord(item)) { + continue; + } + const path = normalizeNonEmptyString(item.path); + const displayPath = normalizeNonEmptyString(item.displayPath); + if (path == null || displayPath == null) { + continue; + } + const alt = normalizeNonEmptyString(item.alt); + images.push({ + path, + displayPath, + ...(alt == null ? {} : { alt }), + }); + } + return images; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value != null && !Array.isArray(value); } diff --git a/src/tools/finish-task.ts b/src/tools/finish-task.ts index fe20ca0..5bd4434 100644 --- a/src/tools/finish-task.ts +++ b/src/tools/finish-task.ts @@ -1,3 +1,4 @@ +import { stat } from "node:fs/promises"; import { type Static, Type } from "@sinclair/typebox"; import { SessionsRepo } from "@/src/storage/repos/sessions.repo.js"; import { @@ -6,6 +7,14 @@ import { } from "@/src/tasks/task-completion.js"; import { toolInternalError, toolRecoverableError } from "@/src/tools/core/errors.js"; import { defineTool, textToolResult } from "@/src/tools/core/types.js"; +import { + createFilesystemAccessController, + formatDisplayPath, + resolveToolCwd, +} from "@/src/tools/helpers/common.js"; + +const MAX_FINISH_TASK_IMAGES = 5; +const MAX_FINISH_TASK_IMAGE_BYTES = 10 * 1024 * 1024; const FINISH_TASK_STATUS_SCHEMA = Type.Union([ Type.Literal("completed"), @@ -25,6 +34,30 @@ export const FINISH_TASK_TOOL_SCHEMA = Type.Object( description: "Primary user-facing final result for this unattended task. This is shown on the task card.", }), + images: Type.Optional( + Type.Array( + Type.Object( + { + path: Type.String({ + minLength: 1, + description: + "Absolute or relative path to a local image file to show under finalMessage on the task card.", + }), + alt: Type.Optional( + Type.String({ + description: "Short accessible description for this image.", + }), + ), + }, + { additionalProperties: false }, + ), + { + maxItems: MAX_FINISH_TASK_IMAGES, + description: + "Optional local image files to include in the finish task card. Supports PNG, JPEG, WEBP, GIF, TIFF, BMP, and ICO files under 10 MB each.", + }, + ), + ), }, { additionalProperties: false }, ); @@ -35,9 +68,9 @@ export function createFinishTaskTool() { return defineTool({ name: TASK_COMPLETION_TOOL_NAME, description: - "Mark an unattended task session as completed, blocked, or failed. Use this only in task sessions. Always include a short summary plus the full finalMessage that should appear on the task card. Calling this ends the current task run after the tool result is recorded.", + "Mark an unattended task session as completed, blocked, or failed. Use this only in task sessions. Always include a short summary plus the full finalMessage that should appear on the task card. Optionally include local image files in images to show them under the finalMessage on the task card. Calling this ends the current task run after the tool result is recorded.", inputSchema: FINISH_TASK_TOOL_SCHEMA, - execute(context, args) { + async execute(context, args) { const session = new SessionsRepo(context.storage).getById(context.sessionId); if (session == null) { throw toolInternalError(`Task completion session not found: ${context.sessionId}`); @@ -50,11 +83,45 @@ export function createFinishTaskTool() { }); } + const cwd = resolveToolCwd(context); + const requestedImages = args.images ?? []; + const filesystem = createFilesystemAccessController(context); + const imageAccess = filesystem.authorize( + requestedImages.map((image) => ({ + kind: "fs.read", + targetPath: image.path, + })), + ); + const images = []; + for (let index = 0; index < requestedImages.length; index += 1) { + const image = requestedImages[index]; + if (image == null) { + continue; + } + const absolutePath = imageAccess[index]?.normalizedPath; + if (absolutePath == null) { + continue; + } + const displayPath = formatDisplayPath(image.path, cwd); + await validateFinishTaskImage({ + absolutePath, + displayPath, + }); + const alt = normalizeOptionalString(image.alt); + const normalizedImage = { + path: absolutePath, + displayPath, + ...(alt == null ? {} : { alt }), + }; + images.push(normalizedImage); + } + const details: TaskCompletionDetails = { taskCompletion: { status: args.status, summary: args.summary.trim(), finalMessage: args.finalMessage.trim(), + ...(images.length === 0 ? {} : { images }), }, }; @@ -62,3 +129,81 @@ export function createFinishTaskTool() { }, }); } + +async function validateFinishTaskImage(input: { + absolutePath: string; + displayPath: string; +}): Promise { + let fileStats: Awaited>; + try { + fileStats = await stat(input.absolutePath); + } catch (error) { + if (isMissingPathError(error)) { + throw toolRecoverableError(`Task finish image not found: ${input.displayPath}`, { + code: "file_not_found", + path: input.absolutePath, + }); + } + throw error; + } + + if (!fileStats.isFile()) { + throw toolRecoverableError( + `Task finish image path is not a regular file: ${input.displayPath}`, + { + code: "not_a_file", + path: input.absolutePath, + }, + ); + } + + if (fileStats.size <= 0) { + throw toolRecoverableError(`Task finish image file is empty: ${input.displayPath}`, { + code: "empty_file", + path: input.absolutePath, + }); + } + + if (!hasSupportedImageExtension(input.displayPath)) { + throw toolRecoverableError( + `Task finish image requires a supported extension (PNG, JPEG, WEBP, GIF, TIFF, BMP, ICO): ${input.displayPath}`, + { + code: "unsupported_image_format", + path: input.absolutePath, + displayPath: input.displayPath, + }, + ); + } + + if (fileStats.size > MAX_FINISH_TASK_IMAGE_BYTES) { + throw toolRecoverableError( + `Task finish image is larger than ${bytesToMegabytes(MAX_FINISH_TASK_IMAGE_BYTES)} MB: ${input.displayPath}`, + { + code: "file_too_large", + path: input.absolutePath, + sizeBytes: fileStats.size, + maxBytes: MAX_FINISH_TASK_IMAGE_BYTES, + }, + ); + } +} + +function normalizeOptionalString(value: string | undefined): string | null { + if (value == null) { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +function hasSupportedImageExtension(displayPath: string): boolean { + return /\.(png|jpe?g|webp|gif|tiff?|bmp|ico)$/i.test(displayPath); +} + +function bytesToMegabytes(bytes: number): number { + return bytes / 1024 / 1024; +} diff --git a/tests/channels/lark/outbound.test.ts b/tests/channels/lark/outbound.test.ts index e344e62..c4d8e0e 100644 --- a/tests/channels/lark/outbound.test.ts +++ b/tests/channels/lark/outbound.test.ts @@ -586,6 +586,486 @@ describe("lark outbound runtime", () => { await runtime.shutdown(); }); + test("sends task outbound image attachments as replies to the task status card", async () => { + handle = await createTestDatabase(import.meta.url); + handle.storage.sqlite.exec(` + INSERT INTO channel_instances (id, provider, account_key, created_at, updated_at) + VALUES ('ci_lark_default', 'lark', 'default', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z'); + + INSERT INTO conversations (id, channel_instance_id, external_chat_id, kind, created_at, updated_at) + VALUES ('conv_1', 'ci_lark_default', 'oc_chat_1', 'dm', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z'); + + INSERT INTO conversation_branches (id, conversation_id, kind, branch_key, created_at, updated_at) + VALUES ('branch_1', 'conv_1', 'dm_main', 'main', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z'); + + INSERT INTO agents (id, conversation_id, main_agent_id, kind, created_at) + VALUES ('agent_1', 'conv_1', NULL, 'main', '2026-03-28T00:00:00.000Z'); + + INSERT INTO sessions ( + id, conversation_id, branch_id, owner_agent_id, purpose, status, created_at, updated_at + ) VALUES ( + 'sess_task', 'conv_1', 'branch_1', 'agent_1', 'task', 'active', + '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z' + ); + + INSERT INTO cron_jobs ( + id, owner_agent_id, target_conversation_id, target_branch_id, + schedule_kind, schedule_value, payload_json, created_at, updated_at + ) VALUES ( + 'cron_1', 'agent_1', 'conv_1', 'branch_1', + 'cron', '0 * * * *', '{}', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z' + ); + + INSERT INTO task_runs ( + id, run_type, owner_agent_id, conversation_id, branch_id, + cron_job_id, execution_session_id, status, started_at + ) VALUES ( + 'task_1', 'cron', 'agent_1', 'conv_1', 'branch_1', + 'cron_1', 'sess_task', 'running', '2026-03-28T00:00:00.000Z' + ); + + INSERT INTO channel_threads ( + id, channel_type, channel_installation_id, home_conversation_id, external_chat_id, + external_thread_id, subject_kind, root_task_run_id, opened_from_message_id, + status, created_at, updated_at + ) VALUES ( + 'thread_task_1', 'lark', 'default', 'conv_1', 'oc_chat_1', + 'omt_task_thread_1', 'task', 'task_1', 'om_old_thread_root_1', + 'active', '2026-03-28T00:00:01.000Z', '2026-03-28T00:00:01.000Z' + ); + + INSERT INTO lark_object_bindings ( + id, channel_installation_id, conversation_id, branch_id, + internal_object_kind, internal_object_id, lark_message_id, lark_card_id, + thread_root_message_id, status, created_at, updated_at + ) VALUES ( + 'binding_task_card_1', 'default', 'conv_1', 'branch_1', + 'run_card', 'task:task_1', 'om_task_status_card_1', 'card_task_status_1', + 'omt_task_thread_1', 'active', '2026-03-28T00:00:02.000Z', '2026-03-28T00:00:02.000Z' + ); + `); + + new ChannelSurfacesRepo(handle.storage.db).upsert({ + id: "surface_1", + channelType: "lark", + channelInstallationId: "default", + conversationId: "conv_1", + branchId: "branch_1", + surfaceKey: "chat:oc_chat_1", + surfaceObjectJson: JSON.stringify({ chat_id: "oc_chat_1" }), + }); + + const tempDir = await mkdtemp(join(tmpdir(), "pokoclaw-lark-image-")); + tempDirs.push(tempDir); + const attachmentPath = join(tempDir, "chart.png"); + await writeFile(attachmentPath, Buffer.from("png")); + + const uploadImage = vi.fn(async (_input: unknown) => ({ + data: { + image_key: "img_v3_uploaded", + }, + })); + const reply = vi.fn(async (_input: unknown) => ({ + data: { + message_id: "om_image_reply_1", + open_message_id: "om_open_image_reply_1", + }, + })); + const bus = new RuntimeEventBus(); + const runtime = createLarkOutboundRuntime({ + storage: handle.storage.db, + outboundEventBus: bus, + clients: { + getOrCreate: () => + ({ + sdk: { + im: { + image: { + create: uploadImage, + }, + message: { + create: vi.fn(async () => { + throw new Error("should not use chat create for task thread images"); + }), + reply, + }, + }, + }, + }) as never, + }, + }); + + runtime.start(); + bus.publish( + makeAttachmentEnvelope( + { + type: "outbound_attachment_requested", + eventId: "evt_image_1", + attachmentPath, + displayPath: "chart.png", + attachmentType: "image", + requestedAt: "2026-03-28T00:00:03.000Z", + }, + { + taskRunId: "task_1", + runType: "cron", + executionSessionId: "sess_task", + }, + ), + ); + + await waitForCondition(() => reply.mock.calls.length === 1); + + expect(uploadImage).toHaveBeenCalledOnce(); + expect(reply).toHaveBeenCalledExactlyOnceWith({ + path: { message_id: "om_task_status_card_1" }, + data: { + msg_type: "image", + content: JSON.stringify({ image_key: "img_v3_uploaded" }), + reply_in_thread: true, + }, + }); + + await runtime.shutdown(); + }); + + test("renders task completion images inside the task status card", async () => { + handle = await createTestDatabase(import.meta.url); + handle.storage.sqlite.exec(` + INSERT INTO channel_instances (id, provider, account_key, created_at, updated_at) + VALUES ('ci_lark_default', 'lark', 'default', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z'); + + INSERT INTO conversations (id, channel_instance_id, external_chat_id, kind, created_at, updated_at) + VALUES ('conv_1', 'ci_lark_default', 'oc_chat_1', 'dm', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z'); + + INSERT INTO conversation_branches (id, conversation_id, kind, branch_key, created_at, updated_at) + VALUES ('branch_1', 'conv_1', 'dm_main', 'main', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z'); + + INSERT INTO agents (id, conversation_id, main_agent_id, kind, created_at) + VALUES ('agent_1', 'conv_1', NULL, 'main', '2026-03-28T00:00:00.000Z'); + + INSERT INTO sessions ( + id, conversation_id, branch_id, owner_agent_id, purpose, status, created_at, updated_at + ) VALUES ( + 'sess_task', 'conv_1', 'branch_1', 'agent_1', 'task', 'active', + '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z' + ); + + INSERT INTO cron_jobs ( + id, owner_agent_id, target_conversation_id, target_branch_id, + schedule_kind, schedule_value, payload_json, created_at, updated_at + ) VALUES ( + 'cron_1', 'agent_1', 'conv_1', 'branch_1', + 'cron', '0 * * * *', '{}', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z' + ); + + INSERT INTO task_runs ( + id, run_type, owner_agent_id, conversation_id, branch_id, + cron_job_id, execution_session_id, status, started_at + ) VALUES ( + 'task_1', 'cron', 'agent_1', 'conv_1', 'branch_1', + 'cron_1', 'sess_task', 'running', '2026-03-28T00:00:00.000Z' + ); + `); + + new ChannelSurfacesRepo(handle.storage.db).upsert({ + id: "surface_1", + channelType: "lark", + channelInstallationId: "default", + conversationId: "conv_1", + branchId: "branch_1", + surfaceKey: "chat:oc_chat_1", + surfaceObjectJson: JSON.stringify({ chat_id: "oc_chat_1" }), + }); + + const tempDir = await mkdtemp(join(tmpdir(), "pokoclaw-lark-task-card-image-")); + tempDirs.push(tempDir); + const imagePath = join(tempDir, "chart.png"); + await writeFile(imagePath, Buffer.from("png")); + + const uploadImage = vi.fn(async (_input: unknown) => ({ + data: { + image_key: "img_v3_finished", + }, + })); + const createCard = vi.fn(async (_input: unknown) => ({ + data: { + card_id: "card_task_status_1", + }, + })); + const createMessage = vi.fn(async (_input: unknown) => ({ + data: { + message_id: "om_task_card_1", + open_message_id: "om_task_open_1", + }, + })); + const bus = new RuntimeEventBus(); + const runtime = createLarkOutboundRuntime({ + storage: handle.storage.db, + outboundEventBus: bus, + clients: { + getOrCreate: () => + ({ + sdk: { + cardkit: { + v1: { + card: { + create: createCard, + update: vi.fn(async () => ({})), + }, + cardElement: { + content: vi.fn(async () => ({})), + }, + }, + }, + im: { + image: { + create: uploadImage, + }, + message: { + create: createMessage, + }, + }, + }, + }) as never, + }, + }); + + runtime.start(); + bus.publish( + makeTaskEnvelope({ + type: "task_run_started", + taskRunId: "task_1", + runType: "cron", + status: "running", + startedAt: "2026-03-28T00:00:00.000Z", + initiatorSessionId: null, + parentRunId: null, + cronJobId: "cron_1", + executionSessionId: "sess_task", + }), + ); + bus.publish( + makeTaskEnvelope({ + type: "task_run_completed", + taskRunId: "task_1", + runType: "cron", + status: "completed", + startedAt: "2026-03-28T00:00:00.000Z", + finishedAt: "2026-03-28T00:01:00.000Z", + durationMs: 60_000, + resultSummary: "Generated the final chart.", + resultImages: [{ path: imagePath, displayPath: "chart.png", alt: "Completion chart" }], + executionSessionId: "sess_task", + }), + ); + + await waitForCondition(() => createMessage.mock.calls.length === 1); + + expect(uploadImage).toHaveBeenCalledOnce(); + const cardCreateInput = createCard.mock.calls[0]?.[0] as + | { data?: { data?: string } } + | undefined; + const card = JSON.parse(cardCreateInput?.data?.data ?? "{}") as { + body?: { elements?: unknown[] }; + }; + expect(card.body?.elements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tag: "img", + img_key: "img_v3_finished", + alt: { + tag: "plain_text", + content: "Completion chart", + }, + }), + ]), + ); + const binding = new LarkObjectBindingsRepo(handle.storage.db).getByInternalObject({ + channelInstallationId: "default", + internalObjectKind: "run_card", + internalObjectId: "task:task_1", + }); + expect(binding?.metadataJson).toContain("img_v3_finished"); + + bus.publish( + makeTaskEnvelope({ + type: "task_run_completed", + taskRunId: "task_1", + runType: "cron", + status: "completed", + startedAt: "2026-03-28T00:00:00.000Z", + finishedAt: "2026-03-28T00:01:00.000Z", + durationMs: 60_000, + resultSummary: "Generated the final chart.", + resultImages: [{ path: imagePath, displayPath: "chart.png", alt: "Completion chart" }], + executionSessionId: "sess_task", + }), + ); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + expect(uploadImage).toHaveBeenCalledOnce(); + + await runtime.shutdown(); + }); + + test("retries task completion card after transient finish image upload failure", async () => { + vi.useFakeTimers(); + handle = await createTestDatabase(import.meta.url); + handle.storage.sqlite.exec(` + INSERT INTO channel_instances (id, provider, account_key, created_at, updated_at) + VALUES ('ci_lark_default', 'lark', 'default', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z'); + + INSERT INTO conversations (id, channel_instance_id, external_chat_id, kind, created_at, updated_at) + VALUES ('conv_1', 'ci_lark_default', 'oc_chat_1', 'dm', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z'); + + INSERT INTO conversation_branches (id, conversation_id, kind, branch_key, created_at, updated_at) + VALUES ('branch_1', 'conv_1', 'dm_main', 'main', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z'); + + INSERT INTO agents (id, conversation_id, main_agent_id, kind, created_at) + VALUES ('agent_1', 'conv_1', NULL, 'main', '2026-03-28T00:00:00.000Z'); + + INSERT INTO sessions ( + id, conversation_id, branch_id, owner_agent_id, purpose, status, created_at, updated_at + ) VALUES ( + 'sess_task', 'conv_1', 'branch_1', 'agent_1', 'task', 'active', + '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z' + ); + + INSERT INTO cron_jobs ( + id, owner_agent_id, target_conversation_id, target_branch_id, + schedule_kind, schedule_value, payload_json, created_at, updated_at + ) VALUES ( + 'cron_1', 'agent_1', 'conv_1', 'branch_1', + 'cron', '0 * * * *', '{}', '2026-03-28T00:00:00.000Z', '2026-03-28T00:00:00.000Z' + ); + + INSERT INTO task_runs ( + id, run_type, owner_agent_id, conversation_id, branch_id, + cron_job_id, execution_session_id, status, started_at + ) VALUES ( + 'task_1', 'cron', 'agent_1', 'conv_1', 'branch_1', + 'cron_1', 'sess_task', 'running', '2026-03-28T00:00:00.000Z' + ); + `); + + new ChannelSurfacesRepo(handle.storage.db).upsert({ + id: "surface_1", + channelType: "lark", + channelInstallationId: "default", + conversationId: "conv_1", + branchId: "branch_1", + surfaceKey: "chat:oc_chat_1", + surfaceObjectJson: JSON.stringify({ chat_id: "oc_chat_1" }), + }); + + const tempDir = await mkdtemp(join(tmpdir(), "pokoclaw-lark-task-card-image-")); + tempDirs.push(tempDir); + const imagePath = join(tempDir, "chart.png"); + await writeFile(imagePath, Buffer.from("png")); + + const uploadImage = vi + .fn() + .mockRejectedValueOnce(new Error("transient lark image upload failure")) + .mockResolvedValueOnce({ + data: { + image_key: "img_v3_finished", + }, + }); + const createCard = vi.fn(async (_input: unknown) => ({ + data: { + card_id: "card_task_status_1", + }, + })); + const createMessage = vi.fn(async (_input: unknown) => ({ + data: { + message_id: "om_task_card_1", + open_message_id: "om_task_open_1", + }, + })); + const bus = new RuntimeEventBus(); + const runtime = createLarkOutboundRuntime({ + storage: handle.storage.db, + outboundEventBus: bus, + clients: { + getOrCreate: () => + ({ + sdk: { + cardkit: { + v1: { + card: { + create: createCard, + update: vi.fn(async () => ({})), + }, + cardElement: { + content: vi.fn(async () => ({})), + }, + }, + }, + im: { + image: { + create: uploadImage, + }, + message: { + create: createMessage, + }, + }, + }, + }) as never, + }, + }); + + runtime.start(); + bus.publish( + makeTaskEnvelope({ + type: "task_run_started", + taskRunId: "task_1", + runType: "cron", + status: "running", + startedAt: "2026-03-28T00:00:00.000Z", + initiatorSessionId: null, + parentRunId: null, + cronJobId: "cron_1", + executionSessionId: "sess_task", + }), + ); + bus.publish( + makeTaskEnvelope({ + type: "task_run_completed", + taskRunId: "task_1", + runType: "cron", + status: "completed", + startedAt: "2026-03-28T00:00:00.000Z", + finishedAt: "2026-03-28T00:01:00.000Z", + durationMs: 60_000, + resultSummary: "Generated the final chart.", + resultImages: [{ path: imagePath, displayPath: "chart.png", alt: "Completion chart" }], + executionSessionId: "sess_task", + }), + ); + + await Promise.resolve(); + await vi.runOnlyPendingTimersAsync(); + + expect(uploadImage).toHaveBeenCalledOnce(); + expect(createCard).not.toHaveBeenCalled(); + expect(createMessage).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(uploadImage).toHaveBeenCalledTimes(2); + expect(createCard).toHaveBeenCalledOnce(); + expect(createMessage).toHaveBeenCalledOnce(); + + const cardCreateInput = createCard.mock.calls[0]?.[0] as + | { data?: { data?: string } } + | undefined; + expect(cardCreateInput?.data?.data ?? "").toContain("img_v3_finished"); + + await runtime.shutdown(); + }); + test("continues sending outbound image attachments after one lark target fails", async () => { handle = await createTestDatabase(import.meta.url); handle.storage.sqlite.exec(` diff --git a/tests/orchestration/agent-manager.test.ts b/tests/orchestration/agent-manager.test.ts index 1485e09..a8d7c11 100644 --- a/tests/orchestration/agent-manager.test.ts +++ b/tests/orchestration/agent-manager.test.ts @@ -1496,6 +1496,13 @@ describe("AgentManager", () => { manager.completeTaskExecution({ taskRunId: created.taskRun.id, resultSummary: "done", + resultImages: [ + { + path: "/tmp/chart.png", + displayPath: "chart.png", + alt: "Completion chart", + }, + ], }); await flushMicrotasks(); @@ -1505,6 +1512,13 @@ describe("AgentManager", () => { type: "task_run_completed", taskRunId: created.taskRun.id, resultSummary: "done", + resultImages: [ + { + path: "/tmp/chart.png", + displayPath: "chart.png", + alt: "Completion chart", + }, + ], }, taskRun: { taskRunId: created.taskRun.id, diff --git a/tests/orchestration/task-runner.test.ts b/tests/orchestration/task-runner.test.ts index e21f556..0ab1687 100644 --- a/tests/orchestration/task-runner.test.ts +++ b/tests/orchestration/task-runner.test.ts @@ -15,7 +15,7 @@ import { SessionRunAbortRegistry } from "@/src/runtime/cancel.js"; import { SessionRuntimeIngress } from "@/src/runtime/ingress.js"; import { MessagesRepo } from "@/src/storage/repos/messages.repo.js"; import { SessionsRepo } from "@/src/storage/repos/sessions.repo.js"; -import { TaskExecutionRunner } from "@/src/tasks/runner.js"; +import { TaskExecutionRunner, type TaskExecutionRunnerLifecycle } from "@/src/tasks/runner.js"; import { ToolRegistry } from "@/src/tools/core/registry.js"; import { createFinishTaskTool } from "@/src/tools/finish-task.js"; import { @@ -229,6 +229,79 @@ describe("TaskExecutionRunner", () => { }); }); + test("passes finish_task images to task lifecycle settlement", async () => { + handle = await createTestDatabase(import.meta.url); + seedFixture(handle); + const db = requireHandle(handle).storage.db; + + const created = createTaskExecution({ + db, + params: { + runType: "delegate", + ownerAgentId: "agent_1", + conversationId: "conv_1", + branchId: "branch_1", + description: "Generate a chart.", + }, + }); + + const completeTaskExecutionMock: TaskExecutionRunnerLifecycle["completeTaskExecution"] = vi.fn( + (input) => + completeTaskExecution({ + db, + ...input, + }), + ); + const runner = new TaskExecutionRunner({ + ingress: { + submitMessage: vi.fn(async () => + makeStartedRun({ + sessionId: created.executionSession.id, + scenario: "task", + stopSignal: { + reason: "task_completion", + payload: { + taskCompletion: { + status: "completed", + summary: "Chart generated.", + finalMessage: "Generated the final chart.", + images: [ + { + path: "/tmp/chart.png", + displayPath: "chart.png", + alt: "Completion chart", + }, + ], + }, + }, + }, + }), + ), + }, + lifecycle: { + ...createLifecycle(db), + completeTaskExecution: completeTaskExecutionMock, + }, + }); + + const result = await runner.runCreatedTaskExecution({ created }); + + expect(result.status).toBe("completed"); + expect(completeTaskExecutionMock).toHaveBeenCalledWith( + expect.objectContaining({ + taskRunId: created.taskRun.id, + resultSummary: "Generated the final chart.", + resultImages: [ + { + path: "/tmp/chart.png", + displayPath: "chart.png", + alt: "Completion chart", + }, + ], + }), + ); + }); + test("completes a task execution through the real ingress and loop path when finish_task is called", async () => { handle = await createTestDatabase(import.meta.url); seedFixture(handle); diff --git a/tests/tools/finish-task.test.ts b/tests/tools/finish-task.test.ts index 65960e7..118d548 100644 --- a/tests/tools/finish-task.test.ts +++ b/tests/tools/finish-task.test.ts @@ -1,5 +1,10 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, test } from "vitest"; import { DEFAULT_CONFIG } from "@/src/config/defaults.js"; +import { SecurityService } from "@/src/security/service.js"; import type { ToolFailure } from "@/src/tools/core/errors.js"; import { ToolRegistry } from "@/src/tools/core/registry.js"; import { createFinishTaskTool } from "@/src/tools/finish-task.js"; @@ -8,16 +13,24 @@ import { destroyTestDatabase, type TestDatabaseHandle, } from "@/tests/storage/helpers/test-db.js"; -import { seedConversationAndAgentFixture } from "@/tests/tools/helpers.js"; +import { + resolveExpectedToolAbsolutePath, + seedConversationAndAgentFixture, +} from "@/tests/tools/helpers.js"; describe("finish_task tool", () => { let handle: TestDatabaseHandle | null = null; + let tempDir: string | null = null; afterEach(async () => { if (handle != null) { await destroyTestDatabase(handle); handle = null; } + if (tempDir != null) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } }); test("records explicit task completion details for unattended task sessions", async () => { @@ -66,6 +79,117 @@ describe("finish_task tool", () => { }); }); + test("records local finish images for unattended task sessions", async () => { + handle = await createTestDatabase(import.meta.url); + seedConversationAndAgentFixture(handle); + handle.storage.sqlite.exec(` + INSERT INTO sessions ( + id, conversation_id, branch_id, owner_agent_id, purpose, status, created_at, updated_at + ) VALUES ( + 'sess_task', 'conv_1', 'branch_1', 'agent_1', 'task', 'active', + '2026-03-30T00:00:00.000Z', '2026-03-30T00:00:00.000Z' + ); + `); + + tempDir = await mkdtemp(path.join(os.tmpdir(), "pokoclaw-finish-task-")); + const imagePath = path.join(tempDir, "chart.png"); + await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + new SecurityService(handle.storage.db).grantScopes({ + ownerAgentId: "agent_1", + grantedBy: "main_agent", + scopes: [{ kind: "fs.read", path: `${tempDir}/**` }], + }); + + const registry = new ToolRegistry([createFinishTaskTool()]); + const result = await registry.execute( + "finish_task", + { + sessionId: "sess_task", + conversationId: "conv_1", + ownerAgentId: "agent_1", + cwd: tempDir, + securityConfig: DEFAULT_CONFIG.security, + storage: handle.storage.db, + }, + { + status: "completed", + summary: "Chart generated.", + finalMessage: "Generated the final chart.", + images: [{ path: "chart.png", alt: "Completion chart" }], + }, + ); + + expect(result.details).toMatchObject({ + taskCompletion: { + status: "completed", + summary: "Chart generated.", + finalMessage: "Generated the final chart.", + images: [ + { + path: await resolveExpectedToolAbsolutePath(imagePath), + displayPath: "chart.png", + alt: "Completion chart", + }, + ], + }, + }); + }); + + test("reports all missing finish image read permissions together", async () => { + handle = await createTestDatabase(import.meta.url); + seedConversationAndAgentFixture(handle); + handle.storage.sqlite.exec(` + INSERT INTO sessions ( + id, conversation_id, branch_id, owner_agent_id, purpose, status, created_at, updated_at + ) VALUES ( + 'sess_task', 'conv_1', 'branch_1', 'agent_1', 'task', 'active', + '2026-03-30T00:00:00.000Z', '2026-03-30T00:00:00.000Z' + ); + `); + + tempDir = await mkdtemp(path.join(os.tmpdir(), "pokoclaw-finish-task-")); + const firstImagePath = path.join(tempDir, "first.png"); + const secondImagePath = path.join(tempDir, "second.png"); + await writeFile(firstImagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + await writeFile(secondImagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const registry = new ToolRegistry([createFinishTaskTool()]); + await expect( + registry.execute( + "finish_task", + { + sessionId: "sess_task", + conversationId: "conv_1", + ownerAgentId: "agent_1", + cwd: tempDir, + securityConfig: DEFAULT_CONFIG.security, + storage: handle.storage.db, + }, + { + status: "completed", + summary: "Charts generated.", + finalMessage: "Generated the final charts.", + images: [{ path: "first.png" }, { path: "second.png" }], + }, + ), + ).rejects.toMatchObject({ + name: "ToolFailure", + kind: "recoverable_error", + details: { + code: "permission_denied", + requestable: true, + entries: expect.arrayContaining([ + expect.objectContaining({ + path: await resolveExpectedToolAbsolutePath(firstImagePath), + }), + expect.objectContaining({ + path: await resolveExpectedToolAbsolutePath(secondImagePath), + }), + ]), + }, + } satisfies Partial); + }); + test("rejects finish_task outside unattended task sessions", async () => { handle = await createTestDatabase(import.meta.url); seedConversationAndAgentFixture(handle);