Compare commits

...
15 changed files with 105 additions and 14 deletions
@@ -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"
+18 -5
View File
@@ -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],
})
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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([]) })),
],
),
)
+1
View File
@@ -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: {},
+25
View File
@@ -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
+2
View File
@@ -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
}
+2
View File
@@ -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
}