Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions packages/opencode/src/cli/cmd/ping.ts
Original file line number Diff line number Diff line change
@@ -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)
}),
})
2 changes: 2 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -176,6 +177,7 @@ const cli = yargs(args)
.command(GithubCommand)
.command(PrCommand)
.command(SessionCommand)
.command(PingCommand)
.command(PluginCommand)
.command(DbCommand)
.fail((msg, err) => {
Expand Down
49 changes: 49 additions & 0 deletions packages/opencode/test/cli/ping.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
21 changes: 20 additions & 1 deletion packages/opencode/test/lib/cli-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<RunResult>
readonly ping: (opts?: PingOpts) => Effect.Effect<RunResult>
// 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`.
Expand Down Expand Up @@ -248,6 +257,16 @@ export function withCliFixture<A, E>(
return spawn(argv, opts)
}

const ping = (opts?: PingOpts): Effect.Effect<RunResult> => {
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
Expand Down Expand Up @@ -401,7 +420,7 @@ export function withCliFixture<A, E>(
} 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`
Expand Down
Loading