Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton 93bfa34b57 fix(core): discover local plugin packages 2026-08-11 12:08:04 -04:00
opencode-agent[bot] 964f7f4254 chore: generate 2026-08-11 15:53:52 +00:00
Aiden Cline 8c27c8485e feat(session): persist previous selections (#41771) 2026-08-11 10:52:14 -05:00
13 changed files with 223 additions and 16 deletions
@@ -69,6 +69,63 @@ describe("v2 session reducer", () => {
})
})
test("prefers durable selection predecessors and derives them for older events", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_previous_model",
type: "model-switched",
model: { id: "old", providerID: "provider" },
time: { created: 1 },
},
]
const reducer = createV2SessionReducer()
const agent = reducer.reduce(
source,
event({
...base,
id: "evt_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
}),
)
const model = reducer.reduce(
source,
event({
...base,
id: "evt_model",
type: "session.model.selected",
data: {
sessionID: "ses_1",
model: { id: "new", providerID: "provider" },
previous: { id: "durable", providerID: "provider" },
},
}),
)
const legacyAgent = reducer.reduce(
source,
event({
...base,
id: "evt_legacy_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan" },
}),
)
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
expect(model?.messages.at(-1)).toMatchObject({
type: "model-switched",
model: { id: "new" },
previous: { id: "durable" },
})
expect(legacyAgent?.messages.at(-1)).toMatchObject({
type: "agent-switched",
agent: "plan",
previous: "build",
})
})
test("folds tool, retry, and completion events", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
@@ -61,6 +61,12 @@ export function createV2SessionReducer() {
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
item.type === "agent-switched" || item.type === "assistant",
)?.agent,
time: { created: event.created },
})
case "session.model.selected":
@@ -69,10 +75,12 @@ export function createV2SessionReducer() {
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
previous: source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
time: { created: event.created },
})
case "session.synthetic":
+10 -2
View File
@@ -339,7 +339,11 @@ export type Endpoint5_31Output =
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
readonly data: {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly previous?: Agent.ID | undefined
}
}
| {
readonly id: Event.ID
@@ -348,7 +352,11 @@ export type Endpoint5_31Output =
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
readonly data: {
readonly sessionID: Session.ID
readonly model: Model.Ref
readonly previous?: Model.Ref | undefined
}
}
| {
readonly id: Event.ID
@@ -436,7 +436,7 @@ export type SessionAgentSelected = {
type: "session.agent.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; agent: string }
data: { sessionID: string; agent: string; previous?: string }
}
export type SessionModelSelected = {
@@ -446,7 +446,7 @@ export type SessionModelSelected = {
type: "session.model.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; model: ModelRef }
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
}
export type SessionMoved = {
+39 -2
View File
@@ -4,7 +4,7 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import { Context, Effect, Layer, Option, PubSub, Schema, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config"
@@ -154,6 +154,12 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
})
const sourceDirectories = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
@@ -166,7 +172,38 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
const children = yield* fs
.scan(`{${sourceDirectories.join(",")}}/*`, {
cwd: directory,
absolute: true,
include: "all",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const directories = yield* Effect.filter(children.sort(), fs.isDir)
const packages = yield* Effect.forEach(directories, (child) => discoverPackage(fs, child))
return [...files.sort(), ...packages.filter((target): target is string => typeof target === "string")].map(
(target): Operation => ({ type: "add", target, options: {} }),
)
})
}
function discoverPackage(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(
(entry): entry is string => typeof entry === "string",
)
: []
const target = yield* Effect.findFirst(
[...configured, "index.ts", "index.js"].map((entry) => path.resolve(directory, entry)),
fs.isFile,
)
return Option.getOrUndefined(target)
})
}
+3 -1
View File
@@ -716,10 +716,11 @@ const layer = Layer.effect(
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
const session = yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
@@ -733,6 +734,7 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model: input.model,
previous: session.model,
})
}),
rename: Effect.fn("Session.rename")(function* (input) {
+2 -2
View File
@@ -61,7 +61,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return Effect.gen(function* () {
const previous = yield* adapter.getAgent()
const previous = event.data.previous ?? (yield* adapter.getAgent())
yield* adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
@@ -76,7 +76,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
const previous = yield* adapter.getModel()
const previous = event.data.previous ?? (yield* adapter.getModel())
yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
+67
View File
@@ -168,6 +168,69 @@ describe("PluginSupervisor config", () => {
),
)
it.live("loads auto-discovered plugin packages from package metadata", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("package-metadata")
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugins", "package-metadata")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(path.join(plugin, "package.json"), JSON.stringify({ exports: "./entry.ts" }))
await fs.writeFile(path.join(plugin, "entry.ts"), discoveredPlugin("package-metadata"))
},
),
)
it.live("loads auto-discovered plugin packages from index fallback", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("index-fallback")
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugins", "index-fallback")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(path.join(plugin, "index.js"), discoveredPlugin("index-fallback"))
},
),
)
it.live("prefers package metadata over index fallback", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("metadata-precedence")
expect(ids).not.toContain("module-collision")
expect(ids).not.toContain("main-collision")
expect(ids).not.toContain("index-collision")
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugins", "collision")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(
path.join(plugin, "package.json"),
JSON.stringify({ exports: "./entry.js", module: "./module.js", main: "./main.js" }),
)
await fs.writeFile(path.join(plugin, "entry.js"), discoveredPlugin("metadata-precedence"))
await fs.writeFile(path.join(plugin, "module.js"), discoveredPlugin("module-collision"))
await fs.writeFile(path.join(plugin, "main.js"), discoveredPlugin("main-collision"))
await fs.writeFile(path.join(plugin, "index.js"), discoveredPlugin("index-collision"))
},
),
)
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
@@ -389,3 +452,7 @@ export default Plugin.define({
})
`
}
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
+11 -3
View File
@@ -654,7 +654,7 @@ describe("Session.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "agent-switched", agent: "plan", previous: "build" },
])
@@ -678,7 +678,12 @@ describe("Session.create", () => {
it.effect("switches the selected model through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const previous = Model.Ref.make({
id: Model.ID.make("haiku"),
providerID: Provider.ID.anthropic,
variant: Model.VariantID.make("default"),
})
const created = yield* session.create({ location, model: previous })
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
@@ -692,7 +697,10 @@ describe("Session.create", () => {
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
)
expect(bus).toMatchObject([{ type: "session.model.selected" }])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "model-switched", model, previous },
])
}),
)
+6
View File
@@ -14373,6 +14373,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14441,6 +14444,9 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
+2
View File
@@ -69,6 +69,7 @@ export const AgentSelected = Event.durable({
schema: {
...Base,
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
},
})
export type AgentSelected = typeof AgentSelected.Type
@@ -79,6 +80,7 @@ export const ModelSelected = Event.durable({
schema: {
...Base,
model: Model.Ref,
previous: Model.Ref.pipe(optional),
},
})
export type ModelSelected = typeof ModelSelected.Type
+6
View File
@@ -14373,6 +14373,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14441,6 +14444,9 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
+6
View File
@@ -14373,6 +14373,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14441,6 +14444,9 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],