Skip to content

Commit f2af052

Browse files
authored
perf(imessage): show typing sooner for slow replies (#95621)
Merged via squash. Prepared head SHA: 65e9ad1 Co-authored-by: omarshahine <[email protected]> Co-authored-by: omarshahine <[email protected]> Reviewed-by: @omarshahine
1 parent c6f5725 commit f2af052

4 files changed

Lines changed: 367 additions & 43 deletions

File tree

docs/channels/imessage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
579579
</Accordion>
580580

581581
<Accordion title="Read receipts and typing">
582-
When the private API bridge is up, accepted inbound chats are marked read before dispatch and a typing bubble is shown to the sender while the agent generates. Disable read-marking with:
582+
When the private API bridge is up, accepted inbound chats are marked read and direct chats show a typing bubble as soon as the turn is accepted, while the agent prepares context and generates. Disable read-marking with:
583583

584584
```json5
585585
{

extensions/imessage/src/monitor.last-route.test.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,183 @@ describe("iMessage monitor last-route updates", () => {
256256
});
257257
});
258258

259+
it("keeps direct progress options when imsg lacks native typing support", async () => {
260+
setCachedIMessagePrivateApiStatus("imsg", {
261+
available: true,
262+
v2Ready: true,
263+
selectors: {},
264+
rpcMethods: ["watch.subscribe", "send", "read"],
265+
});
266+
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
267+
expect(params.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
268+
expect(params.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
269+
expect(params.replyOptions?.onToolStart).toBeUndefined();
270+
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
271+
});
272+
273+
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
274+
const client = {
275+
request: vi.fn(async (method: string) => {
276+
if (method === "watch.subscribe") {
277+
return { subscription: 1 };
278+
}
279+
if (method === "typing") {
280+
throw new Error("typing should not start without native typing support");
281+
}
282+
throw new Error(`unexpected imsg method ${method}`);
283+
}),
284+
waitForClose: vi.fn(async () => {
285+
onNotification?.({
286+
method: "message",
287+
params: {
288+
message: {
289+
id: 13,
290+
chat_id: 123,
291+
sender: "+15550001111",
292+
is_from_me: false,
293+
text: "run a long script without native typing",
294+
is_group: false,
295+
created_at: new Date().toISOString(),
296+
},
297+
},
298+
});
299+
await Promise.resolve();
300+
await Promise.resolve();
301+
}),
302+
stop: vi.fn(async () => {}),
303+
};
304+
createIMessageRpcClientMock.mockImplementation(async (params) => {
305+
if (!params?.onNotification) {
306+
throw new Error("expected iMessage notification handler");
307+
}
308+
onNotification = params.onNotification;
309+
return client as never;
310+
});
311+
312+
await monitorIMessageProvider({
313+
config: {
314+
channels: {
315+
imessage: {
316+
dmPolicy: "allowlist",
317+
allowFrom: ["+15550001111"],
318+
sendReadReceipts: false,
319+
},
320+
},
321+
messages: { inbound: { debounceMs: 0 } },
322+
session: { mainKey: "main" },
323+
} as never,
324+
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
325+
});
326+
327+
await vi.waitFor(() => {
328+
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
329+
});
330+
expect(client.request).not.toHaveBeenCalledWith(
331+
"typing",
332+
expect.objectContaining({ typing: true }),
333+
expect.anything(),
334+
);
335+
});
336+
337+
it("starts direct typing before dispatching the inbound turn", async () => {
338+
setCachedIMessagePrivateApiStatus("imsg", {
339+
available: true,
340+
v2Ready: true,
341+
selectors: {},
342+
rpcMethods: ["watch.subscribe", "send", "typing"],
343+
});
344+
345+
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
346+
const earlyTypingClient = {
347+
request: vi.fn(async (method: string) => {
348+
if (method === "typing") {
349+
return { ok: true };
350+
}
351+
throw new Error(`unexpected imsg typing-client method ${method}`);
352+
}),
353+
stop: vi.fn(async () => {}),
354+
};
355+
const watchClient = {
356+
request: vi.fn(async (method: string) => {
357+
if (method === "watch.subscribe") {
358+
return { subscription: 1 };
359+
}
360+
if (method === "typing") {
361+
return { ok: true };
362+
}
363+
throw new Error(`unexpected imsg watch-client method ${method}`);
364+
}),
365+
waitForClose: vi.fn(async () => {
366+
onNotification?.({
367+
method: "message",
368+
params: {
369+
message: {
370+
id: 12,
371+
chat_id: 123,
372+
sender: "+15550001111",
373+
is_from_me: false,
374+
text: "respond after a slow context build",
375+
is_group: false,
376+
created_at: new Date().toISOString(),
377+
},
378+
},
379+
});
380+
await vi.waitFor(() => {
381+
expect(earlyTypingClient.request).toHaveBeenCalledWith(
382+
"typing",
383+
expect.objectContaining({ typing: true, to: "+15550001111" }),
384+
expect.any(Object),
385+
);
386+
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
387+
});
388+
}),
389+
stop: vi.fn(async () => {}),
390+
};
391+
createIMessageRpcClientMock.mockImplementation(async (params) => {
392+
if (params?.onNotification) {
393+
onNotification = params.onNotification;
394+
return watchClient as never;
395+
}
396+
return earlyTypingClient as never;
397+
});
398+
dispatchInboundMessageMock.mockImplementationOnce(async () => {
399+
expect(earlyTypingClient.request).toHaveBeenCalledWith(
400+
"typing",
401+
expect.objectContaining({ typing: true, to: "+15550001111" }),
402+
expect.any(Object),
403+
);
404+
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
405+
});
406+
407+
await monitorIMessageProvider({
408+
config: {
409+
channels: {
410+
imessage: {
411+
dmPolicy: "allowlist",
412+
allowFrom: ["+15550001111"],
413+
sendReadReceipts: false,
414+
},
415+
},
416+
messages: { inbound: { debounceMs: 0 } },
417+
session: { mainKey: "main" },
418+
} as never,
419+
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
420+
});
421+
422+
expect(watchClient.request).not.toHaveBeenCalledWith(
423+
"typing",
424+
expect.objectContaining({ typing: true }),
425+
expect.anything(),
426+
);
427+
await vi.waitFor(() => {
428+
expect(earlyTypingClient.request).toHaveBeenCalledWith(
429+
"typing",
430+
expect.objectContaining({ typing: false, to: "+15550001111" }),
431+
expect.any(Object),
432+
);
433+
});
434+
});
435+
259436
it.each(["never", "message", "thinking"] as const)(
260437
"does not start direct tool typing when typingMode is %s",
261438
async (typingMode) => {
@@ -420,6 +597,87 @@ describe("iMessage monitor last-route updates", () => {
420597
);
421598
});
422599

600+
it("does not wait for read receipts before dispatching the inbound turn", async () => {
601+
setCachedIMessagePrivateApiStatus("imsg", {
602+
available: true,
603+
v2Ready: true,
604+
selectors: {},
605+
rpcMethods: ["watch.subscribe", "read"],
606+
});
607+
608+
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
609+
const readClient = {
610+
request: vi.fn((method: string) => {
611+
if (method === "read") {
612+
return new Promise(() => {});
613+
}
614+
return Promise.reject(new Error(`unexpected imsg read-client method ${method}`));
615+
}),
616+
stop: vi.fn(async () => {}),
617+
};
618+
const watchClient = {
619+
request: vi.fn((method: string) => {
620+
if (method === "watch.subscribe") {
621+
return Promise.resolve({ subscription: 1 });
622+
}
623+
return Promise.reject(new Error(`unexpected imsg watch-client method ${method}`));
624+
}),
625+
waitForClose: vi.fn(async () => {
626+
onNotification?.({
627+
method: "message",
628+
params: {
629+
message: {
630+
id: 11,
631+
chat_id: 123,
632+
sender: "+15550001111",
633+
is_from_me: false,
634+
text: "respond without waiting for read receipt",
635+
is_group: false,
636+
created_at: new Date().toISOString(),
637+
},
638+
},
639+
});
640+
await vi.waitFor(() => {
641+
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
642+
});
643+
}),
644+
stop: vi.fn(async () => {}),
645+
};
646+
createIMessageRpcClientMock.mockImplementation(async (params) => {
647+
if (params?.onNotification) {
648+
onNotification = params.onNotification;
649+
return watchClient as never;
650+
}
651+
return readClient as never;
652+
});
653+
654+
await monitorIMessageProvider({
655+
config: {
656+
channels: {
657+
imessage: {
658+
dmPolicy: "allowlist",
659+
allowFrom: ["+15550001111"],
660+
},
661+
},
662+
messages: { inbound: { debounceMs: 0 } },
663+
session: { mainKey: "main" },
664+
} as never,
665+
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
666+
});
667+
668+
expect(readClient.request).toHaveBeenCalledWith(
669+
"read",
670+
expect.objectContaining({ to: "+15550001111" }),
671+
expect.any(Object),
672+
);
673+
expect(watchClient.request).not.toHaveBeenCalledWith(
674+
"read",
675+
expect.anything(),
676+
expect.anything(),
677+
);
678+
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
679+
});
680+
423681
it.each([
424682
{
425683
label: "flat true",

extensions/imessage/src/monitor/inbound-processing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1087,7 +1087,7 @@ function buildIMessageEchoScope(params: {
10871087
return scopes;
10881088
}
10891089

1090-
function buildDirectIMessageReplyTarget(params: {
1090+
export function buildDirectIMessageReplyTarget(params: {
10911091
cfg: OpenClawConfig;
10921092
accountId?: string | null;
10931093
sender: string;

0 commit comments

Comments
 (0)