mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 02:46:21 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac7f3c5ece | ||
|
|
3edbc88225 |
@@ -1,7 +1,8 @@
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
@@ -37,11 +38,10 @@ const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: OpenAIResponses.route.defaults,
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
expect(provider.model).toBe(provider.responses)
|
||||
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
})
|
||||
expect(responses).toMatchObject({
|
||||
route: "bedrock-mantle-responses",
|
||||
protocol: "openai-responses",
|
||||
protocol: "open-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-mantle",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "openai-responses",
|
||||
protocol: "open-responses",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
metadata: { model: "openai.gpt-oss-120b" },
|
||||
})
|
||||
|
||||
@@ -122,15 +122,7 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
|
||||
await expect(running).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
|
||||
await timeline.send(
|
||||
|
||||
@@ -163,7 +163,7 @@ function mapProviderOptions(settings: Readonly<Record<string, unknown>>, exclude
|
||||
|
||||
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||
const settings = input.settings
|
||||
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
|
||||
const chat = input.modelID.includes("gpt-oss")
|
||||
return {
|
||||
package: `@opencode/ai/providers/amazon-bedrock/mantle/${chat ? "chat" : "responses"}`,
|
||||
settings: {
|
||||
|
||||
@@ -54,7 +54,7 @@ function make(
|
||||
return define({
|
||||
id,
|
||||
effect: Effect.fn(`OptimizePlugin.${id}`)(function* (ctx) {
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.gen(function* () {
|
||||
const model =
|
||||
(yield* ctx.catalog.model.list()).data.find(
|
||||
@@ -67,8 +67,10 @@ function make(
|
||||
const system = event.system[0]
|
||||
if (!system) return
|
||||
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
|
||||
}).pipe(Effect.catch(() => Effect.void)),
|
||||
)
|
||||
}).pipe(Effect.catch(() => Effect.void))
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
yield* ctx.session.hook("generate", hook)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as WarmingPlugin from "./warming.js"
|
||||
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Clock, Duration, Effect, Scope } from "effect"
|
||||
import { Config } from "../config.js"
|
||||
@@ -54,7 +55,7 @@ export const Plugin = define({
|
||||
},
|
||||
)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.gen(function* () {
|
||||
const active = sessions.get(event.sessionID)
|
||||
const settings = yield* loadSettings()
|
||||
@@ -95,7 +96,9 @@ export const Plugin = define({
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
yield* ctx.session.hook("generate", hook)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Message,
|
||||
type ContentPart,
|
||||
} from "@opencode/ai"
|
||||
import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -402,6 +403,36 @@ export const layer = Layer.effect(
|
||||
recent,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
const supplied = Effect.fn("SessionCompaction.supplied")(function* (
|
||||
input: ExecuteInput,
|
||||
result: SessionCompactionResult,
|
||||
recent: string,
|
||||
) {
|
||||
const context = input.context
|
||||
const usage = result.tokens
|
||||
? { tokens: result.tokens, cost: SessionUsage.calculateCost(context.model.cost, result.tokens) }
|
||||
: undefined
|
||||
if (usage)
|
||||
yield* bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: context.session.id,
|
||||
source: "compaction",
|
||||
...usage,
|
||||
})
|
||||
yield* bus.publish(
|
||||
SessionEvent.Compaction.Ended,
|
||||
{
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
model: context.model.ref,
|
||||
providerState: result.providerState,
|
||||
text: result.summary,
|
||||
recent,
|
||||
...usage,
|
||||
},
|
||||
{ metadata: result.metadata },
|
||||
)
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
// Manual controls settle through the inbox; only automatic work needs a durable interruption record.
|
||||
const interrupted = (input: ExecuteInput) =>
|
||||
input.reason === "auto"
|
||||
@@ -456,6 +487,10 @@ export const layer = Layer.effect(
|
||||
error: { type: "provider.unsupported-operation", message },
|
||||
})
|
||||
const prepared = yield* compactionRequest(input, context.messages, [], "session")
|
||||
if (prepared.event.result) {
|
||||
yield* started(input, "")
|
||||
return yield* supplied(input, prepared.event.result, "")
|
||||
}
|
||||
const request = prepared.request
|
||||
const provenance = SessionProviderContext.provenance(context.model)
|
||||
if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint")
|
||||
@@ -571,6 +606,7 @@ export const layer = Layer.effect(
|
||||
const prepared = yield* compactionRequest(input, history.messages, [
|
||||
Message.user(buildPrompt(previous !== undefined, legacy)),
|
||||
])
|
||||
if (prepared.event.result) return yield* supplied(input, prepared.event.result, history.recent)
|
||||
// Both requests share the retry allowance; rejected output never enters the reminder request.
|
||||
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
|
||||
agent: context.agent.id,
|
||||
|
||||
@@ -409,6 +409,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
yield* adapter.updateCompaction({
|
||||
...current,
|
||||
status: "completed",
|
||||
metadata: event.metadata ? { ...current.metadata, ...event.metadata } : current.metadata,
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
SystemPart,
|
||||
} from "@opencode/ai"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import type { SessionContext, SessionRequest, SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
|
||||
import type {
|
||||
SessionCompaction,
|
||||
SessionContext,
|
||||
SessionGenerate,
|
||||
SessionRequest,
|
||||
SessionRequestKind,
|
||||
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"
|
||||
@@ -178,8 +185,8 @@ type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
|
||||
/** Builds the model request for each session flow. Each entry runs its own plugin hook. */
|
||||
export interface Interface {
|
||||
readonly primary: (input: Input) => Effect.Effect<Prepared<SessionContext>>
|
||||
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionContext>>
|
||||
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionContext>>
|
||||
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionCompaction>>
|
||||
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionGenerate>>
|
||||
readonly title: (input: Input) => Effect.Effect<Prepared<SessionTitle>>
|
||||
}
|
||||
|
||||
@@ -342,13 +349,14 @@ export const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const context = (agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
|
||||
hooks.trigger("session", "context", { ...draft, agent, tools })
|
||||
const agentHook =
|
||||
(name: "context" | "compaction" | "generate", agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
|
||||
hooks.trigger("session", name, { ...draft, agent, tools })
|
||||
|
||||
return Service.of({
|
||||
primary: (input) => prepare("primary", input, context(input.agent)),
|
||||
generate: (input) => prepare("generate", input, context(input.agent)),
|
||||
compaction: (input) => prepare("compaction", input, context(input.agent)),
|
||||
primary: (input) => prepare("primary", input, agentHook("context", input.agent)),
|
||||
compaction: (input) => prepare("compaction", input, agentHook("compaction", input.agent)),
|
||||
generate: (input) => prepare("generate", input, agentHook("generate", input.agent)),
|
||||
title: (input) => prepare("title", input, (draft) => hooks.trigger("session", "title", draft)),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("AISDKNative", () => {
|
||||
settings: { region: "us-east-1" },
|
||||
})
|
||||
expect(map("@ai-sdk/amazon-bedrock/mantle", { region: "us-east-1" }, "openai.gpt-oss-120b")).toEqual({
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
|
||||
settings: { region: "us-east-1" },
|
||||
})
|
||||
})
|
||||
@@ -287,7 +287,7 @@ describe("AISDKNative", () => {
|
||||
}
|
||||
|
||||
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-120b")).toEqual({
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
|
||||
settings: {
|
||||
apiKey: "token",
|
||||
baseURL: "https://mantle.test/v1",
|
||||
@@ -336,7 +336,7 @@ describe("AISDKNative", () => {
|
||||
"openai.gpt-oss-120b",
|
||||
),
|
||||
).toEqual({
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
|
||||
settings: {
|
||||
credentials: {
|
||||
accessKeyId: "key",
|
||||
|
||||
@@ -169,19 +169,19 @@ describe("ModelResolver", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
|
||||
it.effect("maps Bedrock Mantle GPT-OSS models to Chat and other models to Responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "secret" })
|
||||
const responses = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-oss-120b",
|
||||
modelID: "openai.gpt-5.5",
|
||||
settings: { region: "us-east-2" },
|
||||
}),
|
||||
credential,
|
||||
)
|
||||
const chat = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-oss-safeguard-20b",
|
||||
modelID: "openai.gpt-oss-20b",
|
||||
settings: { region: "us-east-2" },
|
||||
}),
|
||||
credential,
|
||||
@@ -1041,7 +1041,7 @@ describe("ModelResolver", () => {
|
||||
["@ai-sdk/amazon-bedrock", "@opencode/ai/providers/amazon-bedrock", "api-model"],
|
||||
[
|
||||
"@ai-sdk/amazon-bedrock/mantle",
|
||||
"@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
"@opencode/ai/providers/amazon-bedrock/mantle/chat",
|
||||
"openai.gpt-oss-120b",
|
||||
],
|
||||
["@ai-sdk/azure", "@opencode/ai/providers/azure/responses", "api-model"],
|
||||
@@ -1271,7 +1271,7 @@ describe("ModelResolver", () => {
|
||||
expect(bedrock.route.id).toBe("bedrock-converse")
|
||||
expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
|
||||
expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
|
||||
expect(mantle.route.id).toBe("bedrock-mantle-responses")
|
||||
expect(mantle.route.id).toBe("bedrock-mantle-chat")
|
||||
expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SessionCompaction } from "@opencode/core/session/compaction"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { SessionModelRequest } from "@opencode/core/session/model-request"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { SessionTable } from "@opencode/core/session/sql"
|
||||
@@ -85,6 +86,7 @@ const it = testEffect(
|
||||
SessionStore.node,
|
||||
SessionCompaction.node,
|
||||
SessionModelRequest.node,
|
||||
PluginHooks.node,
|
||||
]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)],
|
||||
),
|
||||
@@ -440,6 +442,65 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("compaction hooks can supply the summary instead of the model", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = Session.ID.make("ses_hooked_compaction")
|
||||
const session = yield* insertSession(sessionID)
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages = [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user" as const,
|
||||
text: "Hooked compaction should see this conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
]
|
||||
let contexts = 0
|
||||
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
|
||||
yield* hooks.register("session", "compaction", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.sessionID).toBe(sessionID)
|
||||
expect(event.agent).toBe(Agent.defaultID)
|
||||
expect(JSON.stringify(event.messages)).toContain("Hooked compaction should see this conversation.")
|
||||
event.result = { summary: "## Objective\n- hooked summary" }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveContext: () => Effect.succeed(loaded(session, messages)),
|
||||
prepare: modelRequests.compaction,
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_hooked_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(contexts).toBe(0)
|
||||
expect(requests).toEqual([])
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "## Objective\n- hooked summary", recent: "" },
|
||||
])
|
||||
expect(
|
||||
yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual([
|
||||
{ type: Bus.versionedType(SessionEvent.Compaction.Started.type, 1) },
|
||||
{ type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("manual compaction records model resolution failures without calling the model", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LLMResponse, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
|
||||
import {
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
LanguageModel,
|
||||
Message,
|
||||
SystemPart,
|
||||
ToolDefinition,
|
||||
type LLMRequest,
|
||||
} from "@opencode/ai"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
@@ -38,6 +47,7 @@ import {
|
||||
import { SessionStore } from "@opencode/core/session/store"
|
||||
import { SkillInstructions } from "@opencode/core/skill/instructions"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { PluginSupervisor } from "@opencode/core/plugin/supervisor"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
@@ -134,6 +144,7 @@ const it = testEffect(
|
||||
Agent.node,
|
||||
InstructionBuiltIns.node,
|
||||
SessionContext.node,
|
||||
PluginHooks.node,
|
||||
llmClient,
|
||||
]),
|
||||
[
|
||||
@@ -344,6 +355,43 @@ it.effect(
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"runs generate hooks instead of context hooks",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
instruction = "Initial context"
|
||||
const { db, bus, instructions, session, instances } = yield* setup
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
const hooks = yield* PluginHooks.Service
|
||||
let contexts = 0
|
||||
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
|
||||
yield* hooks.register("session", "generate", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.sessionID).toBe(sessionID)
|
||||
expect(event.agent).toBe(Agent.ID.make("build"))
|
||||
expect(Object.keys(event.tools)).toEqual(["lookup"])
|
||||
event.system.push(SystemPart.make("Answer briefly."))
|
||||
event.messages = [Message.user("[redacted]")]
|
||||
event.options.maxTokens = 32
|
||||
event.options.reasoningEffort = "low"
|
||||
}),
|
||||
)
|
||||
|
||||
yield* SessionGenerate.generate({ session, prompt: "Summarize privately" }).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
)
|
||||
|
||||
expect(contexts).toBe(0)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Answer briefly.")
|
||||
expect(userTexts(requests[0])).toEqual(["[redacted]"])
|
||||
expect(requests[0]?.generation).toEqual(expect.objectContaining({ maxTokens: 32 }))
|
||||
expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "low" })
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"blocks unavailable initial instructions before generation",
|
||||
() =>
|
||||
|
||||
@@ -400,6 +400,34 @@ it.live("only known automatic native overflow falls back locally and failed reco
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("compaction hooks supply the summary instead of provider compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
yield* fixture.prompt("Original user")
|
||||
yield* fixture.hooks.register("session", "compaction", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.result = {
|
||||
summary: "## Objective\n- hooked summary",
|
||||
providerState: { responseId: "plugin" },
|
||||
metadata: { plugin: "custom" },
|
||||
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
}),
|
||||
)
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
expect(fixture.state.calls).toBe(0)
|
||||
expect((yield* fixture.load).messages.at(-1)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "## Objective\n- hooked summary",
|
||||
recent: "",
|
||||
providerState: { responseId: "plugin" },
|
||||
metadata: { plugin: "custom" },
|
||||
tokens: { input: 10, output: 5 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects request-hook route rewrites before provider compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
|
||||
@@ -20,6 +20,7 @@ import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
|
||||
import { AnthropicMessages, OpenAIResponses } from "@opencode/ai/protocols"
|
||||
import { compileRequest } from "@opencode/ai/route/client"
|
||||
import { TestLLM } from "@opencode/ai/testing"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Catalog } from "@opencode/core/catalog"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
@@ -2411,15 +2412,16 @@ describe("SessionRunnerLLM", () => {
|
||||
model: { id: ID.make(s.currentModel.id), providerID: Provider.ID.make(s.currentModel.provider), variant },
|
||||
})
|
||||
const requestAgents: Agent.ID[] = []
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
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.options.maxTokens = 4_000
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* hooks.register("session", "context", hook)
|
||||
yield* hooks.register("session", "compaction", hook)
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
requestAgents.push(event.agent)
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { SessionError } from "@opencode/schema/session-error"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import type { JsonSchema, Types } from "effect"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
|
||||
@@ -34,6 +35,20 @@ export interface SessionContext extends SessionRequest {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionCompactionResult {
|
||||
summary: string
|
||||
providerState?: SessionMessage.ProviderState
|
||||
metadata?: Record<string, unknown>
|
||||
tokens?: TokenUsage.Info
|
||||
}
|
||||
|
||||
export interface SessionCompaction extends SessionContext {
|
||||
/** Set to use this compaction and skip the model request. */
|
||||
result?: SessionCompactionResult
|
||||
}
|
||||
|
||||
export interface SessionGenerate extends SessionContext {}
|
||||
|
||||
export interface SessionTitle extends SessionRequest {
|
||||
/** Set to use this title and skip the model request. */
|
||||
result?: string
|
||||
@@ -85,6 +100,8 @@ export interface SessionRetry {
|
||||
export interface SessionHooks {
|
||||
readonly prompt: SessionPrompt
|
||||
readonly context: SessionContext
|
||||
readonly compaction: SessionCompaction
|
||||
readonly generate: SessionGenerate
|
||||
readonly title: SessionTitle
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { SessionError } from "@opencode/schema/session-error"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import type { JsonSchema, Types } from "effect"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
|
||||
@@ -34,6 +35,20 @@ export interface SessionContext extends SessionRequest {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionCompactionResult {
|
||||
summary: string
|
||||
providerState?: SessionMessage.ProviderState
|
||||
metadata?: Record<string, unknown>
|
||||
tokens?: TokenUsage.Info
|
||||
}
|
||||
|
||||
export interface SessionCompaction extends SessionContext {
|
||||
/** Set to use this compaction and skip the model request. */
|
||||
result?: SessionCompactionResult
|
||||
}
|
||||
|
||||
export interface SessionGenerate extends SessionContext {}
|
||||
|
||||
export interface SessionTitle extends SessionRequest {
|
||||
/** Set to use this title and skip the model request. */
|
||||
result?: string
|
||||
@@ -85,6 +100,8 @@ export interface SessionRetry {
|
||||
export interface SessionHooks {
|
||||
readonly prompt: SessionPrompt
|
||||
readonly context: SessionContext
|
||||
readonly compaction: SessionCompaction
|
||||
readonly generate: SessionGenerate
|
||||
readonly title: SessionTitle
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Plugin } from "@opencode/core/plugin"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
@@ -92,11 +93,12 @@ it.live(
|
||||
event.prompt.text += ` [${config.tool}]`
|
||||
}),
|
||||
)
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const tune = (event: SessionHooks["context"]) =>
|
||||
Effect.sync(() => {
|
||||
event.options.temperature = config.temperature
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* ctx.session.hook("context", tune)
|
||||
yield* ctx.session.hook("generate", tune)
|
||||
yield* ctx.permission.hook("evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.effect = event.action === "instance-test" ? "ask" : "allow"
|
||||
|
||||
@@ -427,17 +427,6 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={i18n.t("ui.messagePart.compaction.started")} />
|
||||
</div>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
<PacedMarkdown
|
||||
text={summary()}
|
||||
cacheKey={props.message.id}
|
||||
streaming={props.message.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status === "running"}>
|
||||
<div role="status" class="py-2">
|
||||
<BasicTool
|
||||
@@ -449,6 +438,17 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
<PacedMarkdown
|
||||
text={summary()}
|
||||
cacheKey={props.message.id}
|
||||
streaming={props.message.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status !== "running"}>
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={label()} />
|
||||
|
||||
@@ -1089,7 +1089,9 @@ effect: (ctx) =>
|
||||
|
||||
### Sessions
|
||||
|
||||
Modify assembled system instructions, messages, or tools immediately before model dispatch.
|
||||
Modify assembled system instructions, messages, or tools immediately before model dispatch. `context` runs for the
|
||||
agent loop; `compaction`, `generate`, and `title` run for those auxiliary requests. `compaction` and `title` accept a
|
||||
`result` that skips the model call.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
@@ -1101,6 +1103,11 @@ effect: (ctx) =>
|
||||
delete event.tools.write
|
||||
}),
|
||||
)
|
||||
yield* session.hook("compaction", (event) =>
|
||||
Effect.map(summarize(event.messages), (summary) => {
|
||||
event.result = { summary }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
```
|
||||
|
||||
@@ -1178,6 +1185,9 @@ Context-overflow recovery remains separate because it compacts the conversation
|
||||
```ts
|
||||
interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly compaction: SessionCompaction
|
||||
readonly generate: SessionGenerate
|
||||
readonly title: SessionTitle
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
|
||||
@@ -1197,9 +1197,17 @@ Keep prompt hooks retry-safe. They are not an exactly-once side-effect boundary:
|
||||
- Concurrent submissions can run hooks more than once, but only the first successful admission wins.
|
||||
- Prompt hooks transform input and do not expose a typed rejection API.
|
||||
|
||||
#### Model context
|
||||
#### Model requests
|
||||
|
||||
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
|
||||
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch. Each
|
||||
kind of request a session issues has its own hook, so a plugin can treat the agent loop and auxiliary requests
|
||||
differently:
|
||||
|
||||
- `context` runs for the agent loop, including tool-driven continuations.
|
||||
- `compaction` runs for checkpoint summaries. Set `result` to record the compaction yourself and skip the model
|
||||
call; it takes the same fields as a completed compaction message.
|
||||
- `generate` runs for transient `ctx.session.generate` calls.
|
||||
- `title` runs for title generation. It has no `agent` or `tools`. Set `result` to supply the title yourself.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("context", (event) => {
|
||||
@@ -1208,11 +1216,14 @@ await ctx.session.hook("context", (event) => {
|
||||
event.options.temperature = 0.2
|
||||
event.options.maxTokens = 8_000
|
||||
})
|
||||
|
||||
await ctx.session.hook("compaction", async (event) => {
|
||||
event.result = { summary: await summarize(event.messages) }
|
||||
})
|
||||
```
|
||||
|
||||
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.
|
||||
Changes affect only the outgoing model call, not persisted history or configuration. A hook that should apply to
|
||||
every request must register for each kind.
|
||||
|
||||
Request overrides follow these rules:
|
||||
|
||||
@@ -1321,6 +1332,9 @@ import type { SessionPrompt } from "@opencode/plugin/promise/session"
|
||||
interface SessionHooks {
|
||||
prompt: SessionPrompt
|
||||
context: SessionContextHook
|
||||
compaction: SessionContextHook & { result?: SessionCompactionResult }
|
||||
generate: SessionContextHook
|
||||
title: SessionRequestHook & { result?: string }
|
||||
"model.request": SessionModelRequestHook
|
||||
"http.request": SessionHttpRequestHook
|
||||
"http.response": SessionHttpResponseHook
|
||||
@@ -1338,13 +1352,11 @@ interface SessionRetryHook {
|
||||
decision: RetryDecision
|
||||
}
|
||||
|
||||
interface SessionContextHook {
|
||||
interface SessionRequestHook {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly model: { providerID: string; id: string; variant?: string }
|
||||
system: SystemPart[]
|
||||
messages: Message[]
|
||||
tools: Record<string, { description: string; input: JsonSchema }>
|
||||
options: {
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
@@ -1357,6 +1369,18 @@ interface SessionContextHook {
|
||||
} & Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SessionContextHook extends SessionRequestHook {
|
||||
readonly agent: string
|
||||
tools: Record<string, { description: string; input: JsonSchema }>
|
||||
}
|
||||
|
||||
interface SessionCompactionResult {
|
||||
summary: string
|
||||
providerState?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
tokens?: TokenUsage
|
||||
}
|
||||
|
||||
interface SessionHookContext {
|
||||
hook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
|
||||
Reference in New Issue
Block a user