Compare commits

..

3 Commits

Author SHA1 Message Date
Kit Langton db9a3b6c41 refactor(core): compact question tool schema (#41772) 2026-08-11 12:09:37 -04:00
opencode-agent[bot] 961b51b509 fix(core): yield while clearing migration events (#41775)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-11 16:02:30 +00:00
opencode-agent[bot] 964f7f4254 chore: generate 2026-08-11 15:53:52 +00:00
11 changed files with 97 additions and 79 deletions
+11 -2
View File
@@ -7,7 +7,7 @@ import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { KVTable } from "../kv/sql"
import { EventSequenceTable, EventTable } from "../event/sql"
import { EventSequenceTable } from "../event/sql"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
@@ -161,6 +161,7 @@ type NextMessage = {
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
@@ -485,7 +486,15 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx.delete(EventTable).run()
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
+10
View File
@@ -101,6 +101,16 @@ export const Plugin = define({
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
draft.update(Agent.ID.make("plan"), (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
{ action: "question", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
)
})
draft.update(Agent.ID.make("general"), (item) => {
item.name = Agent.Name.make("General")
item.description =
-2
View File
@@ -60,7 +60,6 @@ import { WellKnown } from "../wellknown"
import { WriteTool } from "../tool/plugin/write"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { PlanPlugin } from "./plan"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { WebSearchPlugins } from "./websearch"
@@ -193,7 +192,6 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
-55
View File
@@ -1,55 +0,0 @@
export * as PlanPlugin from "./plan"
import { ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Agent } from "../agent"
const plan = Agent.ID.make("plan")
const enter = `<system-reminder>
You are in Plan mode. This is a READ-ONLY environment. You are not allowed to edit files, and you may not ask a subagent to edit them either.
</system-reminder>`
const leave = `<system-reminder>
You are no longer in Plan mode. The previous read-only restrictions no longer apply. You may edit files again.
</system-reminder>`
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
item.mode = "primary"
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
})
yield* ctx.tool.hook("execute.before", (event) => {
if (event.agent !== plan) return Effect.void
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
return new ToolFailure({
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
})
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "session.agent.selected"),
Stream.runForEach((event) => {
if (event.data.agent === event.data.previous) return Effect.void
const text = event.data.agent === plan ? enter : event.data.previous === plan ? leave : undefined
if (!text) return Effect.void
return ctx.session
.synthetic({
sessionID: event.data.sessionID,
text,
resume: false,
})
.pipe(Effect.catch(() => Effect.void))
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+3 -1
View File
@@ -21,7 +21,9 @@ Usage notes:
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Input = Schema.Struct({
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
questions: Schema.Array(Question.Prompt)
.check(Schema.isNonEmpty())
.annotate({ description: "Questions to ask" }),
})
export const Output = Schema.Struct({
+1
View File
@@ -165,6 +165,7 @@ describe("Agent", () => {
"compaction",
"explore",
"general",
"plan",
"summary",
"title",
])
+24
View File
@@ -89,6 +89,30 @@ const it = testEffect(
)
describe("QuestionTool", () => {
it.effect("emits one item schema for the nonempty questions array", () =>
Effect.gen(function* () {
captured = undefined
const registry = yield* Tool.Service
const definition = (yield* toolDefinitions(registry)).find((tool) => tool.name === QuestionTool.name)
expect(definition?.inputSchema).toHaveProperty("properties.questions.type", "array")
expect(definition?.inputSchema).toHaveProperty("properties.questions.minItems", 1)
expect(definition?.inputSchema).toHaveProperty("properties.questions.items")
expect(definition?.inputSchema).not.toHaveProperty("properties.questions.prefixItems")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-empty", name: QuestionTool.name, input: { questions: [] } },
}),
).toMatchObject({
status: "error",
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
})
expect(capturedInput()).toBeUndefined()
}),
)
it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
Effect.gen(function* () {
captured = undefined
+30 -1
View File
@@ -11,7 +11,7 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect"
import { Effect, Fiber, Layer, Logger, Schedule, Schema, Scope } from "effect"
import { eq, sql } from "drizzle-orm"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { tmpdir } from "./fixture/tmpdir"
@@ -798,6 +798,35 @@ describe("V1Migration database workflow", () => {
)
})
test("yields while clearing stale events in batches", async () => {
await database(
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('stale', 2500)`)
yield* db.run(sql`
WITH RECURSIVE rows(value) AS (
VALUES(1)
UNION ALL
SELECT value + 1 FROM rows WHERE value < 2500
)
INSERT INTO event (id, aggregate_id, seq, created, type, data)
SELECT printf('event_%04d', value), 'stale', value, 1, 'session.renamed.1', '{}'
FROM rows
`)
let yielded = false
const heartbeat = yield* Effect.yieldNow.pipe(
Effect.andThen(Effect.sync(() => (yielded = true))),
Effect.forkChild({ startImmediately: true }),
)
expect(yield* V1Migration.run()).toEqual({ status: "completed" })
expect(yielded).toBe(true)
yield* Fiber.join(heartbeat)
expect(yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM event`)).toEqual({ value: 0 })
}),
)
})
test("imports previous V2 sessions and messages as part of the migration", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "opencode-next.db")
+6 -6
View File
@@ -14300,15 +14300,9 @@
"agent": {
"type": "string"
},
"previous": {
"type": "string"
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
},
"version": {
"type": "string"
}
@@ -14379,6 +14373,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14447,6 +14444,9 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
+6 -6
View File
@@ -14300,15 +14300,9 @@
"agent": {
"type": "string"
},
"previous": {
"type": "string"
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
},
"version": {
"type": "string"
}
@@ -14379,6 +14373,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14447,6 +14444,9 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
+6 -6
View File
@@ -14300,15 +14300,9 @@
"agent": {
"type": "string"
},
"previous": {
"type": "string"
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
},
"version": {
"type": "string"
}
@@ -14379,6 +14373,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14447,6 +14444,9 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],