Compare commits

..

10 Commits

Author SHA1 Message Date
Yuri Chukhlib
65dedef65b fix(bluebubbles): debounce by messageId to preserve attachments in text+image messages (#4984)
Some checks failed
CI / install-check (push) Has been cancelled
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Has been cancelled
CI / checks (pnpm build, node, build) (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && bunx vitest run, bun, test) (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks (pnpm format, node, format) (push) Has been cancelled
CI / checks (pnpm lint, node, lint) (push) Has been cancelled
CI / checks (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / secrets (push) Has been cancelled
CI / checks-windows (pnpm build, node, build) (push) Has been cancelled
CI / checks-windows (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks-windows (pnpm lint, node, lint) (push) Has been cancelled
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks-macos (pnpm test, test) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
Install Smoke / install-smoke (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
* fix(bluebubbles): debounce by messageId to preserve attachments in text+image messages

BlueBubbles fires multiple webhook events for a single message - first
without attachment metadata, then ~350ms later with it. Previously,
messages with attachments bypassed debouncing and were processed
immediately, while the text-only version was also queued.

Now the debouncer uses messageId as the key when available, coalescing
all webhook events for the same message. The existing combineDebounceEntries
function merges attachments from all events.

Closes #4848

* fix: increase debounce and handle balloon messages

- Increase DEFAULT_INBOUND_DEBOUNCE_MS from 350ms to 500ms
- Update buildKey to use associatedMessageGuid for balloon messages
- Add regression test for debouncing behavior

Fixes issues identified in code review.

---------

Co-authored-by: Yurii Chukhlib <yurii.chukhlib@viber.com>
Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
2026-01-30 15:53:14 -08:00
Tyler Yust
57248a7ca1 fix: prefer requesterOrigin over stale session entry in subagent announce routing (#4957)
* fix: prefer requesterOrigin over stale session entry in subagent announce routing

When a subagent finishes and announces results back, resolveAnnounceOrigin
merged the session entry (primary) with requesterOrigin (fallback). If the
session store had a stale lastChannel (e.g. whatsapp) from a previous
interaction but the user was now on a different channel (e.g. bluebubbles),
the announce would route to the wrong channel.

Swap the merge order so requesterOrigin (captured at spawn time, reflecting
the actual current channel) takes priority, with the session entry as
fallback for any missing fields.

Error before fix:
  Delivery failed (whatsapp to bluebubbles:chat_guid:...): Unknown channel: whatsapp

Adds regression test for the stale-channel scenario.

* fix: match test to exact failure scenario and improve reliability (#4957) (thanks @tyler6204)

- Remove lastTo from stale session store to match the exact mismatch scenario described in the PR
- Replace 5ms setTimeout sleeps with expect.poll for better test reliability
- Prevents flakiness on slower CI machines
2026-01-30 15:52:19 -08:00
Mario Zechner
6a978aa1bc Merge pull request #5021 from mitsuhiko/patch-3
Fix typo from 'p-mono' to 'pi-mono' in agent.md
2026-01-31 00:27:14 +01:00
Armin Ronacher
97895a0239 Fix typo from 'p-mono' to 'pi-mono' in agent.md 2026-01-31 00:23:45 +01:00
Tyler Yust
57c34a324c UI: introduce active minutes constant for chat sessions and enhance session display names 2026-01-30 14:59:08 -08:00
Tyler Yust
0b7aa8cf1d feat(ui): refresh session list after chat commands in Web UI 2026-01-30 14:29:04 -08:00
Gustavo Madeira Santana
34bdbdb405 fix: resolve Control UI assets for global installs (#4909) (thanks @YuriNachos)
Co-authored-by: YuriNachos <YuriNachos@users.noreply.github.com>
2026-01-30 17:08:40 -05:00
Yurii Chukhlib
aa3a8ea869 fix(infra): resolve control-ui assets on npm global install (#4855) 2026-01-30 17:06:58 -05:00
Vignesh
2f0592dbc6 Update deployment link in railway documentation 2026-01-30 14:06:12 -08:00
Gustavo Madeira Santana
39eb0b7bc0 fix: prevent undefined gateway token defaults (#4873) (thanks @Hisleren)
Co-authored-by: Hisleren <Hisleren@users.noreply.github.com>
2026-01-30 16:16:35 -05:00
17 changed files with 315 additions and 45 deletions

View File

@@ -74,6 +74,8 @@ Status: stable.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
- Infra: resolve Control UI assets for npm global installs. (#4909) Thanks @YuriNachos.
- Gateway: prevent blank token prompts from storing "undefined". (#4873) Thanks @Hisleren.
- Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway.
- Telegram: fix HTML nesting for overlapping styles and links. (#4578) Thanks @ThanhNguyxn.
- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796)
@@ -90,6 +92,7 @@ Status: stable.
- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
- Web UI: refresh sessions after queued /new or /reset commands once the run completes.
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow.

View File

@@ -59,11 +59,11 @@ OpenClaw loads skills from three locations (workspace wins on name conflict):
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).
## p-mono integration
## pi-mono integration
OpenClaw reuses pieces of the p-mono codebase (models/tools), but **session management, discovery, and tool wiring are OpenClaw-owned**.
OpenClaw reuses pieces of the pi-mono codebase (models/tools), but **session management, discovery, and tool wiring are OpenClaw-owned**.
- No p-coding agent runtime.
- No pi-coding agent runtime.
- No `~/.pi/agent` or `<workspace>/.pi` settings are consulted.
## Sessions

View File

@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
## One-click deploy
<a href="https://railway.com/deploy/openclaw-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
After deploy, find your public URL in **Railway → your service → Settings → Domains**.

View File

@@ -1150,6 +1150,136 @@ describe("BlueBubbles webhook monitor", () => {
});
});
describe("inbound debouncing", () => {
it("coalesces text-only then attachment webhook events by messageId", async () => {
vi.useFakeTimers();
try {
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
// Use a timing-aware debouncer test double that respects debounceMs/buildKey/shouldDebounce.
core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => {
type Item = any;
const buckets = new Map<string, { items: Item[]; timer: ReturnType<typeof setTimeout> | null }>();
const flush = async (key: string) => {
const bucket = buckets.get(key);
if (!bucket) return;
if (bucket.timer) {
clearTimeout(bucket.timer);
bucket.timer = null;
}
const items = bucket.items;
bucket.items = [];
if (items.length > 0) {
try {
await params.onFlush(items);
} catch (err) {
params.onError?.(err);
throw err;
}
}
};
return {
enqueue: async (item: Item) => {
if (params.shouldDebounce && !params.shouldDebounce(item)) {
await params.onFlush([item]);
return;
}
const key = params.buildKey(item);
const existing = buckets.get(key);
const bucket = existing ?? { items: [], timer: null };
bucket.items.push(item);
if (bucket.timer) clearTimeout(bucket.timer);
bucket.timer = setTimeout(async () => {
await flush(key);
}, params.debounceMs);
buckets.set(key, bucket);
},
flushKey: vi.fn(async (key: string) => {
await flush(key);
}),
};
}) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const messageId = "race-msg-1";
const chatGuid = "iMessage;-;+15551234567";
const payloadA = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: messageId,
chatGuid,
date: Date.now(),
},
};
const payloadB = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: messageId,
chatGuid,
attachments: [
{
guid: "att-1",
mimeType: "image/jpeg",
totalBytes: 1024,
},
],
date: Date.now(),
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", payloadA),
createMockResponse(),
);
// Simulate the real-world delay where the attachment-bearing webhook arrives shortly after.
await vi.advanceTimersByTimeAsync(300);
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", payloadB),
createMockResponse(),
);
// Not flushed yet; still within the debounce window.
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
// After the debounce window, the combined message should be processed exactly once.
await vi.advanceTimersByTimeAsync(600);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
expect(callArgs.ctx.Body).toContain("hello");
} finally {
vi.useRealTimers();
}
});
});
describe("reply metadata", () => {
it("surfaces reply fields in ctx when provided", async () => {
const account = createMockAccount({ dmPolicy: "open" });

View File

@@ -264,7 +264,7 @@ type BlueBubblesDebounceEntry = {
* This helps combine URL text + link preview balloon messages that BlueBubbles
* sends as separate webhook events when no explicit inbound debounce config exists.
*/
const DEFAULT_INBOUND_DEBOUNCE_MS = 350;
const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
/**
* Combines multiple debounced messages into a single message for processing.
@@ -363,7 +363,23 @@ function getOrCreateDebouncer(target: WebhookTarget) {
debounceMs: resolveBlueBubblesDebounceMs(config, core),
buildKey: (entry) => {
const msg = entry.message;
// Build key from account + chat + sender to coalesce messages from same source
// Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
// same message (e.g., text-only then text+attachment).
//
// For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
// messageId than the originating text. When present, key by associatedMessageGuid
// to keep text + balloon coalescing working.
const balloonBundleId = msg.balloonBundleId?.trim();
const associatedMessageGuid = msg.associatedMessageGuid?.trim();
if (balloonBundleId && associatedMessageGuid) {
return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
}
const messageId = msg.messageId?.trim();
if (messageId) {
return `bluebubbles:${account.accountId}:msg:${messageId}`;
}
const chatKey =
msg.chatGuid?.trim() ??
msg.chatIdentifier?.trim() ??
@@ -372,13 +388,12 @@ function getOrCreateDebouncer(target: WebhookTarget) {
},
shouldDebounce: (entry) => {
const msg = entry.message;
// Skip debouncing for messages with attachments - process immediately
if (msg.attachments && msg.attachments.length > 0) return false;
// Skip debouncing for from-me messages (they're just cached, not processed)
if (msg.fromMe) return false;
// Skip debouncing for control commands - process immediately
if (core.channel.text.hasControlCommand(msg.text, config)) return false;
// Debounce normal text messages and URL balloon messages
// Debounce all other messages to coalesce rapid-fire webhook events
// (e.g., text+image arriving as separate webhooks for the same messageId)
return true;
},
onFlush: async (entries) => {

View File

@@ -186,7 +186,7 @@ describe("subagent announce formatting", () => {
});
expect(didAnnounce).toBe(true);
await new Promise((r) => setTimeout(r, 5));
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("whatsapp");
@@ -299,6 +299,44 @@ describe("subagent announce formatting", () => {
expect(call?.params?.accountId).toBe("acct-987");
});
it("prefers requesterOrigin channel over stale session lastChannel in queued announce", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
// Session store has stale whatsapp channel, but the requesterOrigin says bluebubbles.
sessionStore = {
"agent:main:main": {
sessionId: "session-stale",
lastChannel: "whatsapp",
queueMode: "collect",
queueDebounceMs: 0,
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-stale-channel",
requesterSessionKey: "main",
requesterOrigin: { channel: "bluebubbles", to: "bluebubbles:chat_guid:123" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
// The channel should match requesterOrigin, NOT the stale session entry.
expect(call?.params?.channel).toBe("bluebubbles");
expect(call?.params?.to).toBe("bluebubbles:chat_guid:123");
});
it("splits collect-mode announces when accountId differs", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
@@ -343,7 +381,7 @@ describe("subagent announce formatting", () => {
outcome: { status: "ok" },
});
await new Promise((r) => setTimeout(r, 5));
await expect.poll(() => agentSpy.mock.calls.length).toBe(2);
const accountIds = agentSpy.mock.calls.map(
(call) => (call[0] as { params?: Record<string, unknown> }).params?.accountId,

View File

@@ -93,7 +93,10 @@ function resolveAnnounceOrigin(
entry?: DeliveryContextSource,
requesterOrigin?: DeliveryContext,
): DeliveryContext | undefined {
return mergeDeliveryContext(deliveryContextFromSession(entry), requesterOrigin);
// requesterOrigin (captured at spawn time) reflects the channel the user is
// actually on and must take priority over the session entry, which may carry
// stale lastChannel / lastTo values from a previous channel interaction.
return mergeDeliveryContext(requesterOrigin, deliveryContextFromSession(entry));
}
async function sendAnnounce(item: AnnounceQueueItem) {

View File

@@ -119,7 +119,7 @@ describe("normalizeGatewayTokenInput", () => {
expect(normalizeGatewayTokenInput(" token ")).toBe("token");
});
it("coerces non-string input to string", () => {
expect(normalizeGatewayTokenInput(123)).toBe("123");
it("returns empty string for non-string input", () => {
expect(normalizeGatewayTokenInput(123)).toBe("");
});
});

View File

@@ -63,8 +63,8 @@ export function randomToken(): string {
}
export function normalizeGatewayTokenInput(value: unknown): string {
if (value == null) return "";
return String(value).trim();
if (typeof value !== "string") return "";
return value.trim();
}
export function printWizardHeader(runtime: RuntimeEnv) {

View File

@@ -37,11 +37,46 @@ describe("control UI assets helpers", () => {
}
});
it("resolves dist control-ui index path for dist argv1", () => {
it("resolves dist control-ui index path for dist argv1", async () => {
const argv1 = path.resolve("/tmp", "pkg", "dist", "index.js");
const distDir = path.dirname(argv1);
expect(resolveControlUiDistIndexPath(argv1)).toBe(
expect(await resolveControlUiDistIndexPath(argv1)).toBe(
path.join(distDir, "control-ui", "index.html"),
);
});
it("resolves dist control-ui index path from package root argv1", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
try {
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n");
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe(
path.join(tmp, "dist", "control-ui", "index.html"),
);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
it("resolves dist control-ui index path from .bin argv1", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
try {
const binDir = path.join(tmp, "node_modules", ".bin");
const pkgRoot = path.join(tmp, "node_modules", "openclaw");
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(path.join(pkgRoot, "dist", "control-ui"), { recursive: true });
await fs.writeFile(path.join(binDir, "openclaw"), "#!/usr/bin/env node\n");
await fs.writeFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(pkgRoot, "dist", "control-ui", "index.html"), "<html></html>\n");
expect(await resolveControlUiDistIndexPath(path.join(binDir, "openclaw"))).toBe(
path.join(pkgRoot, "dist", "control-ui", "index.html"),
);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
});

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
export function resolveControlUiRepoRoot(
argv1: string | undefined = process.argv[1],
@@ -32,16 +33,23 @@ export function resolveControlUiRepoRoot(
return null;
}
export function resolveControlUiDistIndexPath(
export async function resolveControlUiDistIndexPath(
argv1: string | undefined = process.argv[1],
): string | null {
): Promise<string | null> {
if (!argv1) return null;
const normalized = path.resolve(argv1);
// Case 1: entrypoint is directly inside dist/ (e.g., dist/entry.js)
const distDir = path.dirname(normalized);
if (path.basename(distDir) !== "dist") return null;
if (path.basename(distDir) === "dist") {
return path.join(distDir, "control-ui", "index.html");
}
const packageRoot = await resolveOpenClawPackageRoot({ argv1: normalized });
if (!packageRoot) return null;
return path.join(packageRoot, "dist", "control-ui", "index.html");
}
export type EnsureControlUiAssetsResult = {
ok: boolean;
built: boolean;
@@ -63,7 +71,7 @@ export async function ensureControlUiAssetsBuilt(
runtime: RuntimeEnv = defaultRuntime,
opts?: { timeoutMs?: number },
): Promise<EnsureControlUiAssetsResult> {
const indexFromDist = resolveControlUiDistIndexPath(process.argv[1]);
const indexFromDist = await resolveControlUiDistIndexPath(process.argv[1]);
if (indexFromDist && fs.existsSync(indexFromDist)) {
return { ok: true, built: false };
}

View File

@@ -21,9 +21,11 @@ type ChatHost = {
basePath: string;
hello: GatewayHelloOk | null;
chatAvatarUrl: string | null;
refreshSessionsAfterChat: boolean;
refreshSessionsAfterChat: Set<string>;
};
export const CHAT_SESSIONS_ACTIVE_MINUTES = 10;
export function isChatBusy(host: ChatHost) {
return host.chatSending || Boolean(host.chatRunId);
}
@@ -56,7 +58,12 @@ export async function handleAbortChat(host: ChatHost) {
await abortChatRun(host as unknown as OpenClawApp);
}
function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) {
function enqueueChatMessage(
host: ChatHost,
text: string,
attachments?: ChatAttachment[],
refreshSessions?: boolean,
) {
const trimmed = text.trim();
const hasAttachments = Boolean(attachments && attachments.length > 0);
if (!trimmed && !hasAttachments) return;
@@ -67,6 +74,7 @@ function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAtta
text: trimmed,
createdAt: Date.now(),
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
refreshSessions,
},
];
}
@@ -84,7 +92,8 @@ async function sendChatMessageNow(
},
) {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
const ok = await sendChatMessage(host as unknown as OpenClawApp, message, opts?.attachments);
const runId = await sendChatMessage(host as unknown as OpenClawApp, message, opts?.attachments);
const ok = Boolean(runId);
if (!ok && opts?.previousDraft != null) {
host.chatMessage = opts.previousDraft;
}
@@ -104,8 +113,8 @@ async function sendChatMessageNow(
if (ok && !host.chatRunId) {
void flushChatQueue(host);
}
if (ok && opts?.refreshSessions) {
host.refreshSessionsAfterChat = true;
if (ok && opts?.refreshSessions && runId) {
host.refreshSessionsAfterChat.add(runId);
}
return ok;
}
@@ -115,7 +124,10 @@ async function flushChatQueue(host: ChatHost) {
const [next, ...rest] = host.chatQueue;
if (!next) return;
host.chatQueue = rest;
const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments });
const ok = await sendChatMessageNow(host, next.text, {
attachments: next.attachments,
refreshSessions: next.refreshSessions,
});
if (!ok) {
host.chatQueue = [next, ...host.chatQueue];
}
@@ -153,7 +165,7 @@ export async function handleSendChat(
}
if (isChatBusy(host)) {
enqueueChatMessage(host, message, attachmentsToSend);
enqueueChatMessage(host, message, attachmentsToSend, refreshSessions);
return;
}
@@ -170,7 +182,9 @@ export async function handleSendChat(
export async function refreshChat(host: ChatHost) {
await Promise.all([
loadChatHistory(host as unknown as OpenClawApp),
loadSessions(host as unknown as OpenClawApp, { activeMinutes: 0 }),
loadSessions(host as unknown as OpenClawApp, {
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
}),
refreshChatAvatar(host),
]);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);

View File

@@ -9,7 +9,7 @@ import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } f
import type { Tab } from "./navigation";
import type { UiSettings } from "./storage";
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
import { flushChatQueueForEvent } from "./app-chat";
import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat";
import {
applySettings,
loadCron,
@@ -51,7 +51,7 @@ type GatewayHost = {
assistantAgentId: string | null;
sessionKey: string;
chatRunId: string | null;
refreshSessionsAfterChat: boolean;
refreshSessionsAfterChat: Set<string>;
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
};
@@ -196,10 +196,13 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
void flushChatQueueForEvent(
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
);
if (host.refreshSessionsAfterChat) {
host.refreshSessionsAfterChat = false;
const runId = payload?.runId;
if (runId && host.refreshSessionsAfterChat.has(runId)) {
host.refreshSessionsAfterChat.delete(runId);
if (state === "final") {
void loadSessions(host as unknown as OpenClawApp, { activeMinutes: 0 });
void loadSessions(host as unknown as OpenClawApp, {
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
});
}
}
}

View File

@@ -156,6 +156,17 @@ function resolveMainSessionKey(
return null;
}
function resolveSessionDisplayName(
key: string,
row?: SessionsListResult["sessions"][number],
) {
const label = row?.label?.trim();
if (label) return `${label} (${key})`;
const displayName = row?.displayName?.trim();
if (displayName) return displayName;
return key;
}
function resolveSessionOptions(
sessionKey: string,
sessions: SessionsListResult | null,
@@ -171,13 +182,19 @@ function resolveSessionOptions(
// Add main session key first
if (mainSessionKey) {
seen.add(mainSessionKey);
options.push({ key: mainSessionKey, displayName: resolvedMain?.displayName });
options.push({
key: mainSessionKey,
displayName: resolveSessionDisplayName(mainSessionKey, resolvedMain),
});
}
// Add current session key next
if (!seen.has(sessionKey)) {
seen.add(sessionKey);
options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
options.push({
key: sessionKey,
displayName: resolveSessionDisplayName(sessionKey, resolvedCurrent),
});
}
// Add sessions from the result
@@ -185,7 +202,10 @@ function resolveSessionOptions(
for (const s of sessions.sessions) {
if (!seen.has(s.key)) {
seen.add(s.key);
options.push({ key: s.key, displayName: s.displayName });
options.push({
key: s.key,
displayName: resolveSessionDisplayName(s.key, s),
});
}
}
}

View File

@@ -258,7 +258,7 @@ export class OpenClawApp extends LitElement {
private logsScrollFrame: number | null = null;
private toolStreamById = new Map<string, ToolStreamEntry>();
private toolStreamOrder: string[] = [];
refreshSessionsAfterChat = false;
refreshSessionsAfterChat = new Set<string>();
basePath = "";
private popStateHandler = () =>
onPopStateInternal(

View File

@@ -55,11 +55,11 @@ export async function sendChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): Promise<boolean> {
if (!state.client || !state.connected) return false;
): Promise<string | null> {
if (!state.client || !state.connected) return null;
const msg = message.trim();
const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) return false;
if (!msg && !hasAttachments) return null;
const now = Date.now();
@@ -117,7 +117,7 @@ export async function sendChatMessage(
idempotencyKey: runId,
attachments: apiAttachments,
});
return true;
return runId;
} catch (err) {
const error = String(err);
state.chatRunId = null;
@@ -132,7 +132,7 @@ export async function sendChatMessage(
timestamp: Date.now(),
},
];
return false;
return null;
} finally {
state.chatSending = false;
}

View File

@@ -9,6 +9,7 @@ export type ChatQueueItem = {
text: string;
createdAt: number;
attachments?: ChatAttachment[];
refreshSessions?: boolean;
};
export const CRON_CHANNEL_LAST = "last";