mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 03:59:54 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d57e54327 | |||
| 3bbc3fc267 | |||
| 0f56ebdb28 |
@@ -41,7 +41,6 @@ export type SessionMessageAgentSelected = {
|
||||
time: { created: number }
|
||||
type: "agent-switched"
|
||||
agent: string
|
||||
previous?: string
|
||||
}
|
||||
|
||||
export type PromptBase64 = string
|
||||
@@ -2536,7 +2535,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -2788,7 +2786,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -3040,7 +3037,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"imports": {
|
||||
"#sqlite": {
|
||||
"workerd": "./src/database/sqlite.workerd.ts",
|
||||
"bun": "./src/database/sqlite.bun.ts",
|
||||
"node": "./src/database/sqlite.node.ts",
|
||||
"default": "./src/database/sqlite.bun.ts"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export * as Database from "./database"
|
||||
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { sqliteLayer } from "#sqlite"
|
||||
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import type { SqlClient } from "effect/unstable/sql"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
@@ -27,12 +28,15 @@ const databaseLayer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
if (supportsTuningPragmas) {
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
}
|
||||
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
|
||||
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
return { db }
|
||||
@@ -43,14 +47,19 @@ 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)
|
||||
return provide(join(global.data, filename))
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return layerWith(sqliteLayer({ filename }))
|
||||
return layerWith(sqliteLayer({ filename: join(global.data, filename) }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Builds the database service over an already-configured SqlClient layer for
|
||||
// runtimes that receive database storage instead of opening a filesystem path.
|
||||
export function layerWith(sqlite: Layer.Layer<SqlClient.SqlClient>) {
|
||||
return databaseLayer.pipe(Layer.provide(sqlite))
|
||||
}
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as DatabaseMigration from "./migration"
|
||||
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import { supportsForeignKeyToggle } from "#sqlite"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
@@ -20,8 +21,10 @@ export type Migration = {
|
||||
export function apply(db: Database) {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// OpenCode owns the unprefixed table namespace. Embedders sharing this
|
||||
// database may own underscore-prefixed tables, which bootstrap ignores.
|
||||
const tables = yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
@@ -103,9 +106,15 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
})
|
||||
continue
|
||||
}
|
||||
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
// Durable Object SQLite rejects the foreign_keys toggle; the closest
|
||||
// allowlisted relaxation is deferring enforcement to transaction commit.
|
||||
const relaxForeignKeys = supportsForeignKeyToggle
|
||||
? db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
|
||||
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
|
||||
yield* relaxForeignKeys
|
||||
yield* apply.pipe(
|
||||
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
|
||||
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logError("database migration failed", {
|
||||
migration: migration.id,
|
||||
|
||||
@@ -13,6 +13,11 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
export const supportsTuningPragmas = true
|
||||
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
|
||||
@@ -13,6 +13,11 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
export const supportsTuningPragmas = true
|
||||
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { drizzle } from "drizzle-orm/durable-sqlite"
|
||||
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteWorkerd" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
// Durable Object SQLite only allowlists introspection pragmas; journal_mode,
|
||||
// synchronous, busy_timeout, cache_size, and wal_checkpoint all throw, and
|
||||
// foreign keys are already enforced by default (SQLITE_DEFAULT_FOREIGN_KEYS=1).
|
||||
export const supportsTuningPragmas = false
|
||||
|
||||
// Durable Object SQLite rejects `PRAGMA foreign_keys`: enforcement is always
|
||||
// on (SQLITE_DEFAULT_FOREIGN_KEYS=1) and only `defer_foreign_keys` is
|
||||
// allowlisted for migrations that must relax checking inside a transaction.
|
||||
export const supportsForeignKeyToggle = false
|
||||
|
||||
// Minimal structural types for the Durable Object storage API so this adapter
|
||||
// does not depend on @cloudflare/workers-types (whose ambient globals conflict
|
||||
// with @types/bun). Shapes match the SqlStorage and DurableObjectStorage docs.
|
||||
type SqlStorageValue = ArrayBuffer | string | number | null
|
||||
|
||||
interface SqlStorageCursor {
|
||||
readonly columnNames: Array<string>
|
||||
raw(): IterableIterator<Array<SqlStorageValue>>
|
||||
toArray(): Array<Record<string, SqlStorageValue>>
|
||||
}
|
||||
|
||||
export interface SqlStorage {
|
||||
exec(query: string, ...bindings: Array<unknown>): SqlStorageCursor
|
||||
}
|
||||
|
||||
export interface DurableObjectStorage {
|
||||
readonly sql: SqlStorage
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T>
|
||||
transactionSync<T>(closure: () => T): T
|
||||
}
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly updateValues: never
|
||||
}
|
||||
|
||||
interface Config {
|
||||
readonly storage: DurableObjectStorage
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
// sql.exec() rejects BEGIN/COMMIT/SAVEPOINT, so SqlClient.make's default
|
||||
// transaction SQL can never run. withTransaction is replaced below with a
|
||||
// DurableObjectStorage.transaction-backed implementation; this service only
|
||||
// tracks the active transaction connection for statements and nesting checks.
|
||||
const WorkerdTransaction = Context.Service<SqlClient.TransactionConnection, SqlClient.TransactionConnection.Service>(
|
||||
"@opencode-ai/core/database/SqliteWorkerdTransaction",
|
||||
)
|
||||
|
||||
const transactionError = (message: string) =>
|
||||
new SqlError({
|
||||
reason: new UnknownError({ cause: new Error(message), message, operation: "transaction" }),
|
||||
})
|
||||
|
||||
const makeWithTransaction =
|
||||
(
|
||||
storage: DurableObjectStorage,
|
||||
connection: Connection,
|
||||
semaphore: Semaphore.Semaphore,
|
||||
): SqlClient.SqlClient["withTransaction"] =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, R> =>
|
||||
Effect.withFiber((fiber) => {
|
||||
const services = fiber.context
|
||||
if (Context.getOption(services, WorkerdTransaction)._tag === "Some")
|
||||
return Effect.fail(
|
||||
transactionError("Nested transactions are not supported by Cloudflare Durable Object SQLite storage"),
|
||||
)
|
||||
const effectWithTxn = Effect.provideContext(
|
||||
effect,
|
||||
Context.add(services, WorkerdTransaction, [connection, 0] as const),
|
||||
)
|
||||
return semaphore.withPermits(1)(
|
||||
Effect.callback((resume) => {
|
||||
let interrupted = false
|
||||
const promise = storage
|
||||
.transaction(
|
||||
(txn) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (interrupted) return resolve()
|
||||
resume(
|
||||
Effect.onExit(effectWithTxn, (exit) => {
|
||||
if (Exit.isFailure(exit)) txn.rollback()
|
||||
resolve()
|
||||
// wait for the transaction to complete
|
||||
return Effect.promise(() => promise)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.catch((cause) =>
|
||||
resume(
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed transaction", operation: "transaction" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Effect.suspend(() => {
|
||||
interrupted = true
|
||||
return Effect.promise(() => promise)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DurableObjectStorage
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
// SqlClient.SafeIntegers is ignored: Durable Object SQLite has no bigint
|
||||
// mode and always returns integers as numbers. Blobs come back as
|
||||
// ArrayBuffer and are normalized to Uint8Array to match the other adapters.
|
||||
function* runIterator(query: string, params: ReadonlyArray<unknown> = []) {
|
||||
const cursor = native.sql.exec(query, ...params)
|
||||
const columns = cursor.columnNames
|
||||
for (const row of cursor.raw()) {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const value = row[i]
|
||||
record[columns[i]] = value instanceof ArrayBuffer ? new Uint8Array(value) : value
|
||||
}
|
||||
yield record
|
||||
}
|
||||
}
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.try({
|
||||
try: () => Array.from(runIterator(query, params)),
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.try({
|
||||
try: () =>
|
||||
Array.from(native.sql.exec(query, ...params).raw(), (row) =>
|
||||
row.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value)),
|
||||
),
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const connection = identity<Connection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
transactionService: WorkerdTransaction,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
withTransaction: makeWithTransaction(native, connection, semaphore),
|
||||
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
|
||||
// as the drizzle session must route through withTransaction instead.
|
||||
transactionStatements: false,
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
})
|
||||
|
||||
// Defends against the shared path-based Database.layer, which passes a
|
||||
// filename instead of storage when resolved under the workerd condition.
|
||||
const nativeLayer = (config: Config) =>
|
||||
config.storage
|
||||
? Layer.succeed(Sqlite.Native, config.storage)
|
||||
: Layer.effect(
|
||||
Sqlite.Native,
|
||||
Effect.die(
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
|
||||
),
|
||||
)
|
||||
|
||||
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
|
||||
|
||||
const drizzleLayer = Layer.effect(
|
||||
Sqlite.Drizzle,
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DurableObjectStorage
|
||||
return drizzle(native) as unknown as Sqlite.DrizzleClient
|
||||
}),
|
||||
)
|
||||
|
||||
export const sqliteLayer = (config: Config) => {
|
||||
const native = nativeLayer(config)
|
||||
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
|
||||
Layer.provide(Reactivity.layer),
|
||||
)
|
||||
}
|
||||
@@ -285,8 +285,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
cacheWrite: undefined,
|
||||
},
|
||||
outputTokens: {
|
||||
...outputUsage(responseBody.usage),
|
||||
total: responseBody.usage?.completion_tokens ?? undefined,
|
||||
text: undefined,
|
||||
reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
|
||||
},
|
||||
raw: responseBody.usage ?? undefined,
|
||||
},
|
||||
@@ -356,7 +357,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
cachedTokens: number | undefined
|
||||
}
|
||||
totalTokens: number | undefined
|
||||
rawCompletionTokens: number | undefined
|
||||
} = {
|
||||
completionTokens: undefined,
|
||||
completionTokensDetails: {
|
||||
@@ -369,7 +369,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
totalTokens: undefined,
|
||||
rawCompletionTokens: undefined,
|
||||
}
|
||||
let isFirstChunk = true
|
||||
const providerOptionsName = this.providerOptionsName
|
||||
@@ -433,11 +432,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
} = value.usage
|
||||
|
||||
usage.promptTokens = prompt_tokens ?? undefined
|
||||
usage.rawCompletionTokens = completion_tokens ?? undefined
|
||||
const output = outputUsage(value.usage)
|
||||
usage.completionTokens = output.total
|
||||
usage.completionTokensDetails.reasoningTokens = output.reasoning
|
||||
usage.completionTokens = completion_tokens ?? undefined
|
||||
usage.totalTokens = total_tokens ?? undefined
|
||||
if (completion_tokens_details?.reasoning_tokens != null) {
|
||||
usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
|
||||
}
|
||||
if (completion_tokens_details?.accepted_prediction_tokens != null) {
|
||||
usage.completionTokensDetails.acceptedPredictionTokens =
|
||||
completion_tokens_details?.accepted_prediction_tokens
|
||||
@@ -709,7 +708,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
},
|
||||
raw: {
|
||||
prompt_tokens: usage.promptTokens ?? null,
|
||||
completion_tokens: usage.rawCompletionTokens ?? null,
|
||||
completion_tokens: usage.completionTokens ?? null,
|
||||
total_tokens: usage.totalTokens ?? null,
|
||||
},
|
||||
},
|
||||
@@ -728,7 +727,6 @@ const openaiCompatibleTokenUsageSchema = z
|
||||
.object({
|
||||
prompt_tokens: z.number().nullish(),
|
||||
completion_tokens: z.number().nullish(),
|
||||
reasoning_tokens: z.number().nullish(),
|
||||
total_tokens: z.number().nullish(),
|
||||
prompt_tokens_details: z
|
||||
.object({
|
||||
@@ -745,17 +743,6 @@ const openaiCompatibleTokenUsageSchema = z
|
||||
})
|
||||
.nullish()
|
||||
|
||||
function outputUsage(usage: z.infer<typeof openaiCompatibleTokenUsageSchema>) {
|
||||
const nested = usage?.completion_tokens_details?.reasoning_tokens
|
||||
return {
|
||||
total:
|
||||
usage?.completion_tokens == null
|
||||
? undefined
|
||||
: usage.completion_tokens + (nested == null ? (usage.reasoning_tokens ?? 0) : 0),
|
||||
reasoning: nested ?? usage?.reasoning_tokens ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// limited version of the schema, focussed on what is needed for the implementation
|
||||
// this approach limits breakages when the API changes and increases efficiency
|
||||
const OpenAICompatibleChatResponseSchema = z.object({
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
@@ -60,19 +59,15 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.created": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const previous = yield* adapter.getAgent()
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
})
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.model.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "../agent"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
@@ -231,17 +230,6 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getAgent() {
|
||||
return db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
|
||||
)
|
||||
},
|
||||
getModel() {
|
||||
return db
|
||||
.select({ model: SessionTable.model })
|
||||
@@ -410,15 +398,12 @@ const layer = Layer.effectDiscard(
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* bus.project(SessionEvent.ModelSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -59,25 +59,6 @@ const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
||||
return []
|
||||
}
|
||||
|
||||
const userAttachmentContent = (files: readonly FileAttachment[]) => {
|
||||
const eligible = files.filter(
|
||||
(file) => imageMimes.has(file.mime) && file.source.type === "inline" && file.mention?.text,
|
||||
)
|
||||
if (eligible.length < 2) return files.flatMap(attachmentContent)
|
||||
|
||||
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 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)
|
||||
})
|
||||
}
|
||||
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
const providerMetadata = (
|
||||
@@ -205,7 +186,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
const content = [
|
||||
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
|
||||
...(message.text === "" ? [] : [Message.text(message.text)]),
|
||||
...userAttachmentContent(message.files ?? []),
|
||||
...(message.files ?? []).flatMap(attachmentContent),
|
||||
]
|
||||
if (content.length === 0) return []
|
||||
return [
|
||||
|
||||
@@ -84,6 +84,19 @@ describe("DatabaseMigration", () => {
|
||||
).rejects.toThrow("Database is not empty and has no session table")
|
||||
})
|
||||
|
||||
test("bootstraps alongside underscore-prefixed embedder tables", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE _embedder_state (id text PRIMARY KEY)`)
|
||||
yield* DatabaseMigration.apply(db)
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'`)).toEqual(
|
||||
{ name: "session_v2" },
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("applies generic migrations once and records their order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("doStream", () => {
|
||||
finishReason: { unified: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: { total: 19581 },
|
||||
outputTokens: { total: 187, reasoning: 134 },
|
||||
outputTokens: { total: 53 },
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -259,7 +259,7 @@ describe("doStream", () => {
|
||||
finishReason: { unified: "stop" },
|
||||
usage: {
|
||||
inputTokens: { total: 5778 },
|
||||
outputTokens: { total: 154, reasoning: 95 },
|
||||
outputTokens: { total: 59 },
|
||||
},
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
@@ -391,7 +391,7 @@ describe("doStream", () => {
|
||||
finishReason: { unified: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: { total: 3767 },
|
||||
outputTokens: { total: 30, reasoning: 11 },
|
||||
outputTokens: { total: 19 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -647,7 +647,7 @@ describe("Session.create", () => {
|
||||
it.effect("switches the selected agent through the durable Session event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location, agent: Agent.ID.make("build") })
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
|
||||
|
||||
@@ -655,9 +655,6 @@ describe("Session.create", () => {
|
||||
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" } }])
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -358,7 +358,6 @@ describe("SessionProjector", () => {
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
agent: "plan",
|
||||
model: previousModel,
|
||||
})
|
||||
.run()
|
||||
@@ -460,10 +459,6 @@ describe("SessionProjector", () => {
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
|
||||
agent: build,
|
||||
previous: "plan",
|
||||
})
|
||||
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
command: "pwd",
|
||||
|
||||
@@ -373,103 +373,6 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("deduplicates provider media while preserving durable attachment references", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-duplicate-image"),
|
||||
type: "user",
|
||||
text: "[Image 1] [Image 1] [Image 2]",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 10, end: 19, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
description: "alternate use",
|
||||
mention: { start: 20, end: 29, text: "[Image 2]" },
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "[Image 1] [Image 1] [Image 2]" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||
{
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data,
|
||||
filename: "image.png",
|
||||
metadata: { description: "alternate use" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves provider media with distinct labels or URI sources", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-distinct-images"),
|
||||
type: "user",
|
||||
text: "[Image 1] [Image 2]",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "file:///project/image.png" },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content.filter((part) => part.type === "media")).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("replays durable tool media into canonical tool messages without structured base64", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
|
||||
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
|
||||
// be verified without workerd or Cloudflare runtime dependencies.
|
||||
const makeFakeStorage = () => {
|
||||
const native = new Database(":memory:")
|
||||
const toSqlStorageValue = (value: unknown) => {
|
||||
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
|
||||
const buffer = new ArrayBuffer(value.byteLength)
|
||||
new Uint8Array(buffer).set(value)
|
||||
return buffer
|
||||
}
|
||||
const storage: DurableObjectStorage = {
|
||||
sql: {
|
||||
exec(query: string, ...bindings: Array<unknown>) {
|
||||
const statement = native.query(query)
|
||||
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
|
||||
const columnNames = statement.columnNames
|
||||
return {
|
||||
columnNames,
|
||||
raw: () => rows[Symbol.iterator](),
|
||||
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
|
||||
}
|
||||
},
|
||||
},
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
|
||||
native.run("BEGIN")
|
||||
let rolledBack = false
|
||||
return closure({ rollback: () => (rolledBack = true) }).then(
|
||||
(result) => {
|
||||
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
|
||||
return result
|
||||
},
|
||||
(error) => {
|
||||
native.run("ROLLBACK")
|
||||
throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
transactionSync<T>(closure: () => T): T {
|
||||
return native.transaction(closure)()
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
|
||||
|
||||
describe("sqlite.workerd", () => {
|
||||
test("executes statements with bindings and maps rows to records", async () => {
|
||||
const rows = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
|
||||
yield* sql`INSERT INTO item (id, name) VALUES (${1}, ${"one"}), (${2}, ${"two"})`
|
||||
return yield* sql<{ id: number; name: string }>`SELECT id, name FROM item ORDER BY id`
|
||||
}),
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{ id: 1, name: "one" },
|
||||
{ id: 2, name: "two" },
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
|
||||
const rows = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
|
||||
yield* sql`INSERT INTO blob (data) VALUES (${new Uint8Array([1, 2, 3])})`
|
||||
return yield* sql<{ data: Uint8Array }>`SELECT data FROM blob`
|
||||
}),
|
||||
)
|
||||
expect(rows[0].data).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(rows[0].data)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("withTransaction commits on success and rolls back on failure", async () => {
|
||||
const storage = makeFakeStorage()
|
||||
const count = await run(
|
||||
storage,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
|
||||
yield* sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"kept"})`)
|
||||
yield* sql
|
||||
.withTransaction(
|
||||
Effect.gen(function* () {
|
||||
yield* sql`INSERT INTO t (value) VALUES (${"discarded"})`
|
||||
return yield* Effect.fail("rollback")
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.ignore)
|
||||
return yield* sql<{ count: number }>`SELECT count(*) AS count FROM t`
|
||||
}),
|
||||
)
|
||||
expect(count[0].count).toBe(1)
|
||||
})
|
||||
|
||||
test("nested withTransaction fails with SqlError", async () => {
|
||||
const error = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
|
||||
return yield* sql
|
||||
.withTransaction(sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"nested"})`))
|
||||
.pipe(Effect.flip)
|
||||
}),
|
||||
)
|
||||
expect(error).toBeInstanceOf(SqlError)
|
||||
})
|
||||
|
||||
test("boots the full database layer with migrations over injected storage", async () => {
|
||||
const storage = makeFakeStorage()
|
||||
const core = await import("@opencode-ai/core/database/database")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(Layer.build(core.Database.layerWith(sqliteLayer({ storage })).pipe(Layer.provide(tempGlobalLayer)))),
|
||||
)
|
||||
const names = storage.sql
|
||||
.exec("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.toArray()
|
||||
.map((row) => row.name)
|
||||
expect(names).toContain("migration")
|
||||
expect(names).toContain("session_v2")
|
||||
})
|
||||
})
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
@@ -42,7 +42,6 @@ export const AgentSelected = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("agent-switched"),
|
||||
agent: Agent.ID,
|
||||
previous: Agent.ID.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.AgentSelected" })
|
||||
|
||||
export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
|
||||
|
||||
@@ -60,11 +60,6 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import {
|
||||
deduplicatePromptImages,
|
||||
preserveMentionlessPromptAttachments,
|
||||
promptAttachmentLabel,
|
||||
} from "../../prompt/attachment"
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
|
||||
export type PromptProps = {
|
||||
@@ -336,7 +331,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const imageAttachments = createMemo(() =>
|
||||
(deduplicatePromptImages(store.prompt.files) ?? []).filter((file) => file.uri.startsWith("data:image/")),
|
||||
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
|
||||
)
|
||||
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
|
||||
@@ -741,7 +736,6 @@ export function Prompt(props: PromptProps) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const newMap = new Map<number, PromptPartRef>()
|
||||
const fileExtmarks = new Map<number, NonNullable<PromptInfo["files"]>[number]>()
|
||||
const files: NonNullable<PromptInfo["files"]> = []
|
||||
const agents: NonNullable<PromptInfo["agents"]> = []
|
||||
const skills: NonNullable<PromptInfo["skills"]> = []
|
||||
@@ -755,8 +749,9 @@ export function Prompt(props: PromptProps) {
|
||||
if (!part?.mention) continue
|
||||
part.mention.start = extmark.start
|
||||
part.mention.end = extmark.end
|
||||
const index = files.length
|
||||
files.push(part)
|
||||
fileExtmarks.set(extmark.id, part)
|
||||
newMap.set(extmark.id, { type: "file", index })
|
||||
continue
|
||||
}
|
||||
if (ref.type === "agent") {
|
||||
@@ -788,19 +783,8 @@ export function Prompt(props: PromptProps) {
|
||||
newMap.set(extmark.id, { type: "pasted", index })
|
||||
}
|
||||
|
||||
const nextFiles = preserveMentionlessPromptAttachments(draft.prompt.files, files)
|
||||
const fileIndices = new Map(nextFiles.map((file, index) => [file, index]))
|
||||
for (const [extmark, file] of fileExtmarks) {
|
||||
const index = fileIndices.get(file)
|
||||
if (index !== undefined) newMap.set(extmark, { type: "file", index })
|
||||
}
|
||||
|
||||
draft.extmarkToPart = newMap
|
||||
if (
|
||||
nextFiles.length !== draft.prompt.files?.length ||
|
||||
nextFiles.some((file, index) => file !== draft.prompt.files?.[index])
|
||||
)
|
||||
draft.prompt.files = nextFiles
|
||||
draft.prompt.files = files
|
||||
draft.prompt.agents = agents
|
||||
draft.prompt.skills = skills
|
||||
draft.prompt.pasted = pasted
|
||||
@@ -1154,6 +1138,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
|
||||
if (store.mode === "shell") {
|
||||
move.startSubmit()
|
||||
void client.api.session.shell({
|
||||
@@ -1391,7 +1376,13 @@ export function Prompt(props: PromptProps) {
|
||||
function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
const virtualText = promptAttachmentLabel(store.prompt.files, { uri: file.uri, name: file.filename })
|
||||
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||
const count = pdf
|
||||
? (store.prompt.files?.filter(
|
||||
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
|
||||
).length ?? 0)
|
||||
: imageAttachments().length
|
||||
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
|
||||
|
||||
@@ -386,8 +386,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
}))
|
||||
break
|
||||
case "session.agent.selected": {
|
||||
const previous = store.session.info[event.data.sessionID]?.agent
|
||||
case "session.agent.selected":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
@@ -395,12 +394,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.model.selected":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "model", event.data.model)
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { PromptInput } from "@opencode-ai/schema"
|
||||
|
||||
type PromptFile = PromptInput.FileAttachment
|
||||
type PromptFileIdentity = Pick<PromptFile, "uri" | "name" | "description">
|
||||
type ProjectedFile = Readonly<{
|
||||
data: string
|
||||
mime: string
|
||||
source: { type: string }
|
||||
name?: string
|
||||
description?: string
|
||||
mention?: { text: string }
|
||||
}>
|
||||
|
||||
function attachmentKind(uri: string) {
|
||||
if (uri.startsWith("data:image/")) return "Image"
|
||||
if (uri.startsWith("data:application/pdf;")) return "PDF"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function attachmentMetadata(file: PromptFileIdentity) {
|
||||
return JSON.stringify([file.name ?? null, file.description ?? null])
|
||||
}
|
||||
|
||||
function deduplicateByIdentity<T>(
|
||||
items: readonly T[],
|
||||
identity: (item: T) => { metadata: string; payload: string } | undefined,
|
||||
) {
|
||||
const seen = new Map<string, string[]>()
|
||||
return items.filter((item) => {
|
||||
const key = identity(item)
|
||||
if (!key) return true
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
export function deduplicatePromptImages(files: readonly PromptFile[] | undefined) {
|
||||
if (!files || files.length < 2) return files
|
||||
return deduplicateByIdentity(files, (file) =>
|
||||
file.uri.startsWith("data:image/") && file.mention?.text
|
||||
? {
|
||||
metadata: JSON.stringify([attachmentMetadata(file), file.mention.text]),
|
||||
payload: file.uri,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export function preserveMentionlessPromptAttachments(
|
||||
files: readonly PromptFile[] | undefined,
|
||||
mentioned: PromptFile[],
|
||||
) {
|
||||
if (!files) return mentioned
|
||||
const tracked = mentioned.values()
|
||||
return files.flatMap((file) => {
|
||||
if (!file.mention?.text) return [file]
|
||||
const next = tracked.next()
|
||||
return next.done ? [] : [next.value]
|
||||
})
|
||||
}
|
||||
|
||||
export function deduplicateVisibleImages<T extends ProjectedFile>(files: readonly T[]) {
|
||||
return deduplicateByIdentity(files, (file) =>
|
||||
file.mime.startsWith("image/") && file.source.type === "inline" && file.mention?.text
|
||||
? {
|
||||
metadata: JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text]),
|
||||
payload: file.data,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export function promptAttachmentLabel(files: readonly PromptFile[] | undefined, file: PromptFileIdentity) {
|
||||
const kind = attachmentKind(file.uri)
|
||||
if (!kind) throw new Error(`Unsupported inline attachment: ${file.uri}`)
|
||||
const metadata = attachmentMetadata(file)
|
||||
const existing =
|
||||
kind === "Image"
|
||||
? files?.find(
|
||||
(candidate) =>
|
||||
candidate.uri === file.uri && attachmentMetadata(candidate) === metadata && candidate.mention?.text,
|
||||
)?.mention?.text
|
||||
: undefined
|
||||
if (existing) return existing
|
||||
|
||||
const pattern = new RegExp(`^\\[${kind} (\\d+)\\]$`)
|
||||
const count =
|
||||
files?.reduce((highest, candidate) => {
|
||||
const match = candidate.mention?.text.match(pattern)
|
||||
return match ? Math.max(highest, Number(match[1])) : highest
|
||||
}, 0) ?? 0
|
||||
return `[${kind} ${count + 1}]`
|
||||
}
|
||||
@@ -70,7 +70,6 @@ import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { deduplicateVisibleImages } from "../../prompt/attachment"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
@@ -1652,12 +1651,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const text = () => {
|
||||
if (props.message.type === "agent-switched") {
|
||||
const agent = Locale.titlecase(props.message.agent)
|
||||
if (props.message.previous && props.message.previous !== props.message.agent)
|
||||
return `Switched agent from ${Locale.titlecase(props.message.previous)} to ${agent}`
|
||||
return `Switched agent to ${agent}`
|
||||
}
|
||||
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
|
||||
if (props.message.type === "model-switched")
|
||||
return switchLabel(props.message.model, ctx.models(), props.message.previous)
|
||||
return ""
|
||||
@@ -1905,7 +1899,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const files = createMemo(() => deduplicateVisibleImages(props.message.files ?? []))
|
||||
const files = createMemo(() => props.message.files ?? [])
|
||||
const skills = createMemo(() => props.message.skills ?? [])
|
||||
const images = createMemo(() =>
|
||||
files().flatMap((file) =>
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
deduplicatePromptImages,
|
||||
deduplicateVisibleImages,
|
||||
preserveMentionlessPromptAttachments,
|
||||
promptAttachmentLabel,
|
||||
} from "../../src/prompt/attachment"
|
||||
|
||||
describe("prompt attachments", () => {
|
||||
test("deduplicates identical inline images while preserving other attachments", () => {
|
||||
const files = [
|
||||
{
|
||||
uri: "data:image/png;base64,AAA",
|
||||
name: "first.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
},
|
||||
{ uri: "file:///same", name: "first.txt" },
|
||||
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
|
||||
{
|
||||
uri: "data:image/png;base64,BBB",
|
||||
name: "second.png",
|
||||
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||
},
|
||||
{
|
||||
uri: "data:image/png;base64,AAA",
|
||||
name: "first.png",
|
||||
mention: { start: 20, end: 29, text: "[Image 1]" },
|
||||
},
|
||||
{
|
||||
uri: "data:image/png;base64,AAA",
|
||||
name: "first.png",
|
||||
description: "alternate use",
|
||||
mention: { start: 30, end: 39, text: "[Image 1]" },
|
||||
},
|
||||
{ uri: "file:///same", name: "second.txt" },
|
||||
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
|
||||
]
|
||||
|
||||
expect(deduplicatePromptImages(files)).toEqual([
|
||||
files[0],
|
||||
files[1],
|
||||
files[2],
|
||||
files[3],
|
||||
files[5],
|
||||
files[6],
|
||||
files[7],
|
||||
])
|
||||
expect(files).toHaveLength(8)
|
||||
})
|
||||
|
||||
test("reuses labels for identical image data", () => {
|
||||
const first = "data:image/png;base64,AAA"
|
||||
const second = "data:image/png;base64,BBB"
|
||||
const files = [{ uri: first, mention: { start: 0, end: 9, text: "[Image 1]" } }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri: first })).toBe("[Image 1]")
|
||||
expect(promptAttachmentLabel([...files, { ...files[0], mention: undefined }], { uri: second })).toBe("[Image 2]")
|
||||
expect(promptAttachmentLabel([{ uri: first }], { uri: first })).toBe("[Image 1]")
|
||||
})
|
||||
|
||||
test("numbers PDFs independently from images", () => {
|
||||
const files = [{ uri: "data:image/png;base64,AAA" }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri: "data:application/pdf;base64,BBB" })).toBe("[PDF 1]")
|
||||
})
|
||||
|
||||
test("does not reuse a label when attachment metadata differs", () => {
|
||||
const uri = "data:image/png;base64,AAA"
|
||||
const files = [{ uri, name: "one.png", mention: { start: 0, end: 9, text: "[Image 1]" } }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri, name: "two.png" })).toBe("[Image 2]")
|
||||
})
|
||||
|
||||
test("does not reuse numbers after an earlier attachment is removed", () => {
|
||||
const files = [{ uri: "data:image/png;base64,BBB", mention: { start: 0, end: 9, text: "[Image 2]" } }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri: "data:image/png;base64,CCC" })).toBe("[Image 3]")
|
||||
})
|
||||
|
||||
test("preserves mentionless attachments when tracked mentions are synchronized", () => {
|
||||
const mentionless = { uri: "data:image/png;base64,AAA" }
|
||||
const emptyMention = {
|
||||
uri: "data:image/png;base64,CCC",
|
||||
mention: { start: 0, end: 0, text: "" },
|
||||
}
|
||||
const mentioned = {
|
||||
uri: "data:image/png;base64,BBB",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}
|
||||
|
||||
const restored = preserveMentionlessPromptAttachments([mentionless, emptyMention, mentioned], [mentioned])
|
||||
expect(restored).toEqual([mentionless, emptyMention, mentioned])
|
||||
expect(restored.indexOf(mentioned)).toBe(2)
|
||||
|
||||
const another = {
|
||||
uri: "data:image/png;base64,DDD",
|
||||
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||
}
|
||||
expect(preserveMentionlessPromptAttachments([mentioned, mentionless, another], [another, mentioned])).toEqual([
|
||||
another,
|
||||
mentionless,
|
||||
mentioned,
|
||||
])
|
||||
})
|
||||
|
||||
test("deduplicates visible inline image cards without dropping durable references", () => {
|
||||
const file = {
|
||||
data: "AAA",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "clipboard",
|
||||
mention: { text: "[Image 1]" },
|
||||
}
|
||||
const files = [file, { ...file, mention: { text: "[Image 1]" } }]
|
||||
|
||||
expect(deduplicateVisibleImages(files)).toEqual([file])
|
||||
expect(files).toHaveLength(2)
|
||||
|
||||
const distinct = [
|
||||
{ ...file, mention: { text: "[Image 2]" } },
|
||||
{ ...file, mention: undefined },
|
||||
]
|
||||
expect(deduplicateVisibleImages([file, ...distinct])).toEqual([file, ...distinct])
|
||||
})
|
||||
})
|
||||
@@ -41,21 +41,4 @@ describe("prompt history", () => {
|
||||
const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }])
|
||||
expect(isDuplicateEntry(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves duplicate attachment mentions for prompt restoration", () => {
|
||||
const value = entry("[Image 1] [Image 1]", [
|
||||
{
|
||||
name: "clipboard",
|
||||
uri: "data:image/png;base64,AAA",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
},
|
||||
{
|
||||
name: "clipboard",
|
||||
uri: "data:image/png;base64,AAA",
|
||||
mention: { start: 10, end: 19, text: "[Image 1]" },
|
||||
},
|
||||
])
|
||||
|
||||
expect(parsePromptHistory(JSON.stringify(value))).toEqual([value])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
Reference in New Issue
Block a user