Compare commits

..
Author SHA1 Message Date
Peter Steinberger d1ef5571b6 fix: allow settings scroll on Windows (#1780) (thanks @robbyczgw-cla) 2026-01-25 11:33:38 +00:00
Robby 97501eaaca fix: enable scrolling in settings page on Windows
Fixes #1743

The settings page was unable to scroll because .config-layout has
overflow:hidden which blocks child scrolling. Added min-height:0 and
overflow-y:auto to .config-main to enable scrolling within the grid
layout.
2026-01-25 11:26:52 +00:00
23 changed files with 167 additions and 574 deletions

No files matched your search

+1 -2
View File
@@ -32,11 +32,11 @@ Docs: https://docs.clawd.bot
- Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
- Web UI: enable settings page scrolling on Windows. (#1780) Thanks @robbyczgw-cla.
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
@@ -51,7 +51,6 @@ Docs: https://docs.clawd.bot
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
@@ -574,22 +574,46 @@ public actor GatewayChannelActor {
params: [String: AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.connectOrThrow(context: "gateway connect")
do {
try await self.connect()
} catch {
throw self.wrap(error, context: "gateway connect")
}
let id = UUID().uuidString
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
let data: Data
do {
data = try self.encoder.encode(frame)
} catch {
self.logger.error(
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
self.pending[payload.id] = cont
self.pending[id] = cont
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
}
Task {
do {
try await self.task?.send(.data(payload.data))
try await self.task?.send(.data(data))
} catch {
let wrapped = self.wrap(error, context: "gateway send \(method)")
let waiter = self.pending.removeValue(forKey: payload.id)
let waiter = self.pending.removeValue(forKey: id)
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
@@ -619,29 +643,6 @@ public actor GatewayChannelActor {
return Data() // Should not happen, but tolerate empty payloads.
}
public func send(method: String, params: [String: AnyCodable]?) async throws {
try await self.connectOrThrow(context: "gateway connect")
let payload = try self.encodeRequest(method: method, params: params, kind: "send")
guard let task = self.task else {
throw NSError(
domain: "Gateway",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"])
}
do {
try await task.send(.data(payload.data))
} catch {
let wrapped = self.wrap(error, context: "gateway send \(method)")
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
Task { [weak self] in
guard let self else { return }
await self.scheduleReconnect()
}
throw wrapped
}
}
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error {
if let urlError = error as? URLError {
@@ -656,42 +657,6 @@ public actor GatewayChannelActor {
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
}
private func connectOrThrow(context: String) async throws {
do {
try await self.connect()
} catch {
throw self.wrap(error, context: context)
}
}
private func encodeRequest(
method: String,
params: [String: AnyCodable]?,
kind: String) throws -> (id: String, data: Data)
{
let id = UUID().uuidString
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
do {
let data = try self.encoder.encode(frame)
return (id: id, data: data)
} catch {
self.logger.error(
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
}
private func failPending(_ error: Error) async {
let waiters = self.pending
self.pending.removeAll()
@@ -143,7 +143,7 @@ public actor GatewayNodeSession {
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
]
do {
try await channel.send(method: "node.event", params: params)
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
} catch {
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
}
@@ -224,7 +224,7 @@ public actor GatewayNodeSession {
])
}
do {
try await channel.send(method: "node.invoke.result", params: params)
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
} catch {
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
}
@@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import type {
OpenAIRealtimeSTTProvider,
RealtimeSTTSession,
} from "./providers/stt-openai-realtime.js";
import { MediaStreamHandler } from "./media-stream.js";
const createStubSession = (): RealtimeSTTSession => ({
connect: async () => {},
sendAudio: () => {},
waitForTranscript: async () => "",
onPartial: () => {},
onTranscript: () => {},
onSpeechStart: () => {},
close: () => {},
isConnected: () => true,
});
const createStubSttProvider = (): OpenAIRealtimeSTTProvider =>
({
createSession: () => createStubSession(),
}) as unknown as OpenAIRealtimeSTTProvider;
const flush = async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0));
};
const waitForAbort = (signal: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
signal.addEventListener("abort", () => resolve(), { once: true });
});
describe("MediaStreamHandler TTS queue", () => {
it("serializes TTS playback and resolves in order", async () => {
const handler = new MediaStreamHandler({
sttProvider: createStubSttProvider(),
});
const started: number[] = [];
const finished: number[] = [];
let resolveFirst!: () => void;
const firstGate = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const first = handler.queueTts("stream-1", async () => {
started.push(1);
await firstGate;
finished.push(1);
});
const second = handler.queueTts("stream-1", async () => {
started.push(2);
finished.push(2);
});
await flush();
expect(started).toEqual([1]);
resolveFirst();
await first;
await second;
expect(started).toEqual([1, 2]);
expect(finished).toEqual([1, 2]);
});
it("cancels active playback and clears queued items", async () => {
const handler = new MediaStreamHandler({
sttProvider: createStubSttProvider(),
});
let queuedRan = false;
const started: string[] = [];
const active = handler.queueTts("stream-1", async (signal) => {
started.push("active");
await waitForAbort(signal);
});
void handler.queueTts("stream-1", async () => {
queuedRan = true;
});
await flush();
expect(started).toEqual(["active"]);
handler.clearTtsQueue("stream-1");
await active;
await flush();
expect(queuedRan).toBe(false);
});
});
-114
View File
@@ -29,8 +29,6 @@ export interface MediaStreamConfig {
onPartialTranscript?: (callId: string, partial: string) => void;
/** Callback when stream connects */
onConnect?: (callId: string, streamSid: string) => void;
/** Callback when speech starts (barge-in) */
onSpeechStart?: (callId: string) => void;
/** Callback when stream disconnects */
onDisconnect?: (callId: string) => void;
}
@@ -45,13 +43,6 @@ interface StreamSession {
sttSession: RealtimeSTTSession;
}
type TtsQueueEntry = {
playFn: (signal: AbortSignal) => Promise<void>;
controller: AbortController;
resolve: () => void;
reject: (error: unknown) => void;
};
/**
* Manages WebSocket connections for Twilio media streams.
*/
@@ -59,12 +50,6 @@ export class MediaStreamHandler {
private wss: WebSocketServer | null = null;
private sessions = new Map<string, StreamSession>();
private config: MediaStreamConfig;
/** TTS playback queues per stream (serialize audio to prevent overlap) */
private ttsQueues = new Map<string, TtsQueueEntry[]>();
/** Whether TTS is currently playing per stream */
private ttsPlaying = new Map<string, boolean>();
/** Active TTS playback controllers per stream */
private ttsActiveControllers = new Map<string, AbortController>();
constructor(config: MediaStreamConfig) {
this.config = config;
@@ -163,10 +148,6 @@ export class MediaStreamHandler {
this.config.onTranscript?.(callSid, transcript);
});
sttSession.onSpeechStart(() => {
this.config.onSpeechStart?.(callSid);
});
const session: StreamSession = {
callId: callSid,
streamSid,
@@ -196,7 +177,6 @@ export class MediaStreamHandler {
private handleStop(session: StreamSession): void {
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
this.clearTtsState(session.streamSid);
session.sttSession.close();
this.sessions.delete(session.streamSid);
this.config.onDisconnect?.(session.callId);
@@ -248,46 +228,6 @@ export class MediaStreamHandler {
this.sendToStream(streamSid, { event: "clear", streamSid });
}
/**
* Queue a TTS operation for sequential playback.
* Only one TTS operation plays at a time per stream to prevent overlap.
*/
async queueTts(
streamSid: string,
playFn: (signal: AbortSignal) => Promise<void>,
): Promise<void> {
const queue = this.getTtsQueue(streamSid);
let resolveEntry: () => void;
let rejectEntry: (error: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
resolveEntry = resolve;
rejectEntry = reject;
});
queue.push({
playFn,
controller: new AbortController(),
resolve: resolveEntry!,
reject: rejectEntry!,
});
if (!this.ttsPlaying.get(streamSid)) {
void this.processQueue(streamSid);
}
return promise;
}
/**
* Clear TTS queue and interrupt current playback (barge-in).
*/
clearTtsQueue(streamSid: string): void {
const queue = this.getTtsQueue(streamSid);
queue.length = 0;
this.ttsActiveControllers.get(streamSid)?.abort();
this.clearAudio(streamSid);
}
/**
* Get active session by call ID.
*/
@@ -302,65 +242,11 @@ export class MediaStreamHandler {
*/
closeAll(): void {
for (const session of this.sessions.values()) {
this.clearTtsState(session.streamSid);
session.sttSession.close();
session.ws.close();
}
this.sessions.clear();
}
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
const existing = this.ttsQueues.get(streamSid);
if (existing) return existing;
const queue: TtsQueueEntry[] = [];
this.ttsQueues.set(streamSid, queue);
return queue;
}
/**
* Process the TTS queue for a stream.
* Uses iterative approach to avoid stack accumulation from recursion.
*/
private async processQueue(streamSid: string): Promise<void> {
this.ttsPlaying.set(streamSid, true);
while (true) {
const queue = this.ttsQueues.get(streamSid);
if (!queue || queue.length === 0) {
this.ttsPlaying.set(streamSid, false);
this.ttsActiveControllers.delete(streamSid);
return;
}
const entry = queue.shift()!;
this.ttsActiveControllers.set(streamSid, entry.controller);
try {
await entry.playFn(entry.controller.signal);
entry.resolve();
} catch (error) {
if (entry.controller.signal.aborted) {
entry.resolve();
} else {
console.error("[MediaStream] TTS playback error:", error);
entry.reject(error);
}
} finally {
if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
this.ttsActiveControllers.delete(streamSid);
}
}
}
}
private clearTtsState(streamSid: string): void {
const queue = this.ttsQueues.get(streamSid);
if (queue) queue.length = 0;
this.ttsActiveControllers.get(streamSid)?.abort();
this.ttsActiveControllers.delete(streamSid);
this.ttsPlaying.delete(streamSid);
this.ttsQueues.delete(streamSid);
}
}
/**
@@ -38,8 +38,6 @@ export interface RealtimeSTTSession {
onPartial(callback: (partial: string) => void): void;
/** Set callback for final transcripts */
onTranscript(callback: (transcript: string) => void): void;
/** Set callback when speech starts (VAD) */
onSpeechStart(callback: () => void): void;
/** Close the session */
close(): void;
/** Check if session is connected */
@@ -93,7 +91,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
private pendingTranscript = "";
private onTranscriptCallback: ((transcript: string) => void) | null = null;
private onPartialCallback: ((partial: string) => void) | null = null;
private onSpeechStartCallback: (() => void) | null = null;
constructor(
private readonly apiKey: string,
@@ -246,7 +243,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
case "input_audio_buffer.speech_started":
console.log("[RealtimeSTT] Speech started");
this.pendingTranscript = "";
this.onSpeechStartCallback?.();
break;
case "error":
@@ -277,10 +273,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
this.onTranscriptCallback = callback;
}
onSpeechStart(callback: () => void): void {
this.onSpeechStartCallback = callback;
}
async waitForTranscript(timeoutMs = 30000): Promise<string> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
+11 -29
View File
@@ -135,17 +135,6 @@ export class TwilioProvider implements VoiceCallProvider {
this.callStreamMap.delete(callSid);
}
/**
* Clear TTS queue for a call (barge-in).
* Used when user starts speaking to interrupt current TTS playback.
*/
clearTtsQueue(callSid: string): void {
const streamSid = this.callStreamMap.get(callSid);
if (streamSid && this.mediaStreamHandler) {
this.mediaStreamHandler.clearTtsQueue(streamSid);
}
}
/**
* Make an authenticated request to the Twilio API.
*/
@@ -515,7 +504,7 @@ export class TwilioProvider implements VoiceCallProvider {
/**
* Play TTS via core TTS and Twilio Media Streams.
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
* Uses a queue to serialize playback and prevent overlapping audio.
* Uses a jitter buffer to smooth out timing variations.
*/
private async playTtsViaStream(
text: string,
@@ -525,29 +514,22 @@ export class TwilioProvider implements VoiceCallProvider {
throw new Error("TTS provider and media stream handler required");
}
// Generate audio with core TTS (returns mu-law at 8kHz)
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text);
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
const CHUNK_SIZE = 160;
const CHUNK_DELAY_MS = 20;
const handler = this.mediaStreamHandler;
const ttsProvider = this.ttsProvider;
await handler.queueTts(streamSid, async (signal) => {
// Generate audio with core TTS (returns mu-law at 8kHz)
const muLawAudio = await ttsProvider.synthesizeForTelephony(text);
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
if (signal.aborted) break;
handler.sendAudio(streamSid, chunk);
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
this.mediaStreamHandler.sendAudio(streamSid, chunk);
// Pace the audio to match real-time playback
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
if (signal.aborted) break;
}
// Pace the audio to match real-time playback
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
}
if (!signal.aborted) {
// Send a mark to track when audio finishes
handler.sendMark(streamSid, `tts-${Date.now()}`);
}
});
// Send a mark to track when audio finishes
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
}
/**
-10
View File
@@ -78,11 +78,6 @@ export class VoiceCallWebhookServer {
`[voice-call] Transcript for ${providerCallId}: ${transcript}`,
);
// Clear TTS queue on barge-in (user started speaking, interrupt current playback)
if (this.provider.name === "twilio") {
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
}
// Look up our internal call ID from the provider call ID
const call = this.manager.getCallByProviderCallId(providerCallId);
if (!call) {
@@ -114,11 +109,6 @@ export class VoiceCallWebhookServer {
});
}
},
onSpeechStart: (providerCallId) => {
if (this.provider.name === "twilio") {
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
}
},
onPartialTranscript: (callId, partial) => {
console.log(`[voice-call] Partial for ${callId}: ${partial}`);
},
+1 -1
View File
@@ -599,7 +599,7 @@ export async function runEmbeddedPiAgent(
verboseLevel: params.verboseLevel,
reasoningLevel: params.reasoningLevel,
toolResultFormat: resolvedToolResultFormat,
inlineToolResultsAllowed: false,
inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult,
});
log.debug(
@@ -138,38 +138,6 @@ describe("dispatchReplyFromConfig", () => {
);
});
it("does not provide onToolResult when routing cross-provider", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
mocks.routeReply.mockClear();
const cfg = {} as ClawdbotConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "slack",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:999",
});
const replyResolver = async (
_ctx: MsgContext,
opts: GetReplyOptions | undefined,
_cfg: ClawdbotConfig,
) => {
expect(opts?.onToolResult).toBeUndefined();
return { text: "hi" } satisfies ReplyPayload;
};
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({ text: "hi" }),
}),
);
});
it("fast-aborts without calling the reply resolver", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: true,
+19 -3
View File
@@ -206,7 +206,6 @@ export async function dispatchReplyFromConfig(params: {
const sendPayloadAsync = async (
payload: ReplyPayload,
abortSignal?: AbortSignal,
mirror?: boolean,
): Promise<void> => {
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
// but they're guaranteed non-null when this function is called.
@@ -221,7 +220,6 @@ export async function dispatchReplyFromConfig(params: {
threadId: ctx.MessageThreadId,
cfg,
abortSignal,
mirror,
});
if (!result.ok) {
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
@@ -270,6 +268,24 @@ export async function dispatchReplyFromConfig(params: {
ctx,
{
...params.replyOptions,
onToolResult: (payload: ReplyPayload) => {
const run = async () => {
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
channel: ttsChannel,
kind: "tool",
inboundAudio,
ttsAuto: sessionTtsAuto,
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload);
} else {
dispatcher.sendToolResult(ttsPayload);
}
};
return run();
},
onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => {
const ttsPayload = await maybeApplyTtsToPayload({
@@ -281,7 +297,7 @@ export async function dispatchReplyFromConfig(params: {
ttsAuto: sessionTtsAuto,
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload, context?.abortSignal, false);
await sendPayloadAsync(ttsPayload, context?.abortSignal);
} else {
dispatcher.sendBlockReply(ttsPayload);
}
-17
View File
@@ -379,23 +379,6 @@ describe("routeReply", () => {
}),
);
});
it("skips mirror data when mirror is false", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
sessionKey: "agent:main:main",
mirror: false,
cfg: {} as never,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
mirror: undefined,
}),
);
});
});
const emptyRegistry = createRegistry([]);
+8 -11
View File
@@ -33,8 +33,6 @@ export type RouteReplyParams = {
cfg: ClawdbotConfig;
/** Optional abort signal for cooperative cancellation. */
abortSignal?: AbortSignal;
/** Mirror reply into session transcript (default: true when sessionKey is set). */
mirror?: boolean;
};
export type RouteReplyResult = {
@@ -120,15 +118,14 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
replyToId: resolvedReplyToId ?? null,
threadId: resolvedThreadId,
abortSignal,
mirror:
params.mirror !== false && params.sessionKey
? {
sessionKey: params.sessionKey,
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
text,
mediaUrls,
}
: undefined,
mirror: params.sessionKey
? {
sessionKey: params.sessionKey,
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
text,
mediaUrls,
}
: undefined,
});
const last = results.at(-1);
+7 -4
View File
@@ -34,13 +34,15 @@ vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
beforeEach(() => {
dispatchMock.mockReset().mockImplementation(async (params) => {
if ("dispatcher" in params && params.dispatcher) {
params.dispatcher.sendToolResult({ text: "tool update" });
params.dispatcher.sendFinalReply({ text: "final reply" });
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
}
if ("dispatcherOptions" in params && params.dispatcherOptions) {
const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
dispatcher.sendToolResult({ text: "tool update" });
dispatcher.sendFinalReply({ text: "final reply" });
await dispatcher.waitForIdle();
markDispatchIdle();
@@ -51,7 +53,7 @@ beforeEach(() => {
});
describe("discord native commands", () => {
it("skips tool results for native slash commands", { timeout: 60_000 }, async () => {
it("streams tool results for native slash commands", { timeout: 60_000 }, async () => {
const { ChannelType } = await import("@buape/carbon");
const { createDiscordNativeCommand } = await import("./monitor.js");
@@ -95,7 +97,8 @@ describe("discord native commands", () => {
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(reply).toHaveBeenCalledTimes(1);
expect(followUp).toHaveBeenCalledTimes(0);
expect(reply.mock.calls[0]?.[0]?.content).toContain("final");
expect(followUp).toHaveBeenCalledTimes(1);
expect(reply.mock.calls[0]?.[0]?.content).toContain("tool");
expect(followUp.mock.calls[0]?.[0]?.content).toContain("final");
});
});
@@ -277,12 +277,15 @@ describe("monitorIMessageProvider", () => {
expect(ctx.SessionKey).toBe("agent:main:imessage:group:2");
});
it("prefixes final replies with responsePrefix", async () => {
it("prefixes tool and final replies with responsePrefix", async () => {
config = {
...config,
messages: { responsePrefix: "PFX" },
};
replyMock.mockResolvedValue({ text: "final reply" });
replyMock.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool update" });
return { text: "final reply" };
});
const run = monitorIMessageProvider();
await waitForSubscribe();
@@ -304,8 +307,9 @@ describe("monitorIMessageProvider", () => {
closeResolve?.();
await run;
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => {
@@ -210,9 +210,12 @@ describe("monitorSignalProvider tool results", () => {
);
});
it("skips tool summaries with responsePrefix", async () => {
it("sends tool summaries with responsePrefix", async () => {
const abortController = new AbortController();
replyMock.mockResolvedValue({ text: "final reply" });
replyMock.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool update" });
return { text: "final reply" };
});
streamMock.mockImplementation(async ({ onEvent }) => {
const payload = {
@@ -240,8 +243,9 @@ describe("monitorSignalProvider tool results", () => {
await flush();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
@@ -23,8 +23,11 @@ beforeEach(() => {
});
describe("monitorSlackProvider tool results", () => {
it("skips tool summaries with responsePrefix", async () => {
replyMock.mockResolvedValue({ text: "final reply" });
it("sends tool summaries with responsePrefix", async () => {
replyMock.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool update" });
return { text: "final reply" };
});
const controller = new AbortController();
const run = monitorSlackProvider({
@@ -52,8 +55,9 @@ describe("monitorSlackProvider tool results", () => {
controller.abort();
await run;
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
it("drops events with mismatched api_app_id", async () => {
@@ -126,7 +130,10 @@ describe("monitorSlackProvider tool results", () => {
},
};
replyMock.mockResolvedValue({ text: "final reply" });
replyMock.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool update" });
return { text: "final reply" };
});
const controller = new AbortController();
const run = monitorSlackProvider({
@@ -154,8 +161,9 @@ describe("monitorSlackProvider tool results", () => {
controller.abort();
await run;
expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock.mock.calls[0][1]).toBe("final reply");
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls[0][1]).toBe("tool update");
expect(sendMock.mock.calls[1][1]).toBe("final reply");
});
it("preserves RawBody without injecting processed room history", async () => {
@@ -300,7 +300,7 @@ describe("createTelegramBot", () => {
expect.objectContaining({ message_thread_id: 99 }),
);
});
it("skips tool summaries for native slash commands", async () => {
it("streams tool summaries for native slash commands", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
commandSpy.mockReset();
@@ -338,8 +338,9 @@ describe("createTelegramBot", () => {
match: "on",
});
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("final reply");
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("tool update");
expect(sendMessageSpy.mock.calls[1]?.[1]).toContain("final reply");
});
it("dedupes duplicate message updates by update_id", async () => {
onSpy.mockReset();
@@ -217,12 +217,15 @@ describe("createTelegramBot", () => {
expect(call[2]?.reply_to_message_id).toBeUndefined();
}
});
it("prefixes final replies with responsePrefix", async () => {
it("prefixes tool and final replies with responsePrefix", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
replySpy.mockResolvedValue({ text: "final reply" });
replySpy.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool result" });
return { text: "final reply" };
});
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
@@ -242,8 +245,9 @@ describe("createTelegramBot", () => {
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX final reply");
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX tool result");
expect(sendMessageSpy.mock.calls[1][1]).toBe("PFX final reply");
});
it("honors replyToMode=all for threaded replies", async () => {
onSpy.mockReset();
+12 -7
View File
@@ -865,12 +865,15 @@ describe("createTelegramBot", () => {
}
});
it("prefixes final replies with responsePrefix", async () => {
it("prefixes tool and final replies with responsePrefix", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
replySpy.mockResolvedValue({ text: "final reply" });
replySpy.mockImplementation(async (_ctx, opts) => {
await opts?.onToolResult?.({ text: "tool result" });
return { text: "final reply" };
});
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
@@ -890,8 +893,9 @@ describe("createTelegramBot", () => {
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX final reply");
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX tool result");
expect(sendMessageSpy.mock.calls[1][1]).toBe("PFX final reply");
});
it("honors replyToMode=all for threaded replies", async () => {
@@ -2284,7 +2288,7 @@ describe("createTelegramBot", () => {
);
});
it("skips tool summaries for native slash commands", async () => {
it("streams tool summaries for native slash commands", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
commandSpy.mockReset();
@@ -2322,8 +2326,9 @@ describe("createTelegramBot", () => {
match: "on",
});
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("final reply");
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("tool update");
expect(sendMessageSpy.mock.calls[1]?.[1]).toContain("final reply");
});
it("dedupes duplicate message updates by update_id", async () => {
-123
View File
@@ -1,123 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { botApi, botCtorSpy } = vi.hoisted(() => ({
botApi: {
sendMessage: vi.fn(),
setMessageReaction: vi.fn(),
deleteMessage: vi.fn(),
},
botCtorSpy: vi.fn(),
}));
const { loadConfig } = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
}));
const { makeProxyFetch } = vi.hoisted(() => ({
makeProxyFetch: vi.fn(),
}));
const { resolveTelegramFetch } = vi.hoisted(() => ({
resolveTelegramFetch: vi.fn(),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig,
};
});
vi.mock("./proxy.js", () => ({
makeProxyFetch,
}));
vi.mock("./fetch.js", () => ({
resolveTelegramFetch,
}));
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch; timeoutSeconds?: number } },
) {
botCtorSpy(token, options);
}
},
InputFile: class {},
}));
import { deleteMessageTelegram, reactMessageTelegram, sendMessageTelegram } from "./send.js";
describe("telegram proxy client", () => {
const proxyUrl = "http://proxy.test:8080";
beforeEach(() => {
botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
botApi.setMessageReaction.mockResolvedValue(undefined);
botApi.deleteMessage.mockResolvedValue(true);
botCtorSpy.mockReset();
loadConfig.mockReturnValue({
channels: { telegram: { accounts: { foo: { proxy: proxyUrl } } } },
});
makeProxyFetch.mockReset();
resolveTelegramFetch.mockReset();
});
it("uses proxy fetch for sendMessage", async () => {
const proxyFetch = vi.fn();
const fetchImpl = vi.fn();
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch);
await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" });
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
client: expect.objectContaining({ fetch: fetchImpl }),
}),
);
});
it("uses proxy fetch for reactions", async () => {
const proxyFetch = vi.fn();
const fetchImpl = vi.fn();
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch);
await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" });
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
client: expect.objectContaining({ fetch: fetchImpl }),
}),
);
});
it("uses proxy fetch for deleteMessage", async () => {
const proxyFetch = vi.fn();
const fetchImpl = vi.fn();
makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch);
await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" });
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
client: expect.objectContaining({ fetch: fetchImpl }),
}),
);
});
});
+22 -24
View File
@@ -17,9 +17,8 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import { mediaKindFromMime } from "../media/constants.js";
import { isGifMedia } from "../media/mime.js";
import { loadWebMedia } from "../web/media.js";
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
import { renderTelegramHtmlText } from "./format.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { splitTelegramCaption } from "./caption.js";
@@ -77,25 +76,6 @@ function createTelegramHttpLogger(cfg: ReturnType<typeof loadConfig>) {
};
}
function resolveTelegramClientOptions(
account: ResolvedTelegramAccount,
): ApiClientOptions | undefined {
const proxyUrl = account.config.proxy?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
const fetchImpl = resolveTelegramFetch(proxyFetch);
const timeoutSeconds =
typeof account.config.timeoutSeconds === "number" &&
Number.isFinite(account.config.timeoutSeconds)
? Math.max(1, Math.floor(account.config.timeoutSeconds))
: undefined;
return fetchImpl || timeoutSeconds
? {
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
}
: undefined;
}
function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) {
if (explicit?.trim()) return explicit.trim();
if (!params.token) {
@@ -182,7 +162,19 @@ export async function sendMessageTelegram(
const chatId = normalizeChatId(target.chatId);
// Use provided api or create a new Bot instance. The nullish coalescing
// operator ensures api is always defined (Bot.api is always non-null).
const client = resolveTelegramClientOptions(account);
const fetchImpl = resolveTelegramFetch();
const timeoutSeconds =
typeof account.config.timeoutSeconds === "number" &&
Number.isFinite(account.config.timeoutSeconds)
? Math.max(1, Math.floor(account.config.timeoutSeconds))
: undefined;
const client: ApiClientOptions | undefined =
fetchImpl || timeoutSeconds
? {
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
}
: undefined;
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
const mediaUrl = opts.mediaUrl?.trim();
const replyMarkup = buildInlineKeyboard(opts.buttons);
@@ -424,7 +416,10 @@ export async function reactMessageTelegram(
const token = resolveToken(opts.token, account);
const chatId = normalizeChatId(String(chatIdInput));
const messageId = normalizeMessageId(messageIdInput);
const client = resolveTelegramClientOptions(account);
const fetchImpl = resolveTelegramFetch();
const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined;
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
const request = createTelegramRetryRunner({
retry: opts.retry,
@@ -473,7 +468,10 @@ export async function deleteMessageTelegram(
const token = resolveToken(opts.token, account);
const chatId = normalizeChatId(String(chatIdInput));
const messageId = normalizeMessageId(messageIdInput);
const client = resolveTelegramClientOptions(account);
const fetchImpl = resolveTelegramFetch();
const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined;
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
const request = createTelegramRetryRunner({
retry: opts.retry,
@@ -105,7 +105,7 @@ describe("web auto-reply", () => {
vi.useRealTimers();
});
it("skips tool summaries and sends final reply with responsePrefix", async () => {
it("sends tool summaries immediately with responsePrefix", async () => {
setLoadConfigMock(() => ({
channels: { whatsapp: { allowFrom: ["*"] } },
messages: {
@@ -125,7 +125,15 @@ describe("web auto-reply", () => {
return { close: vi.fn() };
};
const resolver = vi.fn().mockResolvedValue({ text: "final" });
const resolver = vi
.fn()
.mockImplementation(
async (_ctx, opts?: { onToolResult?: (r: { text: string }) => Promise<void> }) => {
await opts?.onToolResult?.({ text: "🧩 tool1" });
await opts?.onToolResult?.({ text: "🧩 tool2" });
return { text: "final" };
},
);
await monitorWebChannel(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
@@ -141,7 +149,7 @@ describe("web auto-reply", () => {
});
const replies = reply.mock.calls.map((call) => call[0]);
expect(replies).toEqual(["🦞 final"]);
expect(replies).toEqual(["🦞 🧩 tool1", "🦞 🧩 tool2", "🦞 final"]);
resetLoadConfigMock();
});
it("uses identity.name for messagePrefix when set", async () => {