From e030269bbfa076e8f975211902cdb937a93e590e Mon Sep 17 00:00:00 2001 From: "fan.xiaofei" Date: Tue, 26 May 2026 14:08:48 +0800 Subject: [PATCH] feat(opencode): add ping command --- packages/opencode/src/cli/cmd/ping.ts | 109 ++++++++++++++++++++++ packages/opencode/src/index.ts | 2 + packages/opencode/test/cli/ping.test.ts | 49 ++++++++++ packages/opencode/test/lib/cli-process.ts | 21 ++++- 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/ping.ts create mode 100644 packages/opencode/test/cli/ping.test.ts diff --git a/packages/opencode/src/cli/cmd/ping.ts b/packages/opencode/src/cli/cmd/ping.ts new file mode 100644 index 000000000000..d972c2fee20a --- /dev/null +++ b/packages/opencode/src/cli/cmd/ping.ts @@ -0,0 +1,109 @@ +import { EOL } from "os" +import { generateText, type ModelMessage } from "ai" +import { Effect } from "effect" +import { Agent } from "@/agent/agent" +import { Provider } from "@/provider/provider" +import { ProviderTransform } from "@/provider/transform" +import { SessionID } from "@/session/schema" +import { effectCmd, fail } from "../effect-cmd" +import { ModelID, ProviderID } from "@/provider/schema" +import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" + +function pick(value: string | undefined) { + if (!value) return + const [providerID, ...rest] = value.split("/") + return { + providerID: ProviderID.make(providerID), + modelID: ModelID.make(rest.join("/")), + } +} + +export const PingCommand = effectCmd({ + command: "ping", + describe: "test the configured AI provider with a single request", + builder: (yargs) => + yargs + .option("model", { + type: "string", + alias: ["m"], + describe: "model to test in the format of provider/model", + }) + .option("agent", { + type: "string", + describe: "agent whose model and options should be used", + }) + .option("variant", { + type: "string", + describe: "model variant to test", + }) + .option("message", { + type: "string", + default: "Reply with exactly: pong", + describe: "message to send", + }), + handler: Effect.fn("Cli.ping")(function* (args) { + const agentSvc = yield* Agent.Service + const provider = yield* Provider.Service + const flags = yield* RuntimeFlags.Service + const agent = args.agent ? yield* agentSvc.get(args.agent) : yield* agentSvc.defaultInfo() + + if (!agent) { + return yield* fail(`Agent not found: ${args.agent}`) + } + + const input = pick(args.model) ?? agent.model ?? (yield* provider.defaultModel()) + const model = yield* provider.getModel(input.providerID, input.modelID) + const language = yield* provider.getLanguage(model) + const sessionID = SessionID.descending() + const variant = + args.variant ?? + (!args.model && agent.model && agent.variant && model.variants?.[agent.variant] ? agent.variant : undefined) + const message = args.message || "Reply with exactly: pong" + const options = { + ...ProviderTransform.options({ + model, + sessionID, + providerOptions: (yield* provider.getProvider(model.providerID)).options, + }), + ...model.options, + ...agent.options, + ...(variant && model.variants ? (model.variants[variant] ?? {}) : {}), + } + const messages: ModelMessage[] = [{ role: "user", content: message }] + const projectID = model.providerID.startsWith("opencode") ? (yield* InstanceState.context).project.id : undefined + const start = performance.now() + const result = yield* Effect.promise(() => + generateText({ + model: language, + messages: ProviderTransform.message(messages, model, options), + temperature: model.capabilities.temperature + ? (agent.temperature ?? ProviderTransform.temperature(model)) + : undefined, + topP: agent.topP ?? ProviderTransform.topP(model), + topK: ProviderTransform.topK(model), + maxOutputTokens: Math.min(32, ProviderTransform.maxOutputTokens(model)), + providerOptions: ProviderTransform.providerOptions(model, options), + headers: { + ...model.headers, + ...(model.providerID.startsWith("opencode") + ? { + ...(projectID ? { "x-opencode-project": projectID } : {}), + "x-opencode-session": sessionID, + "x-opencode-request": "ping", + "x-opencode-client": flags.client, + } + : { + "x-session-affinity": sessionID, + }), + "User-Agent": "opencode", + }, + maxRetries: 0, + }), + ) + + process.stdout.write(`pong ${model.providerID}/${model.id} ${Math.round(performance.now() - start)}ms${EOL}`) + if (result.text.trim()) process.stdout.write(result.text.trim() + EOL) + if (result.usage) process.stdout.write(JSON.stringify(result.usage) + EOL) + }), +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index d20f29dd4d2f..2ac17a807aae 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -30,6 +30,7 @@ import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" +import { PingCommand } from "./cli/cmd/ping" import path from "path" import { Global } from "@opencode-ai/core/global" import { JsonMigration } from "@/storage/json-migration" @@ -176,6 +177,7 @@ const cli = yargs(args) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) + .command(PingCommand) .command(PluginCommand) .command(DbCommand) .fail((msg, err) => { diff --git a/packages/opencode/test/cli/ping.test.ts b/packages/opencode/test/cli/ping.test.ts new file mode 100644 index 000000000000..990935f10bd2 --- /dev/null +++ b/packages/opencode/test/cli/ping.test.ts @@ -0,0 +1,49 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { cliIt } from "../lib/cli-process" + +describe("opencode ping", () => { + cliIt.concurrent( + "calls the configured model once and prints the response", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.text("pong", { usage: { input: 4, output: 1 } }) + + const result = yield* opencode.ping() + + opencode.expectExit(result, 0) + expect(result.stdout).toContain("pong test/test-model") + expect(result.stdout).toContain("pong") + expect(result.stdout).toContain('"inputTokens":4') + expect(yield* llm.calls).toBe(1) + }), + 60_000, + ) + + cliIt.concurrent( + "uses the default ping message", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.text("pong") + + const result = yield* opencode.ping() + const [input] = yield* llm.inputs + + opencode.expectExit(result, 0) + expect(JSON.stringify(input)).toContain("Reply with exactly: pong") + }), + 60_000, + ) + + cliIt.concurrent( + "exits nonzero when the agent is unknown", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.ping({ agent: "missing", timeoutMs: 15_000 }) + + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain("Agent not found: missing") + }), + 30_000, + ) +}) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index e260242fd360..92ee1e0b217d 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -95,6 +95,14 @@ export type RunOpts = SpawnOpts & { readonly extraArgs?: string[] } +export type PingOpts = SpawnOpts & { + readonly model?: string + readonly agent?: string + readonly variant?: string + readonly message?: string + readonly extraArgs?: string[] +} + // `opencode serve` is a long-lived process — it never exits on its own. // `serve(opts)` therefore returns a handle inside the caller's Scope: the // subprocess is killed when the scope closes (test end), and the URL the @@ -147,6 +155,7 @@ export type AcpHandle = { export type OpencodeCli = { // High-level: run a single prompt against the test model. Short-lived. readonly run: (message: string, opts?: RunOpts) => Effect.Effect + readonly ping: (opts?: PingOpts) => Effect.Effect // Spawn `opencode serve` and wait until it's listening. Long-lived: the // returned handle is killed when the caller's Scope closes. Fails if the // listening line doesn't appear within `readyTimeoutMs`. @@ -248,6 +257,16 @@ export function withCliFixture( return spawn(argv, opts) } + const ping = (opts?: PingOpts): Effect.Effect => { + const argv: string[] = ["ping"] + argv.push("--model", opts?.model ?? testModelID) + if (opts?.agent) argv.push("--agent", opts.agent) + if (opts?.variant) argv.push("--variant", opts.variant) + if (opts?.message) argv.push("--message", opts.message) + if (opts?.extraArgs) argv.push(...opts.extraArgs) + return spawn(argv, opts) + } + const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) { const argv = ["serve"] // Default port 0 — let the OS pick a free port, parse the actual one @@ -401,7 +420,7 @@ export function withCliFixture( } satisfies AcpHandle }) - const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents } + const opencode: OpencodeCli = { run, ping, serve, acp, spawn, expectExit, parseJsonEvents } return yield* fn({ llm, home, opencode }) // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`