feat: route slash commands through executeCommand in run/stream

This commit is contained in:
Mohamed Boudra
2026-02-18 10:03:54 +07:00
parent 0337204281
commit 79b64d3e38
4 changed files with 286 additions and 37 deletions
@@ -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=<name> <extra>",
"---",
`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();
@@ -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<AgentRunResult> {
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<AgentRunResult> {
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<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,
};
return;
}
yield* this.streamInternal(prompt, options);
}
private async *streamInternal(
prompt: AgentPromptInput,
options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> {
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 };
}
@@ -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");
@@ -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<AgentRunResult> {
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<AgentStreamEvent> {
// New run => reset interrupt gate.
this.interruptSignal = createDeferred<void>();
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) {