diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml index 5fc4e47a8..5b1af6172 100644 --- a/.github/workflows/agent-release.yml +++ b/.github/workflows/agent-release.yml @@ -23,7 +23,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - name: Set up Node 24 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2aff79fdf..4dfed160b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -49,4 +49,4 @@ jobs: run: pnpm --filter agent build - name: Build code - run: pnpm --filter code build \ No newline at end of file + run: pnpm --filter code build diff --git a/.github/workflows/code-release.yml b/.github/workflows/code-release.yml index 4909a49c9..91e7ffd7c 100644 --- a/.github/workflows/code-release.yml +++ b/.github/workflows/code-release.yml @@ -46,7 +46,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -156,7 +156,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6c0f92a07..497c697d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -47,7 +47,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 0701c6f39..630d9e89a 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts new file mode 100644 index 000000000..8d50b6162 --- /dev/null +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { promptToClaude } from "./acp-to-sdk"; + +describe("promptToClaude", () => { + it("renders file resource links as explicit workspace attachments", () => { + const result = promptToClaude({ + sessionId: "session-1", + prompt: [ + { + type: "resource_link", + uri: "file:///tmp/workspace/.posthog/attachments/run-1/report.pdf", + name: "report.pdf", + }, + ], + }); + + expect(result.message.content).toEqual([ + { + type: "text", + text: [ + "Attached file available in the workspace:", + "- name: report.pdf", + "- path: /tmp/workspace/.posthog/attachments/run-1/report.pdf", + "Use the available tools to inspect this file if needed.", + ].join("\n"), + }, + ]); + }); + + it("preserves non-file resource links as links", () => { + const result = promptToClaude({ + sessionId: "session-1", + prompt: [ + { + type: "resource_link", + uri: "https://example.com/report.pdf", + name: "report.pdf", + }, + ], + }); + + expect(result.message.content).toEqual([ + { + type: "text", + text: "https://example.com/report.pdf", + }, + ]); + }); +}); diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts index cadcacf70..1166c922c 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts @@ -1,4 +1,5 @@ import * as path from "node:path"; +import { fileURLToPath } from "node:url"; import type { PromptRequest } from "@agentclientprotocol/sdk"; import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import type { ContentBlockParam } from "@anthropic-ai/sdk/resources"; @@ -11,11 +12,6 @@ function sdkText(value: string): ContentBlockParam { function formatUriAsLink(uri: string): string { try { - if (uri.startsWith("file://")) { - const filePath = uri.slice(7); - const name = path.basename(filePath) || filePath; - return `[@${name}](${uri})`; - } if (uri.startsWith("zed://")) { const name = path.basename(uri) || uri; return `[@${name}](${uri})`; @@ -26,6 +22,21 @@ function formatUriAsLink(uri: string): string { } } +function formatFileAttachment(uri: string): string { + try { + const filePath = fileURLToPath(uri); + const name = path.basename(filePath) || filePath; + return [ + "Attached file available in the workspace:", + `- name: ${name}`, + `- path: ${filePath}`, + "Use the available tools to inspect this file if needed.", + ].join("\n"); + } catch { + return `Attached file available at ${uri}`; + } +} + function transformMcpCommand(text: string): string { const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/); if (mcpMatch) { @@ -46,7 +57,13 @@ function processPromptChunk( break; case "resource_link": - content.push(sdkText(formatUriAsLink(chunk.uri))); + content.push( + sdkText( + chunk.uri.startsWith("file://") + ? formatFileAttachment(chunk.uri) + : formatUriAsLink(chunk.uri), + ), + ); break; case "resource": diff --git a/packages/agent/src/posthog-api.test.ts b/packages/agent/src/posthog-api.test.ts index 4ab3f3bd2..e7abb651e 100644 --- a/packages/agent/src/posthog-api.test.ts +++ b/packages/agent/src/posthog-api.test.ts @@ -45,4 +45,36 @@ describe("PostHogAPIClient", () => { expect(refreshApiKey).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledTimes(2); }); + + it("downloads artifacts through the backend endpoint", async () => { + const client = new PostHogAPIClient({ + apiUrl: "https://app.posthog.com", + getApiKey: vi.fn().mockResolvedValue("token"), + projectId: 7, + }); + const bytes = new TextEncoder().encode("hello world"); + + mockFetch.mockResolvedValueOnce({ + ok: true, + arrayBuffer: vi.fn().mockResolvedValue(bytes.buffer), + }); + + const artifact = await client.downloadArtifact( + "task-1", + "run-1", + "tasks/artifacts/team_1/task_task-1/run_run-1/file.txt", + ); + + expect(artifact).toEqual(bytes.buffer); + expect(mockFetch).toHaveBeenCalledWith( + "https://app.posthog.com/api/projects/7/tasks/task-1/runs/run-1/artifacts/download/", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + storage_path: "tasks/artifacts/team_1/task_task-1/run_run-1/file.txt", + }), + headers: expect.any(Headers), + }), + ); + }); }); diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index f02554dc6..279ddff5e 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -31,7 +31,9 @@ export type TaskRunUpdate = Partial< | "state" | "environment" > ->; +> & { + state_remove_keys?: string[]; +}; export class PostHogAPIClient { private config: PostHogAPIConfig; @@ -223,45 +225,26 @@ export class PostHogAPIClient { return response.artifacts ?? []; } - async getArtifactPresignedUrl( - taskId: string, - runId: string, - storagePath: string, - ): Promise { - const teamId = this.getTeamId(); - try { - const response = await this.apiRequest<{ - url: string; - expires_in: number; - }>( - `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/presign/`, - { - method: "POST", - body: JSON.stringify({ storage_path: storagePath }), - }, - ); - return response.url; - } catch { - return null; - } - } - /** * Download artifact content by storage path - * Gets a presigned URL and fetches the content + * Streams the file through the PostHog backend so the sandbox does not need + * direct access to object storage. */ async downloadArtifact( taskId: string, runId: string, storagePath: string, ): Promise { - const url = await this.getArtifactPresignedUrl(taskId, runId, storagePath); - if (!url) { - return null; - } + const teamId = this.getTeamId(); try { - const response = await fetch(url); + const response = await this.performRequestWithRetry( + `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/download/`, + { + method: "POST", + body: JSON.stringify({ storage_path: storagePath }), + }, + ); if (!response.ok) { throw new Error(`Failed to download artifact: ${response.status}`); } diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 37b17dd0a..93566798f 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, + vi, } from "vitest"; import { createTestRepo, type TestRepo } from "../test/fixtures/api"; import { createPostHogHandlers } from "../test/mocks/msw-handlers"; @@ -17,6 +18,11 @@ import { type JwtPayload, SANDBOX_CONNECTION_AUDIENCE } from "./jwt"; interface TestableServer { getInitialPromptOverride(run: TaskRun): string | null; + getClearedPendingUserState(run: TaskRun | null): string[] | null; + clearPendingInitialPromptState( + payload: JwtPayload, + run: TaskRun | null, + ): Promise; detectAndAttachPrUrl(payload: unknown, update: unknown): void; detectedPrUrl: string | null; buildCloudSystemPrompt(prUrl?: string | null): string; @@ -294,6 +300,36 @@ describe("AgentServer HTTP Mode", () => { const body = await response.json(); expect(body.error).toBe("No active session for this run"); }, 20000); + + it("accepts artifact-only user_message payloads", async () => { + await createServer().start(); + const token = createToken({ run_id: "different-run-id" }); + + const response = await fetch(`http://localhost:${port}/command`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "user_message", + params: { + artifacts: [ + { + id: "artifact-1", + name: "test.txt", + storage_path: "tasks/artifacts/test.txt", + }, + ], + }, + }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("No active session for this run"); + }, 20000); }); describe("404 handling", () => { @@ -350,6 +386,66 @@ describe("AgentServer HTTP Mode", () => { ); expect(result).toBeNull(); }); + + it("removes pending prompt keys when clearing initial prompt state", async () => { + const s = createServer(); + const updateTaskRun = vi + .spyOn( + ( + s as unknown as { + posthogAPI: { + updateTaskRun: (...args: unknown[]) => Promise; + }; + } + ).posthogAPI, + "updateTaskRun", + ) + .mockResolvedValue({} as never); + const run = { + id: "test-run-id", + task: "test-task-id", + state: { + sandbox_url: "https://sandbox.example.com", + sandbox_connect_token: "token", + pending_user_message: "read this", + pending_user_artifact_ids: ["artifact-1"], + pending_user_message_ts: "123.456", + }, + } as unknown as TaskRun; + + const nextState = ( + s as unknown as TestableServer + ).getClearedPendingUserState(run); + expect(nextState).toEqual([ + "pending_user_message", + "pending_user_artifact_ids", + "pending_user_message_ts", + ]); + + await (s as unknown as TestableServer).clearPendingInitialPromptState( + { + run_id: "test-run-id", + task_id: "test-task-id", + team_id: 1, + user_id: 1, + distinct_id: "test-distinct-id", + mode: "interactive", + }, + run, + ); + + expect(updateTaskRun).toHaveBeenCalledWith( + "test-task-id", + "test-run-id", + { + state_remove_keys: [ + "pending_user_message", + "pending_user_artifact_ids", + "pending_user_message_ts", + ], + }, + ); + }); }); describe("runtime adapter selection", () => { diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 95ad11a1f..8698d533c 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1,3 +1,6 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { pathToFileURL } from "node:url"; import type { ContentBlock, RequestPermissionRequest, @@ -33,13 +36,14 @@ import type { DeviceInfo, LogLevel, TaskRun, + TaskRunArtifact, TreeSnapshotEvent, } from "../types"; +import { resourceLink } from "../utils/acp-content"; import { AsyncMutex } from "../utils/async-mutex"; import { getLlmGatewayUrl } from "../utils/gateway"; import { Logger } from "../utils/logger"; import { - deserializeCloudPrompt, normalizeCloudPromptContent, promptBlocksToText, } from "./cloud-prompt"; @@ -340,7 +344,7 @@ export class AgentServer { }); }, cancel: () => { - this.logger.info("SSE connection closed"); + this.logger.debug("SSE connection closed"); if (this.session?.sseController) { this.session.sseController = null; } @@ -545,9 +549,29 @@ export class AgentServer { switch (method) { case POSTHOG_NOTIFICATIONS.USER_MESSAGE: case "user_message": { - const prompt = normalizeCloudPromptContent( - params.content as string | ContentBlock[], - ); + this.logger.info("Received user_message command", { + hasContent: + typeof params.content === "string" + ? params.content.trim().length > 0 + : Array.isArray(params.content) && params.content.length > 0, + artifactCount: Array.isArray(params.artifacts) + ? params.artifacts.length + : 0, + }); + const prompt = await this.buildPromptFromContentAndArtifacts({ + content: params.content as string | ContentBlock[] | undefined, + artifacts: Array.isArray(params.artifacts) + ? (params.artifacts as TaskRunArtifact[]) + : [], + taskId: this.session.payload.task_id, + runId: this.session.payload.run_id, + }); + if (prompt.length === 0) { + throw new Error("User message cannot be empty"); + } + this.logger.info("Built user_message prompt", { + blockTypes: prompt.map((block) => block.type), + }); const promptPreview = promptBlocksToText(prompt); this.logger.info( @@ -1014,7 +1038,7 @@ export class AgentServer { const initialPromptOverride = taskRun ? this.getInitialPromptOverride(taskRun) : null; - const pendingUserPrompt = this.getPendingUserPrompt(taskRun); + const pendingUserPrompt = await this.getPendingUserPrompt(taskRun); let initialPrompt: ContentBlock[] = []; if (pendingUserPrompt?.length) { initialPrompt = pendingUserPrompt; @@ -1047,6 +1071,8 @@ export class AgentServer { stopReason: result.stopReason, }); + await this.clearPendingInitialPromptState(payload, taskRun); + if (result.stopReason === "end_turn") { void this.syncCloudBranchMetadata(payload); } @@ -1078,7 +1104,7 @@ export class AgentServer { // Read the pending user prompt from TaskRun state (set by the workflow // when the user sends a follow-up message that triggers a resume). - const pendingUserPrompt = this.getPendingUserPrompt(taskRun); + const pendingUserPrompt = await this.getPendingUserPrompt(taskRun); const sandboxContext = this.resumeState.snapshotApplied ? `The workspace environment (all files, packages, and code changes) has been fully restored from where you left off.` @@ -1218,16 +1244,186 @@ export class AgentServer { return trimmed.length > 0 ? trimmed : null; } - private getPendingUserPrompt(taskRun: TaskRun | null): ContentBlock[] | null { + private async getPendingUserPrompt( + taskRun: TaskRun | null, + ): Promise { if (!taskRun) return null; const state = taskRun.state as Record | undefined; const message = state?.pending_user_message; - if (typeof message !== "string") { + const artifactIds = Array.isArray(state?.pending_user_artifact_ids) + ? state.pending_user_artifact_ids.filter( + (artifactId): artifactId is string => + typeof artifactId === "string" && artifactId.trim().length > 0, + ) + : []; + const prompt = await this.buildPromptFromContentAndArtifacts({ + content: typeof message === "string" ? message : undefined, + artifacts: this.getArtifactsById(taskRun.artifacts, artifactIds), + taskId: taskRun.task, + runId: taskRun.id, + }); + this.logger.info("Built pending user prompt", { + hasMessage: typeof message === "string" && message.trim().length > 0, + requestedArtifactCount: artifactIds.length, + blockTypes: prompt.map((block) => block.type), + }); + return prompt.length > 0 ? prompt : null; + } + + private getClearedPendingUserState(taskRun: TaskRun | null): string[] | null { + const state = + taskRun?.state && typeof taskRun.state === "object" + ? (taskRun.state as Record) + : null; + if (!state) { return null; } - const prompt = deserializeCloudPrompt(message); - return prompt.length > 0 ? prompt : null; + const pendingKeys = [ + "pending_user_message", + "pending_user_artifact_ids", + "pending_user_message_ts", + ].filter((key) => key in state); + + return pendingKeys.length > 0 ? pendingKeys : null; + } + + private async clearPendingInitialPromptState( + payload: JwtPayload, + taskRun: TaskRun | null, + ): Promise { + const stateRemoveKeys = this.getClearedPendingUserState(taskRun); + if (!stateRemoveKeys) { + return; + } + + await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, { + state_remove_keys: stateRemoveKeys, + }); + } + + private async buildPromptFromContentAndArtifacts({ + content, + artifacts, + taskId, + runId, + }: { + content?: string | ContentBlock[]; + artifacts?: TaskRunArtifact[]; + taskId: string; + runId: string; + }): Promise { + const contentBlocks = content ? normalizeCloudPromptContent(content) : []; + const artifactBlocks = await this.hydrateArtifactsToPrompt( + taskId, + runId, + artifacts ?? [], + ); + + return [...contentBlocks, ...artifactBlocks]; + } + + private getArtifactsById( + artifacts: TaskRunArtifact[] | undefined, + artifactIds: string[], + ): TaskRunArtifact[] { + if (!artifacts?.length || artifactIds.length === 0) { + return []; + } + + const artifactsById = new Map( + artifacts + .filter( + (artifact): artifact is TaskRunArtifact & { id: string } => + typeof artifact.id === "string" && artifact.id.trim().length > 0, + ) + .map((artifact) => [artifact.id, artifact]), + ); + + return artifactIds.flatMap((artifactId) => { + const artifact = artifactsById.get(artifactId); + if (!artifact) { + this.logger.warn("Pending artifact missing from run manifest", { + artifactId, + }); + return []; + } + + return [artifact]; + }); + } + + private async hydrateArtifactsToPrompt( + taskId: string, + runId: string, + artifacts: TaskRunArtifact[], + ): Promise { + if (artifacts.length === 0) { + return []; + } + + this.logger.debug("Hydrating prompt artifacts", { + taskId, + runId, + artifactCount: artifacts.length, + artifactNames: artifacts.map((artifact) => artifact.name), + }); + + return ( + await Promise.all( + artifacts.map((artifact) => + this.hydrateArtifactToPromptBlock(taskId, runId, artifact), + ), + ) + ).flatMap((artifactBlock) => (artifactBlock ? [artifactBlock] : [])); + } + + private async hydrateArtifactToPromptBlock( + taskId: string, + runId: string, + artifact: TaskRunArtifact, + ): Promise { + if (!artifact.storage_path) { + this.logger.warn("Skipping artifact without storage path", { + taskId, + runId, + artifactName: artifact.name, + }); + return null; + } + + const data = await this.posthogAPI.downloadArtifact( + taskId, + runId, + artifact.storage_path, + ); + if (!data) { + throw new Error(`Failed to download artifact ${artifact.name}`); + } + + const safeName = this.getSafeArtifactName(artifact.name); + const artifactDir = join( + this.config.repositoryPath ?? "/tmp/workspace", + ".posthog", + "attachments", + runId, + artifact.id ?? safeName, + ); + await mkdir(artifactDir, { recursive: true }); + + const artifactPath = join(artifactDir, safeName); + await writeFile(artifactPath, Buffer.from(data)); + + return resourceLink(pathToFileURL(artifactPath).toString(), artifact.name, { + ...(artifact.content_type ? { mimeType: artifact.content_type } : {}), + ...(typeof artifact.size === "number" ? { size: artifact.size } : {}), + }); + } + + private getSafeArtifactName(name: string): string { + const baseName = basename(name).trim(); + const normalizedName = baseName.replace(/[^\w.-]/g, "_"); + return normalizedName.length > 0 ? normalizedName : "attachment"; } private getResumeRunId(taskRun: TaskRun | null): string | null { diff --git a/packages/agent/src/server/schemas.test.ts b/packages/agent/src/server/schemas.test.ts index c2926f905..898f1a270 100644 --- a/packages/agent/src/server/schemas.test.ts +++ b/packages/agent/src/server/schemas.test.ts @@ -125,6 +125,16 @@ describe("validateCommandParams", () => { expect(result.success).toBe(true); }); + it("accepts artifact-only user_message payloads", () => { + const result = validateCommandParams("user_message", { + artifacts: [ + { id: "artifact-1", storage_path: "tasks/artifacts/file.pdf" }, + ], + }); + + expect(result.success).toBe(true); + }); + it("rejects empty content array", () => { const result = validateCommandParams("user_message", { content: [], diff --git a/packages/agent/src/server/schemas.ts b/packages/agent/src/server/schemas.ts index 23f0f1cfe..6f27df93a 100644 --- a/packages/agent/src/server/schemas.ts +++ b/packages/agent/src/server/schemas.ts @@ -41,12 +41,31 @@ export const jsonRpcRequestSchema = z.object({ export type JsonRpcRequest = z.infer; -export const userMessageParamsSchema = z.object({ - content: z.union([ - z.string().min(1, "Content is required"), - z.array(z.record(z.string(), z.unknown())).min(1, "Content is required"), - ]), -}); +export const userMessageParamsSchema = z + .object({ + content: z + .union([ + z.string().min(1, "Content is required"), + z + .array(z.record(z.string(), z.unknown())) + .min(1, "Content is required"), + ]) + .optional(), + artifacts: z.array(z.record(z.string(), z.unknown())).optional(), + }) + .refine( + (params) => { + const hasContent = + typeof params.content === "string" + ? params.content.trim().length > 0 + : Array.isArray(params.content) && params.content.length > 0; + const hasArtifacts = + Array.isArray(params.artifacts) && params.artifacts.length > 0; + + return hasContent || hasArtifacts; + }, + { error: "Either content or artifacts are required" }, + ); export const permissionResponseParamsSchema = z.object({ requestId: z.string().min(1, "requestId is required"), diff --git a/packages/agent/src/test/mocks/msw-handlers.ts b/packages/agent/src/test/mocks/msw-handlers.ts index 5d67989da..efbb865f2 100644 --- a/packages/agent/src/test/mocks/msw-handlers.ts +++ b/packages/agent/src/test/mocks/msw-handlers.ts @@ -5,6 +5,7 @@ type AnyHttpResponse = Response | ReturnType; export interface PostHogHandlersOptions { baseUrl?: string; onAppendLog?: (entries: unknown[]) => void; + onUpdateTaskRun?: (body: unknown) => void; getTask?: () => unknown; getTaskRun?: () => unknown; appendLogResponse?: () => AnyHttpResponse; @@ -14,6 +15,7 @@ export function createPostHogHandlers(options: PostHogHandlersOptions = {}) { const { baseUrl = "http://localhost:8000", onAppendLog, + onUpdateTaskRun, getTask, getTaskRun, appendLogResponse, @@ -85,7 +87,8 @@ export function createPostHogHandlers(options: PostHogHandlersOptions = {}) { // PATCH /runs/:runId - Update task run http.patch( `${baseUrl}/api/projects/:projectId/tasks/:taskId/runs/:runId/`, - () => { + async ({ request }) => { + onUpdateTaskRun?.(await request.json()); return HttpResponse.json({}); }, ), diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index d9cddeb21..81046d85b 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -56,11 +56,14 @@ export type ArtifactType = | "reference" | "output" | "artifact" - | "tree_snapshot"; + | "tree_snapshot" + | "user_attachment"; export interface TaskRunArtifact { + id?: string; name: string; type: ArtifactType; + source?: string; size?: number; content_type?: string; storage_path?: string;