Compare commits

...
13 changed files with 196 additions and 25 deletions

No files matched your search

+5
View File
@@ -76,6 +76,10 @@ export type AgentListInput = { readonly location?: { readonly directory?: string
export type AgentListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Agent.Info> }
export type AgentListOperation<E = never> = (input?: AgentListInput) => Effect.Effect<AgentListOutput, E>
export type AgentDefaultInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type AgentDefaultOutput = { readonly location: Location.PublicRef; readonly data: Agent.Info | undefined }
export type AgentDefaultOperation<E = never> = (input?: AgentDefaultInput) => Effect.Effect<AgentDefaultOutput, E>
export type AgentGetInput = {
readonly agentID: Agent.ID
readonly location?: { readonly directory?: string | undefined } | undefined
@@ -85,6 +89,7 @@ export type AgentGetOperation<E = never> = (input: AgentGetInput) => Effect.Effe
export interface AgentApi<E = never> {
readonly list: AgentListOperation<E>
readonly default: AgentDefaultOperation<E>
readonly get: AgentGetOperation<E>
}
@@ -14,6 +14,8 @@ import type {
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentDefaultInput,
AgentDefaultOutput,
AgentGetInput,
AgentGetOutput,
PluginListInput,
@@ -319,6 +321,11 @@ const EndpointAgentList = (raw: RawClient["server.agent"]) => (input?: AgentList
raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointAgentDefault = (raw: RawClient["server.agent"]) => (input?: AgentDefaultInput) =>
preserveEffect<AgentDefaultOutput>()(
raw["agent.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointAgentGet = (raw: RawClient["server.agent"]) => (input: AgentGetInput) =>
preserveEffect<AgentGetOutput>()(
raw["agent.get"]({ params: { agentID: input["agentID"] }, query: { location: input["location"] } }).pipe(
@@ -328,6 +335,7 @@ const EndpointAgentGet = (raw: RawClient["server.agent"]) => (input: AgentGetInp
const adaptGroupAgent = (raw: RawClient["server.agent"]) => ({
list: EndpointAgentList(raw),
default: EndpointAgentDefault(raw),
get: EndpointAgentGet(raw),
})
@@ -8,6 +8,8 @@ import type {
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentDefaultInput,
AgentDefaultOutput,
AgentGetInput,
AgentGetOutput,
PluginListInput,
@@ -477,6 +479,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
default: (input?: AgentDefaultInput, requestOptions?: RequestOptions) =>
request<AgentDefaultOutput>(
{
method: "GET",
path: `/api/agent/default`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
get: (input: AgentGetInput, requestOptions?: RequestOptions) =>
request<AgentGetOutput>(
{
@@ -2759,6 +2759,12 @@ export type AgentListInput = {
export type AgentListOutput = { location: LocationPublicRef; data: Array<AgentInfo> }
export type AgentDefaultInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
export type AgentDefaultOutput = { location: LocationPublicRef; data: AgentInfo | null }
export type AgentGetInput = {
readonly agentID: { readonly agentID: string }["agentID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
+4
View File
@@ -46,6 +46,7 @@ export interface Interface extends State.Transformable<Editor> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
readonly select: (id?: ID | string) => Effect.Effect<Selection>
readonly default: () => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -121,6 +122,9 @@ const layer = Layer.effect(
const info = selectedDefault()
return { id: info?.id ?? defaultID, info }
}),
default: Effect.fn("Agent.default")(function* () {
return selectedDefault()
}),
list: Effect.fn("Agent.list")(function* () {
const agents = Array.fromIterable(state.get().agents.values())
const selected = selectedDefault()
+16 -25
View File
@@ -96,20 +96,25 @@ export const make = Effect.fn("PluginHost.make")(function* (
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
const listAgents = Effect.fn("PluginHost.listAgents")((ref: Location.Ref) =>
Effect.gen(function* () {
const atAgentLocation = <A>(
input: Parameters<typeof locationRef>[0],
use: (agents: Agent.Interface) => Effect.Effect<A>,
) => {
const ref = locationRef(input)
if (!ref || isCurrentLocation(ref)) return response(use(agents))
return Effect.gen(function* () {
const location = yield* Location.Service
const agents = yield* Agent.Service
const remote = yield* Agent.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* agents.list(),
data: yield* use(remote),
}
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
)
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie, Effect.withSpan("PluginHost.atAgentLocation"))
}
// Keep the instance graph's inferred types independent of Session handles.
const context: Plugin.Context = {
@@ -118,30 +123,16 @@ export const make = Effect.fn("PluginHost.make")(function* (
options: {},
rpc: Object.assign(rpc.client, { register: rpc.register }),
agent: {
get: (input) => {
const ref = locationRef(input)
const output =
ref && !isCurrentLocation(ref)
? listAgents(ref).pipe(
Effect.map((result) => ({
...result,
data: result.data.find((agent) => agent.id === input.agentID),
})),
)
: response(agents.get(input.agentID))
return output.pipe(
get: (input) =>
atAgentLocation(input, (agents) => agents.get(input.agentID)).pipe(
Effect.flatMap((result) =>
result.data
? Effect.succeed({ ...result, data: result.data })
: Effect.fail(new Error(`Agent not found: ${input.agentID}`)),
),
)
},
list: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return listAgents(ref)
return response(agents.list())
},
),
list: (input) => atAgentLocation(input, (agents) => agents.list()),
default: (input) => atAgentLocation(input, (agents) => agents.default()),
reload: agents.reload,
transform: (callback) =>
agents.transform((editor) => {
+67
View File
@@ -87,6 +87,73 @@ describe("Agent", () => {
}),
)
it.effect("resolves the default agent the same way sessions select one", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* agent.transform((editor) => {
editor.update(Agent.ID.make("plan"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("build"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("reviewer"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("secret"), (info) => {
info.hidden = true
})
editor.update(Agent.ID.make("explore"), (info) => {
info.mode = "subagent"
})
})
const resolved = Effect.gen(function* () {
const info = yield* agent.default()
expect((yield* agent.select()).info).toEqual(info)
return info?.id
})
expect(yield* resolved).toBe(Agent.ID.make("build"))
yield* Effect.forEach(
[
["reviewer", "reviewer"],
["missing", "build"],
["secret", "build"],
["explore", "build"],
] as const,
([id, expected]) =>
Effect.gen(function* () {
yield* agent.transform((editor) => editor.default(Agent.ID.make(id)))
expect(yield* resolved).toBe(Agent.ID.make(expected))
}),
)
}),
)
it.effect("falls back to the first selectable agent without build", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* agent.transform((editor) => {
editor.update(Agent.ID.make("explore"), (info) => {
info.mode = "subagent"
})
editor.update(Agent.ID.make("plan"), (info) => {
info.mode = "primary"
})
})
expect((yield* agent.default())?.id).toBe(Agent.ID.make("plan"))
yield* agent.transform((editor) =>
editor.update(Agent.ID.make("plan"), (info) => {
info.mode = "subagent"
}),
)
expect(yield* agent.default()).toBeUndefined()
expect(yield* agent.select()).toEqual({ id: Agent.defaultID, info: undefined })
}),
)
it.effect("rebuilds state when a transform is replaced", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
+2
View File
@@ -39,6 +39,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
default: () => Effect.die("unused agent.default"),
transform: () => Effect.die("unused agent.transform"),
reload: () => Effect.die("unused agent.reload"),
},
@@ -204,6 +205,7 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
),
),
list: () => Effect.die("unused agent.list"),
default: () => Effect.die("unused agent.default"),
reload: agent.reload,
transform: (callback) =>
agent.transform((editor) =>
+1
View File
@@ -56,6 +56,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
agent: {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
default: () => Effect.die("unused agent.default"),
reload: () => Effect.die("unused agent.reload"),
transform: (callback) => {
callback({
+1
View File
@@ -295,6 +295,7 @@ export function fromPromise(plugin: Plugin) {
agent: {
get: adaptApiMethod(AgentEndpoints["agent.get"], host.agent.get),
list: adaptApiMethod(AgentEndpoints["agent.list"], host.agent.list),
default: adaptApiMethod(AgentEndpoints["agent.default"], host.agent.default),
transform: transform(host.agent),
reload: () => run(host.agent.reload()),
},
+15
View File
@@ -20,6 +20,21 @@ export const AgentGroup = HttpApiGroup.make("server.agent")
}),
),
)
.add(
HttpApiEndpoint.get("agent.default", "/api/agent/default", {
query: LocationQuery,
success: Location.response(Schema.UndefinedOr(Agent.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "agent.default",
summary: "Get default agent",
description:
"Retrieve the agent used when a session has no explicit agent selection. The result may precede initial plugin and config settlement.",
}),
),
)
.add(
HttpApiEndpoint.get("agent.get", "/api/agent/:agentID", {
params: { agentID: Agent.ID },
+7
View File
@@ -12,6 +12,13 @@ export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers)
return yield* response(Agent.Service.use((agent) => agent.list()))
}),
)
.handle(
"agent.default",
Effect.fn(function* () {
const agents = yield* Agent.Service
return yield* response(agents.default())
}),
)
.handle(
"agent.get",
Effect.fn(function* (ctx) {
+50
View File
@@ -0,0 +1,50 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Agent } from "@opencode/schema/agent"
import { Effect, Schedule, Schema } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
const DefaultResponse = Schema.Struct({
location: Schema.Struct({ directory: Schema.String }),
data: Schema.NullOr(Agent.Info),
})
it.live("returns the default agent for each location", () =>
Effect.gen(function* () {
const global = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-agent-default-global-")))
const configured = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-agent-default-")))
const unconfigured = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-agent-default-")))
yield* Effect.promise(() =>
fs.writeFile(
path.join(configured.path, "opencode.json"),
JSON.stringify({ default_agent: "reviewer", agents: { reviewer: { mode: "primary" } } }),
),
)
const server = yield* startServer(global.path)
yield* Effect.forEach(
[
{ directory: configured.path, expected: "reviewer" },
{ directory: unconfigured.path, expected: "build" },
],
(project) =>
Effect.gen(function* () {
const url = new URL("/api/agent/default", server.base)
url.searchParams.set("location[directory]", project.directory)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(response.status).toBe(200)
const body = Schema.decodeUnknownSync(DefaultResponse)(yield* Effect.promise(() => response.json()))
expect(body.location.directory).toBe(project.directory)
return body.data?.id
}).pipe(
// Agents register during plugin activation, after the location is served.
Effect.filterOrFail((id) => id === project.expected),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
),
)
}),
)