fix: wire control-ui steer and redirect (#54625) (thanks @fuller-stack-dev)

* feat(ui): wire /steer slash command to sessions.steer RPC

* feat(ui): wire /steer (soft inject) and /redirect (hard restart) slash commands

* test: use generic subagent names in steer/redirect tests

* fix(ui): exempt steer/redirect from busy-queue and guard sessions.list failures

* fix(ui): skip 'all' wildcard in steer/redirect target resolution

* test: register slash-command-executor test in vitest config

* fix(ui): restrict steer target to subagent keys and active sessions

Address two review issues in resolveSteerTarget:

P2: Replace resolveKillTargets with a dedicated resolveSteerSubagent
that matches only on subagent key suffix or label, not agent id.
This prevents false-positive targeting when the first word collides
with an agent id (e.g. "/steer main refine plan").

P1: Filter out ended sessions (endedAt set) so stale subagents with
reused names are not targeted.

* fix(ui): use shared generateUUID for steer idempotency key

* fix: restore telegram test to upstream state (merge artifact)

* fix(ui): track redirected run so Abort works and concurrent sends are blocked

* fix(ui): skip run tracking when /redirect targets a subagent session

* fix(ui): block idle steer runs

* fix(ui): dedupe steer slash command

* fix(ui): show pending steer state

* fix: wire control-ui steer and redirect (#54625) (thanks @fuller-stack-dev)

* fix: tighten steer target resolution (#54625) (thanks @fuller-stack-dev)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
fuller-stack-dev 2026-03-28 22:45:58 -06:00 committed by GitHub
parent e3faa99c6a
commit 83808fe494
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 811 additions and 67 deletions

View File

@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- Agents/Kimi: preserve already-valid Anthropic-compatible tool call argument objects while still clearing cached repairs when later trailing junk exceeds the repair allowance. (#54491) Thanks @yuanaichi.
- Docker/setup: force BuildKit for local image builds (including sandbox image builds) so `./docker-setup.sh` no longer fails on `RUN --mount=...` when hosts default to Docker's legacy builder. (#56681) Thanks @zhanghui-china.
- Control UI/agents: auto-load agent workspace files on initial Files panel open, and populate overview model/workspace/fallbacks from effective runtime agent metadata so defaulted models no longer show as `Not set`. (#56637) Thanks @dxsx84.
- Control UI/slash commands: make `/steer` and `/redirect` work from the chat command palette with visible pending state for active-run `/steer`, correct redirected-run tracking, and a single canonical `/steer` entry in the command menu. (#54625) Thanks @fuller-stack-dev.
## 2026.3.28

View File

@ -13,10 +13,12 @@ vi.mock("./app-settings.ts", () => ({
let handleSendChat: typeof import("./app-chat.ts").handleSendChat;
let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar;
let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun;
async function loadChatHelpers(): Promise<void> {
vi.resetModules();
({ handleSendChat, refreshChatAvatar } = await import("./app-chat.ts"));
({ handleSendChat, refreshChatAvatar, clearPendingQueueItemsForRun } =
await import("./app-chat.ts"));
}
function makeHost(overrides?: Partial<ChatHost>): ChatHost {
@ -96,6 +98,7 @@ describe("handleSendChat", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.doUnmock("./chat/slash-command-executor.ts");
});
it("keeps slash-command model changes in sync with the chat header cache", async () => {
@ -156,6 +159,64 @@ describe("handleSendChat", () => {
});
expect(onSlashAction).toHaveBeenCalledWith("refresh-tools-effective");
});
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")>(
"./chat/slash-command-executor.ts",
);
return {
...actual,
executeSlashCommand: vi.fn(async () => ({
content: "Steered.",
pendingCurrentRun: true,
})),
};
});
await loadChatHelpers();
const host = makeHost({
client: { request: vi.fn() } as unknown as ChatHost["client"],
chatRunId: "run-1",
chatMessage: "/steer tighten the plan",
});
await handleSendChat(host);
expect(host.chatQueue).toEqual([
expect.objectContaining({
text: "/steer tighten the plan",
pendingRunId: "run-1",
}),
]);
});
it("removes pending steer indicators when the run finishes", async () => {
const host = makeHost({
chatQueue: [
{
id: "pending",
text: "/steer tighten the plan",
createdAt: 1,
pendingRunId: "run-1",
},
{
id: "queued",
text: "follow up",
createdAt: 2,
},
],
});
clearPendingQueueItemsForRun(host, "run-1");
expect(host.chatQueue).toEqual([
expect.objectContaining({
id: "queued",
text: "follow up",
}),
]);
});
});
afterAll(() => {

View File

@ -11,6 +11,7 @@ import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import { normalizeBasePath } from "./navigation.ts";
import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts";
import type { SessionsListResult } from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts";
@ -32,6 +33,7 @@ export type ChatHost = {
chatModelOverrides: Record<string, ChatModelOverride | null>;
chatModelsLoading: boolean;
chatModelCatalog: ModelCatalogEntry[];
sessionsResult?: SessionsListResult | null;
updateComplete?: Promise<unknown>;
refreshSessionsAfterChat: Set<string>;
/** Callback for slash-command side effects that need app-level access. */
@ -108,6 +110,22 @@ function enqueueChatMessage(
];
}
function enqueuePendingRunMessage(host: ChatHost, text: string, pendingRunId: string) {
const trimmed = text.trim();
if (!trimmed) {
return;
}
host.chatQueue = [
...host.chatQueue,
{
id: generateUUID(),
text: trimmed,
createdAt: Date.now(),
pendingRunId,
},
];
}
async function sendChatMessageNow(
host: ChatHost,
message: string,
@ -158,11 +176,12 @@ async function flushChatQueue(host: ChatHost) {
if (!host.connected || isChatBusy(host)) {
return;
}
const [next, ...rest] = host.chatQueue;
if (!next) {
const nextIndex = host.chatQueue.findIndex((item) => !item.pendingRunId);
if (nextIndex < 0) {
return;
}
host.chatQueue = rest;
const next = host.chatQueue[nextIndex];
host.chatQueue = host.chatQueue.filter((_, index) => index !== nextIndex);
let ok = false;
try {
if (next.localCommandName) {
@ -189,6 +208,13 @@ export function removeQueuedMessage(host: ChatHost, id: string) {
host.chatQueue = host.chatQueue.filter((item) => item.id !== id);
}
export function clearPendingQueueItemsForRun(host: ChatHost, runId: string | undefined) {
if (!runId) {
return;
}
host.chatQueue = host.chatQueue.filter((item) => item.pendingRunId !== runId);
}
export async function handleSendChat(
host: ChatHost,
messageOverride?: string,
@ -260,7 +286,7 @@ export async function handleSendChat(
}
function shouldQueueLocalSlashCommand(name: string): boolean {
return !["stop", "focus", "export-session"].includes(name);
return !["stop", "focus", "export-session", "steer", "redirect"].includes(name);
}
// ── Slash Command Dispatch ──
@ -307,12 +333,23 @@ async function dispatchSlashCommand(
const targetSessionKey = host.sessionKey;
const result = await executeSlashCommand(host.client, targetSessionKey, name, args, {
chatModelCatalog: host.chatModelCatalog,
sessionsResult: host.sessionsResult,
});
if (result.content) {
injectCommandResult(host, result.content);
}
if (result.trackRunId) {
host.chatRunId = result.trackRunId;
host.chatStream = "";
host.chatSending = false;
}
if (result.pendingCurrentRun && host.chatRunId) {
enqueuePendingRunMessage(host, `/${name} ${args}`.trim(), host.chatRunId);
}
if (result.sessionPatch && "modelOverride" in result.sessionPatch) {
host.chatModelOverrides = {
...host.chatModelOverrides,

View File

@ -2,7 +2,11 @@ import {
GATEWAY_EVENT_UPDATE_AVAILABLE,
type GatewayUpdateAvailableEventPayload,
} from "../../../src/gateway/events.js";
import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat.ts";
import {
CHAT_SESSIONS_ACTIVE_MINUTES,
clearPendingQueueItemsForRun,
flushChatQueueForEvent,
} from "./app-chat.ts";
import type { EventLogEntry } from "./app-events.ts";
import {
applySettings,
@ -289,6 +293,10 @@ function handleTerminalChatEvent(
const toolHost = host as unknown as Parameters<typeof resetToolStream>[0];
const hadToolEvents = toolHost.toolStreamOrder.length > 0;
resetToolStream(toolHost);
clearPendingQueueItemsForRun(
host as unknown as Parameters<typeof clearPendingQueueItemsForRun>[0],
payload?.runId,
);
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[0]);
const runId = payload?.runId;
if (runId && host.refreshSessionsAfterChat.has(runId)) {

View File

@ -6,7 +6,7 @@ import {
OPENAI_GPT5_MINI_MODEL,
} from "../chat-model.test-helpers.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow } from "../types.ts";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
import { executeSlashCommand } from "./slash-command-executor.ts";
function row(key: string, overrides?: Partial<GatewaySessionRow>): GatewaySessionRow {
@ -19,39 +19,26 @@ function row(key: string, overrides?: Partial<GatewaySessionRow>): GatewaySessio
};
}
function createKillRequest(params: { sessions: GatewaySessionRow[]; aborted?: boolean }) {
return vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return { sessions: params.sessions };
}
if (method === "chat.abort") {
return { ok: true, aborted: params.aborted ?? true };
}
throw new Error(`unexpected method: ${method}`);
});
}
function expectAbortCalls(request: ReturnType<typeof vi.fn>, sessionKeys: string[]) {
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
for (const [index, sessionKey] of sessionKeys.entries()) {
expect(request).toHaveBeenNthCalledWith(index + 2, "chat.abort", {
sessionKey,
});
}
}
describe("executeSlashCommand /kill", () => {
it("aborts every sub-agent session for /kill all", async () => {
const request = createKillRequest({
sessions: [
row("main"),
row("agent:main:subagent:one", { spawnedBy: "main" }),
row("agent:main:subagent:parent", { spawnedBy: "main" }),
row("agent:main:subagent:parent:subagent:child", {
spawnedBy: "agent:main:subagent:parent",
}),
row("agent:other:main"),
],
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("main"),
row("agent:main:subagent:one", { spawnedBy: "main" }),
row("agent:main:subagent:parent", { spawnedBy: "main" }),
row("agent:main:subagent:parent:subagent:child", {
spawnedBy: "agent:main:subagent:parent",
}),
row("agent:other:main"),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
@ -62,20 +49,33 @@ describe("executeSlashCommand /kill", () => {
);
expect(result.content).toBe("Aborted 3 sub-agent sessions.");
expectAbortCalls(request, [
"agent:main:subagent:one",
"agent:main:subagent:parent",
"agent:main:subagent:parent:subagent:child",
]);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:parent",
});
expect(request).toHaveBeenNthCalledWith(4, "chat.abort", {
sessionKey: "agent:main:subagent:parent:subagent:child",
});
});
it("aborts matching sub-agent sessions for /kill <agentId>", async () => {
const request = createKillRequest({
sessions: [
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
@ -86,7 +86,13 @@ describe("executeSlashCommand /kill", () => {
);
expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`.");
expectAbortCalls(request, ["agent:main:subagent:one", "agent:main:subagent:two"]);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
it("does not exact-match a session key outside the current subagent subtree", async () => {
@ -123,12 +129,19 @@ describe("executeSlashCommand /kill", () => {
});
it("returns a no-op summary when matching sessions have no active runs", async () => {
const request = createKillRequest({
sessions: [
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
],
aborted: false,
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: false };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
@ -139,17 +152,31 @@ describe("executeSlashCommand /kill", () => {
);
expect(result.content).toBe("No active sub-agent runs to abort.");
expectAbortCalls(request, ["agent:main:subagent:one", "agent:main:subagent:two"]);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
it("treats the legacy main session key as the default agent scope", async () => {
const request = createKillRequest({
sessions: [
row("main"),
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("main"),
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
@ -160,7 +187,13 @@ describe("executeSlashCommand /kill", () => {
);
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
expectAbortCalls(request, ["agent:main:subagent:one", "agent:main:subagent:two"]);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
it("does not abort unrelated same-agent subagents from another root session", async () => {
@ -525,3 +558,392 @@ describe("executeSlashCommand directives", () => {
});
});
});
describe("executeSlashCommand /steer (soft inject)", () => {
it("injects into the current session via chat.send with deliver: false", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return { sessions: [row("agent:main:main", { status: "running" })] };
}
if (method === "chat.send") {
return { status: "started", runId: "run-1", messageSeq: 2 };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"try a different approach",
);
expect(result.content).toBe("Steered.");
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main:main",
message: "try a different approach",
deliver: false,
}),
);
});
it("injects into a matching subagent when the first word resolves to one", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main"),
row("agent:main:subagent:researcher", {
spawnedBy: "agent:main:main",
status: "running",
}),
],
};
}
if (method === "chat.send") {
return { status: "started", runId: "run-2", messageSeq: 1 };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"researcher try a different approach",
);
expect(result.content).toBe("Steered `researcher`.");
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main:subagent:researcher",
message: "try a different approach",
deliver: false,
}),
);
});
it("uses cached sessions to avoid an extra sessions.list round trip", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "chat.send") {
return { status: "started", runId: "run-2", messageSeq: 1 };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"researcher try a different approach",
{
sessionsResult: {
sessions: [
row("agent:main:main"),
row("agent:main:subagent:researcher", {
spawnedBy: "agent:main:main",
status: "running",
}),
],
} as SessionsListResult,
},
);
expect(result.content).toBe("Steered `researcher`.");
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main:subagent:researcher",
message: "try a different approach",
deliver: false,
}),
);
});
it("matches an explicit full subagent session key", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main"),
row("agent:main:subagent:researcher", {
spawnedBy: "agent:main:main",
status: "running",
}),
],
};
}
if (method === "chat.send") {
return { status: "started", runId: "run-2", messageSeq: 1 };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"agent:main:subagent:researcher try a different approach",
);
expect(result.content).toBe("Steered `agent:main:subagent:researcher`.");
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main:subagent:researcher",
message: "try a different approach",
deliver: false,
}),
);
});
it("does not treat 'all' as a subagent wildcard", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return { sessions: [row("agent:main:main", { status: "running" })] };
}
if (method === "chat.send") {
return { status: "started", runId: "run-3", messageSeq: 1 };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"all good now",
);
expect(result.content).toBe("Steered.");
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main:main",
message: "all good now",
deliver: false,
}),
);
});
it("does not match agent id as target — treats 'main' as message text", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main", { status: "running" }),
row("agent:main:subagent:researcher", { spawnedBy: "agent:main:main" }),
],
};
}
if (method === "chat.send") {
return { status: "started", runId: "run-4", messageSeq: 1 };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"main refine the plan",
);
expect(result.content).toBe("Steered.");
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main:main",
message: "main refine the plan",
deliver: false,
}),
);
});
it("ignores ended subagent sessions when resolving target", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main", { status: "running" }),
row("agent:main:subagent:researcher", {
spawnedBy: "agent:main:main",
endedAt: Date.now() - 60_000,
}),
],
};
}
if (method === "chat.send") {
return { status: "started", runId: "run-5", messageSeq: 1 };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"researcher try again",
);
// "researcher" is ended, so the full string is sent to current session
expect(result.content).toBe("Steered.");
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main:main",
message: "researcher try again",
deliver: false,
}),
);
});
it("returns a no-op summary when the current session has no active run", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return { sessions: [row("agent:main:main", { status: "done", endedAt: Date.now() })] };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"try again",
);
expect(result.content).toBe("No active run. Use the chat input or `/redirect` instead.");
expect(request).toHaveBeenCalledWith("sessions.list", {});
expect(request).not.toHaveBeenCalledWith("chat.send", expect.anything());
});
it("returns usage when no message is provided", async () => {
const request = vi.fn();
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"",
);
expect(result.content).toBe("Usage: `/steer [id] <message>`");
expect(request).not.toHaveBeenCalled();
});
it("returns error message on RPC failure", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return { sessions: [row("agent:main:main", { status: "running" })] };
}
throw new Error("connection lost");
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"steer",
"try again",
);
expect(result.content).toBe("Failed to steer: Error: connection lost");
});
});
describe("executeSlashCommand /redirect (hard kill-and-restart)", () => {
it("calls sessions.steer to abort and restart the current session", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return { sessions: [row("agent:main:main")] };
}
if (method === "sessions.steer") {
return { status: "started", runId: "run-1", messageSeq: 2, interruptedActiveRun: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"redirect",
"start over with a new plan",
);
expect(result.content).toBe("Redirected.");
expect(result.trackRunId).toBe("run-1");
expect(request).toHaveBeenCalledWith("sessions.steer", {
key: "agent:main:main",
message: "start over with a new plan",
});
});
it("redirects a matching subagent when the first word resolves to one", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main"),
row("agent:main:subagent:researcher", { spawnedBy: "agent:main:main" }),
],
};
}
if (method === "sessions.steer") {
return { status: "started", runId: "run-2", messageSeq: 1 };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"redirect",
"researcher start over completely",
);
expect(result.content).toBe("Redirected `researcher`.");
// Subagent redirect must NOT set trackRunId — the run belongs to a
// different session so chat events would never clear chatRunId.
expect(result.trackRunId).toBeUndefined();
expect(request).toHaveBeenCalledWith("sessions.steer", {
key: "agent:main:subagent:researcher",
message: "start over completely",
});
});
it("returns usage when no message is provided", async () => {
const request = vi.fn();
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"redirect",
"",
);
expect(result.content).toBe("Usage: `/redirect [id] <message>`");
expect(request).not.toHaveBeenCalled();
});
it("returns error message on RPC failure", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return { sessions: [row("agent:main:main")] };
}
throw new Error("connection lost");
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"redirect",
"try again",
);
expect(result.content).toBe("Failed to redirect: Error: connection lost");
});
});

View File

@ -25,6 +25,7 @@ import type {
SessionsListResult,
SessionsPatchResult,
} from "../types.ts";
import { generateUUID } from "../uuid.ts";
import { SLASH_COMMANDS } from "./slash-commands.ts";
export type SlashCommandResult = {
@ -44,11 +45,16 @@ export type SlashCommandResult = {
sessionPatch?: {
modelOverride?: ChatModelOverride | null;
};
/** When set, the caller should track this as the active run (enables Abort, blocks concurrent sends). */
trackRunId?: string;
/** When set, the caller should surface a visible pending item tied to the current run. */
pendingCurrentRun?: boolean;
};
export type SlashCommandContext = {
chatModelCatalog?: ModelCatalogEntry[];
modelCatalog?: ModelCatalogEntry[];
sessionsResult?: SessionsListResult | null;
};
export async function executeSlashCommand(
client: GatewayBrowserClient,
@ -88,6 +94,10 @@ export async function executeSlashCommand(
return await executeAgents(client);
case "kill":
return await executeKill(client, sessionKey, args);
case "steer":
return await executeSteer(client, sessionKey, args, context);
case "redirect":
return await executeRedirect(client, sessionKey, args, context);
default:
return { content: `Unknown command: \`/${commandName}\`` };
}
@ -604,6 +614,177 @@ function resolveCurrentFastMode(session: GatewaySessionRow | undefined): "on" |
return session?.fastMode === true ? "on" : "off";
}
/**
* Match a target name against active subagent sessions by key/label only.
* Unlike resolveKillTargets, this does NOT match by agent id (avoiding
* false positives for common words like "main") and filters to active
* sessions (no endedAt) so stale subagents are not targeted.
*/
function resolveSteerSubagent(
sessions: GatewaySessionRow[],
currentSessionKey: string,
target: string,
): string[] {
const normalizedTarget = target.trim().toLowerCase();
if (!normalizedTarget) {
return [];
}
const normalizedCurrentSessionKey = currentSessionKey.trim().toLowerCase();
const currentParsed = parseAgentSessionKey(normalizedCurrentSessionKey);
const currentAgentId =
currentParsed?.agentId ??
(normalizedCurrentSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined);
const sessionIndex = buildSessionIndex(sessions);
const keys = new Set<string>();
for (const session of sessions) {
const key = session?.key?.trim();
if (!key || !isSubagentSessionKey(key)) {
continue;
}
// P1: skip ended sessions so stale subagents are not targeted
if (session.endedAt) {
continue;
}
const normalizedKey = key.toLowerCase();
const parsed = parseAgentSessionKey(normalizedKey);
const belongsToCurrentSession = isWithinCurrentSessionSubtree(
normalizedKey,
normalizedCurrentSessionKey,
sessionIndex,
currentAgentId,
parsed?.agentId,
);
if (!belongsToCurrentSession) {
continue;
}
// P2: match only on subagent key suffix or label, not agent id
const isMatch =
normalizedKey === normalizedTarget ||
normalizedKey.endsWith(`:subagent:${normalizedTarget}`) ||
normalizedKey === `subagent:${normalizedTarget}` ||
(session.label ?? "").toLowerCase() === normalizedTarget;
if (isMatch) {
keys.add(key);
}
}
return [...keys];
}
/**
* Resolve an optional subagent target from the first word of args.
* Returns the resolved session key and the remaining message, or
* falls back to the current session key with the full args as message.
*/
async function resolveSteerTarget(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
context: SlashCommandContext,
): Promise<
| { key: string; message: string; label?: string; sessions?: SessionsListResult }
| { error: string }
> {
const trimmed = args.trim();
if (!trimmed) {
return { error: "empty" };
}
const spaceIdx = trimmed.indexOf(" ");
if (spaceIdx > 0) {
const maybeTarget = trimmed.slice(0, spaceIdx);
const rest = trimmed.slice(spaceIdx + 1).trim();
// Skip "all" — resolveKillTargets treats it as a wildcard, but steer/redirect
// target a single session, so "all good now" should not match subagents.
if (rest && maybeTarget.toLowerCase() !== "all") {
const sessions =
context.sessionsResult ?? (await client.request<SessionsListResult>("sessions.list", {}));
const matched = resolveSteerSubagent(sessions?.sessions ?? [], sessionKey, maybeTarget);
if (matched.length === 1) {
return { key: matched[0], message: rest, label: maybeTarget, sessions };
}
if (matched.length > 1) {
return { error: `Multiple sub-agents match \`${maybeTarget}\`. Be more specific.` };
}
}
}
return { key: sessionKey, message: trimmed };
}
function isActiveSteerSession(session: GatewaySessionRow | undefined): boolean {
return session?.status === "running" && session.endedAt == null;
}
/** Soft inject — queues a message into the active run via chat.send (deliver: false). */
async function executeSteer(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
context: SlashCommandContext,
): Promise<SlashCommandResult> {
try {
const resolved = await resolveSteerTarget(client, sessionKey, args, context);
if ("error" in resolved) {
return {
content: resolved.error === "empty" ? "Usage: `/steer [id] <message>`" : resolved.error,
};
}
const sessions =
resolved.sessions ?? (await client.request<SessionsListResult>("sessions.list", {}));
const targetSession = resolveCurrentSession(sessions, resolved.key);
if (!isActiveSteerSession(targetSession)) {
return {
content: resolved.label
? `No active run matched \`${resolved.label}\`. Use \`/redirect\` instead.`
: "No active run. Use the chat input or `/redirect` instead.",
};
}
await client.request("chat.send", {
sessionKey: resolved.key,
message: resolved.message,
deliver: false,
idempotencyKey: generateUUID(),
});
return {
content: resolved.label ? `Steered \`${resolved.label}\`.` : "Steered.",
pendingCurrentRun: resolved.key === sessionKey,
};
} catch (err) {
return { content: `Failed to steer: ${String(err)}` };
}
}
/** Hard redirect — aborts the active run and restarts with a new message. */
async function executeRedirect(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
context: SlashCommandContext,
): Promise<SlashCommandResult> {
try {
const resolved = await resolveSteerTarget(client, sessionKey, args, context);
if ("error" in resolved) {
return {
content: resolved.error === "empty" ? "Usage: `/redirect [id] <message>`" : resolved.error,
};
}
const resp = await client.request<{ runId?: string }>("sessions.steer", {
key: resolved.key,
message: resolved.message,
});
// Only track the run when redirecting the current session. Subagent
// redirects target a different sessionKey, so chat events for that run
// would never clear chatRunId on the current view.
const runId = typeof resp?.runId === "string" ? resp.runId : undefined;
const trackRunId = resolved.key === sessionKey ? runId : undefined;
return {
content: resolved.label ? `Redirected \`${resolved.label}\`.` : "Redirected.",
trackRunId,
};
} catch (err) {
return { content: `Failed to redirect: ${String(err)}` };
}
}
function fmtTokens(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;

View File

@ -82,6 +82,18 @@ describe("parseSlashCommand", () => {
});
});
it("keeps a single local /steer entry with the control-ui metadata", () => {
const steerEntries = SLASH_COMMANDS.filter((entry) => entry.name === "steer");
expect(steerEntries).toHaveLength(1);
expect(steerEntries[0]).toMatchObject({
key: "steer",
description: "Inject a message into the active run",
args: "[id] <message>",
aliases: expect.arrayContaining(["tell"]),
executeLocal: true,
});
});
it("keeps focus as a local slash command", () => {
expect(parseSlashCommand("/focus")).toMatchObject({
command: { key: "focus", executeLocal: true },

View File

@ -66,6 +66,8 @@ const LOCAL_COMMANDS = new Set([
"usage",
"agents",
"kill",
"steer",
"redirect",
]);
const UI_ONLY_COMMANDS: SlashCommandDef[] = [
@ -77,6 +79,15 @@ const UI_ONLY_COMMANDS: SlashCommandDef[] = [
category: "session",
executeLocal: true,
},
{
key: "redirect",
name: "redirect",
description: "Abort and restart with a new message",
args: "[id] <message>",
icon: "refresh",
category: "agents",
executeLocal: true,
},
];
const CATEGORY_OVERRIDES: Partial<Record<string, SlashCommandCategory>> = {
@ -92,6 +103,7 @@ const CATEGORY_OVERRIDES: Partial<Record<string, SlashCommandCategory>> = {
subagents: "agents",
kill: "agents",
steer: "agents",
redirect: "agents",
session: "session",
stop: "session",
reset: "session",
@ -109,6 +121,14 @@ const CATEGORY_OVERRIDES: Partial<Record<string, SlashCommandCategory>> = {
queue: "model",
};
const COMMAND_DESCRIPTION_OVERRIDES: Partial<Record<string, string>> = {
steer: "Inject a message into the active run",
};
const COMMAND_ARGS_OVERRIDES: Partial<Record<string, string>> = {
steer: "[id] <message>",
};
function normalizeUiKey(command: ChatCommandDefinition): string {
return command.key.replace(/[:.-]/g, "_");
}
@ -170,8 +190,8 @@ function toSlashCommand(command: ChatCommandDefinition): SlashCommandDef | null
key: command.key,
name,
aliases: getSlashAliases(command).filter((alias) => alias !== name),
description: command.description,
args: formatArgs(command),
description: COMMAND_DESCRIPTION_OVERRIDES[command.key] ?? command.description,
args: COMMAND_ARGS_OVERRIDES[command.key] ?? formatArgs(command),
icon: mapIcon(command),
category: mapCategory(command),
executeLocal: LOCAL_COMMANDS.has(command.key),

View File

@ -12,6 +12,7 @@ export type ChatQueueItem = {
refreshSessions?: boolean;
localCommandArgs?: string;
localCommandName?: string;
pendingRunId?: string;
};
export const CRON_CHANNEL_LAST = "last";

View File

@ -88,6 +88,7 @@ export default defineConfig({
"ui/src/ui/controllers/sessions.test.ts",
"ui/src/ui/views/sessions.test.ts",
"ui/src/ui/app-gateway.sessions.node.test.ts",
"ui/src/ui/chat/slash-command-executor.node.test.ts",
],
setupFiles: ["test/setup.ts"],
exclude: [