|
1 | 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; |
2 | 2 | import type { OpenClawConfig } from "../config/config.js"; |
| 3 | +import type { SavedMedia } from "../media/store.js"; |
3 | 4 | import type { loadSessionEntry as loadSessionEntryType } from "./session-utils.js"; |
4 | 5 |
|
5 | 6 | const buildSessionLookup = ( |
@@ -90,6 +91,7 @@ const runtimeMocks = vi.hoisted(() => ({ |
90 | 91 | normalizeMainKey: vi.fn((key?: string | null) => key?.trim() || "agent:main:main"), |
91 | 92 | normalizeRpcAttachmentsToChatAttachments: vi.fn((attachments?: unknown[]) => attachments ?? []), |
92 | 93 | parseMessageWithAttachments: parseMessageWithAttachmentsMock, |
| 94 | + persistChatSendImages: vi.fn(async (): Promise<SavedMedia[]> => []), |
93 | 95 | registerApnsRegistration: registerApnsRegistrationMock, |
94 | 96 | requestHeartbeatNow: vi.fn(), |
95 | 97 | resolveChatAttachmentMaxBytes: vi.fn(() => 20 * 1024 * 1024), |
@@ -123,6 +125,8 @@ const runtimeMocks = vi.hoisted(() => ({ |
123 | 125 | model: entry?.model ?? "default-model", |
124 | 126 | }), |
125 | 127 | ), |
| 128 | + resolveTranscriptPath: vi.fn(() => null as string | null), |
| 129 | + rewriteChatSendUserTurnMediaPaths: vi.fn(async () => {}), |
126 | 130 | sanitizeInboundSystemTags: sanitizeInboundSystemTagsMock, |
127 | 131 | scopedHeartbeatWakeOptions: vi.fn((sessionKey?: string, opts?: { reason: string }) => { |
128 | 132 | const wakeOptions = { reason: opts?.reason }; |
@@ -1015,6 +1019,85 @@ describe("agent request events", () => { |
1015 | 1019 | expect(warn).toHaveBeenCalledWith(expect.stringMatching(/attachment parse failed.*non-image/i)); |
1016 | 1020 | }); |
1017 | 1021 |
|
| 1022 | + it("persists offloaded image refs in transcript user turn for agent.request", async () => { |
| 1023 | + // Regression for #60339: iOS share / node ingress dispatches via |
| 1024 | + // agentCommandFromIngress and previously dropped parsed.offloadedRefs, |
| 1025 | + // leaving the transcript user turn without MediaPath/MediaPaths even |
| 1026 | + // though the media was already on disk. The fix mirrors chat.send's |
| 1027 | + // persistence + rewrite contract. |
| 1028 | + const persistChatSendImagesMock = runtimeMocks.persistChatSendImages; |
| 1029 | + const rewriteChatSendUserTurnMediaPathsMock = runtimeMocks.rewriteChatSendUserTurnMediaPaths; |
| 1030 | + const resolveTranscriptPathMock = runtimeMocks.resolveTranscriptPath; |
| 1031 | + persistChatSendImagesMock.mockClear(); |
| 1032 | + rewriteChatSendUserTurnMediaPathsMock.mockClear(); |
| 1033 | + resolveTranscriptPathMock.mockClear(); |
| 1034 | + |
| 1035 | + parseMessageWithAttachmentsMock.mockResolvedValueOnce({ |
| 1036 | + message: "describe\n[media attached: media://inbound/img-1]", |
| 1037 | + images: [], |
| 1038 | + imageOrder: ["offloaded"], |
| 1039 | + offloadedRefs: [ |
| 1040 | + { |
| 1041 | + id: "img-1", |
| 1042 | + path: "/media/inbound/img-1.jpg", |
| 1043 | + mimeType: "image/jpeg", |
| 1044 | + mediaRef: "media://inbound/img-1", |
| 1045 | + sizeBytes: 2_000_000, |
| 1046 | + label: "photo.jpg", |
| 1047 | + }, |
| 1048 | + ], |
| 1049 | + }); |
| 1050 | + const savedImage: SavedMedia = { |
| 1051 | + id: "img-1", |
| 1052 | + path: "/media/inbound/img-1.jpg", |
| 1053 | + size: 0, |
| 1054 | + contentType: "image/jpeg", |
| 1055 | + }; |
| 1056 | + persistChatSendImagesMock.mockResolvedValueOnce([savedImage]); |
| 1057 | + resolveTranscriptPathMock.mockReturnValueOnce("/sessions/sid-x/session.jsonl"); |
| 1058 | + |
| 1059 | + const ctx = buildCtx(); |
| 1060 | + await handleNodeEvent(ctx, "node-ios-share", { |
| 1061 | + event: "agent.request", |
| 1062 | + payloadJSON: JSON.stringify({ |
| 1063 | + sessionKey: "agent:main:main", |
| 1064 | + message: "describe", |
| 1065 | + attachments: [ |
| 1066 | + { |
| 1067 | + type: "image", |
| 1068 | + mimeType: "image/jpeg", |
| 1069 | + fileName: "photo.jpg", |
| 1070 | + content: "BIG_BASE64_PAYLOAD", |
| 1071 | + }, |
| 1072 | + ], |
| 1073 | + }), |
| 1074 | + }); |
| 1075 | + |
| 1076 | + expect(persistChatSendImagesMock).toHaveBeenCalledTimes(1); |
| 1077 | + expect(persistChatSendImagesMock).toHaveBeenCalledWith( |
| 1078 | + expect.objectContaining({ |
| 1079 | + images: [], |
| 1080 | + imageOrder: ["offloaded"], |
| 1081 | + offloadedRefs: [expect.objectContaining({ id: "img-1" })], |
| 1082 | + client: null, |
| 1083 | + }), |
| 1084 | + ); |
| 1085 | + |
| 1086 | + // Wait for the fire-and-forget agentCommandFromIngress chain's |
| 1087 | + // .finally rewrite to settle. |
| 1088 | + await vi.waitFor(() => { |
| 1089 | + expect(rewriteChatSendUserTurnMediaPathsMock).toHaveBeenCalledTimes(1); |
| 1090 | + }); |
| 1091 | + expect(rewriteChatSendUserTurnMediaPathsMock).toHaveBeenCalledWith( |
| 1092 | + expect.objectContaining({ |
| 1093 | + transcriptPath: "/sessions/sid-x/session.jsonl", |
| 1094 | + sessionKey: "agent:main:main", |
| 1095 | + message: "describe\n[media attached: media://inbound/img-1]", |
| 1096 | + savedImages: [savedImage], |
| 1097 | + }), |
| 1098 | + ); |
| 1099 | + }); |
| 1100 | + |
1018 | 1101 | beforeEach(() => { |
1019 | 1102 | resetNodeEventDeduplicationForTests(); |
1020 | 1103 | updatePairedDeviceMetadataMock.mockClear(); |
|
0 commit comments