mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2375102457 | |||
| 573ce945a6 | |||
| 575dc569e9 |
+266
-14
@@ -1,7 +1,7 @@
|
|||||||
export * as SessionV2 from "./session"
|
export * as SessionV2 from "./session"
|
||||||
export * from "./session/schema"
|
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 { ListAnchor } from "@opencode-ai/schema/session"
|
||||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
||||||
import { ProjectV2 } from "./project"
|
import { ProjectV2 } from "./project"
|
||||||
@@ -23,6 +23,7 @@ import { InstallationVersion } from "./installation/version"
|
|||||||
import { Slug } from "./util/slug"
|
import { Slug } from "./util/slug"
|
||||||
import { ProjectTable } from "./project/sql"
|
import { ProjectTable } from "./project/sql"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
import { fromRow } from "./session/info"
|
import { fromRow } from "./session/info"
|
||||||
import { SessionRunner } from "./session/runner/index"
|
import { SessionRunner } from "./session/runner/index"
|
||||||
import { SessionStore } from "./session/store"
|
import { SessionStore } from "./session/store"
|
||||||
@@ -37,6 +38,7 @@ import { SessionCompaction } from "./session/compaction"
|
|||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Revert } from "@opencode-ai/schema/revert"
|
import { Revert } from "@opencode-ai/schema/revert"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
|
import { FileSystem } from "./filesystem"
|
||||||
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
|
|
||||||
@@ -419,7 +421,14 @@ export const layer = Layer.effect(
|
|||||||
// continues from the reverted boundary rather than stale post-boundary history.
|
// continues from the reverted boundary rather than stale post-boundary history.
|
||||||
if (session.revert)
|
if (session.revert)
|
||||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
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 messageID = input.id ?? SessionMessage.ID.create()
|
||||||
const delivery = input.delivery ?? "steer"
|
const delivery = input.delivery ?? "steer"
|
||||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||||
@@ -458,7 +467,9 @@ export const layer = Layer.effect(
|
|||||||
text: skill.content,
|
text: skill.content,
|
||||||
})
|
})
|
||||||
if (input.resume !== false)
|
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) {
|
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
@@ -524,7 +535,9 @@ export const layer = Layer.effect(
|
|||||||
timestamp: yield* DateTime.now,
|
timestamp: yield* DateTime.now,
|
||||||
text: input.text,
|
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) =>
|
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||||
@@ -569,19 +582,258 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.orDie,
|
Layer.orDie,
|
||||||
)
|
)
|
||||||
|
|
||||||
const resolvePrompt = (input: PromptInput.Prompt) =>
|
const MAX_TEXT_ATTACHMENT_BYTES = 256 * 1024
|
||||||
Prompt.make({
|
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,
|
text: input.text,
|
||||||
agents: input.agents,
|
agents: input.agents,
|
||||||
files: input.files?.map((file) => {
|
files: files.map((file) => file.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)),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ import {
|
|||||||
import { SessionMessage } from "../message"
|
import { SessionMessage } from "../message"
|
||||||
import type { FileAttachment } from "../prompt"
|
import type { FileAttachment } from "../prompt"
|
||||||
|
|
||||||
const media = (file: FileAttachment): ContentPart => ({
|
const media = (file: FileAttachment): ContentPart[] => {
|
||||||
type: "media",
|
if (file.mime === "text/plain" || file.mime === "application/x-directory") {
|
||||||
mediaType: file.mime,
|
return file.description ? [{ type: "text", text: file.description }] : []
|
||||||
data: file.uri,
|
}
|
||||||
filename: file.name,
|
if (!file.mime.startsWith("image/") && file.mime !== "application/pdf") return []
|
||||||
metadata: file.description === undefined ? undefined : { description: file.description },
|
return [
|
||||||
})
|
{
|
||||||
|
type: "media",
|
||||||
|
mediaType: file.mime,
|
||||||
|
data: file.uri,
|
||||||
|
filename: file.name,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const toolInput = (tool: SessionMessage.AssistantTool) => {
|
const toolInput = (tool: SessionMessage.AssistantTool) => {
|
||||||
if (tool.state.status !== "pending") return tool.state.input
|
if (tool.state.status !== "pending") return tool.state.input
|
||||||
@@ -122,7 +129,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||||||
Message.make({
|
Message.make({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
|
content: [{ type: "text", text: message.text }, ...(message.files ?? []).flatMap(media)],
|
||||||
metadata: {
|
metadata: {
|
||||||
...message.metadata,
|
...message.metadata,
|
||||||
...(message.agents?.length ? { agents: message.agents } : {}),
|
...(message.agents?.length ? { agents: message.agents } : {}),
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { describe, expect } from "bun:test"
|
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 { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
@@ -88,6 +92,30 @@ const setup = Effect.gen(function* () {
|
|||||||
.pipe(Effect.orDie)
|
.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 admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInput.find(db, id))
|
||||||
const admittedCount = Database.Service.use(({ db }) =>
|
const admittedCount = Database.Service.use(({ db }) =>
|
||||||
db
|
db
|
||||||
@@ -114,7 +142,11 @@ const eventCount = (type: string) =>
|
|||||||
|
|
||||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||||
const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||||
const { id: _, type, ...data } = encodeMessage(
|
const {
|
||||||
|
id: _,
|
||||||
|
type,
|
||||||
|
...data
|
||||||
|
} = encodeMessage(
|
||||||
SessionMessage.Assistant.make({
|
SessionMessage.Assistant.make({
|
||||||
id,
|
id,
|
||||||
type: "assistant",
|
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", () =>
|
it.effect("commits a staged revert before admitting a new prompt", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
Reference in New Issue
Block a user