mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 22:21:25 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2375102457 | |||
| 573ce945a6 | |||
| 575dc569e9 |
+266
-14
@@ -1,7 +1,7 @@
|
||||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { DateTime, Effect, Exit, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
@@ -23,6 +23,7 @@ import { InstallationVersion } from "./installation/version"
|
||||
import { Slug } from "./util/slug"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
@@ -37,6 +38,7 @@ import { SessionCompaction } from "./session/compaction"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
import { SkillV2 } from "./skill"
|
||||
|
||||
@@ -419,7 +421,14 @@ export const layer = Layer.effect(
|
||||
// continues from the reverted boundary rather than stale post-boundary history.
|
||||
if (session.revert)
|
||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||
const prompt = resolvePrompt(input.prompt)
|
||||
const hasFileURL = input.prompt.files?.some(
|
||||
(file) => URL.canParse(file.uri) && new URL(file.uri).protocol === "file:",
|
||||
)
|
||||
const prompt = !input.prompt.files?.length
|
||||
? Prompt.make({ text: input.prompt.text, agents: input.prompt.agents })
|
||||
: hasFileURL
|
||||
? yield* resolvePrompt(input.prompt).pipe(Effect.provide(locations.get(session.location)))
|
||||
: resolveInlinePrompt(input.prompt)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||
@@ -458,7 +467,9 @@ export const layer = Layer.effect(
|
||||
text: skill.content,
|
||||
})
|
||||
if (input.resume !== false)
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
@@ -524,7 +535,9 @@ export const layer = Layer.effect(
|
||||
timestamp: yield* DateTime.now,
|
||||
text: input.text,
|
||||
})
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
@@ -569,19 +582,258 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||
Prompt.make({
|
||||
const MAX_TEXT_ATTACHMENT_BYTES = 256 * 1024
|
||||
const MAX_DIRECTORY_ENTRIES = 200
|
||||
|
||||
const textMime = (mime: string) =>
|
||||
mime.startsWith("text/") ||
|
||||
mime === "application/json" ||
|
||||
mime === "application/xml" ||
|
||||
mime === "application/yaml" ||
|
||||
mime === "application/x-yaml" ||
|
||||
mime === "application/toml" ||
|
||||
mime === "application/x-toml" ||
|
||||
mime.endsWith("+json") ||
|
||||
mime.endsWith("+xml")
|
||||
|
||||
const mediaMime = (mime: string) => mime.startsWith("image/") || mime === "application/pdf"
|
||||
|
||||
const textExtensions = new Set([
|
||||
".c",
|
||||
".cc",
|
||||
".cpp",
|
||||
".css",
|
||||
".go",
|
||||
".html",
|
||||
".java",
|
||||
".js",
|
||||
".jsx",
|
||||
".json",
|
||||
".jsonc",
|
||||
".md",
|
||||
".mjs",
|
||||
".py",
|
||||
".rs",
|
||||
".sh",
|
||||
".sql",
|
||||
".ts",
|
||||
".tsx",
|
||||
".txt",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
])
|
||||
|
||||
const textFile = (filepath: string, mime: string) => textMime(mime) || textExtensions.has(path.extname(filepath))
|
||||
|
||||
const dataMime = (uri: string) => uri.match(/^data:([^;,]+)[;,]/i)?.[1]?.toLowerCase()
|
||||
|
||||
function decodeDataText(uri: string) {
|
||||
const comma = uri.indexOf(",")
|
||||
if (comma === -1) return ""
|
||||
const header = uri.slice(0, comma).toLowerCase()
|
||||
const data = uri.slice(comma + 1)
|
||||
if (header.endsWith(";base64")) return Buffer.from(data, "base64").toString("utf8")
|
||||
return decodeURIComponent(data)
|
||||
}
|
||||
|
||||
function formatAttachmentText(input: { name?: string; uri: string; text: string; truncated?: boolean }) {
|
||||
const title = input.name ?? input.uri
|
||||
const suffix = input.truncated ? "\n[Attachment truncated]" : ""
|
||||
return `<attachment name="${title}">\n${input.text}${suffix}\n</attachment>`
|
||||
}
|
||||
|
||||
function selectedLines(text: string, url: URL) {
|
||||
const start = url.searchParams.get("start")
|
||||
if (start === null) return { text, truncated: text.length > MAX_TEXT_ATTACHMENT_BYTES }
|
||||
const startLine = Math.max(parseInt(start), 1)
|
||||
const end = url.searchParams.get("end")
|
||||
const endLine = end === null ? startLine : Math.max(parseInt(end), startLine)
|
||||
return {
|
||||
text: text
|
||||
.split("\n")
|
||||
.slice(startLine - 1, endLine)
|
||||
.join("\n"),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
function truncateAttachmentText(text: string, truncated: boolean) {
|
||||
if (text.length <= MAX_TEXT_ATTACHMENT_BYTES) return { text, truncated }
|
||||
return { text: text.slice(0, MAX_TEXT_ATTACHMENT_BYTES), truncated: true }
|
||||
}
|
||||
|
||||
function attachmentError(input: { name?: string; uri: string; message: string }) {
|
||||
return formatAttachmentText({
|
||||
name: input.name,
|
||||
uri: input.uri,
|
||||
text: `[Unable to read attachment: ${input.message}]`,
|
||||
})
|
||||
}
|
||||
|
||||
function resolveInlineFileAttachment(file: PromptInput.FileAttachment) {
|
||||
const mime = dataMime(file.uri)
|
||||
if (mime) {
|
||||
if (textMime(mime)) {
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
mime,
|
||||
description: formatAttachmentText({ name: file.name, uri: file.uri, text: decodeDataText(file.uri) }),
|
||||
},
|
||||
}
|
||||
}
|
||||
return { file: { ...file, mime } }
|
||||
}
|
||||
if (!URL.canParse(file.uri)) {
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
mime: "application/octet-stream",
|
||||
description: attachmentError({ name: file.name, uri: file.uri, message: "unsupported attachment URI" }),
|
||||
},
|
||||
}
|
||||
}
|
||||
const url = new URL(file.uri)
|
||||
const target = url.pathname || file.name || file.uri
|
||||
return { file: { ...file, mime: target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target) } }
|
||||
}
|
||||
|
||||
function resolveInlinePrompt(input: PromptInput.Prompt) {
|
||||
const files = (input.files ?? []).map(resolveInlineFileAttachment)
|
||||
return Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
files: input.files?.map((file) => {
|
||||
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
|
||||
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
|
||||
return {
|
||||
...file,
|
||||
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
|
||||
}
|
||||
}),
|
||||
files: files.map((file) => file.file),
|
||||
})
|
||||
}
|
||||
|
||||
const resolveFileAttachment = Effect.fn("Session.resolveFileAttachment")(function* (file: PromptInput.FileAttachment) {
|
||||
const mime = dataMime(file.uri)
|
||||
if (mime) {
|
||||
if (textMime(mime)) {
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
mime,
|
||||
description: formatAttachmentText({ name: file.name, uri: file.uri, text: decodeDataText(file.uri) }),
|
||||
},
|
||||
}
|
||||
}
|
||||
return { file: { ...file, mime } }
|
||||
}
|
||||
|
||||
if (!URL.canParse(file.uri)) {
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
mime: "application/octet-stream",
|
||||
description: attachmentError({ name: file.name, uri: file.uri, message: "unsupported attachment URI" }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(file.uri)
|
||||
if (url.protocol !== "file:") {
|
||||
const target = url.pathname || file.name || file.uri
|
||||
return { file: { ...file, mime: target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target) } }
|
||||
}
|
||||
|
||||
const location = yield* Location.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const filepath = fileURLToPath(url)
|
||||
const relative = path.relative(location.directory, filepath)
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
mime: FSUtil.mimeType(filepath),
|
||||
description: attachmentError({
|
||||
name: file.name ?? filepath,
|
||||
uri: file.uri,
|
||||
message: "file is outside the session location",
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const target = RelativePath.make(relative.split(path.sep).join("/"))
|
||||
const listed = yield* filesystem.list({ path: target }).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(listed)) {
|
||||
const entries = listed.value
|
||||
const visible = entries
|
||||
.slice(0, MAX_DIRECTORY_ENTRIES)
|
||||
.map((entry) => `${entry.path.replaceAll("\\", "/").replace(/\/+$/, "")}${entry.type === "directory" ? "/" : ""}`)
|
||||
.join("\n")
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
mime: "application/x-directory",
|
||||
description: formatAttachmentText({
|
||||
name: file.name ?? filepath,
|
||||
uri: file.uri,
|
||||
text: visible || "[Directory is empty]",
|
||||
truncated: entries.length > MAX_DIRECTORY_ENTRIES,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const read = yield* filesystem.read({ path: target }).pipe(Effect.exit)
|
||||
if (Exit.isFailure(read)) {
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
mime: FSUtil.mimeType(filepath),
|
||||
description: attachmentError({ name: file.name ?? filepath, uri: file.uri, message: "file not found" }),
|
||||
},
|
||||
}
|
||||
}
|
||||
const content = read.value
|
||||
|
||||
if (textFile(filepath, content.mime)) {
|
||||
const selected = selectedLines(Buffer.from(content.content).toString("utf8"), url)
|
||||
const truncated = truncateAttachmentText(selected.text, selected.truncated)
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
mime: "text/plain",
|
||||
description: formatAttachmentText({ name: file.name ?? filepath, uri: file.uri, ...truncated }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaMime(content.mime)) {
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
uri: `data:${content.mime};base64,${Buffer.from(content.content).toString("base64")}`,
|
||||
mime: content.mime,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: {
|
||||
...file,
|
||||
mime: content.mime,
|
||||
description: attachmentError({
|
||||
name: file.name ?? filepath,
|
||||
uri: file.uri,
|
||||
message: `unsupported file type ${content.mime}`,
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
|
||||
const files = yield* Effect.forEach(input.files ?? [], resolveFileAttachment, { concurrency: "unbounded" })
|
||||
return Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
files: files.map((file) => file.file),
|
||||
})
|
||||
})
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
|
||||
@@ -10,13 +10,20 @@ import {
|
||||
import { SessionMessage } from "../message"
|
||||
import type { FileAttachment } from "../prompt"
|
||||
|
||||
const media = (file: FileAttachment): ContentPart => ({
|
||||
type: "media",
|
||||
mediaType: file.mime,
|
||||
data: file.uri,
|
||||
filename: file.name,
|
||||
metadata: file.description === undefined ? undefined : { description: file.description },
|
||||
})
|
||||
const media = (file: FileAttachment): ContentPart[] => {
|
||||
if (file.mime === "text/plain" || file.mime === "application/x-directory") {
|
||||
return file.description ? [{ type: "text", text: file.description }] : []
|
||||
}
|
||||
if (!file.mime.startsWith("image/") && file.mime !== "application/pdf") return []
|
||||
return [
|
||||
{
|
||||
type: "media",
|
||||
mediaType: file.mime,
|
||||
data: file.uri,
|
||||
filename: file.name,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const toolInput = (tool: SessionMessage.AssistantTool) => {
|
||||
if (tool.state.status !== "pending") return tool.state.input
|
||||
@@ -122,7 +129,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
|
||||
content: [{ type: "text", text: message.text }, ...(message.files ?? []).flatMap(media)],
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
...(message.agents?.length ? { agents: message.agents } : {}),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { mkdir, mkdtemp, writeFile } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -88,6 +92,30 @@ const setup = Effect.gen(function* () {
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const setupSession = (input: { id: SessionV2.ID; directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make(input.directory), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
project_id: Project.ID.global,
|
||||
slug: input.id,
|
||||
directory: input.directory,
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInput.find(db, id))
|
||||
const admittedCount = Database.Service.use(({ db }) =>
|
||||
db
|
||||
@@ -114,7 +142,11 @@ const eventCount = (type: string) =>
|
||||
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||
const { id: _, type, ...data } = encodeMessage(
|
||||
const {
|
||||
id: _,
|
||||
type,
|
||||
...data
|
||||
} = encodeMessage(
|
||||
SessionMessage.Assistant.make({
|
||||
id,
|
||||
type: "assistant",
|
||||
@@ -191,6 +223,86 @@ describe("SessionV2.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves tagged text files into durable prompt text", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-prompt-")))
|
||||
const file = path.join(dir, "src", "example.ts")
|
||||
yield* Effect.promise(() => mkdir(path.dirname(file), { recursive: true }))
|
||||
yield* Effect.promise(() => writeFile(file, ["one", "two", "three"].join("\n")))
|
||||
const id = SessionV2.ID.make("ses_prompt_file")
|
||||
yield* setupSession({ id, directory: dir })
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID: id,
|
||||
prompt: {
|
||||
text: "explain this",
|
||||
files: [
|
||||
{
|
||||
uri: `${pathToFileURL(file).href}?start=2&end=3`,
|
||||
name: "src/example.ts#2-3",
|
||||
source: { start: 13, end: 29, text: "@src/example.ts#2-3" },
|
||||
},
|
||||
],
|
||||
},
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.text).toBe("explain this")
|
||||
expect(message.prompt.files?.[0]?.description).toContain('<attachment name="src/example.ts#2-3">')
|
||||
expect(message.prompt.files?.[0]?.description).toContain("two\nthree")
|
||||
expect(message.prompt.files?.[0]?.mime).toBe("text/plain")
|
||||
expect(message.prompt.files?.[0]?.source?.text).toBe("@src/example.ts#2-3")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves tagged directories into durable prompt text", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-prompt-dir-")))
|
||||
yield* Effect.promise(() => mkdir(path.join(dir, "src", "nested"), { recursive: true }))
|
||||
yield* Effect.promise(() => writeFile(path.join(dir, "src", "index.ts"), "export {}"))
|
||||
const id = SessionV2.ID.make("ses_prompt_directory")
|
||||
yield* setupSession({ id, directory: dir })
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID: id,
|
||||
prompt: {
|
||||
text: "inspect directory",
|
||||
files: [{ uri: pathToFileURL(path.join(dir, "src")).href, name: "src/" }],
|
||||
},
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.text).toBe("inspect directory")
|
||||
expect(message.prompt.files?.[0]?.description).toContain('<attachment name="src/">')
|
||||
expect(message.prompt.files?.[0]?.description).toContain("index.ts")
|
||||
expect(message.prompt.files?.[0]?.description).toContain("nested/")
|
||||
expect(message.prompt.files?.[0]?.mime).toBe("application/x-directory")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("materializes local media attachments as data urls", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-prompt-image-")))
|
||||
const file = path.join(dir, "pixel.png")
|
||||
yield* Effect.promise(() => writeFile(file, Buffer.from("iVBORw0KGgo=", "base64")))
|
||||
const id = SessionV2.ID.make("ses_prompt_media")
|
||||
yield* setupSession({ id, directory: dir })
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID: id,
|
||||
prompt: { text: "look", files: [{ uri: pathToFileURL(file).href, name: "pixel.png" }] },
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.text).toBe("look")
|
||||
expect(message.prompt.files?.[0]?.mime).toBe("image/png")
|
||||
expect(message.prompt.files?.[0]?.uri.startsWith("data:image/png;base64,")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("commits a staged revert before admitting a new prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
Reference in New Issue
Block a user