mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 12:10:01 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a6f5635f9 |
@@ -69,63 +69,6 @@ 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,12 +61,6 @@ 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":
|
||||
@@ -75,12 +69,10 @@ export function createV2SessionReducer() {
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
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":
|
||||
|
||||
@@ -339,11 +339,7 @@ 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 previous?: Agent.ID | undefined
|
||||
}
|
||||
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -352,11 +348,7 @@ 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 previous?: Model.Ref | undefined
|
||||
}
|
||||
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
}
|
||||
| {
|
||||
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; previous?: string }
|
||||
data: { sessionID: string; agent: 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; previous?: ModelRef }
|
||||
data: { sessionID: string; model: ModelRef }
|
||||
}
|
||||
|
||||
export type SessionMoved = {
|
||||
|
||||
@@ -194,10 +194,11 @@ async function formatTypescript(input: string) {
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
|
||||
|
||||
export const migrations = [
|
||||
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
|
||||
])
|
||||
).map((module) => module.default)
|
||||
`
|
||||
}
|
||||
|
||||
@@ -153,12 +153,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
})
|
||||
})
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
@@ -170,6 +168,8 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
|
||||
@@ -42,10 +42,10 @@ const databaseLayer = Layer.effect(
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
return provide(join(global.data, filename))
|
||||
}),
|
||||
)
|
||||
|
||||
+45
-84
@@ -1,86 +1,47 @@
|
||||
import type { DatabaseMigration } from "./migration"
|
||||
import m00 from "./migration/20260127222353_familiar_lady_ursula"
|
||||
import m01 from "./migration/20260211171708_add_project_commands"
|
||||
import m02 from "./migration/20260213144116_wakeful_the_professor"
|
||||
import m03 from "./migration/20260225215848_workspace"
|
||||
import m04 from "./migration/20260227213759_add_session_workspace_id"
|
||||
import m05 from "./migration/20260228203230_blue_harpoon"
|
||||
import m06 from "./migration/20260303231226_add_workspace_fields"
|
||||
import m07 from "./migration/20260309230000_move_org_to_state"
|
||||
import m08 from "./migration/20260312043431_session_message_cursor"
|
||||
import m09 from "./migration/20260323234822_events"
|
||||
import m10 from "./migration/20260410174513_workspace-name"
|
||||
import m11 from "./migration/20260413175956_chief_energizer"
|
||||
import m12 from "./migration/20260423070820_add_icon_url_override"
|
||||
import m13 from "./migration/20260427172553_slow_nightmare"
|
||||
import m14 from "./migration/20260428004200_add_session_path"
|
||||
import m15 from "./migration/20260501142318_next_venus"
|
||||
import m16 from "./migration/20260504145000_add_sync_owner"
|
||||
import m17 from "./migration/20260507164347_add_workspace_time"
|
||||
import m18 from "./migration/20260510033149_session_usage"
|
||||
import m19 from "./migration/20260511000411_data_migration_state"
|
||||
import m20 from "./migration/20260511173437_session-metadata"
|
||||
import m21 from "./migration/20260601010001_normalize_storage_paths"
|
||||
import m22 from "./migration/20260601202201_amazing_prowler"
|
||||
import m23 from "./migration/20260602002951_lowly_union_jack"
|
||||
import m24 from "./migration/20260602182828_add_project_directories"
|
||||
import m25 from "./migration/20260603001617_session_message_projection_indexes"
|
||||
import m26 from "./migration/20260603040000_session_message_projection_order"
|
||||
import m27 from "./migration/20260603141458_session_input_inbox"
|
||||
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
|
||||
import m29 from "./migration/20260604172448_event_sourced_session_input"
|
||||
import m30 from "./migration/20260605003541_add_session_context_snapshot"
|
||||
import m31 from "./migration/20260605042240_add_context_epoch_agent"
|
||||
import m32 from "./migration/20260611035744_credential"
|
||||
import m33 from "./migration/20260611192811_lush_chimera"
|
||||
import m34 from "./migration/20260612174303_project_dir_strategy"
|
||||
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
|
||||
import m36 from "./migration/20260622170816_reset_v2_session_state"
|
||||
import m37 from "./migration/20260622202450_simplify_session_input"
|
||||
import m38 from "./migration/20260804233008_loose_psylocke"
|
||||
import m39 from "./migration/20260805200742_import_legacy_credentials"
|
||||
import m40 from "./migration/20260808023530_workspace_domain"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
m01,
|
||||
m02,
|
||||
m03,
|
||||
m04,
|
||||
m05,
|
||||
m06,
|
||||
m07,
|
||||
m08,
|
||||
m09,
|
||||
m10,
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m15,
|
||||
m16,
|
||||
m17,
|
||||
m18,
|
||||
m19,
|
||||
m20,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m25,
|
||||
m26,
|
||||
m27,
|
||||
m28,
|
||||
m29,
|
||||
m30,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m35,
|
||||
m36,
|
||||
m37,
|
||||
m38,
|
||||
m39,
|
||||
m40,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
import("./migration/20260127222353_familiar_lady_ursula"),
|
||||
import("./migration/20260211171708_add_project_commands"),
|
||||
import("./migration/20260213144116_wakeful_the_professor"),
|
||||
import("./migration/20260225215848_workspace"),
|
||||
import("./migration/20260227213759_add_session_workspace_id"),
|
||||
import("./migration/20260228203230_blue_harpoon"),
|
||||
import("./migration/20260303231226_add_workspace_fields"),
|
||||
import("./migration/20260309230000_move_org_to_state"),
|
||||
import("./migration/20260312043431_session_message_cursor"),
|
||||
import("./migration/20260323234822_events"),
|
||||
import("./migration/20260410174513_workspace-name"),
|
||||
import("./migration/20260413175956_chief_energizer"),
|
||||
import("./migration/20260423070820_add_icon_url_override"),
|
||||
import("./migration/20260427172553_slow_nightmare"),
|
||||
import("./migration/20260428004200_add_session_path"),
|
||||
import("./migration/20260501142318_next_venus"),
|
||||
import("./migration/20260504145000_add_sync_owner"),
|
||||
import("./migration/20260507164347_add_workspace_time"),
|
||||
import("./migration/20260510033149_session_usage"),
|
||||
import("./migration/20260511000411_data_migration_state"),
|
||||
import("./migration/20260511173437_session-metadata"),
|
||||
import("./migration/20260601010001_normalize_storage_paths"),
|
||||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
import("./migration/20260603001617_session_message_projection_indexes"),
|
||||
import("./migration/20260603040000_session_message_projection_order"),
|
||||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604172448_event_sourced_session_input"),
|
||||
import("./migration/20260605003541_add_session_context_snapshot"),
|
||||
import("./migration/20260605042240_add_context_epoch_agent"),
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
import("./migration/20260805200742_import_legacy_credentials"),
|
||||
import("./migration/20260808023530_workspace_domain"),
|
||||
])
|
||||
).map((module) => module.default)
|
||||
|
||||
@@ -13,6 +13,7 @@ const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export type Migration = {
|
||||
id: string
|
||||
aliases?: string[]
|
||||
foreignKeys?: boolean
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
|
||||
}
|
||||
@@ -77,6 +78,14 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
|
||||
for (const migration of input) {
|
||||
if (completed.has(migration.id)) continue
|
||||
const alias = migration.aliases?.find((id) => completed.has(id))
|
||||
if (alias) {
|
||||
yield* db.run(
|
||||
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
|
||||
)
|
||||
yield* Effect.logInfo("database migration adopted", { migration: migration.id, alias })
|
||||
continue
|
||||
}
|
||||
const started = Date.now()
|
||||
yield* Effect.logInfo("database migration started", { migration: migration.id })
|
||||
const apply = db.transaction((tx) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260804233008_loose_psylocke",
|
||||
aliases: ["20260730195856_optional_session_title"],
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
|
||||
@@ -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 } from "../event/sql"
|
||||
import { EventSequenceTable, EventTable } from "../event/sql"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { existsSync } from "node:fs"
|
||||
@@ -161,7 +161,6 @@ 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)
|
||||
@@ -486,15 +485,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
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.delete(EventTable).run()
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -612,16 +612,7 @@ export const layer = (options?: Options) =>
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
|
||||
// Best-effort: a cache-write failure must never kill catalog
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
),
|
||||
)
|
||||
yield* kv.set(key, { updatedAt: Date.now(), body: text })
|
||||
return catalog
|
||||
})
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export const ModelsDevPlugin = define({
|
||||
draft.integrationID = Integration.ID.make(provider.info.id)
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,11 +716,10 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
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) {
|
||||
@@ -734,7 +733,6 @@ 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) {
|
||||
|
||||
@@ -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 = event.data.previous ?? (yield* adapter.getAgent())
|
||||
const 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 = event.data.previous ?? (yield* adapter.getModel())
|
||||
const previous = yield* adapter.getModel()
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.ModelSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
|
||||
@@ -65,15 +65,15 @@ const userAttachmentContent = (files: readonly FileAttachment[]) => {
|
||||
)
|
||||
if (eligible.length < 2) return files.flatMap(attachmentContent)
|
||||
|
||||
const seen = new Map<string, Set<string>>()
|
||||
const seen = new Map<string, string[]>()
|
||||
return files.flatMap((file) => {
|
||||
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
|
||||
return attachmentContent(file)
|
||||
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
|
||||
const payloads = seen.get(metadata) ?? new Set<string>()
|
||||
if (payloads.has(file.data)) return []
|
||||
payloads.add(file.data)
|
||||
seen.set(metadata, payloads)
|
||||
const matches = seen.get(metadata)
|
||||
if (matches?.includes(file.data)) return []
|
||||
if (matches) matches.push(file.data)
|
||||
if (!matches) seen.set(metadata, [file.data])
|
||||
return attachmentContent(file)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ 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.Array(Question.Prompt)
|
||||
.check(Schema.isNonEmpty())
|
||||
.annotate({ description: "Questions to ask" }),
|
||||
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import consolidatedV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
const run = <A, E>(
|
||||
@@ -114,6 +115,28 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("adopts the consolidated V2 migration after the superseded chain", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, created integer DEFAULT 0 NOT NULL)`)
|
||||
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO migration (id, time_completed)
|
||||
VALUES ('20260730195856_optional_session_title', 1)
|
||||
`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [consolidatedV2Migration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${consolidatedV2Migration.id}`)).toEqual({
|
||||
id: consolidatedV2Migration.id,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kv'`)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const source = path.join(tmp.path, "auth.json")
|
||||
|
||||
@@ -185,15 +185,6 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
|
||||
]),
|
||||
)
|
||||
|
||||
// Mirrors production KV backends whose writes die as defects (e.g. Durable
|
||||
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
|
||||
const makeFailingWriteKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const makeCache = (): MockCache => ({ values: new Map() })
|
||||
|
||||
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
||||
@@ -257,25 +248,6 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() still populates the catalog when the KV cache write fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeFailingWriteKV(cache)],
|
||||
]),
|
||||
)
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.has(cacheKey)).toBe(false)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses the default models URL when the configured URL is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
|
||||
@@ -215,66 +215,6 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits deprecated models from the catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const activeID = Model.ID.make("current")
|
||||
const deprecatedID = Model.ID.make("legacy")
|
||||
const model = {
|
||||
modelID: activeID,
|
||||
providerID,
|
||||
name: "Current",
|
||||
capabilities: { tools: true, input: [], output: [] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 32_000 },
|
||||
} satisfies Omit<Model.Info, "id">
|
||||
const snapshots = [
|
||||
{
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
environment: [],
|
||||
models: [
|
||||
{ id: activeID, ...model },
|
||||
{
|
||||
id: deprecatedID,
|
||||
...model,
|
||||
modelID: deprecatedID,
|
||||
name: "Legacy",
|
||||
status: "deprecated" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]
|
||||
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
integration: integrationHost(integrations),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
ModelsDev.Service,
|
||||
ModelsDev.Service.of({
|
||||
get: () => Effect.succeed(snapshots),
|
||||
refresh: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* catalog.model.get(providerID, activeID)).toBeDefined()
|
||||
expect(yield* catalog.model.get(providerID, deprecatedID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers key methods for providers with environment variables", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -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", previous: "build" } }])
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||
])
|
||||
@@ -678,12 +678,7 @@ describe("Session.create", () => {
|
||||
it.effect("switches the selected model through the durable Session event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
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 created = yield* session.create({ location })
|
||||
const model = Model.Ref.make({
|
||||
id: Model.ID.make("sonnet"),
|
||||
providerID: Provider.ID.anthropic,
|
||||
@@ -697,10 +692,7 @@ 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, previous })
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||
{ type: "model-switched", model, previous },
|
||||
])
|
||||
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -89,30 +89,6 @@ 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
|
||||
|
||||
@@ -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, Fiber, Layer, Logger, Schedule, Schema, Scope } from "effect"
|
||||
import { Effect, 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,35 +798,6 @@ 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")
|
||||
|
||||
@@ -77,21 +77,20 @@ export function spatialPathSpans(points: readonly DiagramPoint[]): SpatialSpan[]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.flatMap(([y, xs]) => {
|
||||
const sorted = [...xs].sort((left, right) => left - right)
|
||||
const [first, ...rest] = sorted
|
||||
if (first === undefined) return []
|
||||
const spans: SpatialSpan[] = []
|
||||
let start = first
|
||||
let end = first
|
||||
for (const x of rest) {
|
||||
if (x === end + 1) {
|
||||
let start = sorted[0]
|
||||
let end = start
|
||||
if (start === undefined) return spans
|
||||
for (const x of sorted.slice(1)) {
|
||||
if (x === end! + 1) {
|
||||
end = x
|
||||
continue
|
||||
}
|
||||
spans.push(normalizedSpan(y, start, end))
|
||||
spans.push(normalizedSpan(y, start, end!))
|
||||
start = x
|
||||
end = x
|
||||
}
|
||||
spans.push(normalizedSpan(y, start, end))
|
||||
spans.push(normalizedSpan(y, start, end!))
|
||||
return spans
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14373,9 +14373,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "agent"],
|
||||
@@ -14444,9 +14441,6 @@
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "model"],
|
||||
|
||||
@@ -69,7 +69,6 @@ export const AgentSelected = Event.durable({
|
||||
schema: {
|
||||
...Base,
|
||||
agent: Agent.ID,
|
||||
previous: Agent.ID.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type AgentSelected = typeof AgentSelected.Type
|
||||
@@ -80,7 +79,6 @@ export const ModelSelected = Event.durable({
|
||||
schema: {
|
||||
...Base,
|
||||
model: Model.Ref,
|
||||
previous: Model.Ref.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type ModelSelected = typeof ModelSelected.Type
|
||||
|
||||
@@ -62,9 +62,13 @@ function createMarquee(hovered: () => string | undefined, animations: () => bool
|
||||
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
|
||||
|
||||
createEffect(() => {
|
||||
if (!hovered()) {
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
return
|
||||
}
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
if (!hovered()) return
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
const delay = setTimeout(() => {
|
||||
setOffset(1)
|
||||
|
||||
@@ -25,14 +25,14 @@ function deduplicateByIdentity<T>(
|
||||
items: readonly T[],
|
||||
identity: (item: T) => { metadata: string; payload: string } | undefined,
|
||||
) {
|
||||
const seen = new Map<string, Set<string>>()
|
||||
const seen = new Map<string, string[]>()
|
||||
return items.filter((item) => {
|
||||
const key = identity(item)
|
||||
if (!key) return true
|
||||
const payloads = seen.get(key.metadata) ?? new Set<string>()
|
||||
if (payloads.has(key.payload)) return false
|
||||
payloads.add(key.payload)
|
||||
seen.set(key.metadata, payloads)
|
||||
const matches = seen.get(key.metadata)
|
||||
if (matches?.includes(key.payload)) return false
|
||||
if (matches) matches.push(key.payload)
|
||||
if (!matches) seen.set(key.metadata, [key.payload])
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function deduplicatePromptImages(files: readonly PromptFile[] | undefined
|
||||
return deduplicateByIdentity(files, (file) =>
|
||||
file.uri.startsWith("data:image/") && file.mention?.text
|
||||
? {
|
||||
metadata: JSON.stringify([file.name ?? null, file.description ?? null, file.mention.text]),
|
||||
metadata: JSON.stringify([attachmentMetadata(file), file.mention.text]),
|
||||
payload: file.uri,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -14373,9 +14373,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "agent"],
|
||||
@@ -14444,9 +14441,6 @@
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "model"],
|
||||
|
||||
@@ -14373,9 +14373,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "agent"],
|
||||
@@ -14444,9 +14441,6 @@
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "model"],
|
||||
|
||||
Reference in New Issue
Block a user