mirror of https://github.com/openclaw/openclaw.git
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:
parent
e3faa99c6a
commit
83808fe494
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type ChatQueueItem = {
|
|||
refreshSessions?: boolean;
|
||||
localCommandArgs?: string;
|
||||
localCommandName?: string;
|
||||
pendingRunId?: string;
|
||||
};
|
||||
|
||||
export const CRON_CHANNEL_LAST = "last";
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Reference in New Issue