refactor: remove executeCommand in favor of standard run/stream path

This commit is contained in:
Mohamed Boudra
2026-02-18 10:57:21 +07:00
parent 803e31e61c
commit 64b36d0df7
10 changed files with 120 additions and 330 deletions
@@ -31,7 +31,6 @@ import type {
PaseoWorktreeArchiveResponse,
ProjectIconResponse,
ListCommandsResponse,
ExecuteCommandResponse,
ListProviderModelsResponseMessage,
ListAvailableProvidersResponse,
SpeechModelsListResponse,
@@ -208,7 +207,6 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
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"
@@ -2209,25 +2207,6 @@ export class DaemonClient {
});
}
async executeCommand(
agentId: string,
commandName: string,
args?: string,
requestId?: string
): Promise<ExecuteCommandPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "execute_command_request",
agentId,
commandName,
args,
},
responseType: "execute_command_response",
timeout: 30000,
});
}
// ============================================================================
// Permissions
// ============================================================================
@@ -281,15 +281,6 @@ export type AgentSlashCommand = {
argumentHint: string;
};
/**
* Result from executing a slash command.
*/
export type AgentCommandResult = {
text: string;
timeline: AgentTimelineItem[];
usage?: AgentUsage;
};
export type ListPersistedAgentsOptions = {
limit?: number;
};
@@ -353,13 +344,6 @@ export interface AgentSession {
* Commands are provider-specific - Claude supports skills and built-in commands.
*/
listCommands?(): Promise<AgentSlashCommand[]>;
/**
* Execute a slash command by name.
* The command name should NOT include the leading "/" - it will be added automatically.
* @param commandName The command name (e.g., "help", "context")
* @param args Optional arguments to pass to the command
*/
executeCommand?(commandName: string, args?: string): Promise<AgentCommandResult>;
/**
* Update the model used for subsequent turns (if supported by provider).
*/
@@ -3,7 +3,6 @@
*
* Tests the ability to:
* 1. List available slash commands from a ClaudeAgentSession
* 2. Execute slash commands through ClaudeAgentSession
*
* These tests verify that the agent abstraction layer properly exposes
* the Claude Agent SDK's command capabilities.
@@ -91,77 +90,4 @@ const hasClaudeCredentials =
}, 30000);
});
describe("executeCommand()", () => {
it("should execute a command and return a result", async () => {
session = await client.createSession(testConfig);
// The session should have an executeCommand method
expect(typeof session.executeCommand).toBe("function");
// First get available commands to use one that exists
const commands = await session.listCommands!();
expect(commands.length).toBeGreaterThan(0);
// Execute one of the available commands
const firstCommand = commands[0];
const result = await session.executeCommand!(firstCommand.name);
// Should return a result object
expect(result).toBeDefined();
expect(typeof result.text).toBe("string");
// Note: Some commands may return empty text (e.g., plugin descriptions)
await session.close();
}, 60000);
it("should return non-empty output for /context command", async () => {
session = await client.createSession(testConfig);
// Execute /context - a local command that returns token usage info
const result = await session.executeCommand!("context");
expect(result).toBeDefined();
expect(typeof result.text).toBe("string");
// /context MUST return actual content - this is the key test
// If this fails, user messages from SDK are not being captured
expect(result.text.length).toBeGreaterThan(0);
expect(result.text).toContain("Context");
await session.close();
}, 60000);
it("should include timeline items in result", async () => {
session = await client.createSession(testConfig);
// First get available commands to use one that exists
const commands = await session.listCommands!();
expect(commands.length).toBeGreaterThan(0);
const firstCommand = commands[0];
const result = await session.executeCommand!(firstCommand.name);
// Result should include timeline items from the command execution
expect(Array.isArray(result.timeline)).toBe(true);
// Timeline may or may not have items depending on command execution
// Just verify the structure is correct
await session.close();
}, 60000);
it("should throw for non-existent command", async () => {
session = await client.createSession(testConfig);
// Non-existent commands should still work (they get sent as prompts)
// but the response should indicate the command doesn't exist
// This is how the SDK works - invalid commands become regular prompts
const result = await session.executeCommand!("nonexistent_command_xyz");
// The result should exist (command is treated as a prompt)
expect(result).toBeDefined();
await session.close();
}, 60000);
});
});
@@ -33,7 +33,6 @@ import {
import type {
AgentCapabilityFlags,
AgentClient,
AgentCommandResult,
AgentMetadata,
AgentMode,
AgentModelDefinition,
@@ -1015,45 +1014,6 @@ class ClaudeAgentSession implements AgentSession {
}));
}
async executeCommand(commandName: string, args?: string): Promise<AgentCommandResult> {
const commandPrompt = args ? `/${commandName} ${args}` : `/${commandName}`;
// Commands return their output in a user message with <local-command-stdout> tags,
// NOT as an assistant message. We need to extract that specifically.
const events = this.stream(commandPrompt);
const timeline: AgentTimelineItem[] = [];
let commandOutput = "";
let usage: AgentUsage | undefined;
for await (const event of events) {
if (event.type === "timeline") {
timeline.push(event.item);
// Check for command output in user messages
if (event.item.type === "user_message") {
const text = event.item.text;
const match = text.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
if (match) {
commandOutput = match[1].trim();
}
}
// Also capture assistant messages (some commands may produce both)
if (event.item.type === "assistant_message" && !commandOutput) {
commandOutput = event.item.text;
}
} else if (event.type === "turn_completed") {
usage = event.usage;
} else if (event.type === "turn_failed") {
throw new Error(event.error);
}
}
return {
text: commandOutput,
timeline,
usage,
};
}
private async primeSelectableModelIds(query: Query): Promise<void> {
try {
const models = await query.supportedModels();
@@ -516,7 +516,7 @@ describe("Codex app-server provider (integration)", () => {
}, 120000);
test.runIf(isCodexInstalled())(
"listCommands includes custom prompts and executeCommand matches run('/prompts:*') expansion",
"listCommands includes custom prompts and run('/prompts:*') expands them",
async () => {
const cleanup = useTempCodexSessionDir();
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
@@ -553,9 +553,6 @@ describe("Codex app-server provider (integration)", () => {
const executeArgs = "NAME=world extra_value";
const expectedExpanded = `${token}::name=world::pos1=extra_value::dollar=$`;
const executeResult = await session.executeCommand?.("prompts:test", executeArgs);
expect(executeResult?.text).toContain(expectedExpanded);
const rawSlashInput = "/prompts:test NAME=world extra_value";
const runResult = await session.run(rawSlashInput);
expect(runResult.finalText).toContain(expectedExpanded);
@@ -571,6 +568,63 @@ describe("Codex app-server provider (integration)", () => {
120000
);
test.runIf(isCodexInstalled())(
"slash prompt run streams live turn events (turn_started/turn_completed)",
async () => {
const cleanup = useTempCodexSessionDir();
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const promptsDir = path.join(codexHome, "prompts");
const promptPath = path.join(promptsDir, "stream-test.md");
const cwd = tmpCwd("codex-cmd-stream-");
const token = `PASEO_STREAM_TOKEN_${Date.now()}`;
mkdirSync(promptsDir, { recursive: true });
writeFileSync(
promptPath,
[
"---",
"description: Stream Test Prompt",
"---",
`Reply with exactly: ${token}`,
].join("\n"),
"utf8"
);
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
try {
const events = session.stream("/prompts:stream-test");
const seenTypes = new Set<string>();
const assistantChunks: string[] = [];
for await (const event of events) {
seenTypes.add(event.type);
if (event.type === "timeline" && event.item.type === "assistant_message") {
assistantChunks.push(event.item.text);
}
}
expect(seenTypes.has("turn_started")).toBe(true);
expect(seenTypes.has("turn_completed")).toBe(true);
expect(assistantChunks.join("")).toContain(token);
} finally {
await session.close();
}
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
rmSync(promptPath, { force: true });
}
},
120000
);
test.runIf(isCodexInstalled())("command approval flow requests permission and runs command", async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-cmd-approval-");
@@ -1,7 +1,6 @@
import type {
AgentCapabilityFlags,
AgentClient,
AgentCommandResult,
AgentMode,
AgentModelDefinition,
McpServerConfig,
@@ -2034,17 +2033,48 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
private async buildCommandPromptInput(
commandName: string,
args?: string
): Promise<AgentPromptInput> {
if (commandName.startsWith("prompts:")) {
const promptName = commandName.slice("prompts:".length);
const codexHome = resolveCodexHomeDir();
const promptPath = path.join(codexHome, "prompts", `${promptName}.md`);
const raw = await fs.readFile(promptPath, "utf8");
const parsed = parseFrontMatter(raw);
return expandCodexCustomPrompt(parsed.body, args);
}
if (!this.connected) {
await this.connect();
} else {
await this.loadSkills();
}
const skill = this.cachedSkills.find((entry) => entry.name === commandName);
if (skill) {
const input = [
{ type: "skill", name: skill.name, path: skill.path },
] as unknown as AgentPromptContentBlock[];
if (args && args.trim().length > 0) {
input.push({ type: "text", text: args.trim() });
} else {
input.push({ type: "text", text: `$${skill.name}` });
}
return input;
}
return args ? `$${commandName} ${args}` : `$${commandName}`;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
if (slashCommand) {
const result = await this.executeCommand(slashCommand.commandName, slashCommand.args);
const info = await this.getRuntimeInfo();
return {
sessionId: info.sessionId ?? "",
finalText: result.text,
usage: result.usage,
timeline: result.timeline,
};
const commandInput = await this.buildCommandPromptInput(
slashCommand.commandName,
slashCommand.args
);
return this.runInternal(commandInput, options);
}
return this.runInternal(prompt, options);
}
@@ -2086,15 +2116,11 @@ class CodexAppServerAgentSession implements AgentSession {
): AsyncGenerator<AgentStreamEvent> {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
if (slashCommand) {
const result = await this.executeCommand(slashCommand.commandName, slashCommand.args);
for (const item of result.timeline) {
yield { type: "timeline", provider: CODEX_PROVIDER, item };
}
yield {
type: "turn_completed",
provider: CODEX_PROVIDER,
usage: result.usage,
};
const commandInput = await this.buildCommandPromptInput(
slashCommand.commandName,
slashCommand.args
);
yield* this.streamInternal(commandInput, options);
return;
}
yield* this.streamInternal(prompt, options);
@@ -2404,42 +2430,6 @@ class CodexAppServerAgentSession implements AgentSession {
);
}
async executeCommand(commandName: string, args?: string): Promise<AgentCommandResult> {
if (commandName.startsWith("prompts:")) {
const promptName = commandName.slice("prompts:".length);
const codexHome = resolveCodexHomeDir();
const promptPath = path.join(codexHome, "prompts", `${promptName}.md`);
const raw = await fs.readFile(promptPath, "utf8");
const parsed = parseFrontMatter(raw);
const expanded = expandCodexCustomPrompt(parsed.body, args);
const result = await this.runInternal(expanded);
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
}
if (!this.connected) {
await this.connect();
} else {
await this.loadSkills();
}
const skill = this.cachedSkills.find((entry) => entry.name === commandName);
if (skill) {
const input = [
{ type: "skill", name: skill.name, path: skill.path },
] as unknown as AgentPromptContentBlock[];
if (args && args.trim().length > 0) {
input.push({ type: "text", text: args.trim() });
} else {
input.push({ type: "text", text: `$${skill.name}` });
}
const result = await this.runInternal(input);
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
}
const skillPrompt = args ? `$${commandName} ${args}` : `$${commandName}`;
const result = await this.runInternal(skillPrompt);
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
}
private async ensureThread(): Promise<void> {
if (!this.client) return;
if (this.currentThreadId) return;
@@ -77,7 +77,7 @@ describe("codex agent commands E2E", () => {
rmSync(codexHome, { recursive: true, force: true });
}, 30_000);
test("executes a custom prompt command (prompts:*)", async () => {
test("executes a custom prompt via normal sendMessage path (prompts:*)", async () => {
const codexHome = process.env.CODEX_HOME ?? path.join(process.env.HOME ?? "/tmp", ".codex");
const promptsDir = path.join(codexHome, "prompts");
@@ -95,18 +95,19 @@ describe("codex agent commands E2E", () => {
title: "Codex Prompt Execute Test Agent",
});
const result = await ctx.client.executeCommand(
await ctx.client.sendMessage(
agent.id,
"prompts:paseo-test-sayok",
"NAME=world"
"/prompts:paseo-test-sayok NAME=world"
);
expect(result.error).toBeNull();
expect(result.result?.text).toContain("PASEO_OK");
const state = await ctx.client.waitForFinish(agent.id, 30_000);
expect(state.status).toBe("idle");
expect(state.lastMessage).toContain("PASEO_OK");
rmSync(promptPath, { force: true });
}, 30_000);
test("sendMessage routes known slash commands through executeCommand", async () => {
test("sendMessage routes known slash commands through standard run/stream", async () => {
const prevCodexHome = process.env.CODEX_HOME;
const codexHome = tmpDir("codex-home-send-command-");
const promptsDir = path.join(codexHome, "prompts");
-82
View File
@@ -1527,15 +1527,6 @@ export class Session {
await this.handleListCommandsRequest(msg);
break;
case "execute_command_request":
await this.handleExecuteCommandRequest(
msg.agentId,
msg.commandName,
msg.args,
msg.requestId
);
break;
case "register_push_token":
this.handleRegisterPushToken(msg.token);
break;
@@ -3553,79 +3544,6 @@ export class Session {
}
}
/**
* Handle execute command request for an agent
*/
private async handleExecuteCommandRequest(
agentId: string,
commandName: string,
args: string | undefined,
requestId: string
): Promise<void> {
this.sessionLogger.debug(
{ agentId, commandName },
`Handling execute command request for agent ${agentId}`
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "execute_command_response",
payload: {
agentId,
result: null,
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
}
const session = agent.session;
if (!session || !session.executeCommand) {
this.emit({
type: "execute_command_response",
payload: {
agentId,
result: null,
error: `Agent does not support executing commands`,
requestId,
},
});
return;
}
const result = await session.executeCommand(commandName, args);
this.emit({
type: "execute_command_response",
payload: {
agentId,
result,
error: null,
requestId,
},
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, commandName },
"Failed to execute command"
);
this.emit({
type: "execute_command_response",
payload: {
agentId,
result: null,
error: error.message,
requestId,
},
});
}
}
/**
* Handle agent permission response from user
*/
@@ -16,7 +16,6 @@ import type {
AgentSessionConfig,
AgentStreamEvent,
AgentSlashCommand,
AgentCommandResult,
AgentUsage,
ListModelsOptions,
} from "../agent/agent-sdk-types.js";
@@ -251,7 +250,7 @@ class FakeAgentSession implements AgentSession {
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const slashCommand = await this.resolveSlashCommandInput(prompt);
if (slashCommand) {
const result = await this.executeCommand(
const result = await this.runSlashCommand(
slashCommand.commandName,
slashCommand.args
);
@@ -290,7 +289,7 @@ class FakeAgentSession implements AgentSession {
await this.appendHistoryEvent(turnStarted);
yield turnStarted;
const result = await this.executeCommand(
const result = await this.runSlashCommand(
slashCommand.commandName,
slashCommand.args
);
@@ -578,7 +577,14 @@ class FakeAgentSession implements AgentSession {
];
}
async executeCommand(commandName: string, args?: string): Promise<AgentCommandResult> {
private async runSlashCommand(
commandName: string,
args?: string
): Promise<{
text: string;
timeline: AgentRunResult["timeline"];
usage: AgentUsage;
}> {
const fullName = commandName.trim();
if (this.providerName === "codex" && fullName.startsWith("prompts:")) {
const promptId = fullName.slice("prompts:".length);
-28
View File
@@ -941,14 +941,6 @@ export const ListCommandsRequestSchema = z.object({
requestId: z.string(),
});
export const ExecuteCommandRequestSchema = z.object({
type: z.literal("execute_command_request"),
agentId: z.string(),
commandName: z.string(),
args: z.string().optional(),
requestId: z.string(),
});
export const RegisterPushTokenMessageSchema = z.object({
type: z.literal("register_push_token"),
token: z.string(),
@@ -1082,7 +1074,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ClientHeartbeatMessageSchema,
PingMessageSchema,
ListCommandsRequestSchema,
ExecuteCommandRequestSchema,
RegisterPushTokenMessageSchema,
ListTerminalsRequestSchema,
SubscribeTerminalsRequestSchema,
@@ -1868,22 +1859,6 @@ export const ListCommandsResponseSchema = z.object({
}),
});
const AgentCommandResultSchema = z.object({
text: z.string(),
timeline: z.array(AgentTimelineItemPayloadSchema),
usage: AgentUsageSchema.optional(),
});
export const ExecuteCommandResponseSchema = z.object({
type: z.literal("execute_command_response"),
payload: z.object({
agentId: z.string(),
result: AgentCommandResultSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
// ============================================================================
// Terminal Outbound Messages
// ============================================================================
@@ -2050,7 +2025,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SpeechModelsListResponseSchema,
SpeechModelsDownloadResponseSchema,
ListCommandsResponseSchema,
ExecuteCommandResponseSchema,
ListTerminalsResponseSchema,
TerminalsChangedSchema,
CreateTerminalResponseSchema,
@@ -2191,8 +2165,6 @@ export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessa
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
export type ExecuteCommandRequest = z.infer<typeof ExecuteCommandRequestSchema>;
export type ExecuteCommandResponse = z.infer<typeof ExecuteCommandResponseSchema>;
export type RegisterPushTokenMessage = z.infer<typeof RegisterPushTokenMessageSchema>;
// Terminal message types