mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-25 01:57:38 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb05b1896 | ||
|
|
fdb0f745e7 |
@@ -8,6 +8,7 @@ import { CopilotModels } from "../../github-copilot/models.js"
|
||||
import { App } from "../../app.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
@@ -273,6 +274,20 @@ export const GithubCopilotPlugin = define({
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"title",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
const agent = yield* ctx.agent
|
||||
.get({ agentID: Agent.ID.make("title") })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (agent?.data.model) return
|
||||
const id = utilityTitleModels.find((id) => loaded.models?.has(id))
|
||||
if (id) evt.model = Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make(id) })
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
@@ -363,6 +378,10 @@ function request(url: string, init: RequestInit) {
|
||||
|
||||
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
|
||||
|
||||
// Matches the Copilot client: gpt-4o-mini is the "small utility" model. GitHub's integration
|
||||
// guide designates session naming as a utility scenario served free by these models.
|
||||
export const utilityTitleModels = ["gpt-4o-mini"]
|
||||
|
||||
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, app: App.Info): Fetch {
|
||||
const send = upstream ?? fetch
|
||||
return async (input, init) => {
|
||||
@@ -400,7 +419,8 @@ function applyHeaders(
|
||||
// Mirrors the Copilot client's X-Interaction-Type vocabulary: the agent loop is the default,
|
||||
// nested sessions are subagents, and title/compaction are the two utility overrides.
|
||||
export function interactionType(kind: SessionRequestKind, child: boolean) {
|
||||
if (kind === "title") return "conversation-background"
|
||||
// The scenario identifier GitHub asks for on utility-model requests; dev sends it for every title step.
|
||||
if (kind === "title") return "agent-session-name-generation"
|
||||
if (kind === "compaction") return "conversation-compaction"
|
||||
if (child) return "conversation-subagent"
|
||||
return "conversation-agent"
|
||||
|
||||
@@ -20,12 +20,13 @@ import type {
|
||||
SessionTitle,
|
||||
} from "@opencode/plugin/effect/session"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { App } from "../app.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
@@ -49,6 +50,8 @@ export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.
|
||||
|
||||
export interface Prepared<Event = SessionRequest> {
|
||||
readonly event: Event
|
||||
/** The model the request runs on; a hook may have replaced the requested one. */
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
|
||||
@@ -198,12 +201,12 @@ export const layer = Layer.effect(
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const app = yield* App.Metadata
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const catalog = yield* Model.Service
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* <
|
||||
S extends SessionRequest & { tools?: Definitions },
|
||||
>(kind: SessionRequestKind, input: Input, shape: (draft: SessionRequest, tools: Definitions) => Effect.Effect<S>) {
|
||||
const session = input.session
|
||||
const model = input.model
|
||||
const scope = { sessionID: session.id, agent: input.agent, model: model.ref, kind }
|
||||
const tools = input.tools ?? {
|
||||
definitions: [],
|
||||
execute: () => new Tool.Error({ message: "Tools are not available for this request" }),
|
||||
@@ -214,9 +217,18 @@ export const layer = Layer.effect(
|
||||
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
|
||||
)
|
||||
const shaped = yield* shape(
|
||||
{ sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} },
|
||||
{ sessionID: session.id, model: input.model.ref, system: input.system, messages: input.messages, options: {} },
|
||||
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
|
||||
)
|
||||
// A hook may point the request at another catalog model, including picker-disabled ones.
|
||||
// Resolution failure keeps the requested model rather than failing the request.
|
||||
const model = isDeepStrictEqual(shaped.model, input.model.ref)
|
||||
? input.model
|
||||
: yield* models.resolve({ ...session, model: shaped.model }, catalog.all).pipe(
|
||||
Effect.tapError((cause) => Effect.logWarning("ignoring unresolvable hook model", { cause })),
|
||||
Effect.orElseSucceed(() => input.model),
|
||||
)
|
||||
const scope = { sessionID: session.id, agent: input.agent, model: model.ref, kind }
|
||||
// Match by identity first, then by key. Entries matching neither were invented by a
|
||||
// hook and are dropped. `t.name` stays the real name so execution can map renames back.
|
||||
const byName = new Map(tools.definitions.map((t) => [t.name, t]))
|
||||
@@ -342,6 +354,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return {
|
||||
event: shaped,
|
||||
model,
|
||||
request,
|
||||
options: { ...(http ? { http } : {}), ...(webSocket ? { webSocket } : {}) },
|
||||
retry: (event: Parameters<Prepared["retry"]>[0]) =>
|
||||
@@ -382,5 +395,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [PluginHooks.node, SessionModelTransport.node, App.node],
|
||||
deps: [PluginHooks.node, SessionModelTransport.node, App.node, SessionRunnerModel.node, Model.node],
|
||||
})
|
||||
|
||||
@@ -80,7 +80,7 @@ export const resolved = (
|
||||
transport: options.transport,
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
|
||||
@@ -76,7 +76,7 @@ export const layer = Layer.effect(
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, input.model.cost)
|
||||
const step = SessionUsage.record(event.usage, prepared.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { SessionModelRequest } from "@opencode/core/session/model-request"
|
||||
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Location } from "@opencode/core/location"
|
||||
@@ -46,6 +47,8 @@ const it = testEffect(
|
||||
}),
|
||||
),
|
||||
Config.node.replace(config),
|
||||
SessionRunnerModel.node.replace(Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.die("unused") })),
|
||||
Model.node.replace(Layer.mock(Model.Service, { all: () => Effect.succeed([]) })),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).parent, "title")
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-background",
|
||||
"X-Interaction-Type": "agent-session-name-generation",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Location } from "@opencode/core/location"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { GithubCopilotPlugin } from "@opencode/core/plugin/provider/github-copilot"
|
||||
import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai"
|
||||
@@ -264,6 +265,8 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(SessionModelRequest.layer),
|
||||
Effect.provide(SessionRunnerModel.layer),
|
||||
Effect.provide(ModelResolver.layer),
|
||||
Effect.provideService(SessionModelTransport.Service, transport),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SnowflakeCortexPlugin } from "@opencode/core/plugin/provider/snowflake-
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionModelRequest } from "@opencode/core/session/model-request"
|
||||
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { SessionModelTransport } from "@opencode/core/session/model-transport"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Schedule, Stream } from "effect"
|
||||
@@ -89,7 +90,11 @@ const fixture = Effect.fn(function* () {
|
||||
Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.fresh)),
|
||||
Effect.provideService(HttpClient.HttpClient, http),
|
||||
)
|
||||
}).pipe(Effect.provide(SessionModelRequest.layer), Effect.provide(ModelResolver.layer))
|
||||
}).pipe(
|
||||
Effect.provide(SessionModelRequest.layer),
|
||||
Effect.provide(SessionRunnerModel.layer),
|
||||
Effect.provide(ModelResolver.layer),
|
||||
)
|
||||
const stop = Effect.gen(function* () {
|
||||
replies.push(Response.json({ message: "Conversation complete", error: {} }, { status: 400 }))
|
||||
expect((yield* send).find((event) => event.type === "finish")?.reason.normalized).toBe("stop")
|
||||
|
||||
@@ -88,7 +88,12 @@ const it = testEffect(
|
||||
SessionModelRequest.node,
|
||||
PluginHooks.node,
|
||||
]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)],
|
||||
[
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
llmClient.replace(client),
|
||||
SessionRunnerModel.node.replace(Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.die("unused") })),
|
||||
Model.node.replace(Layer.mock(Model.Service, { all: () => Effect.succeed([]) })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Money } from "@opencode/schema/money"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
@@ -54,7 +55,11 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
seen.push({ hook: "response", kind: event.kind, agent: event.agent })
|
||||
}),
|
||||
)
|
||||
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
|
||||
const requests = yield* SessionModelRequest.Service.pipe(
|
||||
Effect.provide(SessionModelRequest.layer),
|
||||
Effect.provide(SessionRunnerModel.layer),
|
||||
Effect.provide(ModelResolver.layer),
|
||||
)
|
||||
|
||||
for (const kind of KINDS) {
|
||||
const prepared = yield* requests[kind]({
|
||||
@@ -125,6 +130,8 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
})
|
||||
const requests = yield* SessionModelRequest.Service.pipe(
|
||||
Effect.provide(SessionModelRequest.layer),
|
||||
Effect.provide(SessionRunnerModel.layer),
|
||||
Effect.provide(ModelResolver.layer),
|
||||
Effect.provideService(SessionModelTransport.Service, websocketTransport),
|
||||
)
|
||||
const prepared = yield* requests.primary({
|
||||
|
||||
@@ -22,10 +22,11 @@ import { SessionModelRequest } from "@opencode/core/session/model-request"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { SessionProviderContext } from "@opencode/core/session/provider-context"
|
||||
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { SessionSchema } from "@opencode/core/session/schema"
|
||||
import { SessionStore } from "@opencode/core/session/store"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { DateTime, Deferred, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
@@ -42,7 +43,11 @@ const it = testEffect(
|
||||
PluginHooks.node,
|
||||
llmClient,
|
||||
]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true }))],
|
||||
[
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
SessionRunnerModel.node.replace(Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.die("unused") })),
|
||||
Model.node.replace(Layer.mock(Model.Service, { all: () => Effect.succeed([]) })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ for (const fixture of [
|
||||
agent: Agent.defaultID,
|
||||
model,
|
||||
prepared: {
|
||||
model,
|
||||
retry: () => Effect.void,
|
||||
request: LLM.request({ model: model.model, prompt: "Run one tool", toolChoice: fixture.toolChoice }),
|
||||
options: {},
|
||||
|
||||
@@ -102,6 +102,7 @@ const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
})
|
||||
const smallModels = Layer.mock(Model.Service, {
|
||||
small: () => Effect.succeed(selectedSmall),
|
||||
all: () => Effect.succeed([]),
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
@@ -285,6 +286,30 @@ it.effect("uses a hook-provided title without a model request", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs the request on a hook-replaced model", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_hook_model")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Hello")
|
||||
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "title", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.model = Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("title-small") })
|
||||
}),
|
||||
)
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
|
||||
expect(requests.map((request) => String(request.model.id))).toEqual(["title-small"])
|
||||
expect(selections.at(-1)).toEqual(Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("title-small") }))
|
||||
const store = yield* SessionStore.Service
|
||||
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses a small model from the primary provider", () =>
|
||||
Effect.gen(function* () {
|
||||
selectedSmall = small
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface SessionCompaction extends SessionContext {
|
||||
export interface SessionGenerate extends SessionContext {}
|
||||
|
||||
export interface SessionTitle extends SessionRequest {
|
||||
/** Replace to run the title request on a different catalog model. */
|
||||
model: Model.Ref
|
||||
/** Set to use this title and skip the model request. */
|
||||
result?: string
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface SessionCompaction extends SessionContext {
|
||||
export interface SessionGenerate extends SessionContext {}
|
||||
|
||||
export interface SessionTitle extends SessionRequest {
|
||||
/** Replace to run the title request on a different catalog model. */
|
||||
model: Model.Ref
|
||||
/** Set to use this title and skip the model request. */
|
||||
result?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user