mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
fix(ui): send btw immediately during active runs
This commit is contained in:
@@ -222,6 +222,88 @@ describe("handleSendChat", () => {
|
||||
expect(onSlashAction).toHaveBeenCalledWith("refresh-tools-effective");
|
||||
});
|
||||
|
||||
it("sends /btw immediately while a main run is active without queueing it", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "chat.send") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatRunId: "run-main",
|
||||
chatStream: "Working...",
|
||||
chatMessage: "/btw what changed?",
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"chat.send",
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main",
|
||||
message: "/btw what changed?",
|
||||
deliver: false,
|
||||
idempotencyKey: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(host.chatQueue).toEqual([]);
|
||||
expect(host.chatRunId).toBe("run-main");
|
||||
expect(host.chatStream).toBe("Working...");
|
||||
expect(host.chatMessages).toEqual([]);
|
||||
expect(host.chatMessage).toBe("");
|
||||
});
|
||||
|
||||
it("sends /btw without adopting a main chat run when idle", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "chat.send") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const host = makeHost({
|
||||
client: { request } as unknown as ChatHost["client"],
|
||||
chatMessage: "/btw summarize this",
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"chat.send",
|
||||
expect.objectContaining({
|
||||
message: "/btw summarize this",
|
||||
deliver: false,
|
||||
}),
|
||||
);
|
||||
expect(host.chatRunId).toBeNull();
|
||||
expect(host.chatMessages).toEqual([]);
|
||||
expect(host.chatMessage).toBe("");
|
||||
});
|
||||
|
||||
it("restores the BTW draft when detached send fails", async () => {
|
||||
const host = makeHost({
|
||||
client: {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "chat.send") {
|
||||
throw new Error("network down");
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}),
|
||||
} as unknown as ChatHost["client"],
|
||||
chatRunId: "run-main",
|
||||
chatStream: "Working...",
|
||||
chatMessage: "/btw what changed?",
|
||||
});
|
||||
|
||||
await handleSendChat(host);
|
||||
|
||||
expect(host.chatQueue).toEqual([]);
|
||||
expect(host.chatRunId).toBe("run-main");
|
||||
expect(host.chatStream).toBe("Working...");
|
||||
expect(host.chatMessage).toBe("/btw what changed?");
|
||||
expect(host.lastError).toContain("network down");
|
||||
});
|
||||
|
||||
it("shows a visible pending item for /steer on the active run", async () => {
|
||||
vi.doMock("./chat/slash-command-executor.ts", async () => {
|
||||
const actual = await vi.importActual<typeof import("./chat/slash-command-executor.ts")>(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
abortChatRun,
|
||||
loadChatHistory,
|
||||
sendChatMessage,
|
||||
sendDetachedChatMessage,
|
||||
type ChatState,
|
||||
} from "./controllers/chat.ts";
|
||||
import { loadModels } from "./controllers/models.ts";
|
||||
@@ -81,6 +82,10 @@ function isChatResetCommand(text: string) {
|
||||
return normalized.startsWith("/new ") || normalized.startsWith("/reset ");
|
||||
}
|
||||
|
||||
function isBtwCommand(text: string) {
|
||||
return /^\/btw(?::|\s|$)/i.test(text.trim());
|
||||
}
|
||||
|
||||
export async function handleAbortChat(host: ChatHost) {
|
||||
if (!host.connected) {
|
||||
return;
|
||||
@@ -177,6 +182,36 @@ async function sendChatMessageNow(
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function sendDetachedBtwMessage(
|
||||
host: ChatHost,
|
||||
message: string,
|
||||
opts?: {
|
||||
previousDraft?: string;
|
||||
attachments?: ChatAttachment[];
|
||||
previousAttachments?: ChatAttachment[];
|
||||
},
|
||||
) {
|
||||
const runId = await sendDetachedChatMessage(
|
||||
host as unknown as ChatState,
|
||||
message,
|
||||
opts?.attachments,
|
||||
);
|
||||
const ok = Boolean(runId);
|
||||
if (!ok && opts?.previousDraft != null) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
}
|
||||
if (!ok && opts?.previousAttachments) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
if (ok) {
|
||||
setLastActiveSessionKey(
|
||||
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
|
||||
host.sessionKey,
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function flushChatQueue(host: ChatHost) {
|
||||
if (!host.connected || isChatBusy(host)) {
|
||||
return;
|
||||
@@ -243,6 +278,19 @@ export async function handleSendChat(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBtwCommand(message)) {
|
||||
if (messageOverride == null) {
|
||||
host.chatMessage = "";
|
||||
host.chatAttachments = [];
|
||||
}
|
||||
await sendDetachedBtwMessage(host, message, {
|
||||
previousDraft: messageOverride == null ? previousDraft : undefined,
|
||||
attachments: hasAttachments ? attachmentsToSend : undefined,
|
||||
previousAttachments: messageOverride == null ? attachments : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept local slash commands (/status, /model, /compact, etc.)
|
||||
const parsed = parseSlashCommand(message);
|
||||
if (parsed?.command.executeLocal) {
|
||||
|
||||
@@ -142,6 +142,38 @@ function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string }
|
||||
return { mimeType: match[1], content: match[2] };
|
||||
}
|
||||
|
||||
function buildApiAttachments(attachments?: ChatAttachment[]) {
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
return hasAttachments
|
||||
? attachments
|
||||
.map((att) => {
|
||||
const parsed = dataUrlToBase64(att.dataUrl);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
mimeType: parsed.mimeType,
|
||||
content: parsed.content,
|
||||
};
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => a !== null)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function requestChatSend(
|
||||
state: ChatState,
|
||||
params: { message: string; attachments?: ChatAttachment[]; runId: string },
|
||||
) {
|
||||
await state.client!.request("chat.send", {
|
||||
sessionKey: state.sessionKey,
|
||||
message: params.message,
|
||||
deliver: false,
|
||||
idempotencyKey: params.runId,
|
||||
attachments: buildApiAttachments(params.attachments),
|
||||
});
|
||||
}
|
||||
|
||||
type AssistantMessageNormalizationOptions = {
|
||||
roleRequirement: "required" | "optional";
|
||||
roleCaseSensitive?: boolean;
|
||||
@@ -238,31 +270,8 @@ export async function sendChatMessage(
|
||||
state.chatStream = "";
|
||||
state.chatStreamStartedAt = now;
|
||||
|
||||
// Convert attachments to API format
|
||||
const apiAttachments = hasAttachments
|
||||
? attachments
|
||||
.map((att) => {
|
||||
const parsed = dataUrlToBase64(att.dataUrl);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
mimeType: parsed.mimeType,
|
||||
content: parsed.content,
|
||||
};
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => a !== null)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
await state.client.request("chat.send", {
|
||||
sessionKey: state.sessionKey,
|
||||
message: msg,
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
attachments: apiAttachments,
|
||||
});
|
||||
await requestChatSend(state, { message: msg, attachments, runId });
|
||||
return runId;
|
||||
} catch (err) {
|
||||
const error = formatConnectError(err);
|
||||
@@ -284,6 +293,30 @@ export async function sendChatMessage(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendDetachedChatMessage(
|
||||
state: ChatState,
|
||||
message: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): Promise<string | null> {
|
||||
if (!state.client || !state.connected) {
|
||||
return null;
|
||||
}
|
||||
const msg = message.trim();
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
if (!msg && !hasAttachments) {
|
||||
return null;
|
||||
}
|
||||
state.lastError = null;
|
||||
const runId = generateUUID();
|
||||
try {
|
||||
await requestChatSend(state, { message: msg, attachments, runId });
|
||||
return runId;
|
||||
} catch (err) {
|
||||
state.lastError = formatConnectError(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortChatRun(state: ChatState): Promise<boolean> {
|
||||
if (!state.client || !state.connected) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user