diff --git a/docs/claude-config.md b/docs/claude-config.md index 56aa074346..d45d94a59d 100644 --- a/docs/claude-config.md +++ b/docs/claude-config.md @@ -62,6 +62,7 @@ Notes on this configuration: - Outbound media from Claude (via `MEDIA:`) follows provider caps: Web resizes images to the configured target (`inbound.reply.mediaMaxMb`, default 5 MB) within hard limits of 6 MB (image), 16 MB (audio/video voice notes), and 100 MB (documents); Twilio still uses the Funnel host with a 5 MB guard. - Voice notes: set `inbound.transcribeAudio.command` to run a CLI that emits the transcript to stdout (e.g., OpenAI Whisper: `openai api audio.transcriptions.create -m whisper-1 -f {{MediaPath}} --response-format text`). If it succeeds, warelay replaces `Body` with the transcript and adds the original media path plus a `Transcript:` block into the prompt before invoking Claude. - To avoid re-sending long system prompts every turn, set `inbound.reply.session.sendSystemOnce: true` and keep your prompt in `bodyPrefix` or `sessionIntro`; they are sent only on the first message of each session (resets on `/new` or idle expiry). +- Typing indicators: for long-running Claude/command replies, `inbound.reply.typingIntervalSeconds` (or the session-level equivalent) refreshes the “composing” indicator periodically (default 30 s for command replies). ## Testing the setup 1. Start a relay (auto-selects Web when logged in, otherwise Twilio polling): diff --git a/src/index.core.test.ts b/src/index.core.test.ts index 87973e74d8..8e9288c6c5 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -602,32 +602,29 @@ describe("config and templating", () => { }); it("refreshes typing indicator while command runs", async () => { - vi.useFakeTimers(); const onReplyStart = vi.fn(); - const runSpy = vi - .spyOn(index, "runCommandWithTimeout") - .mockImplementation( - () => - new Promise((resolve) => - setTimeout( - () => - resolve({ - stdout: "done\n", - stderr: "", - code: 0, - signal: null, - killed: false, - }), - 35_000, - ), + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + stdout: "done\n", + stderr: "", + code: 0, + signal: null, + killed: false, + }), + 120, ), - ); + ), + ); const cfg = { inbound: { reply: { mode: "command" as const, command: ["echo", "{{Body}}"], - typingIntervalSeconds: 10, + typingIntervalSeconds: 0.02, }, }, }; @@ -638,10 +635,48 @@ describe("config and templating", () => { cfg, runSpy, ); - await vi.advanceTimersByTimeAsync(35_000); + await new Promise((r) => setTimeout(r, 200)); await promise; - expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(4); - vi.useRealTimers(); + expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3); + }); + + it("uses session typing interval override", async () => { + const onReplyStart = vi.fn(); + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + stdout: "done\n", + stderr: "", + code: 0, + signal: null, + killed: false, + }), + 120, + ), + ), + ); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + session: { typingIntervalSeconds: 0.02 }, + }, + }, + }; + + const promise = index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + { onReplyStart }, + cfg, + runSpy, + ); + await new Promise((r) => setTimeout(r, 200)); + await promise; + expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3); }); it("injects Claude output format + print flag when configured", async () => {