feat: make slash commands available in draft agent screen

This commit is contained in:
Mohamed Boudra
2026-02-17 23:06:37 +07:00
parent a5842482b0
commit 6aa5e97d75
9 changed files with 391 additions and 32 deletions
@@ -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 () => {
const logger = createMockLogger();
const mock = createMockTransport();
+24 -1
View File
@@ -209,6 +209,14 @@ type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ExecuteCommandPayload = ExecuteCommandResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
>;
type ListCommandsOptions = {
requestId?: string;
draftConfig?: ListCommandsDraftConfig;
};
type SetVoiceModePayload = Extract<
SessionOutboundMessage,
{ type: "set_voice_mode_response" }
@@ -2171,15 +2179,30 @@ export class DaemonClient {
});
}
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
async listCommands(
agentId: string,
requestId?: string
options?: ListCommandsOptions
): Promise<ListCommandsPayload>;
async listCommands(
agentId: string,
requestIdOrOptions?: string | ListCommandsOptions
): Promise<ListCommandsPayload> {
const requestId =
typeof requestIdOrOptions === "string"
? requestIdOrOptions
: requestIdOrOptions?.requestId;
const draftConfig =
typeof requestIdOrOptions === "string"
? undefined
: requestIdOrOptions?.draftConfig;
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "list_commands_request",
agentId,
...(draftConfig ? { draftConfig } : {}),
},
responseType: "list_commands_response",
timeout: 30000,
+78 -22
View File
@@ -1524,7 +1524,7 @@ export class Session {
}
case "list_commands_request":
await this.handleListCommandsRequest(msg.agentId, msg.requestId);
await this.handleListCommandsRequest(msg);
break;
case "execute_command_request":
@@ -3473,9 +3473,12 @@ export class Session {
/**
* 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(
{ agentId },
{ agentId, draftConfig },
`Handling list commands request for agent ${agentId}`
);
@@ -3483,47 +3486,100 @@ export class Session {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
if (agent?.session?.listCommands) {
const commands = await agent.session.listCommands();
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: `Agent not found: ${agentId}`,
commands,
error: null,
requestId,
},
});
return;
}
const session = agent.session;
if (!session || !session.listCommands) {
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: `Agent does not support listing commands`,
requestId,
},
});
if (!agent && draftConfig) {
const provider = draftConfig.provider;
const client = this.providerRegistry[provider].createClient(this.sessionLogger);
const available = await client.isAvailable();
if (!available) {
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: `Provider '${provider}' is not available`,
requestId,
},
});
return;
}
const sessionConfig: AgentSessionConfig = {
provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId
? { thinkingOptionId: draftConfig.thinkingOptionId }
: {}),
};
const ephemeralSession = await client.createSession(sessionConfig);
try {
if (!ephemeralSession.listCommands) {
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: `Provider '${provider}' does not support listing commands`,
requestId,
},
});
return;
}
const commands = await ephemeralSession.listCommands();
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands,
error: null,
requestId,
},
});
} finally {
try {
await ephemeralSession.close();
} catch (closeError) {
this.sessionLogger.warn(
{ err: closeError, provider, agentId },
"Failed to close ephemeral command listing session"
);
}
}
return;
}
const commands = await session.listCommands();
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands,
error: null,
commands: [],
error: agent
? `Agent does not support listing commands`
: `Agent not found: ${agentId}`,
requestId,
},
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId },
{ err: error, agentId, draftConfig },
"Failed to list commands"
);
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",
});
});
});
+9
View File
@@ -926,9 +926,18 @@ export const PingMessageSchema = z.object({
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({
type: z.literal("list_commands_request"),
agentId: z.string(),
draftConfig: ListCommandsDraftConfigSchema.optional(),
requestId: z.string(),
});