mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23e4e35ae8 |
@@ -159,6 +159,58 @@ type NextMessage = {
|
||||
readonly data: string
|
||||
}
|
||||
|
||||
type SourceColumns<Row> = Readonly<Record<keyof Row, true | null>>
|
||||
|
||||
const nextProjectColumns = {
|
||||
id: true,
|
||||
worktree: true,
|
||||
vcs: null,
|
||||
name: null,
|
||||
icon_url: null,
|
||||
icon_url_override: null,
|
||||
icon_color: null,
|
||||
time_created: true,
|
||||
time_updated: true,
|
||||
time_initialized: null,
|
||||
sandboxes: true,
|
||||
commands: null,
|
||||
} satisfies SourceColumns<NextProject>
|
||||
|
||||
const nextSessionColumns = {
|
||||
id: true,
|
||||
project_id: true,
|
||||
workspace_id: null,
|
||||
parent_id: null,
|
||||
fork_session_id: null,
|
||||
fork_boundary: null,
|
||||
slug: true,
|
||||
directory: true,
|
||||
path: null,
|
||||
title: null,
|
||||
version: true,
|
||||
share_url: null,
|
||||
summary_additions: null,
|
||||
summary_deletions: null,
|
||||
summary_files: null,
|
||||
summary_diffs: null,
|
||||
metadata: null,
|
||||
cost: true,
|
||||
tokens_input: true,
|
||||
tokens_output: true,
|
||||
tokens_reasoning: true,
|
||||
tokens_cache_read: true,
|
||||
tokens_cache_write: true,
|
||||
revert: null,
|
||||
permission: null,
|
||||
agent: null,
|
||||
model: null,
|
||||
time_created: true,
|
||||
time_updated: true,
|
||||
time_compacting: null,
|
||||
time_archived: null,
|
||||
time_suspended: null,
|
||||
} satisfies SourceColumns<NextSession>
|
||||
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const MIGRATION_STATE_KEY = "migration.v1-v2"
|
||||
const EVENT_DELETE_BATCH_SIZE = 1_000
|
||||
@@ -678,12 +730,9 @@ function importNextDatabase(
|
||||
}),
|
||||
)
|
||||
const projects = new Map(
|
||||
source
|
||||
.query<NextProject, []>("SELECT * FROM project")
|
||||
.all()
|
||||
.map((project) => [project.id, project]),
|
||||
selectSourceRows<NextProject>(source, "project", nextProjectColumns).map((project) => [project.id, project]),
|
||||
)
|
||||
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
|
||||
const sessions = selectSourceRows<NextSession>(source, "session", nextSessionColumns, "id")
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
const project = projects.get(session.project_id)
|
||||
const projectID = project ? session.project_id : Project.ID.global
|
||||
@@ -776,6 +825,33 @@ function isNextDatabase(source: SQLiteDatabase) {
|
||||
return tables.has("project") && tables.has("session") && tables.has("session_message")
|
||||
}
|
||||
|
||||
function selectSourceRows<Row>(
|
||||
source: SQLiteDatabase,
|
||||
table: "project" | "session",
|
||||
columns: SourceColumns<Row>,
|
||||
orderBy?: keyof Row,
|
||||
) {
|
||||
const available = new Set(
|
||||
source
|
||||
.query<{ name: string }, []>(`PRAGMA table_info("${table}")`)
|
||||
.all()
|
||||
.map((column) => column.name),
|
||||
)
|
||||
const selection = Object.entries(columns)
|
||||
.map(([name, required]) => {
|
||||
if (available.has(name)) return `"${name}"`
|
||||
if (!required) return `NULL AS "${name}"`
|
||||
throw new Error(`Previous V2 database ${table} table is missing required column ${name}`)
|
||||
})
|
||||
.join(", ")
|
||||
return source
|
||||
.query<
|
||||
Row,
|
||||
[]
|
||||
>(`SELECT ${selection} FROM "${table}"${orderBy === undefined ? "" : ` ORDER BY "${String(orderBy)}" DESC`}`)
|
||||
.all()
|
||||
}
|
||||
|
||||
function row(
|
||||
source: SourceMessage,
|
||||
message: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
|
||||
import { Option, Schema } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Model } from "../../model"
|
||||
import { SessionMessage } from "../message"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
@@ -15,13 +14,6 @@ const media = (file: FileAttachment): ContentPart => ({
|
||||
metadata: file.description === undefined ? undefined : { description: file.description },
|
||||
})
|
||||
|
||||
const attachmentLocation = (file: FileAttachment) => {
|
||||
if (file.source.type !== "uri") return undefined
|
||||
const url = URL.parse(file.source.uri)
|
||||
if (url?.protocol !== "file:") return undefined
|
||||
return fileURLToPath(url, { windows: url.hostname !== "" || /^\/[a-zA-Z]:\//.test(url.pathname) })
|
||||
}
|
||||
|
||||
const textAttachment = (file: FileAttachment): ContentPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
@@ -44,7 +36,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
|
||||
const directoryAttachment = (file: FileAttachment): ContentPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
|
||||
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
|
||||
file.description === undefined ? undefined : `Description: ${file.description}`,
|
||||
file.data.length === 0 ? undefined : "",
|
||||
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
|
||||
@@ -63,10 +55,7 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
|
||||
const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
||||
if (file.mime === "text/plain") return [textAttachment(file)]
|
||||
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
|
||||
if (imageMimes.has(file.mime)) {
|
||||
const location = attachmentLocation(file)
|
||||
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
|
||||
}
|
||||
if (imageMimes.has(file.mime)) return [media(file)]
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
CREATE TABLE project (
|
||||
id text PRIMARY KEY,
|
||||
worktree text NOT NULL,
|
||||
vcs text,
|
||||
name text,
|
||||
icon_url text,
|
||||
time_created integer NOT NULL,
|
||||
time_updated integer NOT NULL,
|
||||
time_initialized integer,
|
||||
sandboxes text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY,
|
||||
project_id text NOT NULL,
|
||||
workspace_id text,
|
||||
parent_id text,
|
||||
fork_session_id text,
|
||||
fork_message_id text,
|
||||
fork_seq integer,
|
||||
slug text NOT NULL,
|
||||
directory text NOT NULL,
|
||||
path text,
|
||||
title text,
|
||||
version text NOT NULL,
|
||||
share_url text,
|
||||
summary_additions integer,
|
||||
summary_deletions integer,
|
||||
summary_files integer,
|
||||
summary_diffs text,
|
||||
metadata text,
|
||||
cost real DEFAULT 0 NOT NULL,
|
||||
tokens_input integer DEFAULT 0 NOT NULL,
|
||||
tokens_output integer DEFAULT 0 NOT NULL,
|
||||
tokens_reasoning integer DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read integer DEFAULT 0 NOT NULL,
|
||||
tokens_cache_write integer DEFAULT 0 NOT NULL,
|
||||
revert text,
|
||||
permission text,
|
||||
agent text,
|
||||
model text,
|
||||
time_created integer NOT NULL,
|
||||
time_updated integer NOT NULL,
|
||||
time_compacting integer,
|
||||
time_archived integer
|
||||
);
|
||||
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY,
|
||||
session_id text NOT NULL,
|
||||
type text NOT NULL,
|
||||
seq integer NOT NULL,
|
||||
time_created integer NOT NULL,
|
||||
time_updated integer NOT NULL,
|
||||
data text NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO project (
|
||||
id, worktree, vcs, name, icon_url, time_created, time_updated, time_initialized, sandboxes
|
||||
) VALUES (
|
||||
'old-project', '/tmp/old-next', 'git', 'Old project', 'https://example.test/icon.png', 1, 2, 3, '[]'
|
||||
);
|
||||
|
||||
INSERT INTO session (
|
||||
id, project_id, fork_session_id, fork_message_id, fork_seq, slug, directory, title, version,
|
||||
time_created, time_updated
|
||||
) VALUES (
|
||||
'ses_old_next', 'old-project', 'ses_parent', 'msg_parent', 4, 'old-next', '/tmp/old-next',
|
||||
'Old imported session', '2', 10, 20
|
||||
);
|
||||
|
||||
INSERT INTO session_message VALUES (
|
||||
'msg_old_next', 'ses_old_next', 'user', 0, 12, 13, '{"text":"from old next","time":{"created":12}}'
|
||||
);
|
||||
@@ -249,14 +249,13 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("exposes admitted reference directory source paths in model context", () => {
|
||||
test("lowers directory attachments as directory context", () => {
|
||||
const directory = FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
|
||||
mime: "application/x-directory",
|
||||
source: { type: "uri", uri: "file:///references/harness-engineering" },
|
||||
name: "harness-engineering",
|
||||
source: { type: "uri", uri: "file:///project/src" },
|
||||
name: "src/",
|
||||
})
|
||||
expect(directory.source).toEqual({ type: "uri", uri: "file:///references/harness-engineering" })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
@@ -278,8 +277,8 @@ Recent work
|
||||
{ type: "text", text: "Review this directory" },
|
||||
{
|
||||
type: "text",
|
||||
text: "\n\nAttached directory: /references/harness-engineering\n\nlib/\nindex.ts",
|
||||
metadata: { attachment: { source: directory.source, name: "harness-engineering" } },
|
||||
text: "\n\nAttached directory: src/\n\nlib/\nindex.ts",
|
||||
metadata: { attachment: { source: directory.source, name: "src/" } },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -315,7 +314,7 @@ Recent work
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([
|
||||
"Review these attachments",
|
||||
"\n\nAttached directory: /project/src\n\nindex.ts",
|
||||
"\n\nAttached directory: src/\n\nindex.ts",
|
||||
"\n\nAttached file: main.ts\n\nexport const value = 1",
|
||||
])
|
||||
})
|
||||
@@ -342,9 +341,7 @@ Recent work
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.content).toMatchObject([
|
||||
{ type: "text", text: "\n\nAttached directory: /project/src\n\nindex.ts" },
|
||||
])
|
||||
expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }])
|
||||
})
|
||||
|
||||
test("uses materialized image data as provider media and drops unsupported attachments", () => {
|
||||
@@ -376,64 +373,6 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("exposes admitted local image source paths before provider media", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const image = FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "file:///project/IMG_3480.JPG" },
|
||||
name: "IMG_3480.JPG",
|
||||
})
|
||||
expect(image.source).toEqual({ type: "uri", uri: "file:///project/IMG_3480.JPG" })
|
||||
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-local-image-path"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [image],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "text", text: "Attached file: /project/IMG_3480.JPG" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "IMG_3480.JPG" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not add attachment location text for non-local provider media", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-remote-image"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "https://example.com/image.png" },
|
||||
name: "image.png",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("deduplicates provider media while preserving durable attachment references", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
|
||||
@@ -945,6 +945,44 @@ describe("V1Migration database workflow", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("imports previous V2 sessions from an older source schema", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode-next.db")
|
||||
const sqlite = await import("bun:sqlite")
|
||||
const source = new sqlite.Database(filename)
|
||||
source.exec(await Bun.file(path.join(import.meta.dir, "fixture/v1-migration-old-next.sql")).text())
|
||||
source.close()
|
||||
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
|
||||
expect(
|
||||
yield* db.get(
|
||||
sql`SELECT fork_session_id, fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_old_next'`,
|
||||
),
|
||||
).toEqual({ fork_session_id: "ses_parent", fork_boundary: null, time_suspended: null })
|
||||
expect(
|
||||
yield* db.get(
|
||||
sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'old-project'`,
|
||||
),
|
||||
).toEqual({
|
||||
icon_url: "https://example.test/icon.png",
|
||||
icon_url_override: null,
|
||||
icon_color: null,
|
||||
commands: null,
|
||||
})
|
||||
expect(yield* db.all(sql`SELECT id, seq FROM session_message WHERE session_id = 'ses_old_next'`)).toEqual([
|
||||
{ id: "msg_old_next", seq: 0 },
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT seq FROM event_sequence WHERE aggregate_id = 'ses_old_next'`)).toEqual({
|
||||
seq: 0,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("derives required status from the durable cursor", async () => {
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
|
||||
Reference in New Issue
Block a user