Compare commits
10 Commits
e5a95b5b66
...
65dedef65b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65dedef65b | ||
|
|
57248a7ca1 | ||
|
|
6a978aa1bc | ||
|
|
97895a0239 | ||
|
|
57c34a324c | ||
|
|
0b7aa8cf1d | ||
|
|
34bdbdb405 | ||
|
|
aa3a8ea869 | ||
|
|
2f0592dbc6 | ||
|
|
39eb0b7bc0 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,14 +33,21 @@ 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 = {
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ChatQueueItem = {
|
||||
text: string;
|
||||
createdAt: number;
|
||||
attachments?: ChatAttachment[];
|
||||
refreshSessions?: boolean;
|
||||
};
|
||||
|
||||
export const CRON_CHANNEL_LAST = "last";
|
||||
|
||||
Reference in New Issue
Block a user