Skip to content

Commit 3e10811

Browse files
fix(imessage): stage remote media before understanding
1 parent 8fa5ecb commit 3e10811

2 files changed

Lines changed: 112 additions & 0 deletions

File tree

src/auto-reply/reply/get-reply.message-hooks.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,14 @@ registerGetReplyRuntimeOverrides(mocks);
5050
let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig;
5151
let resolveDefaultModelMock: typeof import("./directive-handling.defaults.js").resolveDefaultModel;
5252
let runPreparedReplyMock: typeof import("./get-reply-run.js").runPreparedReply;
53+
let stageSandboxMediaMock: typeof import("./stage-sandbox-media.runtime.js").stageSandboxMedia;
5354

5455
async function loadGetReplyRuntimeForTest() {
5556
({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url }));
5657
({ resolveDefaultModel: resolveDefaultModelMock } =
5758
await import("./directive-handling.defaults.js"));
5859
({ runPreparedReply: runPreparedReplyMock } = await import("./get-reply-run.js"));
60+
({ stageSandboxMedia: stageSandboxMediaMock } = await import("./stage-sandbox-media.runtime.js"));
5961
}
6062

6163
function emptyAliasIndex() {
@@ -101,6 +103,7 @@ describe("getReplyFromConfig message hooks", () => {
101103
mocks.initSessionState.mockReset();
102104
vi.mocked(resolveDefaultModelMock).mockReset();
103105
vi.mocked(runPreparedReplyMock).mockReset();
106+
vi.mocked(stageSandboxMediaMock).mockReset();
104107
vi.mocked(logVerbose).mockReset();
105108

106109
mocks.applyMediaUnderstanding.mockImplementation(async (...args: unknown[]) => {
@@ -141,6 +144,7 @@ describe("getReplyFromConfig message hooks", () => {
141144
aliasIndex: emptyAliasIndex(),
142145
});
143146
vi.mocked(runPreparedReplyMock).mockResolvedValue({ text: "ok" });
147+
vi.mocked(stageSandboxMediaMock).mockResolvedValue({ staged: new Map() });
144148
mocks.initSessionState.mockResolvedValue(
145149
createGetReplySessionState({
146150
sessionKey: "agent:main:telegram:-100123",
@@ -281,6 +285,70 @@ describe("getReplyFromConfig message hooks", () => {
281285
text: "a tiny dot image",
282286
}),
283287
]);
288+
expect(stageSandboxMediaMock).not.toHaveBeenCalled();
289+
});
290+
291+
it("stages remote iMessage media before media understanding", async () => {
292+
const order: string[] = [];
293+
const remotePath = "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg";
294+
const stagedPath = "/tmp/openclaw-remote-cache/photo.jpg";
295+
vi.mocked(stageSandboxMediaMock).mockImplementationOnce(async (params) => {
296+
order.push("stage");
297+
params.ctx.MediaPath = stagedPath;
298+
params.ctx.MediaPaths = [stagedPath];
299+
params.ctx.MediaUrl = stagedPath;
300+
params.ctx.MediaUrls = [stagedPath];
301+
params.sessionCtx.MediaPath = stagedPath;
302+
params.sessionCtx.MediaPaths = [stagedPath];
303+
params.sessionCtx.MediaUrl = stagedPath;
304+
params.sessionCtx.MediaUrls = [stagedPath];
305+
return { staged: new Map([[remotePath, stagedPath]]) };
306+
});
307+
mocks.applyMediaUnderstanding.mockImplementationOnce(async (...args: unknown[]) => {
308+
order.push("understand");
309+
const { ctx } = args[0] as { ctx: MsgContext };
310+
expect(ctx.MediaPath).toBe(stagedPath);
311+
expect(ctx.MediaPaths).toEqual([stagedPath]);
312+
expect(ctx.MediaUrl).toBe(stagedPath);
313+
expect(ctx.MediaUrls).toEqual([stagedPath]);
314+
expect(ctx.MediaStaged).toBe(true);
315+
});
316+
317+
await getReplyFromConfig(
318+
buildCtx({
319+
Provider: "imessage",
320+
Surface: "imessage",
321+
OriginatingChannel: "imessage",
322+
OriginatingTo: "imessage:chat:abc",
323+
ChatType: "direct",
324+
Body: "please describe this",
325+
BodyForAgent: "please describe this",
326+
RawBody: "please describe this",
327+
CommandBody: "please describe this",
328+
BodyForCommands: "please describe this",
329+
SessionKey: "agent:main:imessage:direct:user",
330+
From: "imessage:user",
331+
To: "imessage:chat:abc",
332+
MediaPath: remotePath,
333+
MediaPaths: [remotePath],
334+
MediaUrl: remotePath,
335+
MediaUrls: [remotePath],
336+
MediaType: "image/jpeg",
337+
MediaTypes: ["image/jpeg"],
338+
MediaRemoteHost: "user@gateway-host",
339+
}),
340+
undefined,
341+
withFastReplyConfig({}),
342+
);
343+
344+
expect(order).toEqual(["stage", "understand"]);
345+
expect(stageSandboxMediaMock).toHaveBeenCalledTimes(1);
346+
expect(stageSandboxMediaMock).toHaveBeenCalledWith(
347+
expect.objectContaining({
348+
sessionKey: "agent:main:imessage:direct:user",
349+
workspaceDir: "/tmp/workspace",
350+
}),
351+
);
284352
});
285353

286354
it("emits only preprocessed when no transcript is produced", async () => {

src/auto-reply/reply/get-reply.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,36 @@ async function applyMediaUnderstandingIfNeeded(params: {
187187
}
188188
}
189189

190+
async function stageRemoteInboundMediaBeforeUnderstandingIfNeeded(params: {
191+
ctx: MsgContext;
192+
cfg: OpenClawConfig;
193+
sessionKey?: string;
194+
workspaceDir: string;
195+
}): Promise<boolean> {
196+
if (
197+
!params.sessionKey ||
198+
params.ctx.MediaStaged ||
199+
!normalizeOptionalString(params.ctx.MediaRemoteHost) ||
200+
!hasInboundMedia(params.ctx)
201+
) {
202+
return false;
203+
}
204+
205+
const { stageSandboxMedia } = await loadStageSandboxMediaRuntime();
206+
const result = await stageSandboxMedia({
207+
ctx: params.ctx,
208+
sessionCtx: params.ctx,
209+
cfg: params.cfg,
210+
sessionKey: params.sessionKey,
211+
workspaceDir: params.workspaceDir,
212+
});
213+
if (result.staged.size > 0) {
214+
params.ctx.MediaStaged = true;
215+
return true;
216+
}
217+
return false;
218+
}
219+
190220
async function applyLinkUnderstandingIfNeeded(params: {
191221
ctx: MsgContext;
192222
cfg: OpenClawConfig;
@@ -387,6 +417,20 @@ export async function getReplyFromConfig(
387417
);
388418
const workspaceDir = workspace.dir;
389419

420+
if (
421+
!isFastTestEnv &&
422+
normalizeOptionalString(finalized.MediaRemoteHost) &&
423+
hasInboundMedia(finalized)
424+
) {
425+
await traceGetReplyPhase("reply.stage_remote_media_pre_understanding", () =>
426+
stageRemoteInboundMediaBeforeUnderstandingIfNeeded({
427+
ctx: finalized,
428+
cfg,
429+
sessionKey: agentSessionKey,
430+
workspaceDir,
431+
}),
432+
);
433+
}
390434
if (!isFastTestEnv && hasInboundMedia(finalized)) {
391435
await traceGetReplyPhase("reply.apply_media_understanding", () =>
392436
applyMediaUnderstandingIfNeeded({

0 commit comments

Comments
 (0)