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 d0becd9e7..86154e12f 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 @@ -515,43 +515,61 @@ describe("Codex app-server provider (integration)", () => { } }, 120000); - test.runIf(isCodexInstalled())("listCommands includes custom prompts and executeCommand runs them", 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, "test.md"); - const cwd = tmpCwd("codex-cmd-"); + test.runIf(isCodexInstalled())( + "listCommands includes custom prompts and executeCommand matches run('/prompts:*') expansion", + 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, "test.md"); + const cwd = tmpCwd("codex-cmd-"); + const token = `PASEO_PROMPT_TOKEN_${Date.now()}`; - mkdirSync(promptsDir, { recursive: true }); - writeFileSync( - promptPath, - "---\ndescription: Test Prompt\n---\nReply with exactly: OK-$ARGUMENTS.", - "utf8" - ); + mkdirSync(promptsDir, { recursive: true }); + writeFileSync( + promptPath, + [ + "---", + "description: Test Prompt", + "argument-hint: NAME= ", + "---", + `Reply with exactly: ${token}::name=$NAME::pos1=$1::dollar=$$`, + ].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 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 commands = await session.listCommands?.(); + expect(commands?.some((cmd) => cmd.name === "prompts:test")).toBe(true); - const commands = await session.listCommands?.(); - expect(commands?.some((cmd) => cmd.name === "prompts:test")).toBe(true); + 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 result = await session.executeCommand?.("prompts:test", "world"); - await session.close(); - - expect(result?.text.trim()).toContain("OK-world"); - } finally { - cleanup(); - rmSync(cwd, { recursive: true, force: true }); - rmSync(promptPath, { force: true }); - } - }, 60000); + const rawSlashInput = "/prompts:test NAME=world extra_value"; + const runResult = await session.run(rawSlashInput); + expect(runResult.finalText).toContain(expectedExpanded); + } 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(); 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 bf2cfa827..d12579dab 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 @@ -1987,8 +1987,73 @@ class CodexAppServerAgentSession implements AgentSession { } } + private parseSlashCommandInput( + text: string + ): { commandName: string; args?: string } | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/") || trimmed.length <= 1) { + return null; + } + const withoutPrefix = trimmed.slice(1); + const firstWhitespaceIdx = withoutPrefix.search(/\s/); + const commandName = + firstWhitespaceIdx === -1 + ? withoutPrefix + : withoutPrefix.slice(0, firstWhitespaceIdx); + if (!commandName || commandName.includes("/")) { + return null; + } + const rawArgs = + firstWhitespaceIdx === -1 + ? "" + : withoutPrefix.slice(firstWhitespaceIdx + 1).trim(); + return rawArgs.length > 0 ? { commandName, args: rawArgs } : { commandName }; + } + + private async resolveSlashCommandInvocation( + prompt: AgentPromptInput + ): Promise<{ commandName: string; args?: string } | null> { + if (typeof prompt !== "string") { + return null; + } + const parsed = this.parseSlashCommandInput(prompt); + if (!parsed) { + return null; + } + try { + const commands = await this.listCommands(); + return commands.some((command) => command.name === parsed.commandName) + ? parsed + : null; + } catch (error) { + this.logger.warn( + { err: error, commandName: parsed.commandName }, + "Failed to resolve slash command; falling back to plain prompt input" + ); + return null; + } + } + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { - const events = this.stream(prompt, options); + 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, + }; + } + return this.runInternal(prompt, options); + } + + private async runInternal( + prompt: AgentPromptInput, + options?: AgentRunOptions + ): Promise { + const events = this.streamInternal(prompt, options); const timeline: AgentTimelineItem[] = []; let finalText = ""; let usage: AgentUsage | undefined; @@ -2018,6 +2083,26 @@ class CodexAppServerAgentSession implements AgentSession { async *stream( prompt: AgentPromptInput, options?: AgentRunOptions + ): 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, + }; + return; + } + yield* this.streamInternal(prompt, options); + } + + private async *streamInternal( + prompt: AgentPromptInput, + options?: AgentRunOptions ): AsyncGenerator { await this.connect(); if (!this.client) return; @@ -2327,7 +2412,7 @@ class CodexAppServerAgentSession implements AgentSession { const raw = await fs.readFile(promptPath, "utf8"); const parsed = parseFrontMatter(raw); const expanded = expandCodexCustomPrompt(parsed.body, args); - const result = await this.run(expanded); + const result = await this.runInternal(expanded); return { text: result.finalText, timeline: result.timeline, usage: result.usage }; } @@ -2346,12 +2431,12 @@ class CodexAppServerAgentSession implements AgentSession { } else { input.push({ type: "text", text: `$${skill.name}` }); } - const result = await this.run(input); + 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.run(skillPrompt); + const result = await this.runInternal(skillPrompt); return { text: result.finalText, timeline: result.timeline, usage: result.usage }; } 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 25260bf5a..3267ddb66 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 @@ -106,6 +106,59 @@ describe("codex agent commands E2E", () => { rmSync(promptPath, { force: true }); }, 30_000); + test("sendMessage routes known slash commands through executeCommand", async () => { + const prevCodexHome = process.env.CODEX_HOME; + const codexHome = tmpDir("codex-home-send-command-"); + const promptsDir = path.join(codexHome, "prompts"); + mkdirSync(promptsDir, { recursive: true }); + writeFileSync( + path.join(promptsDir, "paseo-test-sayok.md"), + ["---", "description: Say OK", "---", "", "Output exactly: PASEO_OK", ""].join("\n"), + "utf8" + ); + process.env.CODEX_HOME = codexHome; + + try { + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd: "/tmp", + title: "Codex Command Route Test", + }); + + await ctx.client.sendMessage(agent.id, "/prompts:paseo-test-sayok NAME=world"); + const state = await ctx.client.waitForFinish(agent.id, 30_000); + + expect(state.status).toBe("idle"); + expect(state.lastMessage).toContain("PASEO_OK paseo-test-sayok"); + } finally { + if (prevCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = prevCodexHome; + } + rmSync(codexHome, { recursive: true, force: true }); + } + }, 30_000); + + test("sendMessage keeps unknown slash input as plain prompt text", async () => { + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd: "/tmp", + title: "Codex Slash Fallback", + }); + + const token = `RAW_PROMPT_TOKEN_${Date.now()}`; + await ctx.client.sendMessage( + agent.id, + `/not-a-real-command respond with exactly: ${token}` + ); + const state = await ctx.client.waitForFinish(agent.id, 30_000); + + expect(state.status).toBe("idle"); + expect(state.lastMessage).toContain(token); + expect(state.lastMessage).not.toContain("PASEO_SKILL_OK"); + }, 30_000); + test("returns error for non-existent agent", async () => { const result = await ctx.client.listCommands("non-existent-agent-id"); 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 20bc0d75d..b58dac66e 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -209,7 +209,59 @@ class FakeAgentSession implements AgentSession { await appendFile(this.historyPath, JSON.stringify(event) + "\n", "utf8"); } + private parseSlashCommandInput( + text: string + ): { commandName: string; args?: string } | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/") || trimmed.length <= 1) { + return null; + } + const withoutPrefix = trimmed.slice(1); + const firstWhitespaceIdx = withoutPrefix.search(/\s/); + const commandName = + firstWhitespaceIdx === -1 + ? withoutPrefix + : withoutPrefix.slice(0, firstWhitespaceIdx); + if (!commandName || commandName.includes("/")) { + return null; + } + const rawArgs = + firstWhitespaceIdx === -1 + ? "" + : withoutPrefix.slice(firstWhitespaceIdx + 1).trim(); + return rawArgs ? { commandName, args: rawArgs } : { commandName }; + } + + private async resolveSlashCommandInput( + prompt: AgentPromptInput + ): Promise<{ commandName: string; args?: string } | null> { + if (this.providerName !== "codex" || typeof prompt !== "string") { + return null; + } + const parsed = this.parseSlashCommandInput(prompt); + if (!parsed) { + return null; + } + const commands = await this.listCommands(); + return commands.some((command) => command.name === parsed.commandName) + ? parsed + : null; + } + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + const slashCommand = await this.resolveSlashCommandInput(prompt); + if (slashCommand) { + const result = await this.executeCommand( + slashCommand.commandName, + slashCommand.args + ); + return { + sessionId: this.id, + finalText: result.text, + timeline: result.timeline, + usage: result.usage, + }; + } const timeline: AgentRunResult["timeline"] = []; const textPrompt = typeof prompt === "string" ? prompt : JSON.stringify(prompt); const resultText = this.buildAssistantText(textPrompt); @@ -221,6 +273,47 @@ class FakeAgentSession implements AgentSession { async *stream(prompt: AgentPromptInput): AsyncGenerator { // New run => reset interrupt gate. this.interruptSignal = createDeferred(); + const slashCommand = await this.resolveSlashCommandInput(prompt); + if (slashCommand) { + const threadStarted: AgentStreamEvent = { + type: "thread_started", + provider: this.providerName, + sessionId: this.id, + }; + await this.appendHistoryEvent(threadStarted); + yield threadStarted; + + const turnStarted: AgentStreamEvent = { + type: "turn_started", + provider: this.providerName, + }; + await this.appendHistoryEvent(turnStarted); + yield turnStarted; + + const result = await this.executeCommand( + slashCommand.commandName, + slashCommand.args + ); + for (const item of result.timeline) { + const timelineEvent: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item, + }; + await this.appendHistoryEvent(timelineEvent); + yield timelineEvent; + } + + const completed: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: result.usage ?? { inputTokens: 1, outputTokens: 1 }, + }; + await this.appendHistoryEvent(completed); + yield completed; + return; + } + const textPrompt = typeof prompt === "string" ? prompt : JSON.stringify(prompt); const markerMatch = /remember (?:this )?(?:marker|string|project name)[^"]*"([^"]+)"/i.exec(textPrompt); if (markerMatch) {