Merge remote-tracking branch 'origin/dev' into fix/anthropic-signed-thinking-order

This commit is contained in:
Aiden Cline
2026-06-01 00:56:46 -05:00
23 changed files with 174 additions and 87 deletions
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
let releasePath!: () => void
const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve
})
await page.route("**/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
await pathBlocked
return route.fallback()
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
try {
await expect(page.getByText(fixture.expected.sourceTitle).first()).toBeVisible({ timeout: 5_000 })
} finally {
releasePath()
}
})
@@ -52,7 +52,7 @@ beforeAll(async () => {
useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => {
queryGroups.push(options)
return [
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
{ isLoading: true, data: undefined },
{ isLoading: false, data: {} },
{ isLoading: false, data: [] },
{ isLoading: false, data: provider },
@@ -128,6 +128,35 @@ describe("createChildStoreManager", () => {
}
})
test("provides the requested directory while the path query is pending", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project", { bootstrap: false })
expect(store.path.directory).toBe("/project")
expect(store.path.worktree).toBe("")
} finally {
dispose()
}
})
test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = queryGroups.length
@@ -204,9 +204,8 @@ export function createChildStoreManager(input: {
},
config: {},
get path() {
if (pathQuery.isLoading || !pathQuery.data)
return { state: "", config: "", worktree: "", directory: "", home: "" }
return pathQuery.data
if (pathQuery.data) return pathQuery.data
return { state: "", config: "", worktree: "", directory, home: "" }
},
status: "loading" as const,
agent: [],
@@ -0,0 +1 @@
ALTER TABLE `session` ADD `metadata` text;
@@ -1 +0,0 @@
ALTER TABLE `session` ADD `metadata` text;
+1 -1
View File
@@ -22,6 +22,6 @@ export const migrations = (
import("./migration/20260507164347_add_workspace_time"),
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260530232709_lovely_romulus"),
import("./migration/20260511173437_session-metadata"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,16 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260511173437_session-metadata",
up(tx) {
return Effect.gen(function* () {
// This column briefly shipped again under 20260530232709_lovely_romulus.
if (
(yield* tx.all<{ name: string }>(`PRAGMA table_info(\`session\`)`)).some((column) => column.name === "metadata")
)
return
yield* tx.run(`ALTER TABLE \`session\` ADD \`metadata\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -1,11 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260530232709_lovely_romulus",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`metadata\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -7,6 +7,7 @@ import { Effect } from "effect"
import { sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
@@ -89,6 +90,44 @@ describe("DatabaseMigration", () => {
)
})
test("does not replay a migrated session metadata column", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
yield* db.run(
sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
)
yield* db.run(sql`
INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at)
VALUES ('hash', 1, '20260511173437_session-metadata', ${new Date().toISOString()})
`)
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
}),
)
})
test("accepts the temporary replacement session metadata migration id", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
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 ('20260530232709_lovely_romulus', 1)`)
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([
{ id: "20260511173437_session-metadata" },
{ id: "20260530232709_lovely_romulus" },
])
}),
)
})
test("skips drizzle import when migration table already has state", async () => {
await run(
Effect.gen(function* () {
+20 -34
View File
@@ -3,13 +3,13 @@ import fuzzysort from "fuzzysort"
import { Config } from "@/config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import * as Log from "@opencode-ai/core/util/log"
import { Log } from "@opencode-ai/core/util/log"
import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "@opencode-ai/core/models-dev"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Auth } from "../auth"
import { Env } from "../env"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -24,7 +24,7 @@ import { EffectPromise } from "@/effect/promise"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import * as ProviderTransform from "./transform"
import { ProviderTransform } from "./transform"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -575,11 +575,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
const auth = yield* dep.auth(input.id)
const apiKey = yield* Effect.sync(() => {
if (auth?.type === "oauth") return auth.access
if (auth?.type === "api") return auth.key
return undefined
})
const apiKey = auth?.type === "oauth" ? auth.access : auth?.type === "api" ? auth.key : undefined
const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
@@ -727,12 +723,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
}
const apiKey = yield* Effect.gen(function* () {
const envToken = env["CLOUDFLARE_API_KEY"]
if (envToken) return envToken
if (auth?.type === "api") return auth.key
return undefined
})
const apiKey = env["CLOUDFLARE_API_KEY"] || (auth?.type === "api" ? auth.key : undefined)
return {
autoload: !!apiKey,
@@ -778,12 +769,8 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
}
// Get API token from env or auth - required for authenticated gateways
const apiToken = yield* Effect.gen(function* () {
const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"]
if (envToken) return envToken
if (auth?.type === "api") return auth.key
return undefined
})
const apiToken =
env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"] || (auth?.type === "api" ? auth.key : undefined)
if (!apiToken) {
throw new Error(
@@ -1671,15 +1658,15 @@ export const layer = Layer.effect(
return loaded as SDK
}
let installedPath: string
if (!model.api.npm.startsWith("file://")) {
const installedPath = await (async () => {
if (model.api.npm.startsWith("file://")) {
log.info("loading local provider", { pkg: model.api.npm })
return model.api.npm
}
const item = await Npm.add(model.api.npm)
if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
installedPath = item.entrypoint
} else {
log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm
}
return item.entrypoint
})()
// `installedPath` is a local entry path or an existing `file://` URL. Normalize
// only path inputs so Node on Windows accepts the dynamic import.
@@ -1778,7 +1765,7 @@ export const layer = Layer.effect(
const provider = s.providers[providerID]
if (!provider) return undefined
let priority = [
const defaultPriority = [
"claude-haiku-4-5",
"claude-haiku-4.5",
"3-5-haiku",
@@ -1787,12 +1774,11 @@ export const layer = Layer.effect(
"gemini-2.5-flash",
"gpt-5-nano",
]
if (providerID.startsWith("opencode")) {
priority = ["gpt-5-nano"]
}
if (providerID.startsWith("github-copilot")) {
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
}
const priority = providerID.startsWith("opencode")
? ["gpt-5-nano"]
: providerID.startsWith("github-copilot")
? ["gpt-5-mini", "claude-haiku-4.5", ...defaultPriority]
: defaultPriority
for (const item of priority) {
if (providerID === ProviderV2.ID.amazonBedrock) {
const crossRegionPrefixes = ["global.", "us.", "eu."]
+2 -2
View File
@@ -1,10 +1,10 @@
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import * as Session from "./session"
import { Session } from "./session"
import { SessionID, MessageID, PartID } from "./schema"
import { Provider } from "@/provider/provider"
import { MessageV2 } from "./message-v2"
import { Token } from "@/util/token"
import * as Log from "@opencode-ai/core/util/log"
import { Log } from "@opencode-ai/core/util/log"
import { SessionProcessor } from "./processor"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
+5 -7
View File
@@ -12,12 +12,6 @@ import { Global } from "@opencode-ai/core/global"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
const files = (disableClaudeCodePrompt: boolean) => [
"AGENTS.md",
...(disableClaudeCodePrompt ? [] : ["CLAUDE.md"]),
"CONTEXT.md", // deprecated
]
function extract(messages: SessionLegacy.WithParts[]) {
const paths = new Set<string>()
for (const msg of messages) {
@@ -65,7 +59,11 @@ export const layer: Layer.Layer<
path.join(global.config, "AGENTS.md"),
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
const instructionFiles = files(flags.disableClaudeCodePrompt)
const instructionFiles = [
"AGENTS.md",
...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []),
"CONTEXT.md", // deprecated
]
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
+1 -1
View File
@@ -1,7 +1,7 @@
import { Provider } from "@/provider/provider"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import * as Log from "@opencode-ai/core/util/log"
import { Log } from "@opencode-ai/core/util/log"
import { Context, Effect, Layer } from "effect"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai"
+1 -1
View File
@@ -30,7 +30,7 @@ import { inArray } from "drizzle-orm"
import { lt } from "drizzle-orm"
import { or } from "drizzle-orm"
import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql"
import * as ProviderError from "@/provider/error"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
import { errorMessage } from "@/util/error"
import { isMedia } from "@/util/media"
+3 -5
View File
@@ -7,7 +7,7 @@ import { Config } from "@/config/config"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Snapshot } from "@/snapshot"
import * as Session from "./session"
import { Session } from "./session"
import { LLM } from "./llm"
import { MessageV2 } from "./message-v2"
import { isOverflow } from "./overflow"
@@ -19,7 +19,7 @@ import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider"
import { Question } from "@/question"
import { errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { Log } from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
@@ -301,8 +301,6 @@ export const layer = Layer.effect(
}
}
const toolInput = (value: unknown): Record<string, any> => (isRecord(value) ? value : { value })
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
switch (value.type) {
case "reasoning-start":
@@ -380,7 +378,7 @@ export const layer = Layer.effect(
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
}
const toolCall = yield* ensureToolCall(value)
const input = toolInput(value.input)
const input = isRecord(value.input) ? value.input : { value: value.input }
if (!toolCall.call.inputEnded) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
+4 -7
View File
@@ -3,9 +3,9 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import os from "os"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import * as Log from "@opencode-ai/core/util/log"
import { Log } from "@opencode-ai/core/util/log"
import { SessionRevert } from "./revert"
import * as Session from "./session"
import { Session } from "./session"
import { Agent } from "../agent/agent"
import { Provider } from "@/provider/provider"
@@ -148,10 +148,6 @@ export const layer = Layer.effect(
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
const files = ConfigMarkdown.files(template)
const seen = new Set<string>()
const mentionSource = (match: RegExpMatchArray) => {
const start = match.index ?? 0
return { value: match[0], start, end: start + match[0].length }
}
yield* Effect.forEach(
files,
Effect.fnUntraced(function* (match) {
@@ -164,7 +160,8 @@ export const layer = Layer.effect(
const alias = slash === -1 ? name : name.slice(0, slash)
const reference = yield* references.get(alias)
if (reference) {
const source = mentionSource(match)
const start = match.index ?? 0
const source = { value: match[0], start, end: start + match[0].length }
if (reference.kind === "invalid") {
parts.push(
referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }),
+1 -1
View File
@@ -7,7 +7,7 @@ import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import * as Session from "./session"
import { Session } from "./session"
import PROMPT_PLAN from "./prompt/plan.txt"
import BUILD_SWITCH from "./prompt/build-switch.txt"
import PLAN_MODE from "./prompt/plan-mode.txt"
+2 -2
View File
@@ -3,8 +3,8 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Snapshot } from "../snapshot"
import { Storage } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log"
import * as Session from "./session"
import { Log } from "@opencode-ai/core/util/log"
import { Session } from "./session"
import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema"
import { SessionRunState } from "./run-state"
+1 -1
View File
@@ -3,7 +3,7 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Runner } from "@/effect/runner"
import { BackgroundJob } from "@/background/job"
import { Effect, Latch, Layer, Scope, Context } from "effect"
import * as Session from "./session"
import { Session } from "./session"
import { MessageV2 } from "./message-v2"
import { SessionID } from "./schema"
import { SessionStatus } from "./status"
+2 -6
View File
@@ -25,7 +25,7 @@ import { or } from "drizzle-orm"
import type { SQL } from "drizzle-orm"
import { PartTable, SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import * as Log from "@opencode-ai/core/util/log"
import { Log } from "@opencode-ai/core/util/log"
import { MessageV2 } from "./message-v2"
import type { InstanceContext } from "../project/instance-context"
import { InstanceState } from "@/effect/instance-state"
@@ -48,10 +48,6 @@ const runtime = makeRuntime(Database.Service, Database.defaultLayer)
const parentTitlePrefix = "New session - "
const childTitlePrefix = "Child session - "
function createDefaultTitle(isChild = false) {
return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString()
}
export function isDefaultTitle(title: string) {
return new RegExp(
`^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`,
@@ -581,7 +577,7 @@ export const layer: Layer.Layer<
path: input.path,
workspaceID: input.workspaceID,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
title: input.title ?? (input.parentID ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString(),
agent: input.agent,
model: input.model,
metadata: input.metadata,
+1 -1
View File
@@ -2,7 +2,7 @@ import { Effect, Layer, Context, Schema } from "effect"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Snapshot } from "@/snapshot"
import * as Session from "./session"
import { Session } from "./session"
import { SessionID, MessageID } from "./schema"
import { Config } from "@/config/config"
+2 -2
View File
@@ -14,10 +14,10 @@ import type { TaskPromptOps } from "@/tool/task"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { Effect } from "effect"
import { MessageV2 } from "./message-v2"
import * as Session from "./session"
import { Session } from "./session"
import { SessionProcessor } from "./processor"
import { PartID } from "./schema"
import * as Log from "@opencode-ai/core/util/log"
import { Log } from "@opencode-ai/core/util/log"
import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"