Compare commits

...
Author SHA1 Message Date
Aiden Cline 9b1dcbb45c test: update server and sdk fixtures to context options bag 2026-09-04 01:10:34 -05:00
Aiden Cline 542e79efd5 fix(core): keep title requests out of session context hooks
Titles will get a dedicated hook. The context event's kind excludes
"title" so plugins cannot branch on a value they never receive.
2026-09-04 01:06:36 -05:00
Aiden Cline 7e228666e9 Revert "refactor(core): detect warm requests by kind instead of prompt text"
This reverts commit a52c7ab089.
2026-09-04 01:02:26 -05:00
Aiden Cline a52c7ab089 refactor(core): detect warm requests by kind instead of prompt text 2026-09-04 00:57:20 -05:00
Aiden Cline 986ee8c060 feat(plugin): run session context hooks for every request kind
The context hook now fires for titles too and carries `kind`, so one rule
covers every outbound request: plugins that only want the agent loop
filter on `kind` instead of relying on the hook not firing.

`generation` and `providerOptions` collapse into one `options` bag. The
typed keys are the protocol-neutral generation settings; any other key is
passed to the protocol as a provider option. Core partitions them when it
builds the request.

Compaction no longer borrows the hidden `compaction` agent as its hook
identity. Every hook sees the session's agent and `kind: "compaction"`,
which removes the `contextAgentID` workaround. Retry hooks gain `kind`
so compaction retries stay distinguishable.
2026-09-04 00:50:22 -05:00
Aiden Cline 89478b36f1 refactor(plugin): rename session request kind "session" to "primary" (#47221) 2026-09-04 00:12:18 -05:00
Aiden Cline 46458f0753 feat(core): tag session http hooks with request kind (#47214) 2026-09-03 23:51:26 -05:00
opencode-agent[bot]andrekram1-node c907d2ba27 fix(tui): preserve model release ordering in search (#47183)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-03 23:18:09 -05:00
25 changed files with 330 additions and 90 deletions
@@ -1,11 +1,11 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session"
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
import { Catalog } from "../../catalog.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
import { CopilotModels } from "../../github-copilot/models.js"
import { App } from "../../app.js"
import { Agent } from "../../agent.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
@@ -259,7 +259,7 @@ export const GithubCopilotPlugin = define({
const session = yield* ctx.session
.get({ sessionID: evt.sessionID })
.pipe(Effect.orElseSucceed(() => undefined))
const interaction = interactionType(evt.agent, session?.parentID !== undefined)
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
evt.headers["X-Interaction-Type"] = interaction
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
}),
@@ -391,9 +391,9 @@ 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(agent: Agent.ID, child: boolean) {
if (agent === Agent.ID.make("title")) return "conversation-background"
if (agent === Agent.ID.make("compaction")) return "conversation-compaction"
export function interactionType(kind: SessionRequestKind, child: boolean) {
if (kind === "title") return "conversation-background"
if (kind === "compaction") return "conversation-compaction"
if (child) return "conversation-subagent"
return "conversation-agent"
}
+3 -4
View File
@@ -11,7 +11,6 @@ import {
Message,
type ContentPart,
} from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
@@ -396,10 +395,10 @@ export const layer = Layer.effect(
messages: history.messages,
})
const prepared = yield* input.prepare({
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
agentID: context.agent.id,
model: context.model,
tools: context.tools,
},
@@ -485,7 +484,7 @@ export const layer = Layer.effect(
const decision = yield* retry({
cause,
error: toSessionError(cause),
agent: Agent.ID.make("compaction"),
agent: context.agent.id,
model: context.model.ref,
hook: prepared.retry,
retry: SessionRunnerRetry.isRetryable(cause),
+1
View File
@@ -38,6 +38,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
messages: history.messages,
})
const prepared = yield* context.prepare({
kind: "generate",
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
+39 -18
View File
@@ -1,7 +1,17 @@
export * as SessionModelRequest from "./model-request.js"
import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode-ai/ai"
import {
GenerationOptions,
type GenerationOptionsFields,
HttpOptions,
LanguageModel,
LLM,
LLMRequest,
Message,
SystemPart,
} from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { SessionRequestKind, SessionRequestOptions } from "@opencode-ai/plugin/effect/session"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Content } from "@opencode-ai/schema/tool"
@@ -25,6 +35,8 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
const responsesWebSocketFlag = (providerID: string) =>
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
@@ -48,7 +60,10 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
export interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
/** Runs retry hooks with this request's kind; the returned event carries the hooked decision. */
readonly retry: (
event: Omit<PluginHooks.Domains["session"]["retry"], "kind">,
) => Effect.Effect<PluginHooks.Domains["session"]["retry"]>
/**
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
@@ -59,11 +74,11 @@ export interface Prepared {
}
interface PrepareInput {
/** Which Session flow issues this request; request hooks receive it alongside the Session identity. */
readonly kind: SessionRequestKind
readonly scope: {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
/** Agent whose context an auxiliary request reuses, without changing its request-hook identity. */
readonly contextAgentID?: Agent.ID
readonly model: SessionRunnerModel.Resolved
/** Omitted for requests that carry no tool definitions, such as titles. */
readonly tools?: Tool.Snapshot
@@ -73,11 +88,6 @@ interface PrepareInput {
readonly messages: Array<Message>
}
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/**
* Session context hooks shape the agent conversation. Standalone requests
* such as titles opt out; compaction uses the selected Session context.
*/
readonly contextHooks?: false
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
readonly webSocket?: "session"
}
@@ -197,6 +207,7 @@ interface HookScope {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
}
const sessionHeaders = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, app: App.Info) => ({
@@ -301,17 +312,26 @@ export const layer = Layer.effect(
)
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
const context: PluginHooks.Domains["session"]["context"] = {
const draft = {
sessionID: session.id,
agent: input.scope.contextAgentID ?? input.scope.agentID,
agent: input.scope.agentID,
model: resolved.ref,
system: input.transcript.system,
messages: input.transcript.messages,
tools: definitions,
generation: {},
providerOptions: {},
options: {} as SessionRequestOptions,
}
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
// Titles are not part of the agent conversation and skip context hooks.
const context =
input.kind === "title" ? draft : yield* hooks.trigger("session", "context", { ...draft, kind: input.kind })
// Typed generation keys and provider-semantic keys share one bag in the hook;
// the request keeps them apart.
const generation = Object.fromEntries(
Object.entries(context.options).filter(([key]) => GENERATION_KEYS.has(key)),
) as GenerationOptionsFields
const providerOptions = Object.fromEntries(
Object.entries(context.options).filter(([key]) => !GENERATION_KEYS.has(key)),
)
// Match each surviving entry back to its tool, by recognizing a moved definition or
// by key. Identity wins so a definition moved onto another tool's name still executes
// the tool it describes. Entries matching neither were invented by a hook and dropped.
@@ -325,7 +345,7 @@ export const layer = Layer.effect(
)
const request = yield* applyModelHooks(
hooks,
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref },
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref, kind: input.kind },
LLM.request({
model,
http: {
@@ -337,8 +357,8 @@ export const layer = Layer.effect(
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: input.toolChoice,
generation: Object.keys(context.generation).length === 0 ? undefined : context.generation,
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
generation: Object.keys(generation).length === 0 ? undefined : generation,
providerOptions: Object.keys(providerOptions).length === 0 ? undefined : providerOptions,
}),
)
const hasHttpHooks =
@@ -356,6 +376,7 @@ export const layer = Layer.effect(
sessionID: session.id,
agent: input.scope.agentID,
model: resolved.ref,
kind: input.kind,
})
: undefined
const options: StreamOptions = {
@@ -368,7 +389,7 @@ export const layer = Layer.effect(
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", { ...event, kind: input.kind })
return {
request,
options,
+1
View File
@@ -217,6 +217,7 @@ const layer = Layer.effect(
messages: loaded.messages,
})
const prepared = yield* context.prepare({
kind: "primary",
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
+4 -5
View File
@@ -6,8 +6,8 @@ import { Model } from "@opencode-ai/schema/model"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Clock, Duration, Effect, Pull, Schedule } from "effect"
import { Bus } from "../../bus.js"
import type { PluginHooks } from "../../plugin/hooks.js"
import { SessionEvent } from "../event.js"
import type { SessionModelRequest } from "../model-request.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
@@ -16,7 +16,7 @@ interface Input {
readonly error: SessionError.Error
readonly agent: Agent.ID
readonly model: Model.Ref
readonly hook: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
readonly hook: SessionModelRequest.Prepared["retry"]
readonly retry: boolean
}
@@ -90,15 +90,14 @@ export const policy = (sessionID: SessionSchema.ID) =>
const [, duration] = next
attempt++
const delay = Math.ceil(Duration.toMillis(duration))
const event: PluginHooks.Domains["session"]["retry"] = {
const event = yield* input.hook({
sessionID,
agent: input.agent,
model: input.model,
error: input.error,
attempt,
decision: input.retry ? { retry: true, delay } : { retry: false },
}
yield* input.hook(event)
})
if (!event.decision.retry) return event.decision
const normalized =
Number.isFinite(event.decision.delay) && event.decision.delay >= 0 ? Math.ceil(event.decision.delay) : delay
+1 -1
View File
@@ -64,12 +64,12 @@ export const layer = Layer.effect(
: Effect.void,
)
const prepared = yield* context.prepare({
kind: "title",
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
},
contextHooks: false,
})
yield* llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
+2 -2
View File
@@ -121,11 +121,11 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
sessionID,
agent,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") },
kind: "primary",
system: [],
messages,
tools: {},
generation: {},
providerOptions: {},
options: {},
})
type ToolErrorEvent = Extract<ToolHooks["execute.after"], { readonly status: "error" }>
@@ -281,6 +281,7 @@ describe("AzurePlugin", () => {
sessionID: Session.ID.make("ses_azure"),
agent: Agent.ID.make("build"),
model,
kind: "primary",
request: new Request("https://test-resource.openai.azure.com/openai/v1/responses", {
headers: { "api-key": "stored-token", "x-keep": "yes" },
}),
@@ -295,6 +296,7 @@ describe("AzurePlugin", () => {
sessionID: Session.ID.make("ses_foundry"),
agent: Agent.ID.make("build"),
model,
kind: "primary",
request: new Request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", {
headers: { "x-api-key": "stored-token" },
}),
@@ -19,6 +19,7 @@ import {
} from "@opencode-ai/core/plugin/provider/github-copilot"
import { Provider } from "@opencode-ai/core/provider"
import { Integration } from "@opencode-ai/core/integration"
import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session"
import { fakeSelectorSdk } from "../fixture/selector"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -44,12 +45,13 @@ const sessions = Effect.fn(function* () {
return { parent: parent.id, child: child.id }
})
const modelRequest = Effect.fn(function* (sessionID: Session.ID, agent: string) {
const modelRequest = Effect.fn(function* (sessionID: Session.ID, kind: SessionRequestKind, agent = "build") {
const hooks = yield* PluginHooks.Service
return yield* hooks.trigger("session", "model.request", {
sessionID,
agent: Agent.ID.make(agent),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
kind,
headers: {},
})
})
@@ -154,6 +156,7 @@ describe("GithubCopilotPlugin", () => {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }),
kind: "primary",
request: new Request("https://api.githubcopilot.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": "token" },
@@ -171,7 +174,7 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies main-loop steps as agent interactions", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).parent, "build")
const event = yield* modelRequest((yield* sessions()).parent, "primary")
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
}),
)
@@ -179,7 +182,7 @@ describe("GithubCopilotPlugin", () => {
it.effect("classifies child-session steps as subagent interactions", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).child, "build")
const event = yield* modelRequest((yield* sessions()).child, "primary")
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", "x-initiator": "agent" })
}),
)
@@ -192,14 +195,22 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("classifies compaction requests", () =>
it.effect("classifies compaction requests by kind rather than agent", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).child, "compaction")
const event = yield* modelRequest((yield* sessions()).child, "compaction", "build")
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", "x-initiator": "agent" })
}),
)
it.effect("does not classify by agent name", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).parent, "primary", "compaction")
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
}),
)
it.effect("ignores other providers' model requests", () =>
Effect.gen(function* () {
yield* addPlugin()
@@ -208,6 +219,7 @@ describe("GithubCopilotPlugin", () => {
sessionID: (yield* sessions()).parent,
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5.4") }),
kind: "primary",
headers: {},
})
expect(event.headers).toEqual({})
@@ -236,6 +248,14 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("classifies session generation requests as agent interactions", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).parent, "generate")
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
}),
)
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
@@ -48,6 +48,7 @@ const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
kind: "primary",
baseURL,
headers: {},
})
@@ -226,6 +227,7 @@ describe("OpenAIPlugin", () => {
const program = Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
return yield* requests.prepare({
kind: "primary",
scope: {
session: Session.Info.make({
id: sessionID,
@@ -29,11 +29,11 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
sessionID: Session.ID.make("ses_system_prompt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
kind: "primary",
system: [SystemPart.make(system)],
messages: [],
tools: {},
generation: {},
providerOptions: {},
options: {},
})
describe("SystemPromptPlugin", () => {
@@ -0,0 +1,80 @@
import { describe, expect } from "bun:test"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Agent } from "@opencode-ai/schema/agent"
import { Money } from "@opencode-ai/schema/money"
import { Session } from "@opencode-ai/schema/session"
import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session"
import { Location } from "@opencode-ai/core/location"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { DateTime, Effect } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
const KINDS: ReadonlyArray<SessionRequestKind> = ["primary", "compaction", "title", "generate"]
const session = Session.Info.make({
id: Session.ID.make("ses_hook_kind"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
})
const model = SessionRunnerModel.resolved(OpenAIChat.route.model({ id: "gpt-5.5", provider: "test" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
})
const transport = SessionModelTransport.Service.of({
bind: () => ({ execute: () => Effect.die("unused WebSocket execution") }),
close: () => Effect.void,
closeAll: Effect.void,
})
describe("SessionModelRequest HTTP hooks", () => {
it.effect("tags every Session request kind on http.request and http.response", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: Array<{ hook: string; kind: SessionRequestKind; agent: Agent.ID }> = []
yield* hooks.register("session", "http.request", (event) =>
Effect.sync(() => {
seen.push({ hook: "request", kind: event.kind, agent: event.agent })
}),
)
yield* hooks.register("session", "http.response", (event) =>
Effect.sync(() => {
seen.push({ hook: "response", kind: event.kind, agent: event.agent })
}),
)
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
for (const kind of KINDS) {
const prepared = yield* requests.prepare({
kind,
scope: { session, agentID: Agent.ID.make("build"), model },
transcript: { system: [], messages: [] },
})
const http = prepared.options.http
if (!http) throw new Error(`Expected HTTP middleware for ${kind}`)
yield* http(HttpClientRequest.post("https://example.test/v1/chat/completions"), (request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 200 }))),
)
}
expect(seen).toEqual(
KINDS.flatMap((kind) => [
{ hook: "request", kind, agent: Agent.ID.make("build") },
{ hook: "response", kind, agent: Agent.ID.make("build") },
]),
)
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
})
+5 -5
View File
@@ -2124,19 +2124,19 @@ describe("SessionRunnerLLM", () => {
sessionID,
model: { id: ID.make(s.currentModel.id), providerID: Provider.ID.make(s.currentModel.provider), variant },
})
const requestAgents: Agent.ID[] = []
const hookRequests: Array<{ agent: Agent.ID; kind: string }> = []
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
expect(event.agent).toBe(agentID)
expect(event.model.variant).toBe(variant)
event.system.push(SystemPart.make("Hook-provided instructions"))
event.tools.echo.description = "Hook-provided tool description"
event.generation.maxTokens = 4_000
event.options.maxTokens = 4_000
}),
)
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
requestAgents.push(event.agent)
hookRequests.push({ agent: event.agent, kind: event.kind })
}),
)
yield* s.llm.push(
@@ -2182,7 +2182,7 @@ describe("SessionRunnerLLM", () => {
expect(compact[field]).toEqual(normal[field])
expect(compact.toolChoice).toBeUndefined()
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
expect(hookRequests[2]).toEqual({ agent: agentID, kind: "compaction" })
expect(s.executions).toEqual(["x".repeat(4_000)])
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
@@ -2297,7 +2297,7 @@ describe("SessionRunnerLLM", () => {
expect(s.requests).toHaveLength(5)
for (const request of s.requests) expect(request).toEqual(s.requests[0])
expect(retries.map((event) => event.attempt)).toEqual([2, 3, 4, 5])
expect(retries.every((event) => event.sessionID === sessionID && event.agent === "compaction")).toBe(true)
expect(retries.every((event) => event.sessionID === sessionID && event.kind === "compaction")).toBe(true)
expect(retries[3].decision).toEqual({ retry: true, delay: 60_000 })
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
status: "completed",
+1 -1
View File
@@ -105,7 +105,7 @@ for (const fixture of [
agent: Agent.defaultID,
model,
prepared: {
retry: () => Effect.void,
retry: (event) => Effect.succeed({ ...event, kind: "primary" as const }),
request: LLM.request({ model: model.model, prompt: "Run one tool", toolChoice: fixture.toolChoice }),
options: {},
executeTool: () =>
+20
View File
@@ -232,6 +232,26 @@ it.effect("generates a title from the sole user message and renames the session"
}),
)
it.effect("does not run context hooks for title requests", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_context_hook")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
const hooks = yield* PluginHooks.Service
let calls = 0
yield* hooks.register("session", "context", () => Effect.sync(() => calls++))
yield* hooks.register("session", "model.request", (event) => Effect.sync(() => expect(event.kind).toBe("title")))
const title = yield* SessionTitle.Service
yield* title.generate(sessionID)
expect(requests).toHaveLength(1)
expect(calls).toBe(0)
}),
)
it.effect("uses a small model from the primary provider", () =>
Effect.gen(function* () {
selectedSmall = small
+21 -3
View File
@@ -18,22 +18,37 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
/**
* Why a Session request is being made. Auxiliary requests share the Session's
* hook identity but need to be told apart from the agent loop.
*/
export type SessionRequestKind = "primary" | "compaction" | "title" | "generate"
/**
* Request overrides. Typed keys are the protocol-neutral generation settings;
* any other key is passed to the selected protocol as a provider option under its
* semantic name, such as `reasoningEffort` for OpenAI Responses. Unset fields
* retain route and model defaults.
*/
export type SessionRequestOptions = Types.DeepMutable<GenerationOptionsFields> & Record<string, unknown>
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
/** Titles do not run context hooks; they will get a dedicated hook. */
readonly kind: Exclude<SessionRequestKind, "title">
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
/** Request overrides; unset fields retain route and model defaults. */
generation: Types.DeepMutable<GenerationOptionsFields>
providerOptions: Record<string, unknown>
options: SessionRequestOptions
}
export interface SessionModelRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
baseURL?: string
headers: Record<string, string>
}
@@ -42,6 +57,7 @@ export interface SessionHttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
request: Request
}
@@ -49,6 +65,7 @@ export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
readonly request: Request
response: Response
}
@@ -59,6 +76,7 @@ export interface SessionRetry {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
readonly error: SessionError.Error
readonly attempt: number
decision: SessionRetryDecision
+21 -3
View File
@@ -18,22 +18,37 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
/**
* Why a Session request is being made. Auxiliary requests share the Session's
* hook identity but need to be told apart from the agent loop.
*/
export type SessionRequestKind = "primary" | "compaction" | "title" | "generate"
/**
* Request overrides. Typed keys are the protocol-neutral generation settings;
* any other key is passed to the selected protocol as a provider option under its
* semantic name, such as `reasoningEffort` for OpenAI Responses. Unset fields
* retain route and model defaults.
*/
export type SessionRequestOptions = Types.DeepMutable<GenerationOptionsFields> & Record<string, unknown>
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
/** Titles do not run context hooks; they will get a dedicated hook. */
readonly kind: Exclude<SessionRequestKind, "title">
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
/** Request overrides; unset fields retain route and model defaults. */
generation: Types.DeepMutable<GenerationOptionsFields>
providerOptions: Record<string, unknown>
options: SessionRequestOptions
}
export interface SessionModelRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
baseURL?: string
headers: Record<string, string>
}
@@ -42,6 +57,7 @@ export interface SessionHttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
request: Request
}
@@ -49,6 +65,7 @@ export interface SessionHttpResponse {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
readonly request: Request
response: Response
}
@@ -59,6 +76,7 @@ export interface SessionRetry {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
readonly error: SessionError.Error
readonly attempt: number
decision: SessionRetryDecision
+1 -1
View File
@@ -69,7 +69,7 @@ it.live(
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.generation.temperature = 0.25
event.options.temperature = 0.25
}),
)
yield* ctx.tool.transform((editor) =>
@@ -94,7 +94,7 @@ it.live(
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.generation.temperature = config.temperature
event.options.temperature = config.temperature
}),
)
yield* ctx.permission.hook("evaluate", (event) =>
+18 -5
View File
@@ -99,11 +99,15 @@ export function DialogModel(props: { providerID?: string }) {
return false
return true
}),
connected(),
)
if (needle) {
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
sortModelOptions(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
false,
),
favoritePriority,
)
}
@@ -179,15 +183,24 @@ export function prioritizeFavorites<T extends { value: { providerID: string; mod
}
export function sortModelOptions<
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
>(options: T[]) {
T extends {
providerID?: string
providerName?: string
releaseDate: string | number
title: string
footer?: string
},
>(options: T[], grouped = true) {
return options.toSorted((a, b) => {
const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode")
const provider = grouped ? Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode") : 0
if (provider !== 0) return provider
const name = (a.providerName ?? "").localeCompare(b.providerName ?? "")
const name = grouped ? (a.providerName ?? "").localeCompare(b.providerName ?? "") : 0
if (name !== 0) return name
const free = Number(b.footer === "Free") - Number(a.footer === "Free")
if (free !== 0) return free
const release = Number(b.releaseDate) - Number(a.releaseDate)
if (release !== 0) return release
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { go } from "fuzzysort"
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
describe("prioritizeFavorites", () => {
@@ -23,6 +24,20 @@ describe("prioritizeFavorites", () => {
})
describe("sortModelOptions", () => {
test.each(["browse", "search", "provider"])("orders %s results free-first, then newest-first", (mode) => {
const options = [
{ providerID: "opencode", title: "Claude Haiku 3", releaseDate: 1 },
{ providerID: "anthropic", title: "Claude Haiku 4.5", releaseDate: 2 },
{ providerID: "anthropic", title: "Claude Haiku Free", releaseDate: 0, footer: "Free" },
].map((item) => ({ ...item, providerID: mode === "provider" ? "anthropic" : item.providerID }))
const matches = mode === "search" ? go("haik", options, { key: "title" }).map((item) => item.obj) : options
expect(sortModelOptions(matches, mode === "provider").map((item) => item.title)).toEqual([
"Claude Haiku Free",
"Claude Haiku 4.5",
"Claude Haiku 3",
])
})
test("orders opencode models before other providers", () => {
const sorted = sortModelOptions([
{ providerID: "openai", providerName: "OpenAI", releaseDate: 3, title: "GPT 5" },
@@ -1089,7 +1089,10 @@ effect: (ctx) =>
### Sessions
Modify assembled system instructions, messages, or tools immediately before model dispatch.
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch. The hook
runs for every request that carries the session conversation; `event.kind` is `"primary"`, `"compaction"`, or
`"generate"`. Title requests do not run context hooks.
Typed `options` keys are generation settings; any other key is passed to the protocol as a provider option.
```ts
effect: (ctx) =>
@@ -1097,14 +1100,17 @@ effect: (ctx) =>
const session = ctx.session
yield* session.hook("context", (event) =>
Effect.sync(() => {
if (event.kind === "compaction") return
event.system.push({ text: "Keep the review focused on correctness." })
delete event.tools.write
event.options.maxTokens = 8_000
}),
)
}),
```
Modify model request settings and optionally scope the hook to one provider.
Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind` as
the HTTP hooks below.
```ts
effect: (ctx) =>
@@ -1119,14 +1125,18 @@ effect: (ctx) =>
```
Modify native provider requests or responses. Their bodies are one-shot streams; clone or replace a body before reading
it.
it. Both hooks run for every request a session issues; `event.kind` is `"primary"`, `"compaction"`, `"title"`, or
`"generate"` depending on which flow issued it.
```ts
effect: (ctx) =>
Effect.gen(function* () {
const session = ctx.session
yield* session.hook("http.request", (event) =>
Effect.sync(() => event.request.headers.set("x-session-id", event.sessionID)),
Effect.sync(() => {
event.request.headers.set("x-session-id", event.sessionID)
if (event.kind === "title") event.request.headers.set("x-priority", "background")
}),
)
yield* session.hook("http.response", (event) =>
Effect.sync(() => {
@@ -1181,10 +1191,13 @@ interface SessionHooks {
type RetryDecision = { retry: false } | { retry: true; delay: number }
type SessionRequestKind = "primary" | "compaction" | "title" | "generate"
interface SessionRetry {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly kind: SessionRequestKind
readonly error: { type: string; message: string; status?: number }
readonly attempt: number
decision: RetryDecision
@@ -1089,28 +1089,34 @@ Keep prompt hooks retry-safe. They are not an exactly-once side-effect boundary:
#### Model context
Modify assembled system instructions, messages, tools, generation settings, or provider options immediately before model
dispatch.
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
```ts
await ctx.session.hook("context", (event) => {
event.system.push({ text: "Keep the review focused on correctness." })
delete event.tools.write
event.generation.temperature = 0.2
event.generation.maxTokens = 8_000
event.options.temperature = 0.2
event.options.maxTokens = 8_000
})
```
Context changes affect only the outgoing model call, not persisted history or
configuration. The hook runs again for subsequent calls such as tool-driven
continuations, transient session generation, and compaction, but not for title requests.
Context changes affect only the outgoing model call, not persisted history or configuration. The hook runs for every
request that carries the session conversation; `event.kind` says which flow issued it: `"primary"` for the agent loop,
`"compaction"` for checkpoint summaries, and `"generate"` for transient `ctx.session.generate` calls. Title requests do
not run context hooks. `event.agent` is always the session's selected agent.
Compaction context hooks receive the selected session agent. Its model-request
and HTTP hooks retain the `compaction` agent identity for provider-specific handling.
```ts
await ctx.session.hook("context", (event) => {
if (event.kind === "compaction") return
event.messages.push({ role: "user", content: [{ type: "text", text: "Reminder: prefer small diffs." }] })
})
```
Request overrides follow these rules:
Request options follow these rules:
- `generation` and `providerOptions` start empty for each model call; they do not contain resolved model settings.
- `options` starts empty for each model call; it does not contain resolved model settings.
- Typed keys (`maxTokens`, `temperature`, `topP`, `topK`, `frequencyPenalty`, `presencePenalty`, `seed`, `stop`) are the
protocol-neutral generation settings. Any other key is passed to the selected protocol as a provider option.
- Hooks run in registration order and see overrides made by earlier hooks.
- Request overrides take precedence over model defaults, which take precedence over route defaults.
- Provider option objects merge recursively; arrays and scalar values replace earlier values.
@@ -1126,7 +1132,7 @@ settings to the matching provider. For example, OpenAI Responses uses `reasoning
await ctx.session.hook(
"context",
(event) => {
event.providerOptions.reasoningEffort = "high"
event.options.reasoningEffort = "high"
},
{ providerID: "openai" },
)
@@ -1139,7 +1145,8 @@ Generation options depend on the selected protocol and model:
#### Model request
Modify model request settings and optionally scope the hook to one provider.
Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind`
as the HTTP hooks below.
```ts
await ctx.session.hook(
@@ -1156,9 +1163,14 @@ await ctx.session.hook(
Modify native provider requests or responses. Their bodies are one-shot streams; clone or replace a body before reading
it.
Both hooks run for every request a session issues. `event.kind` says which flow issued it: `"primary"` for the agent
loop, `"compaction"` for checkpoint summaries, `"title"` for title generation, and `"generate"` for transient
`ctx.session.generate` calls. Use it instead of the agent ID to tell auxiliary requests apart.
```ts
await ctx.session.hook("http.request", (event) => {
event.request.headers.set("x-session-id", event.sessionID)
if (event.kind === "title") event.request.headers.set("x-priority", "background")
})
await ctx.session.hook("http.response", (event) => {
@@ -1216,33 +1228,38 @@ interface SessionHooks {
type RetryDecision = { retry: false } | { retry: true; delay: number }
type SessionRequestKind = "primary" | "compaction" | "title" | "generate"
interface SessionRetryHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly kind: SessionRequestKind
readonly error: { type: string; message: string; status?: number }
readonly attempt: number
decision: RetryDecision
}
type SessionRequestOptions = {
maxTokens?: number
temperature?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
seed?: number
stop?: string[]
} & Record<string, unknown>
interface SessionContextHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly kind: Exclude<SessionRequestKind, "title">
system: SystemPart[]
messages: Message[]
tools: Record<string, { description: string; input: JsonSchema }>
generation: {
maxTokens?: number
temperature?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
seed?: number
stop?: string[]
}
providerOptions: Record<string, unknown>
options: SessionRequestOptions
}
interface SessionHookContext {
+2 -1
View File
@@ -80,7 +80,8 @@ preserves more recent detail but leaves less room for future work. Larger
V2 uses the session's selected agent, model, and variant to generate the summary.
The request reuses the normal instructions, tool definitions, and structured
history prefix, then appends a user message requesting a checkpoint. Context
hooks run as they do for normal session requests.
hooks run as they do for normal session requests, with `kind` set to
`"compaction"`.
Compaction does not dispatch local tool calls or override tool choice. The
summary must contain at least one heading from the requested template, such as