Skip to content

Commit 395fbb8

Browse files
steipetevincentkoc
andauthored
fix(ui): send approvals past busy chat queue (#101532)
Co-authored-by: Vincent Koc <[email protected]>
1 parent 6b76a30 commit 395fbb8

4 files changed

Lines changed: 85 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323

2424
### Fixes
2525

26+
- **Control UI typed approvals:** send `/approve` commands immediately through the authorized Gateway command path while an agent run is blocked instead of queueing the command behind that run. (#77672) Thanks @vincentkoc.
2627
- **Microsoft Teams Graph response bounds:** cap successful file-upload and chat JSON reads so oversized Microsoft Graph responses cannot be buffered without limit. (#97784) Thanks @Alix-007.
2728
- **Packaged speech runtime:** stop treating package-backed `speech-core` as a bundled plugin sidecar, restoring TTS startup in npm installs while release checks keep true activation-bypassing facades package-complete. (#89899, #89425) Thanks @zhangguiping-xydt.
2829
- **Codex app-server protocol:** require app-server 0.142 or newer, remove pre-0.142 wire-shape compatibility, and teach Codex to retrieve deferred native `spawn_agent` through `tool_search` so native subagent task mirroring works on search-capable models. (#101221)

ui/src/e2e/approval-flow.e2e.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ function approval(id: string, command: string, createdAtMs: number) {
2727
};
2828
}
2929

30+
function requireRecord(value: unknown): Record<string, unknown> {
31+
if (!value || typeof value !== "object" || Array.isArray(value)) {
32+
throw new Error("Expected object value");
33+
}
34+
return value as Record<string, unknown>;
35+
}
36+
3037
describeControlUiE2e("Control UI approval flow", () => {
3138
beforeAll(async () => {
3239
server = await startControlUiE2eServer();
@@ -78,4 +85,37 @@ describeControlUiE2e("Control UI approval flow", () => {
7885
.poll(() => currentPage.getByRole("button", { name: "Deny" }).isEnabled())
7986
.toBe(true);
8087
});
88+
89+
it("sends a typed approval command immediately while the active run waits", async () => {
90+
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
91+
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
92+
const currentPage = await context.newPage();
93+
page = currentPage;
94+
const gateway = await installMockGateway(currentPage);
95+
96+
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
97+
await gateway.waitForRequest("sessions.list");
98+
99+
const composer = currentPage.locator(".agent-chat__composer-combobox textarea");
100+
await composer.fill("run a command that needs approval");
101+
await currentPage.getByRole("button", { name: "Send message" }).click();
102+
const firstSend = requireRecord((await gateway.waitForRequest("chat.send")).params);
103+
expect(firstSend.message).toBe("run a command that needs approval");
104+
await currentPage.getByRole("button", { name: "Stop generating" }).waitFor();
105+
106+
await composer.fill("/approve approval-123 allow-once");
107+
await currentPage.getByRole("button", { name: "Queue message" }).click();
108+
109+
await expect
110+
.poll(async () => (await gateway.getRequests("chat.send")).length, { timeout: 10_000 })
111+
.toBe(2);
112+
const sends = await gateway.getRequests("chat.send");
113+
const approvalSend = requireRecord(sends[1]?.params);
114+
expect(approvalSend.message).toBe("/approve approval-123 allow-once");
115+
expect(approvalSend.deliver).toBe(false);
116+
expect(typeof approvalSend.idempotencyKey).toBe("string");
117+
expect(await currentPage.locator(".chat-queue").count()).toBe(0);
118+
expect(await composer.inputValue()).toBe("");
119+
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(1);
120+
});
81121
});

ui/src/pages/chat/chat-send.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2199,6 +2199,40 @@ describe("handleSendChat", () => {
21992199
expect(host.chatMessage).toBe("/btw what changed?");
22002200
});
22012201

2202+
it("sends /approve immediately while a main run is waiting without queueing it", async () => {
2203+
const request = vi.fn(async (method: string) => {
2204+
if (method === "chat.send") {
2205+
return { status: "started" };
2206+
}
2207+
throw new Error(`Unexpected request: ${method}`);
2208+
});
2209+
const host = makeHost({
2210+
client: { request } as unknown as ChatHost["client"],
2211+
chatRunId: "run-main",
2212+
chatStream: "Waiting for approval...",
2213+
chatMessage: "/approve approval-123 allow-once",
2214+
});
2215+
2216+
await handleSendChat(host);
2217+
2218+
const payload = findRequestPayload(
2219+
request as unknown as MockCallSource,
2220+
"chat.send",
2221+
"approval command payload",
2222+
);
2223+
expect(payload.sessionKey).toBe("agent:main");
2224+
expect(payload.message).toBe("/approve approval-123 allow-once");
2225+
expect(payload.deliver).toBe(false);
2226+
expect(payload.idempotencyKey).toEqual(expect.stringMatching(uuidPattern));
2227+
expect(host.chatQueue).toStrictEqual([]);
2228+
expect(host.chatRunId).toBe("run-main");
2229+
expect(host.chatStream).toBe("Waiting for approval...");
2230+
expect(host.chatMessages).toStrictEqual([]);
2231+
expect(host.chatMessage).toBe("");
2232+
expect(navigateChatInputHistory(host, "up")).toBe(true);
2233+
expect(host.chatMessage).toBe("/approve approval-123 allow-once");
2234+
});
2235+
22022236
it("sends /side through the detached BTW path", async () => {
22032237
const request = vi.fn(async (method: string) => {
22042238
if (method === "chat.send") {

ui/src/pages/chat/chat-send.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,7 @@ function attachmentSubmitSignature(attachment: ChatAttachment): string {
856856

857857
function chatSubmitKey(
858858
host: ChatHost,
859-
kind: "btw" | "message",
859+
kind: "detached" | "message",
860860
message: string,
861861
attachments: ChatAttachment[],
862862
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision,
@@ -948,7 +948,7 @@ function snapshotChatAttachments(attachments: readonly ChatAttachment[]): ChatAt
948948
});
949949
}
950950

951-
async function sendDetachedBtwMessage(
951+
async function sendDetachedCommandMessage(
952952
host: ChatHost,
953953
message: string,
954954
opts?: {
@@ -1153,8 +1153,13 @@ export async function handleSendChat(
11531153
return;
11541154
}
11551155

1156-
if (isBtwCommand(message)) {
1157-
const submitKey = chatSubmitKey(host, "btw", message, attachmentsToSend);
1156+
const parsed = parseSlashCommand(message);
1157+
// The backend resolves /approve before active-run admission. Send it now so
1158+
// the approval command cannot queue behind the run that is waiting for it.
1159+
const shouldSendDetachedCommand =
1160+
isBtwCommand(message) || (parsed?.command.key === "approve" && isChatBusy(host));
1161+
if (shouldSendDetachedCommand) {
1162+
const submitKey = chatSubmitKey(host, "detached", message, attachmentsToSend);
11581163
await withChatSubmitGuard(host, submitKey, async () => {
11591164
const modelSwitchReady = waitForPendingChatModelSwitch(host, submittedSessionKey);
11601165
if (modelSwitchReady !== true && !(await modelSwitchReady)) {
@@ -1170,7 +1175,7 @@ export async function handleSendChat(
11701175
if (messageOverride == null) {
11711176
recordNonTranscriptInputHistory(host, message);
11721177
}
1173-
await sendDetachedBtwMessage(host, message, {
1178+
await sendDetachedCommandMessage(host, message, {
11741179
previousDraft: cleared.previousDraft,
11751180
attachments: hasAttachments ? attachmentsToSend : undefined,
11761181
previousAttachments: cleared.previousAttachments,
@@ -1180,7 +1185,6 @@ export async function handleSendChat(
11801185
}
11811186

11821187
// Intercept local slash commands (/status, /model, /compact, etc.)
1183-
const parsed = parseSlashCommand(message);
11841188
if (parsed?.command.executeLocal) {
11851189
if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.key)) {
11861190
if (messageOverride == null) {

0 commit comments

Comments
 (0)