Merge pull request #48 from getpaseo/feature/commands-in-draft-screen-clean
Make slash commands available in the new-agent draft screen
This commit is contained in:
@@ -26,7 +26,10 @@ import {
|
|||||||
} from "./message-input";
|
} from "./message-input";
|
||||||
import { Theme } from "@/styles/theme";
|
import { Theme } from "@/styles/theme";
|
||||||
import { CommandAutocomplete } from "./command-autocomplete";
|
import { CommandAutocomplete } from "./command-autocomplete";
|
||||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
import {
|
||||||
|
useAgentCommandsQuery,
|
||||||
|
type DraftCommandConfig,
|
||||||
|
} from "@/hooks/use-agent-commands-query";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
@@ -55,6 +58,8 @@ interface AgentInputAreaProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
/** Callback to expose the addImages function to parent components */
|
/** Callback to expose the addImages function to parent components */
|
||||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||||
|
/** Optional draft context for listing commands before an agent exists. */
|
||||||
|
commandDraftConfig?: DraftCommandConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||||
@@ -69,6 +74,7 @@ export function AgentInputArea({
|
|||||||
onChangeText,
|
onChangeText,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
|
commandDraftConfig,
|
||||||
}: AgentInputAreaProps) {
|
}: AgentInputAreaProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -117,13 +123,39 @@ export function AgentInputArea({
|
|||||||
// Command autocomplete logic
|
// Command autocomplete logic
|
||||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||||
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
|
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
|
||||||
|
const normalizedCommandDraftConfig = useMemo(() => {
|
||||||
|
if (!commandDraftConfig) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Prefetch commands when agent is available (not on new agent screen)
|
const cwd = commandDraftConfig.cwd.trim();
|
||||||
|
if (!cwd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeId = commandDraftConfig.modeId?.trim() ?? "";
|
||||||
|
const model = commandDraftConfig.model?.trim() ?? "";
|
||||||
|
const thinkingOptionId = commandDraftConfig.thinkingOptionId?.trim() ?? "";
|
||||||
|
return {
|
||||||
|
provider: commandDraftConfig.provider,
|
||||||
|
cwd,
|
||||||
|
...(modeId ? { modeId } : {}),
|
||||||
|
...(model ? { model } : {}),
|
||||||
|
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||||
|
};
|
||||||
|
}, [commandDraftConfig]);
|
||||||
|
|
||||||
|
// Prefetch commands for real agents and for draft screens when context is available.
|
||||||
const isRealAgent = agentId && !agentId.startsWith("__");
|
const isRealAgent = agentId && !agentId.startsWith("__");
|
||||||
|
const commandQueryDraftConfig = isRealAgent
|
||||||
|
? undefined
|
||||||
|
: normalizedCommandDraftConfig;
|
||||||
|
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!commandQueryDraftConfig);
|
||||||
const { commands } = useAgentCommandsQuery({
|
const { commands } = useAgentCommandsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled: !!isRealAgent && !!serverId,
|
enabled: canLoadCommands,
|
||||||
|
draftConfig: commandQueryDraftConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter commands for keyboard navigation
|
// Filter commands for keyboard navigation
|
||||||
@@ -742,13 +774,14 @@ export function AgentInputArea({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Command autocomplete dropdown */}
|
{/* Command autocomplete dropdown */}
|
||||||
{showCommandAutocomplete && isRealAgent && (
|
{showCommandAutocomplete && canLoadCommands && (
|
||||||
<CommandAutocomplete
|
<CommandAutocomplete
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
filter={commandFilter}
|
filter={commandFilter}
|
||||||
selectedIndex={commandSelectedIndex}
|
selectedIndex={commandSelectedIndex}
|
||||||
onSelect={handleCommandSelect}
|
onSelect={handleCommandSelect}
|
||||||
|
draftConfig={commandQueryDraftConfig}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { View, Text, Pressable, ScrollView } from "react-native";
|
import { View, Text, Pressable, ScrollView } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
import {
|
||||||
|
useAgentCommandsQuery,
|
||||||
|
type DraftCommandConfig,
|
||||||
|
} from "@/hooks/use-agent-commands-query";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { Theme } from "@/styles/theme";
|
import { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
@@ -16,6 +19,7 @@ interface CommandAutocompleteProps {
|
|||||||
filter: string;
|
filter: string;
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (command: AgentSlashCommand) => void;
|
onSelect: (command: AgentSlashCommand) => void;
|
||||||
|
draftConfig?: DraftCommandConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandAutocomplete({
|
export function CommandAutocomplete({
|
||||||
@@ -24,12 +28,14 @@ export function CommandAutocomplete({
|
|||||||
filter,
|
filter,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
draftConfig,
|
||||||
}: CommandAutocompleteProps) {
|
}: CommandAutocompleteProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
|
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
draftConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter commands based on input after /
|
// Filter commands based on input after /
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
||||||
|
|
||||||
@@ -9,20 +10,43 @@ interface AgentSlashCommand {
|
|||||||
argumentHint: string;
|
argumentHint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandsQueryKey(serverId: string, agentId: string) {
|
export interface DraftCommandConfig {
|
||||||
return ["agentCommands", serverId, agentId] as const;
|
provider: AgentProvider;
|
||||||
|
cwd: string;
|
||||||
|
modeId?: string;
|
||||||
|
model?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandsQueryKey(
|
||||||
|
serverId: string,
|
||||||
|
agentId: string,
|
||||||
|
draftConfig?: DraftCommandConfig
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"agentCommands",
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
draftConfig?.provider ?? null,
|
||||||
|
draftConfig?.cwd ?? null,
|
||||||
|
draftConfig?.modeId ?? null,
|
||||||
|
draftConfig?.model ?? null,
|
||||||
|
draftConfig?.thinkingOptionId ?? null,
|
||||||
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseAgentCommandsQueryOptions {
|
interface UseAgentCommandsQueryOptions {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
draftConfig?: DraftCommandConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgentCommandsQuery({
|
export function useAgentCommandsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
draftConfig,
|
||||||
}: UseAgentCommandsQueryOptions) {
|
}: UseAgentCommandsQueryOptions) {
|
||||||
const client = useSessionStore(
|
const client = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
(state) => state.sessions[serverId]?.client ?? null
|
||||||
@@ -32,12 +56,12 @@ export function useAgentCommandsQuery({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: commandsQueryKey(serverId, agentId),
|
queryKey: commandsQueryKey(serverId, agentId, draftConfig),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Daemon client not available");
|
throw new Error("Daemon client not available");
|
||||||
}
|
}
|
||||||
const response = await client.listCommands(agentId);
|
const response = await client.listCommands(agentId, { draftConfig });
|
||||||
return response.commands as AgentSlashCommand[];
|
return response.commands as AgentSlashCommand[];
|
||||||
},
|
},
|
||||||
enabled: enabled && !!client && isConnected && !!agentId,
|
enabled: enabled && !!client && isConnected && !!agentId,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Combobox } from "@/components/ui/combobox";
|
|||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import {
|
import {
|
||||||
CHECKOUT_STATUS_STALE_TIME,
|
CHECKOUT_STATUS_STALE_TIME,
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
@@ -788,6 +789,33 @@ export function DraftAgentScreen({
|
|||||||
const createAgentClient = useSessionStore((state) =>
|
const createAgentClient = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
||||||
);
|
);
|
||||||
|
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||||
|
const cwd = (
|
||||||
|
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||||
|
).trim();
|
||||||
|
if (!cwd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd,
|
||||||
|
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||||
|
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
||||||
|
...(selectedThinkingOptionId.trim()
|
||||||
|
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isAttachWorktree,
|
||||||
|
modeOptions.length,
|
||||||
|
selectedMode,
|
||||||
|
selectedModel,
|
||||||
|
selectedProvider,
|
||||||
|
selectedThinkingOptionId,
|
||||||
|
selectedWorktreePath,
|
||||||
|
workingDir,
|
||||||
|
]);
|
||||||
|
|
||||||
const promptValue = machine.tag === "draft" ? machine.promptText : "";
|
const promptValue = machine.tag === "draft" ? machine.promptText : "";
|
||||||
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||||
@@ -1240,6 +1268,7 @@ export function DraftAgentScreen({
|
|||||||
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
|
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
|
||||||
autoFocus={machine.tag === "draft"}
|
autoFocus={machine.tag === "draft"}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
|
commandDraftConfig={draftCommandConfig}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -769,6 +769,139 @@ describe("DaemonClient", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lists commands with draft config via RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.listCommands("__new_agent__", {
|
||||||
|
draftConfig: {
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
model: "gpt-5",
|
||||||
|
thinkingOptionId: "off",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
|
||||||
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
|
type: "session";
|
||||||
|
message: {
|
||||||
|
type: "list_commands_request";
|
||||||
|
agentId: string;
|
||||||
|
draftConfig?: {
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
modeId?: string;
|
||||||
|
model?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
};
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(request.message.type).toBe("list_commands_request");
|
||||||
|
expect(request.message.agentId).toBe("__new_agent__");
|
||||||
|
expect(request.message.draftConfig).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
model: "gpt-5",
|
||||||
|
thinkingOptionId: "off",
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "list_commands_response",
|
||||||
|
payload: {
|
||||||
|
agentId: "__new_agent__",
|
||||||
|
commands: [
|
||||||
|
{ name: "help", description: "Show help", argumentHint: "" },
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
requestId: request.message.requestId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
agentId: "__new_agent__",
|
||||||
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
||||||
|
error: null,
|
||||||
|
requestId: request.message.requestId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists commands with legacy requestId signature via RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.listCommands("agent-1", "req-legacy");
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
|
||||||
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
|
type: "session";
|
||||||
|
message: {
|
||||||
|
type: "list_commands_request";
|
||||||
|
agentId: string;
|
||||||
|
draftConfig?: unknown;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(request.message.type).toBe("list_commands_request");
|
||||||
|
expect(request.message.agentId).toBe("agent-1");
|
||||||
|
expect(request.message.requestId).toBe("req-legacy");
|
||||||
|
expect(request.message.draftConfig).toBeUndefined();
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "list_commands_response",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent-1",
|
||||||
|
commands: [],
|
||||||
|
error: null,
|
||||||
|
requestId: "req-legacy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
agentId: "agent-1",
|
||||||
|
commands: [],
|
||||||
|
error: null,
|
||||||
|
requestId: "req-legacy",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("auto-acks terminal stream chunks after delivery", async () => {
|
test("auto-acks terminal stream chunks after delivery", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -209,6 +209,14 @@ type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
|
|||||||
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
|
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
|
||||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||||
type ExecuteCommandPayload = ExecuteCommandResponse["payload"];
|
type ExecuteCommandPayload = ExecuteCommandResponse["payload"];
|
||||||
|
type ListCommandsDraftConfig = Pick<
|
||||||
|
AgentSessionConfig,
|
||||||
|
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
|
||||||
|
>;
|
||||||
|
type ListCommandsOptions = {
|
||||||
|
requestId?: string;
|
||||||
|
draftConfig?: ListCommandsDraftConfig;
|
||||||
|
};
|
||||||
type SetVoiceModePayload = Extract<
|
type SetVoiceModePayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "set_voice_mode_response" }
|
{ type: "set_voice_mode_response" }
|
||||||
@@ -2171,15 +2179,30 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
|
||||||
async listCommands(
|
async listCommands(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
requestId?: string
|
options?: ListCommandsOptions
|
||||||
|
): Promise<ListCommandsPayload>;
|
||||||
|
async listCommands(
|
||||||
|
agentId: string,
|
||||||
|
requestIdOrOptions?: string | ListCommandsOptions
|
||||||
): Promise<ListCommandsPayload> {
|
): Promise<ListCommandsPayload> {
|
||||||
|
const requestId =
|
||||||
|
typeof requestIdOrOptions === "string"
|
||||||
|
? requestIdOrOptions
|
||||||
|
: requestIdOrOptions?.requestId;
|
||||||
|
const draftConfig =
|
||||||
|
typeof requestIdOrOptions === "string"
|
||||||
|
? undefined
|
||||||
|
: requestIdOrOptions?.draftConfig;
|
||||||
|
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
message: {
|
message: {
|
||||||
type: "list_commands_request",
|
type: "list_commands_request",
|
||||||
agentId,
|
agentId,
|
||||||
|
...(draftConfig ? { draftConfig } : {}),
|
||||||
},
|
},
|
||||||
responseType: "list_commands_response",
|
responseType: "list_commands_response",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { z } from "zod";
|
|||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentSlashCommand,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
@@ -437,6 +438,36 @@ export class AgentManager {
|
|||||||
return Promise.all(checks);
|
return Promise.all(checks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
|
||||||
|
const normalizedConfig = await this.normalizeConfig(config);
|
||||||
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
|
const available = await client.isAvailable();
|
||||||
|
if (!available) {
|
||||||
|
throw new Error(
|
||||||
|
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await client.createSession(normalizedConfig);
|
||||||
|
try {
|
||||||
|
if (!session.listCommands) {
|
||||||
|
throw new Error(
|
||||||
|
`Provider '${normalizedConfig.provider}' does not support listing commands`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await session.listCommands();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await session.close();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, provider: normalizedConfig.provider },
|
||||||
|
"Failed to close draft command listing session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getAgent(id: string): ManagedAgent | null {
|
getAgent(id: string): ManagedAgent | null {
|
||||||
const agent = this.agents.get(id);
|
const agent = this.agents.get(id);
|
||||||
return agent ? { ...agent } : null;
|
return agent ? { ...agent } : null;
|
||||||
|
|||||||
@@ -1524,7 +1524,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "list_commands_request":
|
case "list_commands_request":
|
||||||
await this.handleListCommandsRequest(msg.agentId, msg.requestId);
|
await this.handleListCommandsRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "execute_command_request":
|
case "execute_command_request":
|
||||||
@@ -3473,9 +3473,12 @@ export class Session {
|
|||||||
/**
|
/**
|
||||||
* Handle list commands request for an agent
|
* Handle list commands request for an agent
|
||||||
*/
|
*/
|
||||||
private async handleListCommandsRequest(agentId: string, requestId: string): Promise<void> {
|
private async handleListCommandsRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "list_commands_request" }>
|
||||||
|
): Promise<void> {
|
||||||
|
const { agentId, requestId, draftConfig } = msg;
|
||||||
this.sessionLogger.debug(
|
this.sessionLogger.debug(
|
||||||
{ agentId },
|
{ agentId, draftConfig },
|
||||||
`Handling list commands request for agent ${agentId}`
|
`Handling list commands request for agent ${agentId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3483,47 +3486,59 @@ export class Session {
|
|||||||
const agents = this.agentManager.listAgents();
|
const agents = this.agentManager.listAgents();
|
||||||
const agent = agents.find((a) => a.id === agentId);
|
const agent = agents.find((a) => a.id === agentId);
|
||||||
|
|
||||||
if (!agent) {
|
if (agent?.session?.listCommands) {
|
||||||
|
const commands = await agent.session.listCommands();
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "list_commands_response",
|
type: "list_commands_response",
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
agentId,
|
||||||
commands: [],
|
commands,
|
||||||
error: `Agent not found: ${agentId}`,
|
error: null,
|
||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = agent.session;
|
if (!agent && draftConfig) {
|
||||||
if (!session || !session.listCommands) {
|
const sessionConfig: AgentSessionConfig = {
|
||||||
|
provider: draftConfig.provider,
|
||||||
|
cwd: expandTilde(draftConfig.cwd),
|
||||||
|
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
|
||||||
|
...(draftConfig.model ? { model: draftConfig.model } : {}),
|
||||||
|
...(draftConfig.thinkingOptionId
|
||||||
|
? { thinkingOptionId: draftConfig.thinkingOptionId }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const commands = await this.agentManager.listDraftCommands(sessionConfig);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "list_commands_response",
|
type: "list_commands_response",
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
agentId,
|
||||||
commands: [],
|
commands,
|
||||||
error: `Agent does not support listing commands`,
|
error: null,
|
||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commands = await session.listCommands();
|
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "list_commands_response",
|
type: "list_commands_response",
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
agentId,
|
||||||
commands,
|
commands: [],
|
||||||
error: null,
|
error: agent
|
||||||
|
? `Agent does not support listing commands`
|
||||||
|
: `Agent not found: ${agentId}`,
|
||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
{ err: error, agentId },
|
{ err: error, agentId, draftConfig },
|
||||||
"Failed to list commands"
|
"Failed to list commands"
|
||||||
);
|
);
|
||||||
this.emit({
|
this.emit({
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { SessionInboundMessageSchema } from "./messages.js";
|
||||||
|
|
||||||
|
describe("list_commands_request schema", () => {
|
||||||
|
test("accepts legacy agent-only payload", () => {
|
||||||
|
const parsed = SessionInboundMessageSchema.parse({
|
||||||
|
type: "list_commands_request",
|
||||||
|
agentId: "agent-123",
|
||||||
|
requestId: "req-123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.type).toBe("list_commands_request");
|
||||||
|
if (parsed.type !== "list_commands_request") {
|
||||||
|
throw new Error("Expected list_commands_request message");
|
||||||
|
}
|
||||||
|
expect(parsed.agentId).toBe("agent-123");
|
||||||
|
expect(parsed.draftConfig).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts draft command context payload", () => {
|
||||||
|
const parsed = SessionInboundMessageSchema.parse({
|
||||||
|
type: "list_commands_request",
|
||||||
|
agentId: "__new_agent__",
|
||||||
|
draftConfig: {
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
model: "gpt-5",
|
||||||
|
thinkingOptionId: "off",
|
||||||
|
},
|
||||||
|
requestId: "req-456",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.type).toBe("list_commands_request");
|
||||||
|
if (parsed.type !== "list_commands_request") {
|
||||||
|
throw new Error("Expected list_commands_request message");
|
||||||
|
}
|
||||||
|
expect(parsed.draftConfig).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
modeId: "bypassPermissions",
|
||||||
|
model: "gpt-5",
|
||||||
|
thinkingOptionId: "off",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -926,9 +926,18 @@ export const PingMessageSchema = z.object({
|
|||||||
clientSentAt: z.number().int().optional(),
|
clientSentAt: z.number().int().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ListCommandsDraftConfigSchema = z.object({
|
||||||
|
provider: AgentProviderSchema,
|
||||||
|
cwd: z.string(),
|
||||||
|
modeId: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
thinkingOptionId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const ListCommandsRequestSchema = z.object({
|
export const ListCommandsRequestSchema = z.object({
|
||||||
type: z.literal("list_commands_request"),
|
type: z.literal("list_commands_request"),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
|
draftConfig: ListCommandsDraftConfigSchema.optional(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user