fix: stop typing after dispatcher idle
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
|
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp.
|
||||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||||
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
||||||
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export async function getReplyFromConfig(
|
|||||||
silentToken: SILENT_REPLY_TOKEN,
|
silentToken: SILENT_REPLY_TOKEN,
|
||||||
log: defaultRuntime.log,
|
log: defaultRuntime.log,
|
||||||
});
|
});
|
||||||
|
opts?.onTypingController?.(typing);
|
||||||
|
|
||||||
let transcribedText: string | undefined;
|
let transcribedText: string | undefined;
|
||||||
if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) {
|
if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ function createTyping(): TypingController {
|
|||||||
startTypingLoop: vi.fn(async () => {}),
|
startTypingLoop: vi.fn(async () => {}),
|
||||||
startTypingOnText: vi.fn(async () => {}),
|
startTypingOnText: vi.fn(async () => {}),
|
||||||
refreshTypingTtl: vi.fn(),
|
refreshTypingTtl: vi.fn(),
|
||||||
|
markRunComplete: vi.fn(),
|
||||||
|
markDispatchIdle: vi.fn(),
|
||||||
cleanup: vi.fn(),
|
cleanup: vi.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,6 +514,6 @@ export async function runReplyAgent(params: {
|
|||||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
typing.cleanup();
|
typing.markRunComplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ function createTyping(): TypingController {
|
|||||||
startTypingLoop: vi.fn(async () => {}),
|
startTypingLoop: vi.fn(async () => {}),
|
||||||
startTypingOnText: vi.fn(async () => {}),
|
startTypingOnText: vi.fn(async () => {}),
|
||||||
refreshTypingTtl: vi.fn(),
|
refreshTypingTtl: vi.fn(),
|
||||||
|
markRunComplete: vi.fn(),
|
||||||
|
markDispatchIdle: vi.fn(),
|
||||||
cleanup: vi.fn(),
|
cleanup: vi.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,153 +58,157 @@ export function createFollowupRunner(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return async (queued: FollowupRun) => {
|
return async (queued: FollowupRun) => {
|
||||||
const runId = crypto.randomUUID();
|
|
||||||
if (queued.run.sessionKey) {
|
|
||||||
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
|
|
||||||
}
|
|
||||||
let autoCompactionCompleted = false;
|
|
||||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
|
||||||
let fallbackProvider = queued.run.provider;
|
|
||||||
let fallbackModel = queued.run.model;
|
|
||||||
try {
|
try {
|
||||||
const fallbackResult = await runWithModelFallback({
|
const runId = crypto.randomUUID();
|
||||||
cfg: queued.run.config,
|
if (queued.run.sessionKey) {
|
||||||
provider: queued.run.provider,
|
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
|
||||||
model: queued.run.model,
|
}
|
||||||
run: (provider, model) =>
|
let autoCompactionCompleted = false;
|
||||||
runEmbeddedPiAgent({
|
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||||
sessionId: queued.run.sessionId,
|
let fallbackProvider = queued.run.provider;
|
||||||
sessionKey: queued.run.sessionKey,
|
let fallbackModel = queued.run.model;
|
||||||
surface: queued.run.surface,
|
try {
|
||||||
sessionFile: queued.run.sessionFile,
|
const fallbackResult = await runWithModelFallback({
|
||||||
workspaceDir: queued.run.workspaceDir,
|
cfg: queued.run.config,
|
||||||
config: queued.run.config,
|
provider: queued.run.provider,
|
||||||
skillsSnapshot: queued.run.skillsSnapshot,
|
model: queued.run.model,
|
||||||
prompt: queued.prompt,
|
run: (provider, model) =>
|
||||||
extraSystemPrompt: queued.run.extraSystemPrompt,
|
runEmbeddedPiAgent({
|
||||||
ownerNumbers: queued.run.ownerNumbers,
|
sessionId: queued.run.sessionId,
|
||||||
enforceFinalTag: queued.run.enforceFinalTag,
|
sessionKey: queued.run.sessionKey,
|
||||||
provider,
|
surface: queued.run.surface,
|
||||||
model,
|
sessionFile: queued.run.sessionFile,
|
||||||
authProfileId: queued.run.authProfileId,
|
workspaceDir: queued.run.workspaceDir,
|
||||||
thinkLevel: queued.run.thinkLevel,
|
config: queued.run.config,
|
||||||
verboseLevel: queued.run.verboseLevel,
|
skillsSnapshot: queued.run.skillsSnapshot,
|
||||||
bashElevated: queued.run.bashElevated,
|
prompt: queued.prompt,
|
||||||
timeoutMs: queued.run.timeoutMs,
|
extraSystemPrompt: queued.run.extraSystemPrompt,
|
||||||
runId,
|
ownerNumbers: queued.run.ownerNumbers,
|
||||||
blockReplyBreak: queued.run.blockReplyBreak,
|
enforceFinalTag: queued.run.enforceFinalTag,
|
||||||
onAgentEvent: (evt) => {
|
provider,
|
||||||
if (evt.stream !== "compaction") return;
|
model,
|
||||||
const phase = String(evt.data.phase ?? "");
|
authProfileId: queued.run.authProfileId,
|
||||||
const willRetry = Boolean(evt.data.willRetry);
|
thinkLevel: queued.run.thinkLevel,
|
||||||
if (phase === "end" && !willRetry) {
|
verboseLevel: queued.run.verboseLevel,
|
||||||
autoCompactionCompleted = true;
|
bashElevated: queued.run.bashElevated,
|
||||||
}
|
timeoutMs: queued.run.timeoutMs,
|
||||||
},
|
runId,
|
||||||
}),
|
blockReplyBreak: queued.run.blockReplyBreak,
|
||||||
});
|
onAgentEvent: (evt) => {
|
||||||
runResult = fallbackResult.result;
|
if (evt.stream !== "compaction") return;
|
||||||
fallbackProvider = fallbackResult.provider;
|
const phase = String(evt.data.phase ?? "");
|
||||||
fallbackModel = fallbackResult.model;
|
const willRetry = Boolean(evt.data.willRetry);
|
||||||
} catch (err) {
|
if (phase === "end" && !willRetry) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
autoCompactionCompleted = true;
|
||||||
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
|
}
|
||||||
return;
|
},
|
||||||
}
|
}),
|
||||||
|
|
||||||
const payloadArray = runResult.payloads ?? [];
|
|
||||||
if (payloadArray.length === 0) return;
|
|
||||||
const sanitizedPayloads = payloadArray.flatMap((payload) => {
|
|
||||||
const text = payload.text;
|
|
||||||
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
|
||||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
|
||||||
const hasMedia =
|
|
||||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
|
||||||
if (stripped.shouldSkip && !hasMedia) return [];
|
|
||||||
return [{ ...payload, text: stripped.text }];
|
|
||||||
});
|
|
||||||
|
|
||||||
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
|
||||||
.map((payload) => {
|
|
||||||
const { cleaned, replyToId } = extractReplyToTag(payload.text);
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
text: cleaned ? cleaned : undefined,
|
|
||||||
replyToId: replyToId ?? payload.replyToId,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
(payload) =>
|
|
||||||
payload.text ||
|
|
||||||
payload.mediaUrl ||
|
|
||||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (replyTaggedPayloads.length === 0) return;
|
|
||||||
|
|
||||||
if (autoCompactionCompleted) {
|
|
||||||
const count = await incrementCompactionCount({
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey,
|
|
||||||
storePath,
|
|
||||||
});
|
|
||||||
if (queued.run.verboseLevel === "on") {
|
|
||||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
|
||||||
replyTaggedPayloads.unshift({
|
|
||||||
text: `🧹 Auto-compaction complete${suffix}.`,
|
|
||||||
});
|
});
|
||||||
|
runResult = fallbackResult.result;
|
||||||
|
fallbackProvider = fallbackResult.provider;
|
||||||
|
fallbackModel = fallbackResult.model;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionStore && sessionKey) {
|
const payloadArray = runResult.payloads ?? [];
|
||||||
const usage = runResult.meta.agentMeta?.usage;
|
if (payloadArray.length === 0) return;
|
||||||
const modelUsed =
|
const sanitizedPayloads = payloadArray.flatMap((payload) => {
|
||||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
const text = payload.text;
|
||||||
const contextTokensUsed =
|
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
||||||
agentCfgContextTokens ??
|
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||||
lookupContextTokens(modelUsed) ??
|
const hasMedia =
|
||||||
sessionEntry?.contextTokens ??
|
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||||
DEFAULT_CONTEXT_TOKENS;
|
if (stripped.shouldSkip && !hasMedia) return [];
|
||||||
|
return [{ ...payload, text: stripped.text }];
|
||||||
|
});
|
||||||
|
|
||||||
if (usage) {
|
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
||||||
const entry = sessionStore[sessionKey];
|
.map((payload) => {
|
||||||
if (entry) {
|
const { cleaned, replyToId } = extractReplyToTag(payload.text);
|
||||||
const input = usage.input ?? 0;
|
return {
|
||||||
const output = usage.output ?? 0;
|
...payload,
|
||||||
const promptTokens =
|
text: cleaned ? cleaned : undefined,
|
||||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
replyToId: replyToId ?? payload.replyToId,
|
||||||
sessionStore[sessionKey] = {
|
|
||||||
...entry,
|
|
||||||
inputTokens: input,
|
|
||||||
outputTokens: output,
|
|
||||||
totalTokens:
|
|
||||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
|
||||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
|
||||||
model: modelUsed,
|
|
||||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
};
|
||||||
if (storePath) {
|
})
|
||||||
await saveSessionStore(storePath, sessionStore);
|
.filter(
|
||||||
}
|
(payload) =>
|
||||||
|
payload.text ||
|
||||||
|
payload.mediaUrl ||
|
||||||
|
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (replyTaggedPayloads.length === 0) return;
|
||||||
|
|
||||||
|
if (autoCompactionCompleted) {
|
||||||
|
const count = await incrementCompactionCount({
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
|
});
|
||||||
|
if (queued.run.verboseLevel === "on") {
|
||||||
|
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||||
|
replyTaggedPayloads.unshift({
|
||||||
|
text: `🧹 Auto-compaction complete${suffix}.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else if (modelUsed || contextTokensUsed) {
|
}
|
||||||
const entry = sessionStore[sessionKey];
|
|
||||||
if (entry) {
|
if (sessionStore && sessionKey) {
|
||||||
sessionStore[sessionKey] = {
|
const usage = runResult.meta.agentMeta?.usage;
|
||||||
...entry,
|
const modelUsed =
|
||||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||||
model: modelUsed ?? entry.model,
|
const contextTokensUsed =
|
||||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
agentCfgContextTokens ??
|
||||||
};
|
lookupContextTokens(modelUsed) ??
|
||||||
if (storePath) {
|
sessionEntry?.contextTokens ??
|
||||||
await saveSessionStore(storePath, sessionStore);
|
DEFAULT_CONTEXT_TOKENS;
|
||||||
|
|
||||||
|
if (usage) {
|
||||||
|
const entry = sessionStore[sessionKey];
|
||||||
|
if (entry) {
|
||||||
|
const input = usage.input ?? 0;
|
||||||
|
const output = usage.output ?? 0;
|
||||||
|
const promptTokens =
|
||||||
|
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||||
|
sessionStore[sessionKey] = {
|
||||||
|
...entry,
|
||||||
|
inputTokens: input,
|
||||||
|
outputTokens: output,
|
||||||
|
totalTokens:
|
||||||
|
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||||
|
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||||
|
model: modelUsed,
|
||||||
|
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
if (storePath) {
|
||||||
|
await saveSessionStore(storePath, sessionStore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (modelUsed || contextTokensUsed) {
|
||||||
|
const entry = sessionStore[sessionKey];
|
||||||
|
if (entry) {
|
||||||
|
sessionStore[sessionKey] = {
|
||||||
|
...entry,
|
||||||
|
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||||
|
model: modelUsed ?? entry.model,
|
||||||
|
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||||
|
};
|
||||||
|
if (storePath) {
|
||||||
|
await saveSessionStore(storePath, sessionStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await sendFollowupPayloads(replyTaggedPayloads);
|
await sendFollowupPayloads(replyTaggedPayloads);
|
||||||
|
} finally {
|
||||||
|
typing.markRunComplete();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,4 +79,18 @@ describe("createReplyDispatcher", () => {
|
|||||||
await dispatcher.waitForIdle();
|
await dispatcher.waitForIdle();
|
||||||
expect(delivered).toEqual(["tool", "block", "final"]);
|
expect(delivered).toEqual(["tool", "block", "final"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fires onIdle when the queue drains", async () => {
|
||||||
|
const deliver = vi.fn(
|
||||||
|
async () => await new Promise((resolve) => setTimeout(resolve, 5)),
|
||||||
|
);
|
||||||
|
const onIdle = vi.fn();
|
||||||
|
const dispatcher = createReplyDispatcher({ deliver, onIdle });
|
||||||
|
|
||||||
|
dispatcher.sendToolResult({ text: "one" });
|
||||||
|
dispatcher.sendFinalReply({ text: "two" });
|
||||||
|
|
||||||
|
await dispatcher.waitForIdle();
|
||||||
|
expect(onIdle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ReplyDispatcherOptions = {
|
|||||||
deliver: ReplyDispatchDeliverer;
|
deliver: ReplyDispatchDeliverer;
|
||||||
responsePrefix?: string;
|
responsePrefix?: string;
|
||||||
onHeartbeatStrip?: () => void;
|
onHeartbeatStrip?: () => void;
|
||||||
|
onIdle?: () => void;
|
||||||
onError?: ReplyDispatchErrorHandler;
|
onError?: ReplyDispatchErrorHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +71,8 @@ export function createReplyDispatcher(
|
|||||||
options: ReplyDispatcherOptions,
|
options: ReplyDispatcherOptions,
|
||||||
): ReplyDispatcher {
|
): ReplyDispatcher {
|
||||||
let sendChain: Promise<void> = Promise.resolve();
|
let sendChain: Promise<void> = Promise.resolve();
|
||||||
|
// Track in-flight deliveries so we can emit a reliable "idle" signal.
|
||||||
|
let pending = 0;
|
||||||
// Serialize outbound replies to preserve tool/block/final order.
|
// Serialize outbound replies to preserve tool/block/final order.
|
||||||
const queuedCounts: Record<ReplyDispatchKind, number> = {
|
const queuedCounts: Record<ReplyDispatchKind, number> = {
|
||||||
tool: 0,
|
tool: 0,
|
||||||
@@ -81,10 +84,17 @@ export function createReplyDispatcher(
|
|||||||
const normalized = normalizeReplyPayload(payload, options);
|
const normalized = normalizeReplyPayload(payload, options);
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
queuedCounts[kind] += 1;
|
queuedCounts[kind] += 1;
|
||||||
|
pending += 1;
|
||||||
sendChain = sendChain
|
sendChain = sendChain
|
||||||
.then(() => options.deliver(normalized, { kind }))
|
.then(() => options.deliver(normalized, { kind }))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
options.onError?.(err, { kind });
|
options.onError?.(err, { kind });
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending -= 1;
|
||||||
|
if (pending === 0) {
|
||||||
|
options.onIdle?.();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export type TypingController = {
|
|||||||
startTypingLoop: () => Promise<void>;
|
startTypingLoop: () => Promise<void>;
|
||||||
startTypingOnText: (text?: string) => Promise<void>;
|
startTypingOnText: (text?: string) => Promise<void>;
|
||||||
refreshTypingTtl: () => void;
|
refreshTypingTtl: () => void;
|
||||||
|
markRunComplete: () => void;
|
||||||
|
markDispatchIdle: () => void;
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,6 +23,9 @@ export function createTypingController(params: {
|
|||||||
log,
|
log,
|
||||||
} = params;
|
} = params;
|
||||||
let started = false;
|
let started = false;
|
||||||
|
let active = false;
|
||||||
|
let runComplete = false;
|
||||||
|
let dispatchIdle = false;
|
||||||
let typingTimer: NodeJS.Timeout | undefined;
|
let typingTimer: NodeJS.Timeout | undefined;
|
||||||
let typingTtlTimer: NodeJS.Timeout | undefined;
|
let typingTtlTimer: NodeJS.Timeout | undefined;
|
||||||
const typingIntervalMs = typingIntervalSeconds * 1000;
|
const typingIntervalMs = typingIntervalSeconds * 1000;
|
||||||
@@ -30,6 +35,13 @@ export function createTypingController(params: {
|
|||||||
return `${Math.round(ms / 1000)}s`;
|
return `${Math.round(ms / 1000)}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetCycle = () => {
|
||||||
|
started = false;
|
||||||
|
active = false;
|
||||||
|
runComplete = false;
|
||||||
|
dispatchIdle = false;
|
||||||
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (typingTtlTimer) {
|
if (typingTtlTimer) {
|
||||||
clearTimeout(typingTtlTimer);
|
clearTimeout(typingTtlTimer);
|
||||||
@@ -39,6 +51,7 @@ export function createTypingController(params: {
|
|||||||
clearInterval(typingTimer);
|
clearInterval(typingTimer);
|
||||||
typingTimer = undefined;
|
typingTimer = undefined;
|
||||||
}
|
}
|
||||||
|
resetCycle();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshTypingTtl = () => {
|
const refreshTypingTtl = () => {
|
||||||
@@ -61,11 +74,22 @@ export function createTypingController(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureStart = async () => {
|
const ensureStart = async () => {
|
||||||
|
if (!active) {
|
||||||
|
active = true;
|
||||||
|
runComplete = false;
|
||||||
|
dispatchIdle = false;
|
||||||
|
}
|
||||||
if (started) return;
|
if (started) return;
|
||||||
started = true;
|
started = true;
|
||||||
await triggerTyping();
|
await triggerTyping();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const maybeStopOnIdle = () => {
|
||||||
|
if (!active) return;
|
||||||
|
// Stop only when the model run is done and the dispatcher queue is empty.
|
||||||
|
if (runComplete && dispatchIdle) cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
const startTypingLoop = async () => {
|
const startTypingLoop = async () => {
|
||||||
if (!onReplyStart) return;
|
if (!onReplyStart) return;
|
||||||
if (typingIntervalMs <= 0) return;
|
if (typingIntervalMs <= 0) return;
|
||||||
@@ -85,11 +109,23 @@ export function createTypingController(params: {
|
|||||||
await startTypingLoop();
|
await startTypingLoop();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const markRunComplete = () => {
|
||||||
|
runComplete = true;
|
||||||
|
maybeStopOnIdle();
|
||||||
|
};
|
||||||
|
|
||||||
|
const markDispatchIdle = () => {
|
||||||
|
dispatchIdle = true;
|
||||||
|
maybeStopOnIdle();
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onReplyStart: ensureStart,
|
onReplyStart: ensureStart,
|
||||||
startTypingLoop,
|
startTypingLoop,
|
||||||
startTypingOnText,
|
startTypingOnText,
|
||||||
refreshTypingTtl,
|
refreshTypingTtl,
|
||||||
|
markRunComplete,
|
||||||
|
markDispatchIdle,
|
||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import type { TypingController } from "./reply/typing.js";
|
||||||
|
|
||||||
export type GetReplyOptions = {
|
export type GetReplyOptions = {
|
||||||
onReplyStart?: () => Promise<void> | void;
|
onReplyStart?: () => Promise<void> | void;
|
||||||
|
onTypingController?: (typing: TypingController) => void;
|
||||||
isHeartbeat?: boolean;
|
isHeartbeat?: boolean;
|
||||||
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
|
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
onBlockReply?: (payload: ReplyPayload) => Promise<void> | void;
|
onBlockReply?: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "../auto-reply/reply/mentions.js";
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import type { TypingController } from "../auto-reply/reply/typing.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type {
|
import type {
|
||||||
DiscordSlashCommandConfig,
|
DiscordSlashCommandConfig,
|
||||||
@@ -541,6 +542,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
|
let typingController: TypingController | undefined;
|
||||||
const dispatcher = createReplyDispatcher({
|
const dispatcher = createReplyDispatcher({
|
||||||
responsePrefix: cfg.messages?.responsePrefix,
|
responsePrefix: cfg.messages?.responsePrefix,
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
@@ -554,6 +556,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
});
|
});
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
},
|
},
|
||||||
|
onIdle: () => {
|
||||||
|
typingController?.markDispatchIdle();
|
||||||
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(
|
runtime.error?.(
|
||||||
danger(`discord ${info.kind} reply failed: ${String(err)}`),
|
danger(`discord ${info.kind} reply failed: ${String(err)}`),
|
||||||
@@ -565,6 +570,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
ctxPayload,
|
ctxPayload,
|
||||||
{
|
{
|
||||||
onReplyStart: () => sendTyping(message),
|
onReplyStart: () => sendTyping(message),
|
||||||
|
onTypingController: (typing) => {
|
||||||
|
typingController = typing;
|
||||||
|
},
|
||||||
onToolResult: (payload) => {
|
onToolResult: (payload) => {
|
||||||
dispatcher.sendToolResult(payload);
|
dispatcher.sendToolResult(payload);
|
||||||
},
|
},
|
||||||
@@ -584,6 +592,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
}
|
}
|
||||||
await dispatcher.waitForIdle();
|
await dispatcher.waitForIdle();
|
||||||
|
typingController?.markDispatchIdle();
|
||||||
if (!queuedFinal) {
|
if (!queuedFinal) {
|
||||||
if (
|
if (
|
||||||
isGuildMessage &&
|
isGuildMessage &&
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../auto-reply/reply/mentions.js";
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import type { TypingController } from "../auto-reply/reply/typing.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type { ReplyToMode } from "../config/config.js";
|
import type { ReplyToMode } from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
@@ -235,6 +236,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let typingController: TypingController | undefined;
|
||||||
const dispatcher = createReplyDispatcher({
|
const dispatcher = createReplyDispatcher({
|
||||||
responsePrefix: cfg.messages?.responsePrefix,
|
responsePrefix: cfg.messages?.responsePrefix,
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
@@ -248,6 +250,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
textLimit,
|
textLimit,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onIdle: () => {
|
||||||
|
typingController?.markDispatchIdle();
|
||||||
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(
|
runtime.error?.(
|
||||||
danger(`telegram ${info.kind} reply failed: ${String(err)}`),
|
danger(`telegram ${info.kind} reply failed: ${String(err)}`),
|
||||||
@@ -259,6 +264,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
ctxPayload,
|
ctxPayload,
|
||||||
{
|
{
|
||||||
onReplyStart: sendTyping,
|
onReplyStart: sendTyping,
|
||||||
|
onTypingController: (typing) => {
|
||||||
|
typingController = typing;
|
||||||
|
},
|
||||||
onToolResult: dispatcher.sendToolResult,
|
onToolResult: dispatcher.sendToolResult,
|
||||||
onBlockReply: dispatcher.sendBlockReply,
|
onBlockReply: dispatcher.sendBlockReply,
|
||||||
},
|
},
|
||||||
@@ -274,6 +282,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||||
}
|
}
|
||||||
await dispatcher.waitForIdle();
|
await dispatcher.waitForIdle();
|
||||||
|
typingController?.markDispatchIdle();
|
||||||
if (!queuedFinal) return;
|
if (!queuedFinal) return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(danger(`handler failed: ${String(err)}`));
|
runtime.error?.(danger(`handler failed: ${String(err)}`));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "../auto-reply/reply/mentions.js";
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import type { TypingController } from "../auto-reply/reply/typing.js";
|
||||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import { waitForever } from "../cli/wait.js";
|
import { waitForever } from "../cli/wait.js";
|
||||||
@@ -1113,6 +1114,7 @@ export async function monitorWebProvider(
|
|||||||
const textLimit = resolveTextChunkLimit(cfg, "whatsapp");
|
const textLimit = resolveTextChunkLimit(cfg, "whatsapp");
|
||||||
let didLogHeartbeatStrip = false;
|
let didLogHeartbeatStrip = false;
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
|
let typingController: TypingController | undefined;
|
||||||
const dispatcher = createReplyDispatcher({
|
const dispatcher = createReplyDispatcher({
|
||||||
responsePrefix: cfg.messages?.responsePrefix,
|
responsePrefix: cfg.messages?.responsePrefix,
|
||||||
onHeartbeatStrip: () => {
|
onHeartbeatStrip: () => {
|
||||||
@@ -1163,6 +1165,9 @@ export async function monitorWebProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onIdle: () => {
|
||||||
|
typingController?.markDispatchIdle();
|
||||||
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
const label =
|
const label =
|
||||||
info.kind === "tool"
|
info.kind === "tool"
|
||||||
@@ -1202,6 +1207,9 @@ export async function monitorWebProvider(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReplyStart: msg.sendComposing,
|
onReplyStart: msg.sendComposing,
|
||||||
|
onTypingController: (typing) => {
|
||||||
|
typingController = typing;
|
||||||
|
},
|
||||||
onToolResult: (payload) => {
|
onToolResult: (payload) => {
|
||||||
dispatcher.sendToolResult(payload);
|
dispatcher.sendToolResult(payload);
|
||||||
},
|
},
|
||||||
@@ -1222,6 +1230,7 @@ export async function monitorWebProvider(
|
|||||||
queuedFinal = dispatcher.sendFinalReply(replyPayload) || queuedFinal;
|
queuedFinal = dispatcher.sendFinalReply(replyPayload) || queuedFinal;
|
||||||
}
|
}
|
||||||
await dispatcher.waitForIdle();
|
await dispatcher.waitForIdle();
|
||||||
|
typingController?.markDispatchIdle();
|
||||||
if (!queuedFinal) {
|
if (!queuedFinal) {
|
||||||
if (shouldClearGroupHistory && didSendReply) {
|
if (shouldClearGroupHistory && didSendReply) {
|
||||||
groupHistories.set(conversationId, []);
|
groupHistories.set(conversationId, []);
|
||||||
|
|||||||
Reference in New Issue
Block a user