@posthog/agent 2.3.341 → 2.3.346

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -39,10 +39,12 @@ interface Task {
39
39
  };
40
40
  latest_run?: TaskRun;
41
41
  }
42
- type ArtifactType = "plan" | "context" | "reference" | "output" | "artifact" | "tree_snapshot";
42
+ type ArtifactType = "plan" | "context" | "reference" | "output" | "artifact" | "tree_snapshot" | "user_attachment";
43
43
  interface TaskRunArtifact {
44
+ id?: string;
44
45
  name: string;
45
46
  type: ArtifactType;
47
+ source?: string;
46
48
  size?: number;
47
49
  content_type?: string;
48
50
  storage_path?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.341",
3
+ "version": "2.3.346",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { promptToClaude } from "./acp-to-sdk";
3
+
4
+ describe("promptToClaude", () => {
5
+ it("renders file resource links as explicit workspace attachments", () => {
6
+ const result = promptToClaude({
7
+ sessionId: "session-1",
8
+ prompt: [
9
+ {
10
+ type: "resource_link",
11
+ uri: "file:///tmp/workspace/.posthog/attachments/run-1/report.pdf",
12
+ name: "report.pdf",
13
+ },
14
+ ],
15
+ });
16
+
17
+ expect(result.message.content).toEqual([
18
+ {
19
+ type: "text",
20
+ text: [
21
+ "Attached file available in the workspace:",
22
+ "- name: report.pdf",
23
+ "- path: /tmp/workspace/.posthog/attachments/run-1/report.pdf",
24
+ "Use the available tools to inspect this file if needed.",
25
+ ].join("\n"),
26
+ },
27
+ ]);
28
+ });
29
+
30
+ it("preserves non-file resource links as links", () => {
31
+ const result = promptToClaude({
32
+ sessionId: "session-1",
33
+ prompt: [
34
+ {
35
+ type: "resource_link",
36
+ uri: "https://example.com/report.pdf",
37
+ name: "report.pdf",
38
+ },
39
+ ],
40
+ });
41
+
42
+ expect(result.message.content).toEqual([
43
+ {
44
+ type: "text",
45
+ text: "https://example.com/report.pdf",
46
+ },
47
+ ]);
48
+ });
49
+ });
@@ -1,4 +1,5 @@
1
1
  import * as path from "node:path";
2
+ import { fileURLToPath } from "node:url";
2
3
  import type { PromptRequest } from "@agentclientprotocol/sdk";
3
4
  import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
4
5
  import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
@@ -11,11 +12,6 @@ function sdkText(value: string): ContentBlockParam {
11
12
 
12
13
  function formatUriAsLink(uri: string): string {
13
14
  try {
14
- if (uri.startsWith("file://")) {
15
- const filePath = uri.slice(7);
16
- const name = path.basename(filePath) || filePath;
17
- return `[@${name}](${uri})`;
18
- }
19
15
  if (uri.startsWith("zed://")) {
20
16
  const name = path.basename(uri) || uri;
21
17
  return `[@${name}](${uri})`;
@@ -26,6 +22,21 @@ function formatUriAsLink(uri: string): string {
26
22
  }
27
23
  }
28
24
 
25
+ function formatFileAttachment(uri: string): string {
26
+ try {
27
+ const filePath = fileURLToPath(uri);
28
+ const name = path.basename(filePath) || filePath;
29
+ return [
30
+ "Attached file available in the workspace:",
31
+ `- name: ${name}`,
32
+ `- path: ${filePath}`,
33
+ "Use the available tools to inspect this file if needed.",
34
+ ].join("\n");
35
+ } catch {
36
+ return `Attached file available at ${uri}`;
37
+ }
38
+ }
39
+
29
40
  function transformMcpCommand(text: string): string {
30
41
  const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
31
42
  if (mcpMatch) {
@@ -46,7 +57,13 @@ function processPromptChunk(
46
57
  break;
47
58
 
48
59
  case "resource_link":
49
- content.push(sdkText(formatUriAsLink(chunk.uri)));
60
+ content.push(
61
+ sdkText(
62
+ chunk.uri.startsWith("file://")
63
+ ? formatFileAttachment(chunk.uri)
64
+ : formatUriAsLink(chunk.uri),
65
+ ),
66
+ );
50
67
  break;
51
68
 
52
69
  case "resource":
@@ -45,4 +45,36 @@ describe("PostHogAPIClient", () => {
45
45
  expect(refreshApiKey).toHaveBeenCalledTimes(1);
46
46
  expect(mockFetch).toHaveBeenCalledTimes(2);
47
47
  });
48
+
49
+ it("downloads artifacts through the backend endpoint", async () => {
50
+ const client = new PostHogAPIClient({
51
+ apiUrl: "https://app.posthog.com",
52
+ getApiKey: vi.fn().mockResolvedValue("token"),
53
+ projectId: 7,
54
+ });
55
+ const bytes = new TextEncoder().encode("hello world");
56
+
57
+ mockFetch.mockResolvedValueOnce({
58
+ ok: true,
59
+ arrayBuffer: vi.fn().mockResolvedValue(bytes.buffer),
60
+ });
61
+
62
+ const artifact = await client.downloadArtifact(
63
+ "task-1",
64
+ "run-1",
65
+ "tasks/artifacts/team_1/task_task-1/run_run-1/file.txt",
66
+ );
67
+
68
+ expect(artifact).toEqual(bytes.buffer);
69
+ expect(mockFetch).toHaveBeenCalledWith(
70
+ "https://app.posthog.com/api/projects/7/tasks/task-1/runs/run-1/artifacts/download/",
71
+ expect.objectContaining({
72
+ method: "POST",
73
+ body: JSON.stringify({
74
+ storage_path: "tasks/artifacts/team_1/task_task-1/run_run-1/file.txt",
75
+ }),
76
+ headers: expect.any(Headers),
77
+ }),
78
+ );
79
+ });
48
80
  });
@@ -31,7 +31,9 @@ export type TaskRunUpdate = Partial<
31
31
  | "state"
32
32
  | "environment"
33
33
  >
34
- >;
34
+ > & {
35
+ state_remove_keys?: string[];
36
+ };
35
37
 
36
38
  export class PostHogAPIClient {
37
39
  private config: PostHogAPIConfig;
@@ -223,45 +225,26 @@ export class PostHogAPIClient {
223
225
  return response.artifacts ?? [];
224
226
  }
225
227
 
226
- async getArtifactPresignedUrl(
227
- taskId: string,
228
- runId: string,
229
- storagePath: string,
230
- ): Promise<string | null> {
231
- const teamId = this.getTeamId();
232
- try {
233
- const response = await this.apiRequest<{
234
- url: string;
235
- expires_in: number;
236
- }>(
237
- `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/presign/`,
238
- {
239
- method: "POST",
240
- body: JSON.stringify({ storage_path: storagePath }),
241
- },
242
- );
243
- return response.url;
244
- } catch {
245
- return null;
246
- }
247
- }
248
-
249
228
  /**
250
229
  * Download artifact content by storage path
251
- * Gets a presigned URL and fetches the content
230
+ * Streams the file through the PostHog backend so the sandbox does not need
231
+ * direct access to object storage.
252
232
  */
253
233
  async downloadArtifact(
254
234
  taskId: string,
255
235
  runId: string,
256
236
  storagePath: string,
257
237
  ): Promise<ArrayBuffer | null> {
258
- const url = await this.getArtifactPresignedUrl(taskId, runId, storagePath);
259
- if (!url) {
260
- return null;
261
- }
238
+ const teamId = this.getTeamId();
262
239
 
263
240
  try {
264
- const response = await fetch(url);
241
+ const response = await this.performRequestWithRetry(
242
+ `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/download/`,
243
+ {
244
+ method: "POST",
245
+ body: JSON.stringify({ storage_path: storagePath }),
246
+ },
247
+ );
265
248
  if (!response.ok) {
266
249
  throw new Error(`Failed to download artifact: ${response.status}`);
267
250
  }
@@ -8,6 +8,7 @@ import {
8
8
  describe,
9
9
  expect,
10
10
  it,
11
+ vi,
11
12
  } from "vitest";
12
13
  import { createTestRepo, type TestRepo } from "../test/fixtures/api";
13
14
  import { createPostHogHandlers } from "../test/mocks/msw-handlers";
@@ -17,6 +18,11 @@ import { type JwtPayload, SANDBOX_CONNECTION_AUDIENCE } from "./jwt";
17
18
 
18
19
  interface TestableServer {
19
20
  getInitialPromptOverride(run: TaskRun): string | null;
21
+ getClearedPendingUserState(run: TaskRun | null): string[] | null;
22
+ clearPendingInitialPromptState(
23
+ payload: JwtPayload,
24
+ run: TaskRun | null,
25
+ ): Promise<void>;
20
26
  detectAndAttachPrUrl(payload: unknown, update: unknown): void;
21
27
  detectedPrUrl: string | null;
22
28
  buildCloudSystemPrompt(prUrl?: string | null): string;
@@ -294,6 +300,36 @@ describe("AgentServer HTTP Mode", () => {
294
300
  const body = await response.json();
295
301
  expect(body.error).toBe("No active session for this run");
296
302
  }, 20000);
303
+
304
+ it("accepts artifact-only user_message payloads", async () => {
305
+ await createServer().start();
306
+ const token = createToken({ run_id: "different-run-id" });
307
+
308
+ const response = await fetch(`http://localhost:${port}/command`, {
309
+ method: "POST",
310
+ headers: {
311
+ Authorization: `Bearer ${token}`,
312
+ "Content-Type": "application/json",
313
+ },
314
+ body: JSON.stringify({
315
+ jsonrpc: "2.0",
316
+ method: "user_message",
317
+ params: {
318
+ artifacts: [
319
+ {
320
+ id: "artifact-1",
321
+ name: "test.txt",
322
+ storage_path: "tasks/artifacts/test.txt",
323
+ },
324
+ ],
325
+ },
326
+ }),
327
+ });
328
+
329
+ expect(response.status).toBe(400);
330
+ const body = await response.json();
331
+ expect(body.error).toBe("No active session for this run");
332
+ }, 20000);
297
333
  });
298
334
 
299
335
  describe("404 handling", () => {
@@ -350,6 +386,66 @@ describe("AgentServer HTTP Mode", () => {
350
386
  );
351
387
  expect(result).toBeNull();
352
388
  });
389
+
390
+ it("removes pending prompt keys when clearing initial prompt state", async () => {
391
+ const s = createServer();
392
+ const updateTaskRun = vi
393
+ .spyOn(
394
+ (
395
+ s as unknown as {
396
+ posthogAPI: {
397
+ updateTaskRun: (...args: unknown[]) => Promise<unknown>;
398
+ };
399
+ }
400
+ ).posthogAPI,
401
+ "updateTaskRun",
402
+ )
403
+ .mockResolvedValue({} as never);
404
+ const run = {
405
+ id: "test-run-id",
406
+ task: "test-task-id",
407
+ state: {
408
+ sandbox_url: "https://sandbox.example.com",
409
+ sandbox_connect_token: "token",
410
+ pending_user_message: "read this",
411
+ pending_user_artifact_ids: ["artifact-1"],
412
+ pending_user_message_ts: "123.456",
413
+ },
414
+ } as unknown as TaskRun;
415
+
416
+ const nextState = (
417
+ s as unknown as TestableServer
418
+ ).getClearedPendingUserState(run);
419
+ expect(nextState).toEqual([
420
+ "pending_user_message",
421
+ "pending_user_artifact_ids",
422
+ "pending_user_message_ts",
423
+ ]);
424
+
425
+ await (s as unknown as TestableServer).clearPendingInitialPromptState(
426
+ {
427
+ run_id: "test-run-id",
428
+ task_id: "test-task-id",
429
+ team_id: 1,
430
+ user_id: 1,
431
+ distinct_id: "test-distinct-id",
432
+ mode: "interactive",
433
+ },
434
+ run,
435
+ );
436
+
437
+ expect(updateTaskRun).toHaveBeenCalledWith(
438
+ "test-task-id",
439
+ "test-run-id",
440
+ {
441
+ state_remove_keys: [
442
+ "pending_user_message",
443
+ "pending_user_artifact_ids",
444
+ "pending_user_message_ts",
445
+ ],
446
+ },
447
+ );
448
+ });
353
449
  });
354
450
 
355
451
  describe("runtime adapter selection", () => {
@@ -1,3 +1,6 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
1
4
  import type {
2
5
  ContentBlock,
3
6
  RequestPermissionRequest,
@@ -33,13 +36,14 @@ import type {
33
36
  DeviceInfo,
34
37
  LogLevel,
35
38
  TaskRun,
39
+ TaskRunArtifact,
36
40
  TreeSnapshotEvent,
37
41
  } from "../types";
42
+ import { resourceLink } from "../utils/acp-content";
38
43
  import { AsyncMutex } from "../utils/async-mutex";
39
44
  import { getLlmGatewayUrl } from "../utils/gateway";
40
45
  import { Logger } from "../utils/logger";
41
46
  import {
42
- deserializeCloudPrompt,
43
47
  normalizeCloudPromptContent,
44
48
  promptBlocksToText,
45
49
  } from "./cloud-prompt";
@@ -340,7 +344,7 @@ export class AgentServer {
340
344
  });
341
345
  },
342
346
  cancel: () => {
343
- this.logger.info("SSE connection closed");
347
+ this.logger.debug("SSE connection closed");
344
348
  if (this.session?.sseController) {
345
349
  this.session.sseController = null;
346
350
  }
@@ -545,9 +549,29 @@ export class AgentServer {
545
549
  switch (method) {
546
550
  case POSTHOG_NOTIFICATIONS.USER_MESSAGE:
547
551
  case "user_message": {
548
- const prompt = normalizeCloudPromptContent(
549
- params.content as string | ContentBlock[],
550
- );
552
+ this.logger.info("Received user_message command", {
553
+ hasContent:
554
+ typeof params.content === "string"
555
+ ? params.content.trim().length > 0
556
+ : Array.isArray(params.content) && params.content.length > 0,
557
+ artifactCount: Array.isArray(params.artifacts)
558
+ ? params.artifacts.length
559
+ : 0,
560
+ });
561
+ const prompt = await this.buildPromptFromContentAndArtifacts({
562
+ content: params.content as string | ContentBlock[] | undefined,
563
+ artifacts: Array.isArray(params.artifacts)
564
+ ? (params.artifacts as TaskRunArtifact[])
565
+ : [],
566
+ taskId: this.session.payload.task_id,
567
+ runId: this.session.payload.run_id,
568
+ });
569
+ if (prompt.length === 0) {
570
+ throw new Error("User message cannot be empty");
571
+ }
572
+ this.logger.info("Built user_message prompt", {
573
+ blockTypes: prompt.map((block) => block.type),
574
+ });
551
575
  const promptPreview = promptBlocksToText(prompt);
552
576
 
553
577
  this.logger.info(
@@ -1014,7 +1038,7 @@ export class AgentServer {
1014
1038
  const initialPromptOverride = taskRun
1015
1039
  ? this.getInitialPromptOverride(taskRun)
1016
1040
  : null;
1017
- const pendingUserPrompt = this.getPendingUserPrompt(taskRun);
1041
+ const pendingUserPrompt = await this.getPendingUserPrompt(taskRun);
1018
1042
  let initialPrompt: ContentBlock[] = [];
1019
1043
  if (pendingUserPrompt?.length) {
1020
1044
  initialPrompt = pendingUserPrompt;
@@ -1047,6 +1071,8 @@ export class AgentServer {
1047
1071
  stopReason: result.stopReason,
1048
1072
  });
1049
1073
 
1074
+ await this.clearPendingInitialPromptState(payload, taskRun);
1075
+
1050
1076
  if (result.stopReason === "end_turn") {
1051
1077
  void this.syncCloudBranchMetadata(payload);
1052
1078
  }
@@ -1078,7 +1104,7 @@ export class AgentServer {
1078
1104
 
1079
1105
  // Read the pending user prompt from TaskRun state (set by the workflow
1080
1106
  // when the user sends a follow-up message that triggers a resume).
1081
- const pendingUserPrompt = this.getPendingUserPrompt(taskRun);
1107
+ const pendingUserPrompt = await this.getPendingUserPrompt(taskRun);
1082
1108
 
1083
1109
  const sandboxContext = this.resumeState.snapshotApplied
1084
1110
  ? `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 {
1218
1244
  return trimmed.length > 0 ? trimmed : null;
1219
1245
  }
1220
1246
 
1221
- private getPendingUserPrompt(taskRun: TaskRun | null): ContentBlock[] | null {
1247
+ private async getPendingUserPrompt(
1248
+ taskRun: TaskRun | null,
1249
+ ): Promise<ContentBlock[] | null> {
1222
1250
  if (!taskRun) return null;
1223
1251
  const state = taskRun.state as Record<string, unknown> | undefined;
1224
1252
  const message = state?.pending_user_message;
1225
- if (typeof message !== "string") {
1253
+ const artifactIds = Array.isArray(state?.pending_user_artifact_ids)
1254
+ ? state.pending_user_artifact_ids.filter(
1255
+ (artifactId): artifactId is string =>
1256
+ typeof artifactId === "string" && artifactId.trim().length > 0,
1257
+ )
1258
+ : [];
1259
+ const prompt = await this.buildPromptFromContentAndArtifacts({
1260
+ content: typeof message === "string" ? message : undefined,
1261
+ artifacts: this.getArtifactsById(taskRun.artifacts, artifactIds),
1262
+ taskId: taskRun.task,
1263
+ runId: taskRun.id,
1264
+ });
1265
+ this.logger.info("Built pending user prompt", {
1266
+ hasMessage: typeof message === "string" && message.trim().length > 0,
1267
+ requestedArtifactCount: artifactIds.length,
1268
+ blockTypes: prompt.map((block) => block.type),
1269
+ });
1270
+ return prompt.length > 0 ? prompt : null;
1271
+ }
1272
+
1273
+ private getClearedPendingUserState(taskRun: TaskRun | null): string[] | null {
1274
+ const state =
1275
+ taskRun?.state && typeof taskRun.state === "object"
1276
+ ? (taskRun.state as Record<string, unknown>)
1277
+ : null;
1278
+ if (!state) {
1226
1279
  return null;
1227
1280
  }
1228
1281
 
1229
- const prompt = deserializeCloudPrompt(message);
1230
- return prompt.length > 0 ? prompt : null;
1282
+ const pendingKeys = [
1283
+ "pending_user_message",
1284
+ "pending_user_artifact_ids",
1285
+ "pending_user_message_ts",
1286
+ ].filter((key) => key in state);
1287
+
1288
+ return pendingKeys.length > 0 ? pendingKeys : null;
1289
+ }
1290
+
1291
+ private async clearPendingInitialPromptState(
1292
+ payload: JwtPayload,
1293
+ taskRun: TaskRun | null,
1294
+ ): Promise<void> {
1295
+ const stateRemoveKeys = this.getClearedPendingUserState(taskRun);
1296
+ if (!stateRemoveKeys) {
1297
+ return;
1298
+ }
1299
+
1300
+ await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
1301
+ state_remove_keys: stateRemoveKeys,
1302
+ });
1303
+ }
1304
+
1305
+ private async buildPromptFromContentAndArtifacts({
1306
+ content,
1307
+ artifacts,
1308
+ taskId,
1309
+ runId,
1310
+ }: {
1311
+ content?: string | ContentBlock[];
1312
+ artifacts?: TaskRunArtifact[];
1313
+ taskId: string;
1314
+ runId: string;
1315
+ }): Promise<ContentBlock[]> {
1316
+ const contentBlocks = content ? normalizeCloudPromptContent(content) : [];
1317
+ const artifactBlocks = await this.hydrateArtifactsToPrompt(
1318
+ taskId,
1319
+ runId,
1320
+ artifacts ?? [],
1321
+ );
1322
+
1323
+ return [...contentBlocks, ...artifactBlocks];
1324
+ }
1325
+
1326
+ private getArtifactsById(
1327
+ artifacts: TaskRunArtifact[] | undefined,
1328
+ artifactIds: string[],
1329
+ ): TaskRunArtifact[] {
1330
+ if (!artifacts?.length || artifactIds.length === 0) {
1331
+ return [];
1332
+ }
1333
+
1334
+ const artifactsById = new Map(
1335
+ artifacts
1336
+ .filter(
1337
+ (artifact): artifact is TaskRunArtifact & { id: string } =>
1338
+ typeof artifact.id === "string" && artifact.id.trim().length > 0,
1339
+ )
1340
+ .map((artifact) => [artifact.id, artifact]),
1341
+ );
1342
+
1343
+ return artifactIds.flatMap((artifactId) => {
1344
+ const artifact = artifactsById.get(artifactId);
1345
+ if (!artifact) {
1346
+ this.logger.warn("Pending artifact missing from run manifest", {
1347
+ artifactId,
1348
+ });
1349
+ return [];
1350
+ }
1351
+
1352
+ return [artifact];
1353
+ });
1354
+ }
1355
+
1356
+ private async hydrateArtifactsToPrompt(
1357
+ taskId: string,
1358
+ runId: string,
1359
+ artifacts: TaskRunArtifact[],
1360
+ ): Promise<ContentBlock[]> {
1361
+ if (artifacts.length === 0) {
1362
+ return [];
1363
+ }
1364
+
1365
+ this.logger.debug("Hydrating prompt artifacts", {
1366
+ taskId,
1367
+ runId,
1368
+ artifactCount: artifacts.length,
1369
+ artifactNames: artifacts.map((artifact) => artifact.name),
1370
+ });
1371
+
1372
+ return (
1373
+ await Promise.all(
1374
+ artifacts.map((artifact) =>
1375
+ this.hydrateArtifactToPromptBlock(taskId, runId, artifact),
1376
+ ),
1377
+ )
1378
+ ).flatMap((artifactBlock) => (artifactBlock ? [artifactBlock] : []));
1379
+ }
1380
+
1381
+ private async hydrateArtifactToPromptBlock(
1382
+ taskId: string,
1383
+ runId: string,
1384
+ artifact: TaskRunArtifact,
1385
+ ): Promise<ContentBlock | null> {
1386
+ if (!artifact.storage_path) {
1387
+ this.logger.warn("Skipping artifact without storage path", {
1388
+ taskId,
1389
+ runId,
1390
+ artifactName: artifact.name,
1391
+ });
1392
+ return null;
1393
+ }
1394
+
1395
+ const data = await this.posthogAPI.downloadArtifact(
1396
+ taskId,
1397
+ runId,
1398
+ artifact.storage_path,
1399
+ );
1400
+ if (!data) {
1401
+ throw new Error(`Failed to download artifact ${artifact.name}`);
1402
+ }
1403
+
1404
+ const safeName = this.getSafeArtifactName(artifact.name);
1405
+ const artifactDir = join(
1406
+ this.config.repositoryPath ?? "/tmp/workspace",
1407
+ ".posthog",
1408
+ "attachments",
1409
+ runId,
1410
+ artifact.id ?? safeName,
1411
+ );
1412
+ await mkdir(artifactDir, { recursive: true });
1413
+
1414
+ const artifactPath = join(artifactDir, safeName);
1415
+ await writeFile(artifactPath, Buffer.from(data));
1416
+
1417
+ return resourceLink(pathToFileURL(artifactPath).toString(), artifact.name, {
1418
+ ...(artifact.content_type ? { mimeType: artifact.content_type } : {}),
1419
+ ...(typeof artifact.size === "number" ? { size: artifact.size } : {}),
1420
+ });
1421
+ }
1422
+
1423
+ private getSafeArtifactName(name: string): string {
1424
+ const baseName = basename(name).trim();
1425
+ const normalizedName = baseName.replace(/[^\w.-]/g, "_");
1426
+ return normalizedName.length > 0 ? normalizedName : "attachment";
1231
1427
  }
1232
1428
 
1233
1429
  private getResumeRunId(taskRun: TaskRun | null): string | null {
@@ -125,6 +125,16 @@ describe("validateCommandParams", () => {
125
125
  expect(result.success).toBe(true);
126
126
  });
127
127
 
128
+ it("accepts artifact-only user_message payloads", () => {
129
+ const result = validateCommandParams("user_message", {
130
+ artifacts: [
131
+ { id: "artifact-1", storage_path: "tasks/artifacts/file.pdf" },
132
+ ],
133
+ });
134
+
135
+ expect(result.success).toBe(true);
136
+ });
137
+
128
138
  it("rejects empty content array", () => {
129
139
  const result = validateCommandParams("user_message", {
130
140
  content: [],
@@ -41,12 +41,31 @@ export const jsonRpcRequestSchema = z.object({
41
41
 
42
42
  export type JsonRpcRequest = z.infer<typeof jsonRpcRequestSchema>;
43
43
 
44
- export const userMessageParamsSchema = z.object({
45
- content: z.union([
46
- z.string().min(1, "Content is required"),
47
- z.array(z.record(z.string(), z.unknown())).min(1, "Content is required"),
48
- ]),
49
- });
44
+ export const userMessageParamsSchema = z
45
+ .object({
46
+ content: z
47
+ .union([
48
+ z.string().min(1, "Content is required"),
49
+ z
50
+ .array(z.record(z.string(), z.unknown()))
51
+ .min(1, "Content is required"),
52
+ ])
53
+ .optional(),
54
+ artifacts: z.array(z.record(z.string(), z.unknown())).optional(),
55
+ })
56
+ .refine(
57
+ (params) => {
58
+ const hasContent =
59
+ typeof params.content === "string"
60
+ ? params.content.trim().length > 0
61
+ : Array.isArray(params.content) && params.content.length > 0;
62
+ const hasArtifacts =
63
+ Array.isArray(params.artifacts) && params.artifacts.length > 0;
64
+
65
+ return hasContent || hasArtifacts;
66
+ },
67
+ { error: "Either content or artifacts are required" },
68
+ );
50
69
 
51
70
  export const permissionResponseParamsSchema = z.object({
52
71
  requestId: z.string().min(1, "requestId is required"),