feat: route slash commands through executeCommand in run/stream
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user