debug: add logging for webchat chat event handling

- Add token hint to token_mismatch errors for debugging auth issues
- Fix device auth token always clearing on connect failure (not just fallback)
- Add debug logging in server-chat.ts to trace emitChatDelta flow
- Add debug logging in UI to trace handleChatEvent and applySessionDefaults
- Bump version to 2026.1.27-beta.3-patched

This helps diagnose why webchat responses appear in TUI but not webchat UI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
maxmaxrouge 2026-01-29 12:19:38 +10:00
parent fdcac0ccf4
commit 9bcfb78d8e
15 changed files with 151 additions and 17 deletions

Binary file not shown.

View File

@ -1,6 +1,6 @@
{ {
"name": "moltbot", "name": "moltbot",
"version": "2026.1.27-beta.1", "version": "2026.1.27-beta.3-patched",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@ -17,8 +17,14 @@ export type GatewayAuthResult = {
method?: "token" | "password" | "tailscale" | "device-token"; method?: "token" | "password" | "tailscale" | "device-token";
user?: string; user?: string;
reason?: string; reason?: string;
tokenHint?: string;
}; };
function maskToken(token: string): string {
if (token.length <= 8) return token.slice(0, 2) + "..." + token.slice(-2);
return token.slice(0, 4) + "..." + token.slice(-4);
}
type ConnectAuth = { type ConnectAuth = {
token?: string; token?: string;
password?: string; password?: string;
@ -229,7 +235,11 @@ export async function authorizeGatewayConnect(params: {
return { ok: false, reason: "token_missing" }; return { ok: false, reason: "token_missing" };
} }
if (!safeEqual(connectAuth.token, auth.token)) { if (!safeEqual(connectAuth.token, auth.token)) {
return { ok: false, reason: "token_mismatch" }; return {
ok: false,
reason: "token_mismatch",
tokenHint: `got=${maskToken(connectAuth.token)} expected=${maskToken(auth.token)}`,
};
} }
return { ok: true, method: "token" }; return { ok: true, method: "token" };
} }

View File

@ -134,10 +134,16 @@ export function createAgentEventHandler({
clearAgentRunContext, clearAgentRunContext,
}: AgentEventHandlerOptions) { }: AgentEventHandlerOptions) {
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => { const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
console.log(
`[DEBUG] emitChatDelta: sessionKey=${sessionKey} runId=${clientRunId} textLen=${text.length}`,
);
chatRunState.buffers.set(clientRunId, text); chatRunState.buffers.set(clientRunId, text);
const now = Date.now(); const now = Date.now();
const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0; const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0;
if (now - last < 150) return; if (now - last < 150) {
console.log(`[DEBUG] emitChatDelta: throttled (${now - last}ms < 150ms)`);
return;
}
chatRunState.deltaSentAt.set(clientRunId, now); chatRunState.deltaSentAt.set(clientRunId, now);
const payload = { const payload = {
runId: clientRunId, runId: clientRunId,
@ -151,9 +157,13 @@ export function createAgentEventHandler({
}, },
}; };
// Suppress webchat broadcast for heartbeat runs when showOk is false // Suppress webchat broadcast for heartbeat runs when showOk is false
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { const suppressed = shouldSuppressHeartbeatBroadcast(clientRunId);
console.log(`[DEBUG] emitChatDelta: heartbeatSuppressed=${suppressed}`);
if (!suppressed) {
console.log(`[DEBUG] emitChatDelta: calling broadcast("chat")`);
broadcast("chat", payload, { dropIfSlow: true }); broadcast("chat", payload, { dropIfSlow: true });
} }
console.log(`[DEBUG] emitChatDelta: calling nodeSendToSession`);
nodeSendToSession(sessionKey, "chat", payload); nodeSendToSession(sessionKey, "chat", payload);
}; };
@ -216,8 +226,11 @@ export function createAgentEventHandler({
}; };
return (evt: AgentEventPayload) => { return (evt: AgentEventPayload) => {
console.log(`[DEBUG] agent event: runId=${evt.runId} stream=${evt.stream} seq=${evt.seq}`);
const chatLink = chatRunState.registry.peek(evt.runId); const chatLink = chatRunState.registry.peek(evt.runId);
console.log(`[DEBUG] chatLink: ${chatLink ? JSON.stringify(chatLink) : "null"}`);
const sessionKey = chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId); const sessionKey = chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId);
console.log(`[DEBUG] sessionKey: ${sessionKey}`);
const clientRunId = chatLink?.clientRunId ?? evt.runId; const clientRunId = chatLink?.clientRunId ?? evt.runId;
const isAborted = const isAborted =
chatRunState.abortedRuns.has(clientRunId) || chatRunState.abortedRuns.has(evt.runId); chatRunState.abortedRuns.has(clientRunId) || chatRunState.abortedRuns.has(evt.runId);
@ -249,7 +262,11 @@ export function createAgentEventHandler({
if (sessionKey) { if (sessionKey) {
nodeSendToSession(sessionKey, "agent", agentPayload); nodeSendToSession(sessionKey, "agent", agentPayload);
console.log(
`[DEBUG] checking emitChatDelta: isAborted=${isAborted} stream=${evt.stream} hasText=${typeof evt.data?.text === "string"} text="${String(evt.data?.text ?? "").slice(0, 50)}..."`,
);
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
console.log(`[DEBUG] calling emitChatDelta`);
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { } else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
if (chatLink) { if (chatLink) {

View File

@ -9,6 +9,7 @@ import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { import {
extractShortModelName, extractShortModelName,
type ResponsePrefixContext, type ResponsePrefixContext,
@ -362,13 +363,14 @@ export const chatHandlers: GatewayRequestHandlers = {
return; return;
} }
} }
const { cfg, entry } = loadSessionEntry(p.sessionKey); const { cfg, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({ const timeoutMs = resolveAgentTimeoutMs({
cfg, cfg,
overrideMs: p.timeoutMs, overrideMs: p.timeoutMs,
}); });
const now = Date.now(); const now = Date.now();
const clientRunId = p.idempotencyKey; const clientRunId = p.idempotencyKey;
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
const sendPolicy = resolveSendPolicy({ const sendPolicy = resolveSendPolicy({
cfg, cfg,
@ -430,6 +432,10 @@ export const chatHandlers: GatewayRequestHandlers = {
startedAtMs: now, startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }), expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
}); });
context.addChatRun(clientRunId, {
sessionKey: p.sessionKey,
clientRunId,
});
const ackPayload = { const ackPayload = {
runId: clientRunId, runId: clientRunId,
@ -449,7 +455,7 @@ export const chatHandlers: GatewayRequestHandlers = {
BodyForCommands: commandBody, BodyForCommands: commandBody,
RawBody: parsedMessage, RawBody: parsedMessage,
CommandBody: commandBody, CommandBody: commandBody,
SessionKey: p.sessionKey, SessionKey: canonicalKey,
Provider: INTERNAL_MESSAGE_CHANNEL, Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL,
OriginatingChannel: INTERNAL_MESSAGE_CHANNEL, OriginatingChannel: INTERNAL_MESSAGE_CHANNEL,

View File

@ -14,7 +14,24 @@ import { defaultRuntime } from "../runtime.js";
import { deliveryContextFromSession, mergeDeliveryContext } from "../utils/delivery-context.js"; import { deliveryContextFromSession, mergeDeliveryContext } from "../utils/delivery-context.js";
import { loadSessionEntry } from "./session-utils.js"; import { loadSessionEntry } from "./session-utils.js";
export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { export type AddChatRunFn = (
runId: string,
entry: { sessionKey: string; clientRunId: string },
) => void;
export type BroadcastFn = (
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean },
) => void;
export interface RestartSentinelParams {
deps: CliDeps;
addChatRun?: AddChatRunFn;
broadcast?: BroadcastFn;
}
export async function scheduleRestartSentinelWake(params: RestartSentinelParams) {
const sentinel = await consumeRestartSentinel(); const sentinel = await consumeRestartSentinel();
if (!sentinel) return; if (!sentinel) return;
const payload = sentinel.payload; const payload = sentinel.payload;
@ -61,7 +78,27 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
const channel = channelRaw ? normalizeChannelId(channelRaw) : null; const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
const to = origin?.to; const to = origin?.to;
if (!channel || !to) { if (!channel || !to) {
// No outbound channel (e.g., webchat sessions use WebSocket, not channel delivery).
// For webchat: register the run with addChatRun so responses stream to the client.
enqueueSystemEvent(message, { sessionKey }); enqueueSystemEvent(message, { sessionKey });
if (params.addChatRun && params.broadcast) {
const runId = `restart-${Date.now()}`;
params.addChatRun(runId, { sessionKey, clientRunId: runId });
try {
await agentCommand(
{
message: `System: ${summary}`,
sessionKey,
runId,
deliver: false,
},
defaultRuntime,
params.deps,
);
} catch {
// Agent turn failed; system event already queued as fallback context
}
}
return; return;
} }

View File

@ -19,6 +19,8 @@ import type { loadMoltbotPlugins } from "../plugins/loader.js";
import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js"; import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { startBrowserControlServerIfEnabled } from "./server-browser.js";
import { import {
type AddChatRunFn,
type BroadcastFn,
scheduleRestartSentinelWake, scheduleRestartSentinelWake,
shouldWakeFromRestartSentinel, shouldWakeFromRestartSentinel,
} from "./server-restart-sentinel.js"; } from "./server-restart-sentinel.js";
@ -37,6 +39,8 @@ export async function startGatewaySidecars(params: {
}; };
logChannels: { info: (msg: string) => void; error: (msg: string) => void }; logChannels: { info: (msg: string) => void; error: (msg: string) => void };
logBrowser: { error: (msg: string) => void }; logBrowser: { error: (msg: string) => void };
addChatRun?: AddChatRunFn;
broadcast?: BroadcastFn;
}) { }) {
// Start clawd browser control server (unless disabled via config). // Start clawd browser control server (unless disabled via config).
let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = null; let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = null;
@ -152,7 +156,11 @@ export async function startGatewaySidecars(params: {
if (shouldWakeFromRestartSentinel()) { if (shouldWakeFromRestartSentinel()) {
setTimeout(() => { setTimeout(() => {
void scheduleRestartSentinelWake({ deps: params.deps }); void scheduleRestartSentinelWake({
deps: params.deps,
addChatRun: params.addChatRun,
broadcast: params.broadcast,
});
}, 750); }, 750);
} }

View File

@ -503,6 +503,8 @@ export async function startGatewayServer(
logHooks, logHooks,
logChannels, logChannels,
logBrowser, logBrowser,
addChatRun,
broadcast,
})); }));
const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({ const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({

View File

@ -605,6 +605,7 @@ export function attachGatewayWsMessageHandler(params: {
authMode: resolvedAuth.mode, authMode: resolvedAuth.mode,
authProvided, authProvided,
authReason: authResult.reason, authReason: authResult.reason,
authTokenHint: authResult.tokenHint,
allowTailscale: resolvedAuth.allowTailscale, allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.id, client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName, clientDisplayName: connectParams.client.displayName,

View File

@ -56,6 +56,33 @@ describe("buildTelegramMessageContext dm thread sessions", () => {
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:42"); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:42");
}); });
it("sets ThreadLabel for dm topics for display in Sessions tab", async () => {
const ctx = await buildContext({
message_id: 1,
chat: { id: 1234, type: "private" },
date: 1700000000,
text: "hello",
message_thread_id: 42,
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.ThreadLabel).toBe("Thread: 42");
});
it("does not set ThreadLabel for dm without thread id", async () => {
const ctx = await buildContext({
message_id: 1,
chat: { id: 1234, type: "private" },
date: 1700000000,
text: "hello",
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.ThreadLabel).toBeUndefined();
});
it("keeps legacy dm session key when no thread id", async () => { it("keeps legacy dm session key when no thread id", async () => {
const ctx = await buildContext({ const ctx = await buildContext({
message_id: 2, message_id: 2,

View File

@ -161,6 +161,9 @@ export const buildTelegramMessageContext = async ({
isForum, isForum,
messageThreadId, messageThreadId,
}); });
// Effective thread ID for outbound delivery: groups use forum-resolved; DMs use raw messageThreadId
// (private chats never set is_forum=true per Telegram Bot API, so resolvedThreadId is always undefined for DMs)
const effectiveThreadId = isGroup ? resolvedThreadId : messageThreadId;
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
const route = resolveAgentRoute({ const route = resolveAgentRoute({
@ -203,7 +206,8 @@ export const buildTelegramMessageContext = async ({
const sendTyping = async () => { const sendTyping = async () => {
await withTelegramApiErrorLogging({ await withTelegramApiErrorLogging({
operation: "sendChatAction", operation: "sendChatAction",
fn: () => bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId)), fn: () =>
bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(effectiveThreadId)),
}); });
}; };
@ -212,7 +216,11 @@ export const buildTelegramMessageContext = async ({
await withTelegramApiErrorLogging({ await withTelegramApiErrorLogging({
operation: "sendChatAction", operation: "sendChatAction",
fn: () => fn: () =>
bot.api.sendChatAction(chatId, "record_voice", buildTypingThreadParams(resolvedThreadId)), bot.api.sendChatAction(
chatId,
"record_voice",
buildTypingThreadParams(effectiveThreadId),
),
}); });
} catch (err) { } catch (err) {
logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`); logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`);
@ -605,6 +613,8 @@ export const buildTelegramMessageContext = async ({
// For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId // For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId
MessageThreadId: isGroup ? resolvedThreadId : messageThreadId, MessageThreadId: isGroup ? resolvedThreadId : messageThreadId,
IsForum: isForum, IsForum: isForum,
// DM thread label for display in Sessions tab (e.g., "Sender Name (Thread: 42)")
ThreadLabel: !isGroup && messageThreadId != null ? `Thread: ${messageThreadId}` : undefined,
// Originating channel for reply routing. // Originating channel for reply routing.
OriginatingChannel: "telegram" as const, OriginatingChannel: "telegram" as const,
OriginatingTo: `telegram:${chatId}`, OriginatingTo: `telegram:${chatId}`,
@ -655,7 +665,7 @@ export const buildTelegramMessageContext = async ({
msg, msg,
chatId, chatId,
isGroup, isGroup,
resolvedThreadId, resolvedThreadId: effectiveThreadId,
isForum, isForum,
historyKey, historyKey,
historyLimit, historyLimit,

View File

@ -81,7 +81,12 @@ function normalizeSessionKeyForDefaults(
} }
function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) { function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) {
if (!defaults?.mainSessionKey) return; console.log(`[DEBUG UI] applySessionDefaults: defaults=${JSON.stringify(defaults)}`);
console.log(`[DEBUG UI] applySessionDefaults: host.sessionKey="${host.sessionKey}" host.settings.sessionKey="${host.settings.sessionKey}"`);
if (!defaults?.mainSessionKey) {
console.log(`[DEBUG UI] applySessionDefaults: no mainSessionKey, returning early`);
return;
}
const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults); const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults);
const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults( const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults(
host.settings.sessionKey, host.settings.sessionKey,
@ -91,6 +96,7 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
host.settings.lastActiveSessionKey, host.settings.lastActiveSessionKey,
defaults, defaults,
); );
console.log(`[DEBUG UI] applySessionDefaults: resolved="${resolvedSessionKey}" settingsResolved="${resolvedSettingsSessionKey}"`);
const nextSessionKey = resolvedSessionKey || resolvedSettingsSessionKey || host.sessionKey; const nextSessionKey = resolvedSessionKey || resolvedSettingsSessionKey || host.sessionKey;
const nextSettings = { const nextSettings = {
...host.settings, ...host.settings,
@ -100,7 +106,9 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
const shouldUpdateSettings = const shouldUpdateSettings =
nextSettings.sessionKey !== host.settings.sessionKey || nextSettings.sessionKey !== host.settings.sessionKey ||
nextSettings.lastActiveSessionKey !== host.settings.lastActiveSessionKey; nextSettings.lastActiveSessionKey !== host.settings.lastActiveSessionKey;
console.log(`[DEBUG UI] applySessionDefaults: nextSessionKey="${nextSessionKey}" shouldUpdate=${shouldUpdateSettings}`);
if (nextSessionKey !== host.sessionKey) { if (nextSessionKey !== host.sessionKey) {
console.log(`[DEBUG UI] applySessionDefaults: updating host.sessionKey from "${host.sessionKey}" to "${nextSessionKey}"`);
host.sessionKey = nextSessionKey; host.sessionKey = nextSessionKey;
} }
if (shouldUpdateSettings) { if (shouldUpdateSettings) {

View File

@ -35,6 +35,11 @@ type LifecycleHost = {
export function handleConnected(host: LifecycleHost) { export function handleConnected(host: LifecycleHost) {
host.basePath = inferBasePath(); host.basePath = inferBasePath();
// Apply URL settings FIRST so sessionKey is read before syncTabWithLocation
// overwrites it with the localStorage value
applySettingsFromUrl(
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
);
syncTabWithLocation( syncTabWithLocation(
host as unknown as Parameters<typeof syncTabWithLocation>[0], host as unknown as Parameters<typeof syncTabWithLocation>[0],
true, true,
@ -46,9 +51,6 @@ export function handleConnected(host: LifecycleHost) {
host as unknown as Parameters<typeof attachThemeListener>[0], host as unknown as Parameters<typeof attachThemeListener>[0],
); );
window.addEventListener("popstate", host.popStateHandler); window.addEventListener("popstate", host.popStateHandler);
applySettingsFromUrl(
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
);
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]); connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]); startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "logs") { if (host.tab === "logs") {

View File

@ -159,8 +159,12 @@ export function handleChatEvent(
state: ChatState, state: ChatState,
payload?: ChatEventPayload, payload?: ChatEventPayload,
) { ) {
console.log(`[DEBUG UI] handleChatEvent: payload.sessionKey="${payload?.sessionKey}" state.sessionKey="${state.sessionKey}"`);
if (!payload) return null; if (!payload) return null;
if (payload.sessionKey !== state.sessionKey) return null; if (payload.sessionKey !== state.sessionKey) {
console.log(`[DEBUG UI] handleChatEvent: sessionKey mismatch, ignoring event`);
return null;
}
// Final from another run (e.g. sub-agent announce): refresh history to show new message. // Final from another run (e.g. sub-agent announce): refresh history to show new message.
// See https://github.com/moltbot/moltbot/issues/1909 // See https://github.com/moltbot/moltbot/issues/1909

View File

@ -220,7 +220,9 @@ export class GatewayBrowserClient {
this.opts.onHello?.(hello); this.opts.onHello?.(hello);
}) })
.catch(() => { .catch(() => {
if (canFallbackToShared && deviceIdentity) { // Always clear device auth token on connect failure when we have device identity.
// This handles stale tokens from previous gateway sessions that cause token_mismatch.
if (deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
} }
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");