From b60aa2488cdec421363d73251475ef760653704b Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Fri, 24 Jul 2026 13:58:12 +0100 Subject: [PATCH 1/4] fix(tasks): expose artifact downloads in task conversations Generated-By: PostHog Code Task-Id: 0a111328-1757-4b88-aec3-0725e3733bdb --- docs/cloud-task-artifacts.md | 2 + packages/core/src/sessions/sessionService.ts | 1 + packages/shared/src/domain-types.ts | 2 + packages/shared/src/sessions.ts | 3 +- .../CloudArtifactDownloads.test.tsx | 78 +++++++++++++ .../components/CloudArtifactDownloads.tsx | 110 ++++++++++++++++++ .../sessions/components/ConversationView.tsx | 2 + .../components/chat-thread/ChatThread.tsx | 18 +-- 8 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx create mode 100644 packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx diff --git a/docs/cloud-task-artifacts.md b/docs/cloud-task-artifacts.md index fa5a17315d..93f1e48342 100644 --- a/docs/cloud-task-artifacts.md +++ b/docs/cloud-task-artifacts.md @@ -4,4 +4,6 @@ Cloud agents run with `/tmp/workspace` as the sandbox workspace. A repository ta Files left in the sandbox filesystem are temporary and cannot be downloaded after the sandbox is released. Agents should call the `upload_artifact` tool for every non-code deliverable they create. The tool accepts files inside the session working directory, uploads them directly to task object storage, and registers them as `output` artifacts on the task run. Registered artifacts are available through the existing task artifact download endpoint. +Uploaded output files appear at the end of the task conversation, where users can download them by name. + Repository changes should continue to be delivered through git rather than duplicated as task artifacts. A single uploaded artifact is limited to 30 MB. diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index d7bc02d754..f9eced579f 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -4673,6 +4673,7 @@ export class SessionService { cloudStatus: run.status, cloudStage: run.stage ?? null, cloudOutput: run.output ?? null, + cloudArtifacts: run.artifacts ?? [], cloudErrorMessage: run.error_message, logUrl: run.log_url ?? session.logUrl, }); diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 273bf9a87b..e070d38788 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -3,6 +3,7 @@ import type { Adapter } from "./adapter"; import type { AgentRuntime } from "./agent-runtime"; import type { DismissalReasonOptionValue } from "./dismissal-reasons"; import type { StoredLogEntry } from "./session-events"; +import type { TaskRunArtifact } from "./task"; // Execution mode schema and type - shared between main and renderer export const executionModeSchema = z.enum([ @@ -180,6 +181,7 @@ export interface TaskRun { error_message: string | null; output: Record | null; // Structured output (PR URL, commit SHA, etc.) state: Record; // Intermediate run state (defaults to {}, never null) + artifacts?: TaskRunArtifact[]; created_at: string; updated_at: string; completed_at: string | null; diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index ca64dcb01e..76a49cd736 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -10,7 +10,7 @@ import type { Adapter } from "./adapter"; import type { SkillButtonId } from "./analytics-events"; import type { ExecutionMode } from "./exec-types"; import type { AcpMessage } from "./session-events"; -import type { TaskRunStatus } from "./task"; +import type { TaskRunArtifact, TaskRunStatus } from "./task"; export type { Adapter }; @@ -94,6 +94,7 @@ export interface AgentSession { cloudStatus?: TaskRunStatus; cloudStage?: string | null; cloudOutput?: Record | null; + cloudArtifacts?: TaskRunArtifact[]; cloudErrorMessage?: string | null; initialPrompt?: ContentBlock[]; cloudBranch?: string | null; diff --git a/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx new file mode 100644 index 0000000000..71d73652e3 --- /dev/null +++ b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx @@ -0,0 +1,78 @@ +import { Theme } from "@radix-ui/themes"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CloudArtifactDownloads } from "./CloudArtifactDownloads"; + +const getCloudAttachmentPreviewUrl = vi.fn(); + +vi.mock("@posthog/core/sessions/sessionService", () => ({ + SESSION_SERVICE: Symbol("SESSION_SERVICE"), +})); + +vi.mock("@posthog/di/react", () => ({ + useService: () => ({ getCloudAttachmentPreviewUrl }), +})); + +vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({ + useSessionSelector: () => undefined, +})); + +const task = { + id: "task-1", + latest_run: { + id: "run-1", + artifacts: [ + { + id: "output-1", + name: "report.pdf", + type: "output", + size: 12_000, + storage_path: "tasks/run-1/report.pdf", + }, + { + id: "internal-1", + name: "handoff.pack", + type: "artifact", + storage_path: "tasks/run-1/handoff.pack", + }, + ], + }, +} as never; + +describe("CloudArtifactDownloads", () => { + beforeEach(() => { + getCloudAttachmentPreviewUrl.mockReset(); + }); + + it("shows output artifacts and opens their download URL", async () => { + getCloudAttachmentPreviewUrl.mockResolvedValue( + "https://files.example/report.pdf", + ); + const open = vi.spyOn(window, "open").mockImplementation(() => null); + + render( + + + , + ); + + expect(screen.getByText("report.pdf")).toBeInTheDocument(); + expect(screen.getByText("12 KB")).toBeInTheDocument(); + expect(screen.queryByText("handoff.pack")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Download" })); + + await waitFor(() => + expect(open).toHaveBeenCalledWith( + "https://files.example/report.pdf", + "_blank", + "noopener,noreferrer", + ), + ); + expect(getCloudAttachmentPreviewUrl).toHaveBeenCalledWith( + "task-1", + "run-1", + "output-1", + ); + }); +}); diff --git a/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx new file mode 100644 index 0000000000..9fd3bfbbe1 --- /dev/null +++ b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx @@ -0,0 +1,110 @@ +import { DownloadSimple } from "@phosphor-icons/react"; +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { useService } from "@posthog/di/react"; +import { Button } from "@posthog/quill"; +import type { TaskRunArtifact } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { useSessionSelector } from "@posthog/ui/features/sessions/sessionStore"; +import { FileIcon } from "@posthog/ui/primitives/FileIcon"; +import { toast } from "@posthog/ui/primitives/toast"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import { useCallback, useMemo, useState } from "react"; + +function formatFileSize(size: number | undefined): string | null { + if (size === undefined) return null; + if (size < 1_000) return `${size} B`; + if (size < 1_000_000) return `${Math.round(size / 1_000)} KB`; + return `${(size / 1_000_000).toFixed(1)} MB`; +} + +export function CloudArtifactDownloads({ + taskId, + task, +}: { + taskId: string | undefined; + task: Task | undefined; +}) { + const sessionService = useService(SESSION_SERVICE); + const sessionArtifacts = useSessionSelector( + taskId, + (session) => session?.cloudArtifacts, + ); + const [downloadingId, setDownloadingId] = useState(null); + const runId = task?.latest_run?.id; + const artifacts = useMemo( + () => + (sessionArtifacts ?? task?.latest_run?.artifacts ?? []).filter( + (artifact) => artifact.type === "output", + ), + [sessionArtifacts, task?.latest_run?.artifacts], + ); + + const downloadArtifact = useCallback( + async (artifact: TaskRunArtifact): Promise => { + if (!taskId || !runId || !artifact.id) return; + setDownloadingId(artifact.id); + try { + const url = await sessionService.getCloudAttachmentPreviewUrl( + taskId, + runId, + artifact.id, + ); + if (!url) { + toast.error("This file is no longer available"); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); + } catch { + toast.error("Couldn't download file"); + } finally { + setDownloadingId(null); + } + }, + [runId, sessionService, taskId], + ); + + if (!runId || artifacts.length === 0) return null; + + return ( + + Files + + {artifacts.map((artifact) => { + const size = formatFileSize(artifact.size); + const canDownload = Boolean(artifact.id); + return ( + + + + {artifact.name} + {size && ( + + {size} + + )} + + + + ); + })} + + + ); +} diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 5a049e930a..e16b3ede16 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -16,6 +16,7 @@ import type { TurnContext, } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; +import { CloudArtifactDownloads } from "@posthog/ui/features/sessions/components/CloudArtifactDownloads"; import { PROMPT_RECALL_HINT_KEY, type PromptRecallHandler, @@ -452,6 +453,7 @@ export function ConversationView({ const footer = (
+ + <> + + + } /> Date: Fri, 24 Jul 2026 14:02:20 +0100 Subject: [PATCH 2/4] chore(tasks): remove artifact UI documentation Generated-By: PostHog Code Task-Id: 0a111328-1757-4b88-aec3-0725e3733bdb --- docs/cloud-task-artifacts.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/cloud-task-artifacts.md b/docs/cloud-task-artifacts.md index 93f1e48342..fa5a17315d 100644 --- a/docs/cloud-task-artifacts.md +++ b/docs/cloud-task-artifacts.md @@ -4,6 +4,4 @@ Cloud agents run with `/tmp/workspace` as the sandbox workspace. A repository ta Files left in the sandbox filesystem are temporary and cannot be downloaded after the sandbox is released. Agents should call the `upload_artifact` tool for every non-code deliverable they create. The tool accepts files inside the session working directory, uploads them directly to task object storage, and registers them as `output` artifacts on the task run. Registered artifacts are available through the existing task artifact download endpoint. -Uploaded output files appear at the end of the task conversation, where users can download them by name. - Repository changes should continue to be delivered through git rather than duplicated as task artifacts. A single uploaded artifact is limited to 30 MB. From 5b1d661c6baf4cad0fea06c056973672a94f6d1d Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Fri, 24 Jul 2026 14:25:31 +0100 Subject: [PATCH 3/4] fix(tasks): address artifact UI quality checks Generated-By: PostHog Code Task-Id: 0a111328-1757-4b88-aec3-0725e3733bdb --- .../src/features/sessions/components/CloudArtifactDownloads.tsx | 2 +- .../ui/src/features/sessions/components/ConversationView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx index 9fd3bfbbe1..4bc43432d9 100644 --- a/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx +++ b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx @@ -86,7 +86,7 @@ export function CloudArtifactDownloads({ {artifact.name} - {size && ( + {size !== null && ( {size} diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index e16b3ede16..9f6ab47eab 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -15,8 +15,8 @@ import type { ConversationItem, TurnContext, } from "@posthog/ui/features/sessions/components/buildConversationItems"; -import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; import { CloudArtifactDownloads } from "@posthog/ui/features/sessions/components/CloudArtifactDownloads"; +import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; import { PROMPT_RECALL_HINT_KEY, type PromptRecallHandler, From 078738ee95386966656bbfef66a8719436319505 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Fri, 24 Jul 2026 14:33:39 +0100 Subject: [PATCH 4/4] fix(tasks): fetch cloud artifact manifests for downloads Generated-By: PostHog Code Task-Id: 0a111328-1757-4b88-aec3-0725e3733bdb --- packages/core/src/sessions/sessionService.ts | 25 +++++++++--- .../CloudArtifactDownloads.test.tsx | 40 ++++++++++++------- .../components/CloudArtifactDownloads.tsx | 35 +++++++++++++--- 3 files changed, 74 insertions(+), 26 deletions(-) diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index f9eced579f..502d92c5a8 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -35,6 +35,7 @@ import { type StoredLogEntry, sendableQueuePrefixLength, sessionSupportsNativeSteer, + type TaskRunArtifact, type TaskRunStatus, } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; @@ -1599,7 +1600,7 @@ export class SessionService { /** Deduplicates concurrent manifest reads when a message renders many images. */ private cloudAttachmentManifestRequests = new Map< string, - Promise> + Promise >(); private idleKilledSubscription: { unsubscribe: () => void } | null = null; /** @@ -7294,22 +7295,34 @@ export class SessionService { } } + async getCloudRunArtifacts( + taskId: string, + runId: string, + ): Promise { + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind !== "ready") return []; + + return this.getCloudAttachmentManifest( + authStatus.auth.client, + `${authStatus.auth.apiHost}:${authStatus.auth.projectId}`, + taskId, + runId, + ); + } + private getCloudAttachmentManifest( client: AuthClient, authIdentity: string, taskId: string, runId: string, - ): Promise> { + ): Promise { const key = `${authIdentity}:${taskId}:${runId}`; const existing = this.cloudAttachmentManifestRequests.get(key); if (existing) return existing; const request = client .getTaskRun(taskId, runId) - .then( - (run: { artifacts?: Array<{ id?: string; storage_path?: string }> }) => - run.artifacts ?? [], - ); + .then((run: { artifacts?: TaskRunArtifact[] }) => run.artifacts ?? []); this.cloudAttachmentManifestRequests.set(key, request); const clear = () => { diff --git a/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx index 71d73652e3..c7cc9f8581 100644 --- a/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx +++ b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx @@ -4,6 +4,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { CloudArtifactDownloads } from "./CloudArtifactDownloads"; const getCloudAttachmentPreviewUrl = vi.fn(); +const fetchedArtifacts = [ + { + id: "output-1", + name: "report.pdf", + type: "output", + size: 12_000, + storage_path: "tasks/run-1/report.pdf", + }, + { + id: "internal-1", + name: "handoff.pack", + type: "artifact", + storage_path: "tasks/run-1/handoff.pack", + }, +]; vi.mock("@posthog/core/sessions/sessionService", () => ({ SESSION_SERVICE: Symbol("SESSION_SERVICE"), @@ -17,25 +32,20 @@ vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({ useSessionSelector: () => undefined, })); +vi.mock("@posthog/ui/features/auth/store", () => ({ + getAuthIdentity: () => "auth-1", + useAuthStateValue: () => "auth-1", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: fetchedArtifacts }), +})); + const task = { id: "task-1", latest_run: { id: "run-1", - artifacts: [ - { - id: "output-1", - name: "report.pdf", - type: "output", - size: 12_000, - storage_path: "tasks/run-1/report.pdf", - }, - { - id: "internal-1", - name: "handoff.pack", - type: "artifact", - storage_path: "tasks/run-1/handoff.pack", - }, - ], + status: "completed", }, } as never; diff --git a/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx index 4bc43432d9..364629846c 100644 --- a/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx +++ b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx @@ -6,11 +6,16 @@ import { import { useService } from "@posthog/di/react"; import { Button } from "@posthog/quill"; import type { TaskRunArtifact } from "@posthog/shared"; -import type { Task } from "@posthog/shared/domain-types"; +import { isTerminalStatus, type Task } from "@posthog/shared/domain-types"; +import { + getAuthIdentity, + useAuthStateValue, +} from "@posthog/ui/features/auth/store"; import { useSessionSelector } from "@posthog/ui/features/sessions/sessionStore"; import { FileIcon } from "@posthog/ui/primitives/FileIcon"; import { toast } from "@posthog/ui/primitives/toast"; import { Box, Flex, Text } from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; function formatFileSize(size: number | undefined): string | null { @@ -32,14 +37,34 @@ export function CloudArtifactDownloads({ taskId, (session) => session?.cloudArtifacts, ); + const cloudStatus = useSessionSelector( + taskId, + (session) => session?.cloudStatus, + ); + const authIdentity = useAuthStateValue(getAuthIdentity); const [downloadingId, setDownloadingId] = useState(null); const runId = task?.latest_run?.id; + const { data: fetchedArtifacts } = useQuery({ + queryKey: ["cloudRunArtifacts", authIdentity, taskId, runId], + queryFn: () => + sessionService.getCloudRunArtifacts(taskId ?? "", runId ?? ""), + enabled: + authIdentity !== null && + taskId !== undefined && + runId !== undefined && + isTerminalStatus(cloudStatus ?? task?.latest_run?.status), + retry: false, + staleTime: Infinity, + }); const artifacts = useMemo( () => - (sessionArtifacts ?? task?.latest_run?.artifacts ?? []).filter( - (artifact) => artifact.type === "output", - ), - [sessionArtifacts, task?.latest_run?.artifacts], + ( + fetchedArtifacts ?? + sessionArtifacts ?? + task?.latest_run?.artifacts ?? + [] + ).filter((artifact) => artifact.type === "output"), + [fetchedArtifacts, sessionArtifacts, task?.latest_run?.artifacts], ); const downloadArtifact = useCallback(