Merge branch 'main' of github.com:getpaseo/paseo into dev

This commit is contained in:
Mohamed Boudra
2026-04-02 14:37:00 +07:00
3 changed files with 180 additions and 20 deletions

View File

@@ -0,0 +1,47 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
import { getFullAccessConfig } from "../../daemon-e2e/agent-configs.js";
describe("opencode agent commands E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test("lists available slash commands for an opencode agent", async () => {
const agent = await ctx.client.createAgent({
...getFullAccessConfig("opencode"),
cwd: "/tmp",
title: "OpenCode Commands Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("opencode");
expect(agent.status).toBe("idle");
const result = await ctx.client.listCommands(agent.id);
expect(result.error).toBeNull();
expect(result.commands.length).toBeGreaterThan(0);
for (const cmd of result.commands) {
expect(cmd.name).toBeTruthy();
expect(typeof cmd.description).toBe("string");
expect(typeof cmd.argumentHint).toBe("string");
expect(cmd.name.startsWith("/")).toBe(false);
}
}, 60_000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
expect(result.commands).toEqual([]);
}, 60_000);
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import pino from "pino";
import { isCommandAvailable } from "../provider-launch-config.js";
import type { AgentSlashCommand } from "../agent-sdk-types.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
describe("opencode agent commands contract (real)", () => {
test("lists slash commands with the expected contract", async () => {
expect(isCommandAvailable("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "plan",
});
try {
expect(typeof session.listCommands).toBe("function");
const commands = await session.listCommands!();
expect(Array.isArray(commands)).toBe(true);
expect(commands.length).toBeGreaterThan(0);
for (const command of commands) {
const typed = command as AgentSlashCommand;
expect(typeof typed.name).toBe("string");
expect(typed.name.length).toBeGreaterThan(0);
expect(typed.name.startsWith("/")).toBe(false);
expect(typeof typed.description).toBe("string");
expect(typeof typed.argumentHint).toBe("string");
}
} finally {
await session.close();
}
}, 60_000);
});

View File

@@ -21,6 +21,7 @@ import type {
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
@@ -445,7 +446,7 @@ export class OpenCodeAgentClient implements AgentClient {
throw new Error("OpenCode session creation returned no data");
}
return new OpenCodeAgentSession(openCodeConfig, client, session.id);
return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger);
}
async resumeSession(
@@ -470,7 +471,7 @@ export class OpenCodeAgentClient implements AgentClient {
directory: openCodeConfig.cwd,
});
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId);
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger);
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
@@ -975,6 +976,7 @@ class OpenCodeAgentSession implements AgentSession {
private readonly config: OpenCodeAgentConfig;
private readonly client: OpencodeClient;
private readonly sessionId: string;
private readonly logger: Logger;
private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null;
@@ -994,10 +996,16 @@ class OpenCodeAgentSession implements AgentSession {
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
constructor(config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string) {
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string,
logger: Logger,
) {
this.config = config;
this.client = client;
this.sessionId = sessionId;
this.logger = logger;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
}
@@ -1129,23 +1137,37 @@ class OpenCodeAgentSession implements AgentSession {
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
...(options?.outputSchema
? {
format: {
type: "json_schema" as const,
schema: options.outputSchema as Record<string, unknown>,
},
}
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
let promptResponse;
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
if (slashCommand) {
promptResponse = await this.client.session.command({
sessionID: this.sessionId,
directory: this.config.cwd,
command: slashCommand.commandName,
arguments: slashCommand.args,
...(this.config.model ? { model: this.config.model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
} else {
promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
...(options?.outputSchema
? {
format: {
type: "json_schema" as const,
schema: options.outputSchema as Record<string, unknown>,
},
}
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
}
if (promptResponse.error) {
const errorMsg = JSON.stringify(promptResponse.error);
@@ -1335,6 +1357,20 @@ class OpenCodeAgentSession implements AgentSession {
return this.currentMode;
}
async listCommands(): Promise<AgentSlashCommand[]> {
const result = await this.client.command.list({
directory: this.config.cwd,
});
if (result.error || !result.data) {
return [];
}
return result.data.map((cmd) => ({
name: cmd.name,
description: cmd.description ?? "",
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
}));
}
async setMode(modeId: string): Promise<void> {
this.currentMode = normalizeOpenCodeModeId(modeId);
}
@@ -1418,6 +1454,45 @@ class OpenCodeAgentSession implements AgentSession {
.map((p) => ({ type: "text", text: p.text }));
}
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;
}
}
private parseModel(model?: string): { providerID: string; modelID: string } | undefined {
if (!model) {
return undefined;