From 64b36d0df756837304dc6a4b4194cb9bfd0aec67 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Feb 2026 10:57:21 +0700 Subject: [PATCH] refactor: remove executeCommand in favor of standard run/stream path --- packages/server/src/client/daemon-client.ts | 21 ---- .../src/server/agent/agent-sdk-types.ts | 16 --- .../providers/claude-agent-commands.test.ts | 74 -------------- .../server/agent/providers/claude-agent.ts | 40 -------- .../providers/codex-app-server-agent.test.ts | 62 +++++++++++- .../agent/providers/codex-app-server-agent.ts | 98 +++++++++---------- .../codex-mcp-agent-commands.e2e.test.ts | 15 +-- packages/server/src/server/session.ts | 82 ---------------- .../server/test-utils/fake-agent-client.ts | 14 ++- packages/server/src/shared/messages.ts | 28 ------ 10 files changed, 120 insertions(+), 330 deletions(-) diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index d4c41001a..dd1fdcb8d 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -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 { - return this.sendCorrelatedSessionRequest({ - requestId, - message: { - type: "execute_command_request", - agentId, - commandName, - args, - }, - responseType: "execute_command_response", - timeout: 30000, - }); - } - // ============================================================================ // Permissions // ============================================================================ diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index afffb5014..af075ecd7 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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; - /** - * 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; /** * Update the model used for subsequent turns (if supported by provider). */ diff --git a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts index 321e7c7c4..530cc8b8e 100644 --- a/packages/server/src/server/agent/providers/claude-agent-commands.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent-commands.test.ts @@ -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); - }); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 7bd4ae29d..f6c378351 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -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 { - const commandPrompt = args ? `/${commandName} ${args}` : `/${commandName}`; - - // Commands return their output in a user message with 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(/([\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 { try { const models = await query.supportedModels(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 86154e12f..ce5a3ea64 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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(); + 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-"); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index d12579dab..16c4e8f72 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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 { + 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 { 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 { 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 { - 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 { if (!this.client) return; if (this.currentThreadId) return; diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts index 3267ddb66..958490a4e 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent-commands.e2e.test.ts @@ -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"); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 295acd47d..b668e3f7e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { - 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 */ diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index b58dac66e..71a817438 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -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 { 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 { + 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); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 37870c1db..f4bd08bfb 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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; export type ListCommandsRequest = z.infer; export type ListCommandsResponse = z.infer; -export type ExecuteCommandRequest = z.infer; -export type ExecuteCommandResponse = z.infer; export type RegisterPushTokenMessage = z.infer; // Terminal message types