feat(plugin): expose app metadata (#38179)

This commit is contained in:
Dax 2026-07-21 18:16:25 -04:00 committed by GitHub
commit 8de40be6ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 477 additions and 286 deletions

View file

@ -0,0 +1,8 @@
import { expect, test } from "bun:test"
import { App } from "@opencode-ai/core/app"
test("formats app metadata as a user agent", () => {
expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe(
"opencode/beta/1.2.3/sdk",
)
})

View file

@ -39,12 +39,13 @@ describe("resource", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES =
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
expect(resource("cli").attributes).toMatchObject({
const app = { client: "cli", version: "1.2.3", channel: "beta" }
expect(resource(app).attributes).toMatchObject({
"opencode.client": "cli",
"service.namespace": "anomalyco",
})
expect(resource("cli").attributes["service.instance.id"]).not.toBe("override")
expect(resource("cli").attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
expect(resource(app).attributes["service.instance.id"]).not.toBe("override")
expect(resource(app).attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
})
})

View file

@ -136,6 +136,7 @@ function resourceServer(
return {
state,
url: http.url.toString(),
clientVersion: () => protocol.getClientVersion(),
sendResourceListChanged: () => protocol.sendResourceListChanged(),
completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
close: async () => {
@ -151,10 +152,11 @@ function resourceServer(
function resourceMcpLayer(
server: string | typeof ConfigMCP.Server.Type,
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
options?: MCP.Options,
) {
const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service")
return MCP.layer.pipe(
return MCP.layer(options).pipe(
Layer.provideMerge(Form.layer),
Layer.provide(
Layer.mergeAll(
@ -643,7 +645,12 @@ test("loads and reads MCP resources", async () => {
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
}).pipe(
Effect.provide(
resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }),
),
)
}),
),
)

View file

@ -302,7 +302,7 @@ describe("ModelsDev Service", () => {
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
expect(final.calls[0].userAgent).toContain("/cli")
expect(final.calls[0].userAgent).toContain("/opencode")
}),
)

View file

@ -19,6 +19,7 @@ type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
export function host(overrides: Overrides = {}): PluginContext {
return {
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
options: {},
agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"),

View file

@ -1,4 +1,5 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { App } from "@opencode-ai/core/app"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
@ -62,6 +63,7 @@ describe("GithubCopilotPlugin", () => {
return Response.json({ ok: true })
},
false,
App.make({ name: "test", version: "1.2.3", channel: "beta" }),
)
yield* Effect.promise(() =>
send("https://api.githubcopilot.com/chat/completions", {
@ -77,6 +79,7 @@ describe("GithubCopilotPlugin", () => {
expect(requests[0]?.get("x-initiator")).toBe("user")
expect(requests[0]?.get("copilot-vision-request")).toBe("true")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
}),
)

View file

@ -3,7 +3,6 @@ import { NodeFileSystem } from "@effect/platform-node"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
@ -21,6 +20,7 @@ describe("SkillPlugin.Plugin", () => {
const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect(
host({
app: { name: "test", version: "1.2.3", channel: "beta" },
skill: {
list: () => Effect.die("unused skill.list"),
transform: skill.transform,
@ -54,7 +54,8 @@ describe("SkillPlugin.Plugin", () => {
}),
)
expect(report?.slash).toBe(true)
expect(report?.content).toContain(`- opencode version: ${InstallationVersion}`)
expect(report?.content).toContain("- opencode version: 1.2.3")
expect(report?.content).toContain("- install/channel: beta")
}),
)
})

View file

@ -18,7 +18,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { App } from "@opencode-ai/core/app"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
@ -192,10 +192,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"x-parent-session-id": parentID,
"User-Agent": `opencode/${InstallationVersion}`,
"User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": "cli",
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")

View file

@ -22,7 +22,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { App } from "@opencode-ai/core/app"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project"
@ -3180,10 +3180,10 @@ describe("SessionRunnerLLM", () => {
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"User-Agent": `opencode/${InstallationVersion}`,
"User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": "cli",
"x-opencode-client": "opencode",
})
}),
)

View file

@ -17,7 +17,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { App } from "@opencode-ai/core/app"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Stream } from "effect"
@ -155,10 +155,10 @@ it.effect("generates a title from the sole user message and renames the session"
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"User-Agent": `opencode/${InstallationVersion}`,
"User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": "cli",
"x-opencode-client": "opencode",
})
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID)