Merge remote-tracking branch 'origin/dev' into sqlite2

This commit is contained in:
Dax Raad
2026-02-07 01:11:15 -05:00
363 changed files with 25929 additions and 8534 deletions
+1 -1
View File
@@ -74,7 +74,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.4.0",
"@gitlab/gitlab-ai-provider": "3.5.0",
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
+6 -4
View File
@@ -29,6 +29,7 @@ import {
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -1008,7 +1009,7 @@ export namespace ACP {
type: "image",
mimeType: effectiveMime,
data: base64Data,
uri: `file://${filename}`,
uri: pathToFileURL(filename).href,
},
},
})
@@ -1018,13 +1019,14 @@ export namespace ACP {
} else {
// Non-image: text types get decoded, binary types stay as blob
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
const fileUri = pathToFileURL(filename).href
const resource = isText
? {
uri: `file://${filename}`,
uri: fileUri,
mimeType: effectiveMime,
text: Buffer.from(base64Data, "base64").toString("utf-8"),
}
: { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data }
: { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
await this.connection
.sessionUpdate({
@@ -1566,7 +1568,7 @@ export namespace ACP {
const name = path.split("/").pop() || path
return {
type: "file",
url: `file://${path}`,
url: pathToFileURL(path).href,
filename: name,
mime: "text/plain",
}
+5 -13
View File
@@ -160,25 +160,17 @@ export function parseGitHubRemote(url: string): { owner: string; repo: string }
/**
* Extracts displayable text from assistant response parts.
* Returns null for tool-only or reasoning-only responses (signals summary needed).
* Throws for truly unusable responses (empty, step-start only, etc.).
* Returns null for non-text responses (signals summary needed).
* Throws only for truly empty responses.
*/
export function extractResponseText(parts: MessageV2.Part[]): string | null {
// Priority 1: Look for text parts
const textPart = parts.findLast((p) => p.type === "text")
if (textPart) return textPart.text
// Priority 2: Reasoning-only - return null to signal summary needed
const reasoningPart = parts.findLast((p) => p.type === "reasoning")
if (reasoningPart) return null
// Non-text parts (tools, reasoning, step-start/step-finish, etc.) - signal summary needed
if (parts.length > 0) return null
// Priority 3: Tool-only - return null to signal summary needed
const toolParts = parts.filter((p) => p.type === "tool" && p.state.status === "completed")
if (toolParts.length > 0) return null
// No usable parts - throw with debug info
const partTypes = parts.map((p) => p.type).join(", ") || "none"
throw new Error(`Failed to parse response. Part types found: [${partTypes}]`)
throw new Error("Failed to parse response: no parts returned")
}
export const GithubCommand = cmd({
+70 -21
View File
@@ -1,18 +1,74 @@
import type { Argv } from "yargs"
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { Session } from "../../session"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { Database } from "../../storage/db"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
export type ShareData =
| { type: "session"; data: SDKSession }
| { type: "message"; data: Message }
| { type: "part"; data: Part }
| { type: "session_diff"; data: unknown }
| { type: "model"; data: unknown }
/** Extract share ID from a share URL like https://opncd.ai/share/abc123 */
export function parseShareUrl(url: string): string | null {
const match = url.match(/^https?:\/\/[^/]+\/share\/([a-zA-Z0-9_-]+)$/)
return match ? match[1] : null
}
/**
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
*
* The API returns a flat array: [session, message, message, part, part, ...]
* Local storage expects: { info: session, messages: [{ info: message, parts: [part, ...] }, ...] }
*
* This groups parts by their messageID to reconstruct the hierarchy before writing to disk.
*/
export function transformShareData(shareData: ShareData[]): {
info: SDKSession
messages: Array<{ info: Message; parts: Part[] }>
} | null {
const sessionItem = shareData.find((d) => d.type === "session")
if (!sessionItem) return null
const messageMap = new Map<string, Message>()
const partMap = new Map<string, Part[]>()
for (const item of shareData) {
if (item.type === "message") {
messageMap.set(item.data.id, item.data)
} else if (item.type === "part") {
if (!partMap.has(item.data.messageID)) {
partMap.set(item.data.messageID, [])
}
partMap.get(item.data.messageID)!.push(item.data)
}
}
if (messageMap.size === 0) return null
return {
info: sessionItem.data,
messages: Array.from(messageMap.values()).map((msg) => ({
info: msg,
parts: partMap.get(msg.id) ?? [],
})),
}
}
export const ImportCommand = cmd({
command: "import <file>",
describe: "import session data from JSON file or URL",
builder: (yargs: Argv) => {
return yargs.positional("file", {
describe: "path to JSON file or opencode.ai share URL",
describe: "path to JSON file or share URL",
type: "string",
demandOption: true,
})
@@ -23,8 +79,8 @@ export const ImportCommand = cmd({
| {
info: Session.Info
messages: Array<{
info: any
parts: any[]
info: Message
parts: Part[]
}>
}
| undefined
@@ -32,15 +88,16 @@ export const ImportCommand = cmd({
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
if (isUrl) {
const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/share\/([a-zA-Z0-9_-]+)/)
if (!urlMatch) {
process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/share/<slug>`)
const slug = parseShareUrl(args.file)
if (!slug) {
const baseUrl = await ShareNext.url()
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
process.stdout.write(EOL)
return
}
const slug = urlMatch[1]
const response = await fetch(`https://opncd.ai/api/share/${slug}`)
const baseUrl = await ShareNext.url()
const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
@@ -48,24 +105,16 @@ export const ImportCommand = cmd({
return
}
const data = await response.json()
const shareData: ShareData[] = await response.json()
const transformed = transformShareData(shareData)
if (!data.info || !data.messages || Object.keys(data.messages).length === 0) {
process.stdout.write(`Share not found: ${slug}`)
if (!transformed) {
process.stdout.write(`Share not found or empty: ${slug}`)
process.stdout.write(EOL)
return
}
exportData = {
info: data.info,
messages: Object.values(data.messages).map((msg: any) => {
const { parts, ...info } = msg
return {
info,
parts,
}
}),
}
exportData = transformed
} else {
const file = Bun.file(args.file)
exportData = await file.json().catch(() => {})
+19 -5
View File
@@ -1,5 +1,6 @@
import type { Argv } from "yargs"
import path from "path"
import { pathToFileURL } from "bun"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
@@ -236,6 +237,10 @@ export const RunCommand = cmd({
describe: "session id to continue",
type: "string",
})
.option("fork", {
describe: "fork the session before continuing (requires --continue or --session)",
type: "boolean",
})
.option("share", {
type: "boolean",
describe: "share the session",
@@ -310,7 +315,7 @@ export const RunCommand = cmd({
files.push({
type: "file",
url: `file://${resolvedPath}`,
url: pathToFileURL(resolvedPath).href,
filename: path.basename(resolvedPath),
mime,
})
@@ -324,6 +329,11 @@ export const RunCommand = cmd({
process.exit(1)
}
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exit(1)
}
const rules: PermissionNext.Ruleset = [
{
permission: "question",
@@ -349,11 +359,15 @@ export const RunCommand = cmd({
}
async function session(sdk: OpencodeClient) {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
if (baseID && args.fork) {
const forked = await sdk.session.fork({ sessionID: baseID })
return forked.data?.id
}
if (args.session) return args.session
if (baseID) return baseID
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
+29 -2
View File
@@ -250,7 +250,8 @@ function App() {
})
local.model.set({ providerID, modelID }, { recent: true })
}
if (args.sessionID) {
// Handle --session without --fork immediately (fork is handled in createEffect below)
if (args.sessionID && !args.fork) {
route.navigate({
type: "session",
sessionID: args.sessionID,
@@ -268,10 +269,36 @@ function App() {
.find((x) => x.parentID === undefined)?.id
if (match) {
continued = true
route.navigate({ type: "session", sessionID: match })
if (args.fork) {
sdk.client.session.fork({ sessionID: match }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
})
} else {
route.navigate({ type: "session", sessionID: match })
}
}
})
// Handle --session with --fork: wait for sync to be fully complete before forking
// (session list loads in non-blocking phase for --session, so we must wait for "complete"
// to avoid a race where reconcile overwrites the newly forked session)
let forked = false
createEffect(() => {
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
forked = true
sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
})
})
createEffect(
on(
() => sync.status === "complete" && sync.data.provider.length === 0,
@@ -124,6 +124,7 @@ function AutoMethod(props: AutoMethodProps) {
const dialog = useDialog()
const sync = useSync()
const toast = useToast()
const [hover, setHover] = createSignal(false)
useKeyboard((evt) => {
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
@@ -154,9 +155,16 @@ function AutoMethod(props: AutoMethodProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box gap={1}>
<Link href={props.authorization.url} fg={theme.primary} />
@@ -1,8 +1,9 @@
import { TextAttributes } from "@opentui/core"
import { fileURLToPath } from "bun"
import { useTheme } from "../context/theme"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
import { For, Match, Switch, Show, createMemo } from "solid-js"
import { For, Match, Switch, Show, createMemo, createSignal } from "solid-js"
import { Installation } from "@/installation"
export type DialogStatusProps = {}
@@ -11,6 +12,7 @@ export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()
const dialog = useDialog()
const [hover, setHover] = createSignal(false)
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
@@ -18,7 +20,7 @@ export function DialogStatus() {
const list = sync.data.config.plugin ?? []
const result = list.map((value) => {
if (value.startsWith("file://")) {
const path = value.substring("file://".length)
const path = fileURLToPath(value)
const parts = path.split("/")
const filename = parts.pop() || path
if (!filename.includes(".")) return { name: filename }
@@ -45,9 +47,16 @@ export function DialogStatus() {
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<text fg={theme.textMuted}>OpenCode v{Installation.VERSION}</text>
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
@@ -1,4 +1,5 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
@@ -246,17 +247,17 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
let url = `file://${process.cwd()}/${item}`
const fullPath = `${process.cwd()}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item
if (lineRange && !item.endsWith("/")) {
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
const urlObj = new URL(url)
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
url = urlObj.toString()
}
const url = urlObj.href
const isDir = item.endsWith("/")
return {
@@ -6,6 +6,7 @@ export interface Args {
prompt?: string
continue?: boolean
sessionID?: string
fork?: boolean
}
export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({
@@ -1099,6 +1099,9 @@ export function Session() {
sessionID={route.sessionID}
/>
</box>
<Show when={!sidebarVisible() || !wide()}>
<Footer />
</Show>
</Show>
<Toast />
</box>
@@ -64,6 +64,10 @@ export const TuiThreadCommand = cmd({
type: "string",
describe: "session id to continue",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
@@ -73,6 +77,11 @@ export const TuiThreadCommand = cmd({
describe: "agent to use",
}),
handler: async (args) => {
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exit(1)
}
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
@@ -150,6 +159,7 @@ export const TuiThreadCommand = cmd({
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
onExit: async () => {
await client.call("shutdown", undefined)
@@ -2,6 +2,7 @@ import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { createSignal } from "solid-js"
export type DialogAlertProps = {
title: string
@@ -12,6 +13,7 @@ export type DialogAlertProps = {
export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
useKeyboard((evt) => {
if (evt.name === "return") {
@@ -25,9 +27,16 @@ export function DialogAlert(props: DialogAlertProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>{props.message}</text>
@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { createSignal, For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util/locale"
@@ -16,6 +16,7 @@ export type DialogConfirmProps = {
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const [store, setStore] = createStore({
active: "confirm" as "confirm" | "cancel",
})
@@ -37,9 +38,16 @@ export function DialogConfirm(props: DialogConfirmProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>{props.message}</text>
@@ -2,7 +2,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { onMount, Show, type JSX } from "solid-js"
import { createSignal, onMount, Show, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogExportOptionsProps = {
@@ -25,6 +25,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { theme } = useTheme()
let textarea: TextareaRenderable
const [hover, setHover] = createSignal(false)
const [store, setStore] = createStore({
thinking: props.defaultThinking,
toolDetails: props.defaultToolDetails,
@@ -80,9 +81,16 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Export Options
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box gap={1}>
<box>
@@ -3,11 +3,13 @@ import { useTheme } from "@tui/context/theme"
import { useDialog } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import { createSignal } from "solid-js"
export function DialogHelp() {
const dialog = useDialog()
const { theme } = useTheme()
const keybind = useKeybind()
const [hover, setHover] = createSignal(false)
useKeyboard((evt) => {
if (evt.name === "return" || evt.name === "escape") {
@@ -21,9 +23,16 @@ export function DialogHelp() {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Help
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc/enter
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc/enter</text>
</box>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>
@@ -1,7 +1,7 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { onMount, type JSX } from "solid-js"
import { createSignal, onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogPromptProps = {
@@ -17,6 +17,7 @@ export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
let textarea: TextareaRenderable
const [hover, setHover] = createSignal(false)
useKeyboard((evt) => {
if (evt.name === "return") {
@@ -39,9 +40,16 @@ export function DialogPrompt(props: DialogPromptProps) {
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box gap={1}>
{props.description}
@@ -1,7 +1,7 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
@@ -49,6 +49,7 @@ export type DialogSelectRef<T> = {
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const [store, setStore] = createStore({
selected: 0,
filter: "",
@@ -226,9 +227,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={hover() ? theme.primary : undefined}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => dialog.clear()}
>
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
</box>
</box>
<box paddingTop={1}>
<input
+13 -8
View File
@@ -33,6 +33,8 @@ import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const log = Log.create({ service: "config" })
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
@@ -653,19 +655,23 @@ export namespace Config {
template: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
model: ModelId.optional(),
subtask: z.boolean().optional(),
})
export type Command = z.infer<typeof Command>
export const Skills = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
.array(z.string())
.optional()
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
})
export type Skills = z.infer<typeof Skills>
export const Agent = z
.object({
model: z.string().optional(),
model: ModelId.optional(),
variant: z
.string()
.optional()
@@ -1036,11 +1042,10 @@ export namespace Config {
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: z
.string()
.describe("Small model to use for tasks like title generation in the format of provider/model")
.optional(),
model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ModelId.describe(
"Small model to use for tasks like title generation in the format of provider/model",
).optional(),
default_agent: z
.string()
.optional()
@@ -1267,7 +1272,7 @@ export namespace Config {
})
).trim()
// escape newlines/quotes, strip outer quotes
text = text.replace(match, JSON.stringify(fileContent).slice(1, -1))
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
import { pathToFileURL } from "url"
import { pathToFileURL, fileURLToPath } from "url"
import { LSPServer } from "./server"
import z from "zod"
import { Config } from "../config/config"
@@ -369,7 +369,7 @@ export namespace LSP {
}
export async function documentSymbol(uri: string) {
const file = new URL(uri).pathname
const file = fileURLToPath(uri)
return run(file, (client) =>
client.connection
.sendRequest("textDocument/documentSymbol", {
+1
View File
@@ -24,6 +24,7 @@ export namespace Plugin {
const state = Instance.state(async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: Instance.directory,
// @ts-ignore - fetch type incompatibility
fetch: async (...args) => Server.App().fetch(...args),
})
+26 -2
View File
@@ -1171,8 +1171,32 @@ export namespace Provider {
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
}
for (const item of priority) {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return getModel(providerID, model)
if (providerID === "amazon-bedrock") {
const crossRegionPrefixes = ["global.", "us.", "eu."]
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
// Model selection priority:
// 1. global. prefix (works everywhere)
// 2. User's region prefix (us., eu.)
// 3. Unprefixed model
const globalMatch = candidates.find((m) => m.startsWith("global."))
if (globalMatch) return getModel(providerID, globalMatch)
const region = provider.options?.region
if (region) {
const regionPrefix = region.split("-")[0]
if (regionPrefix === "us" || regionPrefix === "eu") {
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
if (regionalMatch) return getModel(providerID, regionalMatch)
}
}
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
if (unprefixed) return getModel(providerID, unprefixed)
} else {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return getModel(providerID, model)
}
}
}
}
@@ -646,6 +646,7 @@ export namespace ProviderTransform {
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
result["reasoningSummary"] = "auto"
}
// Only set textVerbosity for non-chat gpt-5.x models
@@ -536,7 +536,7 @@ export const SessionRoutes = lazy(() =>
},
auto: body.auto,
})
await SessionPrompt.loop(sessionID)
await SessionPrompt.loop({ sessionID })
return c.json(true)
},
)
@@ -108,6 +108,7 @@ export namespace SessionCompaction {
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
variant: userMessage.variant,
summary: true,
path: {
cwd: Instance.directory,
@@ -388,6 +388,7 @@ export namespace MessageV2 {
write: z.number(),
}),
}),
variant: z.string().optional(),
finish: z.string().optional(),
}).meta({
ref: "AssistantMessage",
+34 -7
View File
@@ -32,7 +32,7 @@ import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $, fileURLToPath } from "bun"
import { $, fileURLToPath, pathToFileURL } from "bun"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
@@ -172,7 +172,7 @@ export namespace SessionPrompt {
return message
}
return loop(input.sessionID)
return loop({ sessionID: input.sessionID })
})
export async function resolvePromptParts(template: string): Promise<PromptInput["parts"]> {
@@ -208,7 +208,7 @@ export namespace SessionPrompt {
if (stats.isDirectory()) {
parts.push({
type: "file",
url: `file://${filepath}`,
url: pathToFileURL(filepath).href,
filename: name,
mime: "application/x-directory",
})
@@ -217,7 +217,7 @@ export namespace SessionPrompt {
parts.push({
type: "file",
url: `file://${filepath}`,
url: pathToFileURL(filepath).href,
filename: name,
mime: "text/plain",
})
@@ -237,6 +237,13 @@ export namespace SessionPrompt {
return controller.signal
}
function resume(sessionID: string) {
const s = state()
if (!s[sessionID]) return
return s[sessionID].abort.signal
}
export function cancel(sessionID: string) {
log.info("cancel", { sessionID })
const s = state()
@@ -251,8 +258,14 @@ export namespace SessionPrompt {
return
}
export const loop = fn(Identifier.schema("session"), async (sessionID) => {
const abort = start(sessionID)
export const LoopInput = z.object({
sessionID: Identifier.schema("session"),
resume_existing: z.boolean().optional(),
})
export const loop = fn(LoopInput, async (input) => {
const { sessionID, resume_existing } = input
const abort = resume_existing ? resume(sessionID) : start(sessionID)
if (!abort) {
return new Promise<MessageV2.WithParts>((resolve, reject) => {
const callbacks = state()[sessionID].callbacks
@@ -321,6 +334,7 @@ export namespace SessionPrompt {
sessionID,
mode: task.agent,
agent: task.agent,
variant: lastUser.variant,
path: {
cwd: Instance.directory,
root: Instance.worktree,
@@ -524,6 +538,7 @@ export namespace SessionPrompt {
role: "assistant",
mode: agent.name,
agent: agent.name,
variant: lastUser.variant,
path: {
cwd: Instance.directory,
root: Instance.worktree,
@@ -1364,7 +1379,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (!abort) {
throw new Session.BusyError(input.sessionID)
}
using _ = defer(() => cancel(input.sessionID))
using _ = defer(() => {
// If no queued callbacks, cancel (the default)
const callbacks = state()[input.sessionID]?.callbacks ?? []
if (callbacks.length === 0) {
cancel(input.sessionID)
} else {
// Otherwise, trigger the session loop to process queued items
loop({ sessionID: input.sessionID, resume_existing: true }).catch((error) => {
log.error("session loop failed to resume after shell command", { sessionID: input.sessionID, error })
})
}
})
const session = await Session.get(input.sessionID)
if (session.revert) {
@@ -0,0 +1,97 @@
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: 2 + 2
assistant: 4
</example>
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: Yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: How many golf balls fit inside a jetta?
assistant: 150000
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
<example>
user: write tests for new feature
assistant: [uses grep or glob to find where similar tests are defined, then read relevant files one at a time (one tool per message, wait for each result), then edit or write to add tests]
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
- Use the available search tools to understand the codebase and the user's query. Use one tool per message; after each result, decide the next step and call one tool again.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
# Tool usage policy
- When doing file search, prefer to use the Task tool in order to reduce context usage.
- Use exactly one tool per assistant message. After each tool call, wait for the result before continuing.
- When the user's request is vague, use the question tool to clarify before reading files or making changes.
- Avoid repeating the same tool with the same parameters once you have useful results. Use the result to take the next step (e.g. pick one match, read that file, then act); do not search again in a loop.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>
+2
View File
@@ -8,6 +8,7 @@ import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_CODEX from "./prompt/codex_header.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider"
export namespace SystemPrompt {
@@ -21,6 +22,7 @@ export namespace SystemPrompt {
return [PROMPT_BEAST]
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}
+1 -1
View File
@@ -12,7 +12,7 @@ import type * as SDK from "@opencode-ai/sdk/v2"
export namespace ShareNext {
const log = Log.create({ service: "share-next" })
async function url() {
export async function url() {
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
}
+97
View File
@@ -0,0 +1,97 @@
import path from "path"
import { mkdir } from "fs/promises"
import { Log } from "../util/log"
import { Global } from "../global"
export namespace Discovery {
const log = Log.create({ service: "skill-discovery" })
type Index = {
skills: Array<{
name: string
description: string
files: string[]
}>
}
export function dir() {
return path.join(Global.Path.cache, "skills")
}
async function get(url: string, dest: string): Promise<boolean> {
if (await Bun.file(dest).exists()) return true
return fetch(url)
.then(async (response) => {
if (!response.ok) {
log.error("failed to download", { url, status: response.status })
return false
}
await Bun.write(dest, await response.text())
return true
})
.catch((err) => {
log.error("failed to download", { url, err })
return false
})
}
export async function pull(url: string): Promise<string[]> {
const result: string[] = []
const base = url.endsWith("/") ? url : `${url}/`
const index = new URL("index.json", base).href
const cache = dir()
const host = base.slice(0, -1)
log.info("fetching index", { url: index })
const data = await fetch(index)
.then(async (response) => {
if (!response.ok) {
log.error("failed to fetch index", { url: index, status: response.status })
return undefined
}
return response
.json()
.then((json) => json as Index)
.catch((err) => {
log.error("failed to parse index", { url: index, err })
return undefined
})
})
.catch((err) => {
log.error("failed to fetch index", { url: index, err })
return undefined
})
if (!data?.skills || !Array.isArray(data.skills)) {
log.warn("invalid index format", { url: index })
return result
}
const list = data.skills.filter((skill) => {
if (!skill?.name || !Array.isArray(skill.files)) {
log.warn("invalid skill entry", { url: index, skill })
return false
}
return true
})
await Promise.all(
list.map(async (skill) => {
const root = path.join(cache, skill.name)
await Promise.all(
skill.files.map(async (file) => {
const link = new URL(file, `${host}/${skill.name}/`).href
const dest = path.join(root, file)
await mkdir(path.dirname(dest), { recursive: true })
await get(link, dest)
}),
)
const md = path.join(root, "SKILL.md")
if (await Bun.file(md).exists()) result.push(root)
}),
)
return result
}
}
+17
View File
@@ -11,6 +11,7 @@ import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { Session } from "@/session"
import { Discovery } from "./discovery"
export namespace Skill {
const log = Log.create({ service: "skill" })
@@ -151,6 +152,22 @@ export namespace Skill {
}
}
// Download and load skills from URLs
for (const url of config.skills?.urls ?? []) {
const list = await Discovery.pull(url)
for (const dir of list) {
dirs.add(dir)
for await (const match of SKILL_GLOB.scan({
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
await addSkill(match)
}
}
}
return {
skills,
dirs: Array.from(dirs),
-38
View File
@@ -2,7 +2,6 @@ import { Tool } from "./tool"
import DESCRIPTION from "./task.txt"
import z from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
@@ -118,28 +117,6 @@ export const TaskTool = Tool.define("task", async (ctx) => {
})
const messageID = Identifier.ascending("message")
const parts: Record<string, { id: string; tool: string; state: { status: string; title?: string } }> = {}
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
if (evt.properties.part.sessionID !== session.id) return
if (evt.properties.part.messageID === messageID) return
if (evt.properties.part.type !== "tool") return
const part = evt.properties.part
parts[part.id] = {
id: part.id,
tool: part.tool,
state: {
status: part.state.status,
title: part.state.status === "completed" ? part.state.title : undefined,
},
}
ctx.metadata({
title: params.description,
metadata: {
sessionId: session.id,
model,
},
})
})
function cancel() {
SessionPrompt.cancel(session.id)
@@ -163,22 +140,8 @@ export const TaskTool = Tool.define("task", async (ctx) => {
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
}).finally(() => {
unsub()
})
const messages = await Session.messages({ sessionID: session.id })
const summary = messages
.filter((x) => x.info.role === "assistant")
.flatMap((msg) => msg.parts.filter((x: any) => x.type === "tool") as MessageV2.ToolPart[])
.map((part) => ({
id: part.id,
tool: part.tool,
state: {
status: part.state.status,
title: part.state.status === "completed" ? part.state.title : undefined,
},
}))
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
const output = [
@@ -192,7 +155,6 @@ export const TaskTool = Tool.define("task", async (ctx) => {
return {
title: params.description,
metadata: {
summary,
sessionId: session.id,
model,
},
+6 -1
View File
@@ -413,8 +413,13 @@ export namespace Worktree {
if (key === directory) return item
}
})()
if (!entry?.path) {
throw new RemoveFailedError({ message: "Worktree not found" })
const directoryExists = await exists(directory)
if (directoryExists) {
await fs.rm(directory, { recursive: true, force: true })
}
return true
}
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
@@ -67,6 +67,18 @@ function createStepStartPart(): MessageV2.Part {
}
}
function createStepFinishPart(): MessageV2.Part {
return {
id: "1",
sessionID: "s",
messageID: "m",
type: "step-finish" as const,
reason: "done",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
}
describe("extractResponseText", () => {
test("returns text from text part", () => {
const parts = [createTextPart("Hello world")]
@@ -103,18 +115,38 @@ describe("extractResponseText", () => {
expect(extractResponseText(parts)).toBeNull()
})
test("ignores running tool parts (throws since no completed tools)", () => {
test("returns null for running tool parts (signals summary needed)", () => {
const parts = [createToolPart("bash", "", "running")]
expect(() => extractResponseText(parts)).toThrow("Failed to parse response")
expect(extractResponseText(parts)).toBeNull()
})
test("throws with part types on empty array", () => {
expect(() => extractResponseText([])).toThrow("Part types found: [none]")
test("throws on empty array", () => {
expect(() => extractResponseText([])).toThrow("no parts returned")
})
test("throws with part types on unhandled parts", () => {
test("returns null for step-start only", () => {
const parts = [createStepStartPart()]
expect(() => extractResponseText(parts)).toThrow("Part types found: [step-start]")
expect(extractResponseText(parts)).toBeNull()
})
test("returns null for step-finish only", () => {
const parts = [createStepFinishPart()]
expect(extractResponseText(parts)).toBeNull()
})
test("returns null for step-start and step-finish", () => {
const parts = [createStepStartPart(), createStepFinishPart()]
expect(extractResponseText(parts)).toBeNull()
})
test("returns text from multi-step response", () => {
const parts = [
createStepStartPart(),
createToolPart("read", "src/file.ts"),
createTextPart("Done"),
createStepFinishPart(),
]
expect(extractResponseText(parts)).toBe("Done")
})
test("prefers text over reasoning when both present", () => {
+38
View File
@@ -0,0 +1,38 @@
import { test, expect } from "bun:test"
import { parseShareUrl, transformShareData, type ShareData } from "../../src/cli/cmd/import"
// parseShareUrl tests
test("parses valid share URLs", () => {
expect(parseShareUrl("https://opncd.ai/share/Jsj3hNIW")).toBe("Jsj3hNIW")
expect(parseShareUrl("https://custom.example.com/share/abc123")).toBe("abc123")
expect(parseShareUrl("http://localhost:3000/share/test_id-123")).toBe("test_id-123")
})
test("rejects invalid URLs", () => {
expect(parseShareUrl("https://opncd.ai/s/Jsj3hNIW")).toBeNull() // legacy format
expect(parseShareUrl("https://opncd.ai/share/")).toBeNull()
expect(parseShareUrl("https://opncd.ai/share/id/extra")).toBeNull()
expect(parseShareUrl("not-a-url")).toBeNull()
})
// transformShareData tests
test("transforms share data to storage format", () => {
const data: ShareData[] = [
{ type: "session", data: { id: "sess-1", title: "Test" } as any },
{ type: "message", data: { id: "msg-1", sessionID: "sess-1" } as any },
{ type: "part", data: { id: "part-1", messageID: "msg-1" } as any },
{ type: "part", data: { id: "part-2", messageID: "msg-1" } as any },
]
const result = transformShareData(data)!
expect(result.info.id).toBe("sess-1")
expect(result.messages).toHaveLength(1)
expect(result.messages[0].parts).toHaveLength(2)
})
test("returns null for invalid share data", () => {
expect(transformShareData([])).toBeNull()
expect(transformShareData([{ type: "message", data: {} as any }])).toBeNull()
expect(transformShareData([{ type: "session", data: { id: "s" } as any }])).toBeNull() // no messages
})
@@ -193,6 +193,25 @@ test("handles file inclusion substitution", async () => {
})
})
test("handles file inclusion with replacement tokens", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
theme: "{file:included.md}",
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("const out = await Bun.$`echo hi`")
},
})
})
test("validates config schema and throws on invalid fields", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -0,0 +1,56 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageV2 } from "../../src/session/message-v2"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("session.prompt special characters", () => {
test("handles filenames with # character", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
// Verify the URL is properly encoded (# should be %23)
expect(fileParts[0].url).toContain("%23")
// Verify the URL can be correctly converted back to a file path
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
// Verify the file content was read correctly
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
})
})
})
@@ -0,0 +1,60 @@
import { describe, test, expect } from "bun:test"
import { Discovery } from "../../src/skill/discovery"
import path from "path"
const CLOUDFLARE_SKILLS_URL = "https://developers.cloudflare.com/.well-known/skills/"
describe("Discovery.pull", () => {
test("downloads skills from cloudflare url", async () => {
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
expect(dir).toStartWith(Discovery.dir())
const md = path.join(dir, "SKILL.md")
expect(await Bun.file(md).exists()).toBe(true)
}
}, 30_000)
test("url without trailing slash works", async () => {
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
const md = path.join(dir, "SKILL.md")
expect(await Bun.file(md).exists()).toBe(true)
}
}, 30_000)
test("returns empty array for invalid url", async () => {
const dirs = await Discovery.pull("https://example.invalid/.well-known/skills/")
expect(dirs).toEqual([])
})
test("returns empty array for non-json response", async () => {
const dirs = await Discovery.pull("https://example.com/")
expect(dirs).toEqual([])
})
test("downloads reference files alongside SKILL.md", async () => {
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
// find a skill dir that should have reference files (e.g. agents-sdk)
const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk"))
if (agentsSdk) {
const refs = path.join(agentsSdk, "references")
expect(await Bun.file(path.join(agentsSdk, "SKILL.md")).exists()).toBe(true)
// agents-sdk has reference files per the index
const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))
expect(refDir.length).toBeGreaterThan(0)
}
}, 30_000)
test("caches downloaded files on second pull", async () => {
// first pull to populate cache
const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(first.length).toBeGreaterThan(0)
// second pull should return same results from cache
const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(second.length).toBe(first.length)
expect(second.sort()).toEqual(first.sort())
}, 60_000)
})