fix(webui): finalize turn-ending slash commands
maintainer edit: keep /new and manually submitted /stop from leaving stale WebUI streaming state after they cancel or reset the active turn.
This commit is contained in:
@@ -109,8 +109,21 @@ const FALLBACK_SIDE_CHANNEL_COMMANDS = new Set([
|
|||||||
]);
|
]);
|
||||||
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
|
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
|
||||||
|
|
||||||
|
function slashCommandName(content: string): string {
|
||||||
|
return content.split(/\s+/, 1)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExactSlashCommand(content: string, commandName: string): boolean {
|
||||||
|
if (slashCommandName(content) !== commandName) return false;
|
||||||
|
return content.slice(commandName.length).trim().length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFinalizeActiveTurn(content: string): boolean {
|
||||||
|
return isExactSlashCommand(content, "/new");
|
||||||
|
}
|
||||||
|
|
||||||
function isSlashCommandSideChannel(content: string, visibleSlashCommands: SlashCommand[]): boolean {
|
function isSlashCommandSideChannel(content: string, visibleSlashCommands: SlashCommand[]): boolean {
|
||||||
const commandName = content.split(/\s+/, 1)[0];
|
const commandName = slashCommandName(content);
|
||||||
if (!commandName.startsWith("/")) return false;
|
if (!commandName.startsWith("/")) return false;
|
||||||
if (commandName === "/goal" && content.slice(commandName.length).trim().length > 0) {
|
if (commandName === "/goal" && content.slice(commandName.length).trim().length > 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1509,15 +1522,37 @@ export function ThreadComposer({
|
|||||||
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
|
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const isSlashSideChannel =
|
const hasPlainTextCommandPayload =
|
||||||
payload === undefined
|
payload === undefined
|
||||||
&& attachedCliApps.length === 0
|
&& attachedCliApps.length === 0
|
||||||
&& attachedMcpPresets.length === 0
|
&& attachedMcpPresets.length === 0;
|
||||||
|
if (
|
||||||
|
hasPlainTextCommandPayload
|
||||||
|
&& isStreaming
|
||||||
|
&& onStop
|
||||||
|
&& isExactSlashCommand(content, "/stop")
|
||||||
|
) {
|
||||||
|
handleStop();
|
||||||
|
setQueuedPrompts([]);
|
||||||
|
clear();
|
||||||
|
clearComposerText();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isSlashSideChannel =
|
||||||
|
hasPlainTextCommandPayload
|
||||||
&& isSlashCommandSideChannel(content, visibleSlashCommands);
|
&& isSlashCommandSideChannel(content, visibleSlashCommands);
|
||||||
|
const finalizeActiveTurn =
|
||||||
|
isSlashSideChannel && shouldFinalizeActiveTurn(content);
|
||||||
onSend(
|
onSend(
|
||||||
content,
|
content,
|
||||||
payload,
|
payload,
|
||||||
isSlashSideChannel ? { ...options, sideChannel: true } : options,
|
isSlashSideChannel
|
||||||
|
? {
|
||||||
|
...options,
|
||||||
|
sideChannel: true,
|
||||||
|
...(finalizeActiveTurn ? { finalizeActiveTurn } : {}),
|
||||||
|
}
|
||||||
|
: options,
|
||||||
);
|
);
|
||||||
setQueuedPrompts([]);
|
setQueuedPrompts([]);
|
||||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||||
@@ -1530,9 +1565,12 @@ export function ThreadComposer({
|
|||||||
canSend,
|
canSend,
|
||||||
clear,
|
clear,
|
||||||
clearComposerText,
|
clearComposerText,
|
||||||
|
handleStop,
|
||||||
|
isStreaming,
|
||||||
modelNeedsSetup,
|
modelNeedsSetup,
|
||||||
onModelBadgeClick,
|
onModelBadgeClick,
|
||||||
onSend,
|
onSend,
|
||||||
|
onStop,
|
||||||
readyImages,
|
readyImages,
|
||||||
value,
|
value,
|
||||||
visibleSlashCommands,
|
visibleSlashCommands,
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ export interface SendOptions {
|
|||||||
mcpPresets?: OutboundMcpPresetMention[];
|
mcpPresets?: OutboundMcpPresetMention[];
|
||||||
workspaceScope?: WorkspaceScopePayload | null;
|
workspaceScope?: WorkspaceScopePayload | null;
|
||||||
sideChannel?: boolean;
|
sideChannel?: boolean;
|
||||||
|
finalizeActiveTurn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
||||||
@@ -1111,19 +1112,26 @@ export function useNanobotStream(
|
|||||||
if (!hasImages && !content.trim()) return;
|
if (!hasImages && !content.trim()) return;
|
||||||
|
|
||||||
const sideChannel = options?.sideChannel === true;
|
const sideChannel = options?.sideChannel === true;
|
||||||
|
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
|
||||||
flushPendingStreamEvents();
|
flushPendingStreamEvents();
|
||||||
|
if (finalizeActiveTurn) {
|
||||||
|
cancelStreamEndTimer();
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
const turnId = crypto.randomUUID();
|
const turnId = crypto.randomUUID();
|
||||||
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
|
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
|
||||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
if (!sideChannel) {
|
if (!sideChannel || finalizeActiveTurn) {
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
activeAssistantRef.current = null;
|
activeAssistantRef.current = null;
|
||||||
closedAssistantStreamIdsRef.current.clear();
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
clearActivitySegment();
|
clearActivitySegment();
|
||||||
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
}
|
}
|
||||||
|
const base = finalizeActiveTurn ? finalizeStreamedTurn(prev) : prev;
|
||||||
return [
|
return [
|
||||||
...(sideChannel ? prev : pruneReasoningOnlyPlaceholders(prev)),
|
...(sideChannel ? base : pruneReasoningOnlyPlaceholders(base)),
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -1142,9 +1150,10 @@ export function useNanobotStream(
|
|||||||
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
|
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
|
||||||
const wireOptions = { ...options, turnId };
|
const wireOptions = { ...options, turnId };
|
||||||
delete wireOptions.sideChannel;
|
delete wireOptions.sideChannel;
|
||||||
|
delete wireOptions.finalizeActiveTurn;
|
||||||
client.sendMessage(chatId, content, wireMedia, wireOptions);
|
client.sendMessage(chatId, content, wireMedia, wireOptions);
|
||||||
},
|
},
|
||||||
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
|
|||||||
@@ -1345,6 +1345,47 @@ describe("ThreadComposer", () => {
|
|||||||
expect(onSend).toHaveBeenCalledWith("/status", undefined, { sideChannel: true });
|
expect(onSend).toHaveBeenCalledWith("/status", undefined, { sideChannel: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks new chat commands as side-channel sends that finalize the active turn", () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "/new" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||||
|
|
||||||
|
expect(onSend).toHaveBeenCalledWith(
|
||||||
|
"/new",
|
||||||
|
undefined,
|
||||||
|
{ sideChannel: true, finalizeActiveTurn: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes a manually submitted stop command through the stop handler", () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
const onStop = vi.fn();
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={onStop}
|
||||||
|
isStreaming
|
||||||
|
placeholder="Type your message..."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
fireEvent.change(input, { target: { value: "/stop" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Escape" });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(onStop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps goal task commands on the normal agent turn path", () => {
|
it("keeps goal task commands on the normal agent turn path", () => {
|
||||||
const onSend = vi.fn();
|
const onSend = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -1653,6 +1653,63 @@ describe("useNanobotStream", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("finalizes active streaming before turn-ending side-channel commands", async () => {
|
||||||
|
const fake = fakeClient();
|
||||||
|
const { result } = renderHook(() => useNanobotStream("chat-new", EMPTY_MESSAGES), {
|
||||||
|
wrapper: wrap(fake.client),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.send("long task");
|
||||||
|
});
|
||||||
|
const activeTurnId = fake.client.sendMessage.mock.calls.at(-1)![3]?.turnId;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-new", {
|
||||||
|
event: "delta",
|
||||||
|
chat_id: "chat-new",
|
||||||
|
text: "partial answer",
|
||||||
|
turn_id: activeTurnId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
expect(result.current.messages.find((message) => message.content === "partial answer"))
|
||||||
|
.toMatchObject({ isStreaming: true });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.send("/new", undefined, {
|
||||||
|
sideChannel: true,
|
||||||
|
finalizeActiveTurn: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCall = fake.client.sendMessage.mock.calls.at(-1)!;
|
||||||
|
expect(newCall[3]).not.toHaveProperty("sideChannel");
|
||||||
|
expect(newCall[3]).not.toHaveProperty("finalizeActiveTurn");
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(result.current.messages.find((message) => message.content === "partial answer"))
|
||||||
|
.toMatchObject({ isStreaming: false });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fake.emit("chat-new", {
|
||||||
|
event: "message",
|
||||||
|
chat_id: "chat-new",
|
||||||
|
text: "New session started.",
|
||||||
|
turn_id: newCall[3]?.turnId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||||
|
"long task",
|
||||||
|
"partial answer",
|
||||||
|
"/new",
|
||||||
|
"New session started.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("lets stream_end finish streaming while side-channel status replies arrive", () => {
|
it("lets stream_end finish streaming while side-channel status replies arrive", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user