Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 8ea4cb8

Browse files
authored
fix(tasks): expose artifact downloads in task conversations (#3788)
1 parent f4fb079 commit 8ea4cb8

7 files changed

Lines changed: 260 additions & 14 deletions

File tree

‎packages/core/src/sessions/sessionService.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
type StoredLogEntry,
3636
sendableQueuePrefixLength,
3737
sessionSupportsNativeSteer,
38+
type TaskRunArtifact,
3839
type TaskRunStatus,
3940
} from "@posthog/shared";
4041
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
@@ -1599,7 +1600,7 @@ export class SessionService {
15991600
/** Deduplicates concurrent manifest reads when a message renders many images. */
16001601
private cloudAttachmentManifestRequests = new Map<
16011602
string,
1602-
Promise<Array<{ id?: string; storage_path?: string }>>
1603+
Promise<TaskRunArtifact[]>
16031604
>();
16041605
private idleKilledSubscription: { unsubscribe: () => void } | null = null;
16051606
/**
@@ -4673,6 +4674,7 @@ export class SessionService {
46734674
cloudStatus: run.status,
46744675
cloudStage: run.stage ?? null,
46754676
cloudOutput: run.output ?? null,
4677+
cloudArtifacts: run.artifacts ?? [],
46764678
cloudErrorMessage: run.error_message,
46774679
logUrl: run.log_url ?? session.logUrl,
46784680
});
@@ -7293,22 +7295,34 @@ export class SessionService {
72937295
}
72947296
}
72957297

7298+
async getCloudRunArtifacts(
7299+
taskId: string,
7300+
runId: string,
7301+
): Promise<TaskRunArtifact[]> {
7302+
const authStatus = await this.getAuthCredentialsStatus();
7303+
if (authStatus.kind !== "ready") return [];
7304+
7305+
return this.getCloudAttachmentManifest(
7306+
authStatus.auth.client,
7307+
`${authStatus.auth.apiHost}:${authStatus.auth.projectId}`,
7308+
taskId,
7309+
runId,
7310+
);
7311+
}
7312+
72967313
private getCloudAttachmentManifest(
72977314
client: AuthClient,
72987315
authIdentity: string,
72997316
taskId: string,
73007317
runId: string,
7301-
): Promise<Array<{ id?: string; storage_path?: string }>> {
7318+
): Promise<TaskRunArtifact[]> {
73027319
const key = `${authIdentity}:${taskId}:${runId}`;
73037320
const existing = this.cloudAttachmentManifestRequests.get(key);
73047321
if (existing) return existing;
73057322

73067323
const request = client
73077324
.getTaskRun(taskId, runId)
7308-
.then(
7309-
(run: { artifacts?: Array<{ id?: string; storage_path?: string }> }) =>
7310-
run.artifacts ?? [],
7311-
);
7325+
.then((run: { artifacts?: TaskRunArtifact[] }) => run.artifacts ?? []);
73127326
this.cloudAttachmentManifestRequests.set(key, request);
73137327

73147328
const clear = () => {

‎packages/shared/src/domain-types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Adapter } from "./adapter";
33
import type { AgentRuntime } from "./agent-runtime";
44
import type { DismissalReasonOptionValue } from "./dismissal-reasons";
55
import type { StoredLogEntry } from "./session-events";
6+
import type { TaskRunArtifact } from "./task";
67

78
// Execution mode schema and type - shared between main and renderer
89
export const executionModeSchema = z.enum([
@@ -180,6 +181,7 @@ export interface TaskRun {
180181
error_message: string | null;
181182
output: Record<string, unknown> | null; // Structured output (PR URL, commit SHA, etc.)
182183
state: Record<string, unknown>; // Intermediate run state (defaults to {}, never null)
184+
artifacts?: TaskRunArtifact[];
183185
created_at: string;
184186
updated_at: string;
185187
completed_at: string | null;

‎packages/shared/src/sessions.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { Adapter } from "./adapter";
1010
import type { SkillButtonId } from "./analytics-events";
1111
import type { ExecutionMode } from "./exec-types";
1212
import type { AcpMessage } from "./session-events";
13-
import type { TaskRunStatus } from "./task";
13+
import type { TaskRunArtifact, TaskRunStatus } from "./task";
1414

1515
export type { Adapter };
1616

@@ -94,6 +94,7 @@ export interface AgentSession {
9494
cloudStatus?: TaskRunStatus;
9595
cloudStage?: string | null;
9696
cloudOutput?: Record<string, unknown> | null;
97+
cloudArtifacts?: TaskRunArtifact[];
9798
cloudErrorMessage?: string | null;
9899
initialPrompt?: ContentBlock[];
99100
cloudBranch?: string | null;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Theme } from "@radix-ui/themes";
2+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { CloudArtifactDownloads } from "./CloudArtifactDownloads";
5+
6+
const getCloudAttachmentPreviewUrl = vi.fn();
7+
const fetchedArtifacts = [
8+
{
9+
id: "output-1",
10+
name: "report.pdf",
11+
type: "output",
12+
size: 12_000,
13+
storage_path: "tasks/run-1/report.pdf",
14+
},
15+
{
16+
id: "internal-1",
17+
name: "handoff.pack",
18+
type: "artifact",
19+
storage_path: "tasks/run-1/handoff.pack",
20+
},
21+
];
22+
23+
vi.mock("@posthog/core/sessions/sessionService", () => ({
24+
SESSION_SERVICE: Symbol("SESSION_SERVICE"),
25+
}));
26+
27+
vi.mock("@posthog/di/react", () => ({
28+
useService: () => ({ getCloudAttachmentPreviewUrl }),
29+
}));
30+
31+
vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({
32+
useSessionSelector: () => undefined,
33+
}));
34+
35+
vi.mock("@posthog/ui/features/auth/store", () => ({
36+
getAuthIdentity: () => "auth-1",
37+
useAuthStateValue: () => "auth-1",
38+
}));
39+
40+
vi.mock("@tanstack/react-query", () => ({
41+
useQuery: () => ({ data: fetchedArtifacts }),
42+
}));
43+
44+
const task = {
45+
id: "task-1",
46+
latest_run: {
47+
id: "run-1",
48+
status: "completed",
49+
},
50+
} as never;
51+
52+
describe("CloudArtifactDownloads", () => {
53+
beforeEach(() => {
54+
getCloudAttachmentPreviewUrl.mockReset();
55+
});
56+
57+
it("shows output artifacts and opens their download URL", async () => {
58+
getCloudAttachmentPreviewUrl.mockResolvedValue(
59+
"https://files.example/report.pdf",
60+
);
61+
const open = vi.spyOn(window, "open").mockImplementation(() => null);
62+
63+
render(
64+
<Theme>
65+
<CloudArtifactDownloads taskId="task-1" task={task} />
66+
</Theme>,
67+
);
68+
69+
expect(screen.getByText("report.pdf")).toBeInTheDocument();
70+
expect(screen.getByText("12 KB")).toBeInTheDocument();
71+
expect(screen.queryByText("handoff.pack")).not.toBeInTheDocument();
72+
73+
fireEvent.click(screen.getByRole("button", { name: "Download" }));
74+
75+
await waitFor(() =>
76+
expect(open).toHaveBeenCalledWith(
77+
"https://files.example/report.pdf",
78+
"_blank",
79+
"noopener,noreferrer",
80+
),
81+
);
82+
expect(getCloudAttachmentPreviewUrl).toHaveBeenCalledWith(
83+
"task-1",
84+
"run-1",
85+
"output-1",
86+
);
87+
});
88+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { DownloadSimple } from "@phosphor-icons/react";
2+
import {
3+
SESSION_SERVICE,
4+
type SessionService,
5+
} from "@posthog/core/sessions/sessionService";
6+
import { useService } from "@posthog/di/react";
7+
import { Button } from "@posthog/quill";
8+
import type { TaskRunArtifact } from "@posthog/shared";
9+
import { isTerminalStatus, type Task } from "@posthog/shared/domain-types";
10+
import {
11+
getAuthIdentity,
12+
useAuthStateValue,
13+
} from "@posthog/ui/features/auth/store";
14+
import { useSessionSelector } from "@posthog/ui/features/sessions/sessionStore";
15+
import { FileIcon } from "@posthog/ui/primitives/FileIcon";
16+
import { toast } from "@posthog/ui/primitives/toast";
17+
import { Box, Flex, Text } from "@radix-ui/themes";
18+
import { useQuery } from "@tanstack/react-query";
19+
import { useCallback, useMemo, useState } from "react";
20+
21+
function formatFileSize(size: number | undefined): string | null {
22+
if (size === undefined) return null;
23+
if (size < 1_000) return `${size} B`;
24+
if (size < 1_000_000) return `${Math.round(size / 1_000)} KB`;
25+
return `${(size / 1_000_000).toFixed(1)} MB`;
26+
}
27+
28+
export function CloudArtifactDownloads({
29+
taskId,
30+
task,
31+
}: {
32+
taskId: string | undefined;
33+
task: Task | undefined;
34+
}) {
35+
const sessionService = useService<SessionService>(SESSION_SERVICE);
36+
const sessionArtifacts = useSessionSelector(
37+
taskId,
38+
(session) => session?.cloudArtifacts,
39+
);
40+
const cloudStatus = useSessionSelector(
41+
taskId,
42+
(session) => session?.cloudStatus,
43+
);
44+
const authIdentity = useAuthStateValue(getAuthIdentity);
45+
const [downloadingId, setDownloadingId] = useState<string | null>(null);
46+
const runId = task?.latest_run?.id;
47+
const { data: fetchedArtifacts } = useQuery({
48+
queryKey: ["cloudRunArtifacts", authIdentity, taskId, runId],
49+
queryFn: () =>
50+
sessionService.getCloudRunArtifacts(taskId ?? "", runId ?? ""),
51+
enabled:
52+
authIdentity !== null &&
53+
taskId !== undefined &&
54+
runId !== undefined &&
55+
isTerminalStatus(cloudStatus ?? task?.latest_run?.status),
56+
retry: false,
57+
staleTime: Infinity,
58+
});
59+
const artifacts = useMemo(
60+
() =>
61+
(
62+
fetchedArtifacts ??
63+
sessionArtifacts ??
64+
task?.latest_run?.artifacts ??
65+
[]
66+
).filter((artifact) => artifact.type === "output"),
67+
[fetchedArtifacts, sessionArtifacts, task?.latest_run?.artifacts],
68+
);
69+
70+
const downloadArtifact = useCallback(
71+
async (artifact: TaskRunArtifact): Promise<void> => {
72+
if (!taskId || !runId || !artifact.id) return;
73+
setDownloadingId(artifact.id);
74+
try {
75+
const url = await sessionService.getCloudAttachmentPreviewUrl(
76+
taskId,
77+
runId,
78+
artifact.id,
79+
);
80+
if (!url) {
81+
toast.error("This file is no longer available");
82+
return;
83+
}
84+
window.open(url, "_blank", "noopener,noreferrer");
85+
} catch {
86+
toast.error("Couldn't download file");
87+
} finally {
88+
setDownloadingId(null);
89+
}
90+
},
91+
[runId, sessionService, taskId],
92+
);
93+
94+
if (!runId || artifacts.length === 0) return null;
95+
96+
return (
97+
<Box className="mb-3 rounded-lg border border-gray-4 bg-gray-2 p-3">
98+
<Text className="mb-2 block font-medium text-[13px]">Files</Text>
99+
<Flex direction="column" gap="1">
100+
{artifacts.map((artifact) => {
101+
const size = formatFileSize(artifact.size);
102+
const canDownload = Boolean(artifact.id);
103+
return (
104+
<Flex
105+
key={artifact.id ?? artifact.storage_path ?? artifact.name}
106+
align="center"
107+
justify="between"
108+
gap="3"
109+
className="min-w-0 rounded-md bg-background px-2 py-1.5"
110+
>
111+
<Flex align="center" gap="2" className="min-w-0">
112+
<FileIcon filename={artifact.name} size={16} />
113+
<Text className="truncate text-[13px]">{artifact.name}</Text>
114+
{size !== null && (
115+
<Text color="gray" className="shrink-0 text-[12px]">
116+
{size}
117+
</Text>
118+
)}
119+
</Flex>
120+
<Button
121+
size="sm"
122+
variant="outline"
123+
disabled={!canDownload || downloadingId === artifact.id}
124+
onClick={() => void downloadArtifact(artifact)}
125+
>
126+
<DownloadSimple size={14} />
127+
{downloadingId === artifact.id ? "Opening..." : "Download"}
128+
</Button>
129+
</Flex>
130+
);
131+
})}
132+
</Flex>
133+
</Box>
134+
);
135+
}

‎packages/ui/src/features/sessions/components/ConversationView.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ConversationItem,
1616
TurnContext,
1717
} from "@posthog/ui/features/sessions/components/buildConversationItems";
18+
import { CloudArtifactDownloads } from "@posthog/ui/features/sessions/components/CloudArtifactDownloads";
1819
import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar";
1920
import {
2021
PROMPT_RECALL_HINT_KEY,
@@ -452,6 +453,7 @@ export function ConversationView({
452453

453454
const footer = (
454455
<div className={compact ? "pb-1" : "pb-16"}>
456+
<CloudArtifactDownloads taskId={taskId} task={task} />
455457
<SessionFooter
456458
task={task}
457459
isPromptPending={isPromptPending}

‎packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { useSmoothedText } from "@posthog/ui/features/editor/components/useSmoot
3737
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
3838
import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore";
3939
import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems";
40+
import { CloudArtifactDownloads } from "@posthog/ui/features/sessions/components/CloudArtifactDownloads";
4041
import {
4142
ChatMarkdown,
4243
ChatStreamingMarkdown,
@@ -1166,13 +1167,16 @@ function ChatThreadRenderer({
11661167
keyboardFocusedMessageId={keyboardFocusedMessageId}
11671168
onUserInteract={clearKeyboardFocus}
11681169
footer={
1169-
<ChatThreadFooter
1170-
events={footerEvents}
1171-
isPromptPending={isPromptPending}
1172-
promptStartedAt={promptStartedAt}
1173-
task={task}
1174-
taskId={taskId}
1175-
/>
1170+
<>
1171+
<CloudArtifactDownloads taskId={taskId} task={task} />
1172+
<ChatThreadFooter
1173+
events={footerEvents}
1174+
isPromptPending={isPromptPending}
1175+
promptStartedAt={promptStartedAt}
1176+
task={task}
1177+
taskId={taskId}
1178+
/>
1179+
</>
11761180
}
11771181
/>
11781182
<ThreadKeyboardNav

0 commit comments

Comments
 (0)