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);
|
}, 120000);
|
||||||
|
|
||||||
test.runIf(isCodexInstalled())("listCommands includes custom prompts and executeCommand runs them", async () => {
|
test.runIf(isCodexInstalled())(
|
||||||
const cleanup = useTempCodexSessionDir();
|
"listCommands includes custom prompts and executeCommand matches run('/prompts:*') expansion",
|
||||||
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
async () => {
|
||||||
const promptsDir = path.join(codexHome, "prompts");
|
const cleanup = useTempCodexSessionDir();
|
||||||
const promptPath = path.join(promptsDir, "test.md");
|
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||||
const cwd = tmpCwd("codex-cmd-");
|
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 });
|
mkdirSync(promptsDir, { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
promptPath,
|
promptPath,
|
||||||
"---\ndescription: Test Prompt\n---\nReply with exactly: OK-$ARGUMENTS.",
|
[
|
||||||
"utf8"
|
"---",
|
||||||
);
|
"description: Test Prompt",
|
||||||
|
"argument-hint: NAME=<name> <extra>",
|
||||||
|
"---",
|
||||||
|
`Reply with exactly: ${token}::name=$NAME::pos1=$1::dollar=$$`,
|
||||||
|
].join("\n"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = new CodexAppServerAgentClient(logger);
|
const client = new CodexAppServerAgentClient(logger);
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd,
|
cwd,
|
||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
model: CODEX_TEST_MODEL,
|
model: CODEX_TEST_MODEL,
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
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?.();
|
const executeArgs = "NAME=world extra_value";
|
||||||
expect(commands?.some((cmd) => cmd.name === "prompts:test")).toBe(true);
|
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");
|
const rawSlashInput = "/prompts:test NAME=world extra_value";
|
||||||
await session.close();
|
const runResult = await session.run(rawSlashInput);
|
||||||
|
expect(runResult.finalText).toContain(expectedExpanded);
|
||||||
expect(result?.text.trim()).toContain("OK-world");
|
} finally {
|
||||||
} finally {
|
await session.close();
|
||||||
cleanup();
|
}
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
} finally {
|
||||||
rmSync(promptPath, { force: true });
|
cleanup();
|
||||||
}
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 60000);
|
rmSync(promptPath, { force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
120000
|
||||||
|
);
|
||||||
|
|
||||||
test.runIf(isCodexInstalled())("command approval flow requests permission and runs command", async () => {
|
test.runIf(isCodexInstalled())("command approval flow requests permission and runs command", async () => {
|
||||||
const cleanup = useTempCodexSessionDir();
|
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> {
|
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[] = [];
|
const timeline: AgentTimelineItem[] = [];
|
||||||
let finalText = "";
|
let finalText = "";
|
||||||
let usage: AgentUsage | undefined;
|
let usage: AgentUsage | undefined;
|
||||||
@@ -2018,6 +2083,26 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
async *stream(
|
async *stream(
|
||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
options?: AgentRunOptions
|
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> {
|
): AsyncGenerator<AgentStreamEvent> {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
if (!this.client) return;
|
if (!this.client) return;
|
||||||
@@ -2327,7 +2412,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
const raw = await fs.readFile(promptPath, "utf8");
|
const raw = await fs.readFile(promptPath, "utf8");
|
||||||
const parsed = parseFrontMatter(raw);
|
const parsed = parseFrontMatter(raw);
|
||||||
const expanded = expandCodexCustomPrompt(parsed.body, args);
|
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 };
|
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2346,12 +2431,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
} else {
|
} else {
|
||||||
input.push({ type: "text", text: `$${skill.name}` });
|
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 };
|
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillPrompt = args ? `$${commandName} ${args}` : `$${commandName}`;
|
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 };
|
return { text: result.finalText, timeline: result.timeline, usage: result.usage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,59 @@ describe("codex agent commands E2E", () => {
|
|||||||
rmSync(promptPath, { force: true });
|
rmSync(promptPath, { force: true });
|
||||||
}, 30_000);
|
}, 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 () => {
|
test("returns error for non-existent agent", async () => {
|
||||||
const result = await ctx.client.listCommands("non-existent-agent-id");
|
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");
|
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> {
|
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 timeline: AgentRunResult["timeline"] = [];
|
||||||
const textPrompt = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
|
const textPrompt = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
|
||||||
const resultText = this.buildAssistantText(textPrompt);
|
const resultText = this.buildAssistantText(textPrompt);
|
||||||
@@ -221,6 +273,47 @@ class FakeAgentSession implements AgentSession {
|
|||||||
async *stream(prompt: AgentPromptInput): AsyncGenerator<AgentStreamEvent> {
|
async *stream(prompt: AgentPromptInput): AsyncGenerator<AgentStreamEvent> {
|
||||||
// New run => reset interrupt gate.
|
// New run => reset interrupt gate.
|
||||||
this.interruptSignal = createDeferred<void>();
|
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 textPrompt = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
|
||||||
const markerMatch = /remember (?:this )?(?:marker|string|project name)[^"]*"([^"]+)"/i.exec(textPrompt);
|
const markerMatch = /remember (?:this )?(?:marker|string|project name)[^"]*"([^"]+)"/i.exec(textPrompt);
|
||||||
if (markerMatch) {
|
if (markerMatch) {
|
||||||
|
|||||||
Reference in New Issue
Block a user