Compare commits

...

4 Commits

Author SHA1 Message Date
opencode
98325dcdc6 release: v1.4.0 2026-04-08 00:32:31 +00:00
opencode-agent[bot]
0788a535e2 chore: generate 2026-04-07 23:49:25 +00:00
Dax
b7fab49b64 refactor(snapshot): store unified patches in file diffs (#21244)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-04-07 19:48:23 -04:00
Dax
463318486f core: refactor tool system to remove agent context from initialization (#21052) 2026-04-07 19:48:12 -04:00
72 changed files with 882 additions and 748 deletions

View File

@@ -9,6 +9,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:",
},
"devDependencies": {
@@ -26,7 +27,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -80,7 +81,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -114,7 +115,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -141,7 +142,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -165,7 +166,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -189,7 +190,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -222,7 +223,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -254,7 +255,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -283,7 +284,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -299,7 +300,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.17",
"version": "1.4.0",
"bin": {
"opencode": "./bin/opencode",
},
@@ -435,7 +436,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -469,7 +470,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -484,7 +485,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -519,7 +520,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -532,6 +533,7 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
@@ -567,7 +569,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"zod": "catalog:",
},
@@ -578,7 +580,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -3257,6 +3259,8 @@
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],

View File

@@ -91,6 +91,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.17",
"version": "1.4.0",
"description": "",
"type": "module",
"exports": {

View File

@@ -1,7 +1,6 @@
import { Binary } from "@opencode-ai/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
FileDiff,
Message,
Part,
PermissionRequest,
@@ -9,6 +8,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
@@ -161,7 +161,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiff[] }
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
break
}

View File

@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test"
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -33,7 +33,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@@ -64,7 +64,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>

View File

@@ -1,10 +1,10 @@
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>

View File

@@ -2,7 +2,6 @@ import type {
Agent,
Command,
Config,
FileDiff,
LspStatus,
McpStatus,
Message,
@@ -14,6 +13,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
@@ -48,7 +48,7 @@ export type State = {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
[sessionID: string]: SnapshotFileDiff[]
}
todo: {
[sessionID: string]: Todo[]

View File

@@ -1,4 +1,4 @@
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import {
@@ -68,7 +68,7 @@ type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "session" | "turn"
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
@@ -463,13 +463,6 @@ export default function Page() {
if (!id) return false
return sync.session.history.loading(id)
})
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasSessionReview()) return true
return sync.data.session_diff[id] !== undefined
})
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
@@ -527,10 +520,19 @@ export default function Page() {
deferRender: false,
})
const [vcs, setVcs] = createStore({
const [vcs, setVcs] = createStore<{
diff: {
git: [] as FileDiff[],
branch: [] as FileDiff[],
git: VcsFileDiff[]
branch: VcsFileDiff[]
}
ready: {
git: boolean
branch: boolean
}
}>({
diff: {
git: [] as VcsFileDiff[],
branch: [] as VcsFileDiff[],
},
ready: {
git: false,
@@ -648,6 +650,7 @@ export default function Page() {
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
@@ -659,7 +662,7 @@ export default function Page() {
) {
list.push("branch")
}
list.push("session", "turn")
list.push("turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
@@ -668,20 +671,17 @@ export default function Page() {
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch
if (store.changes === "session") return diffs()
return turnDiffs()
})
const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
if (store.changes === "session") return sessionCount()
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
if (store.changes === "session") return !hasSessionReview() || diffsReady()
return true
})
@@ -749,13 +749,6 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto")
}
const sessionEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.empty"
})
function upsert(next: Project) {
const list = globalSync.data.project
sync.set("project", next.id)
@@ -1156,7 +1149,6 @@ export default function Page() {
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
if (option === "session") return language.t("ui.sessionReview.title")
return language.t("ui.sessionReview.title.lastTurn")
}
@@ -1179,11 +1171,26 @@ export default function Page() {
</div>
)
const createGit = (input: { emptyClass: string }) => (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</div>
)
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (store.changes === "turn") return language.t("session.review.noChanges")
return language.t(sessionEmptyKey())
return language.t("session.review.noChanges")
})
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
@@ -1193,31 +1200,10 @@ export default function Page() {
}
if (store.changes === "turn") {
if (nogit()) return createGit(input)
return empty(reviewEmptyText())
}
if (hasSessionReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (sessionEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</div>
)
}
return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>

View File

@@ -1,6 +1,6 @@
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"
import type {
SessionReviewCommentActions,
@@ -14,10 +14,12 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element
empty?: JSX.Element
diffs: () => FileDiff[]
diffs: () => ReviewDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void

View File

@@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -27,7 +27,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => FileDiff[]
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.17",
"version": "1.4.0",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.17",
"version": "1.4.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.17",
"version": "1.4.0",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,4 +1,4 @@
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife"
import z from "zod"
@@ -27,7 +27,7 @@ export namespace Share {
}),
z.object({
type: z.literal("session_diff"),
data: z.custom<FileDiff[]>(),
data: z.custom<SnapshotFileDiff[]>(),
}),
z.object({
type: z.literal("model"),

View File

@@ -1,4 +1,4 @@
import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context"
@@ -51,7 +51,7 @@ const getData = query(async (shareID) => {
shareID: string
session: Session[]
session_diff: {
[sessionID: string]: FileDiff[]
[sessionID: string]: SnapshotFileDiff[]
}
session_status: {
[sessionID: string]: SessionStatus

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.17"
version = "1.4.0"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.3.17",
"version": "1.4.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.17",
"version": "1.4.0",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -1,8 +1,4 @@
# 2.0
What we would change if we could
## Keybindings vs. Keymappings
# Keybindings vs. Keymappings
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like

View File

@@ -0,0 +1,136 @@
# Message Shape
Problem:
- stored messages need enough data to replay and resume a session later
- prompt hooks often just want to append a synthetic user/assistant message
- today that means faking ids, timestamps, and request metadata
## Option 1: Two Message Shapes
Keep `User` / `Assistant` for stored history, but clean them up.
```ts
type User = {
role: "user"
time: { created: number }
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type Assistant = {
role: "assistant"
run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Add a separate transient `PromptMessage` for prompt surgery.
```ts
type PromptMessage = {
role: "user" | "assistant"
parts: PromptPart[]
}
```
Plugin hook example:
```ts
prompt.push({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.
## Option 2: Prompt Mutators
Keep `User` / `Assistant` as the stored history model.
Prompt hooks do not build messages directly. The runtime gives them prompt mutators.
```ts
type PromptEditor = {
append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
}
```
Plugin hook examples:
```ts
prompt.append({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
```ts
prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])
```
Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.
## Option 3: Separate Turn State
Move execution settings out of `User` and into a separate turn/request object.
```ts
type Turn = {
id: string
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type User = {
role: "user"
turnID: string
time: { created: number }
}
type Assistant = {
role: "assistant"
turnID: string
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Examples:
```ts
const turn = {
request: {
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
},
}
```
```ts
const msg = {
role: "user",
turnID: turn.id,
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
}
```
Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to.

View File

@@ -71,7 +71,10 @@ export const AgentCommand = cmd({
async function getAvailableTools(agent: Agent.Info) {
const model = agent.model ?? (await Provider.defaultModel())
return ToolRegistry.tools(model, agent)
return ToolRegistry.tools({
...model,
agent,
})
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {

View File

@@ -2124,7 +2124,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
</text>
}
>
<Diff diff={file.diff} filePath={file.filePath} />
<Diff diff={file.patch} filePath={file.filePath} />
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
</Show>
</BlockTool>

View File

@@ -1,4 +1,5 @@
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
@@ -7,7 +8,6 @@ import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import z from "zod"
@@ -49,6 +49,8 @@ export namespace Vcs {
map: Map<string, { additions: number; deletions: number }>,
) {
const base = ref ? yield* git.prefix(cwd) : ""
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
const next = yield* Effect.forEach(
list,
(item) =>
@@ -58,12 +60,11 @@ export namespace Vcs {
const stat = map.get(item.file)
return {
file: item.file,
before,
after,
patch: patch(item.file, before, after),
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies Snapshot.FileDiff
} satisfies FileDiff
}),
{ concurrency: 8 },
)
@@ -125,11 +126,24 @@ export namespace Vcs {
})
export type Info = z.infer<typeof Info>
export const FileDiff = z
.object({
file: z.string(),
patch: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "VcsFileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
}
interface State {

View File

@@ -154,7 +154,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
description: "VCS diff",
content: {
"application/json": {
schema: resolver(Snapshot.FileDiff.array()),
schema: resolver(Vcs.FileDiff.array()),
},
},
},

View File

@@ -15,6 +15,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace"
import { Agent } from "@/agent/agent"
const ConsoleOrgOption = z.object({
accountID: z.string(),
@@ -181,7 +182,11 @@ export const ExperimentalRoutes = lazy(() =>
),
async (c) => {
const { provider, model } = c.req.valid("query")
const tools = await ToolRegistry.tools({ providerID: ProviderID.make(provider), modelID: ModelID.make(model) })
const tools = await ToolRegistry.tools({
providerID: ProviderID.make(provider),
modelID: ModelID.make(model),
agent: await Agent.get(await Agent.defaultAgent()),
})
return c.json(
tools.map((t) => ({
id: t.id,

View File

@@ -11,7 +11,6 @@ import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { ProviderTransform } from "../provider/transform"
import { SystemPrompt } from "./system"
@@ -24,7 +23,6 @@ import { ToolRegistry } from "../tool/registry"
import { Runner } from "@/effect/runner"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
@@ -37,7 +35,6 @@ import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { SessionStatus } from "./status"
@@ -50,6 +47,7 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -433,10 +431,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
),
})
for (const item of yield* registry.tools(
{ modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID },
input.agent,
)) {
for (const item of yield* registry.tools({
modelID: ModelID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
@@ -560,7 +559,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const taskTool = yield* Effect.promise(() => registry.named.task.init())
const taskTool = yield* registry.fromID(TaskTool.id)
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
@@ -583,7 +582,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: assistantMessage.sessionID,
type: "tool",
callID: ulid(),
tool: registry.named.task.id,
tool: TaskTool.id,
state: {
status: "running",
input: {
@@ -1113,7 +1112,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
]
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
const read = yield* registry.fromID("read").pipe(
Effect.flatMap((t) =>
provider.getModel(info.model.providerID, info.model.modelID).pipe(
Effect.flatMap((mdl) =>
@@ -1177,7 +1176,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.mime === "application/x-directory") {
const args = { filePath: filepath }
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
const result = yield* registry.fromID("read").pipe(
Effect.flatMap((t) =>
Effect.promise(() =>
t.execute(args, {

View File

@@ -59,7 +59,7 @@ export namespace ShareNext {
}
| {
type: "session_diff"
data: SDK.FileDiff[]
data: SDK.SnapshotFileDiff[]
}
| {
type: "model"

View File

@@ -239,22 +239,28 @@ export namespace Skill {
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
...list
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
return [
"## Available Skills",
...list
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n")
}
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -1,6 +1,6 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import z from "zod"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -22,14 +22,13 @@ export namespace Snapshot {
export const FileDiff = z
.object({
file: z.string(),
before: z.string(),
after: z.string(),
patch: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
ref: "SnapshotFileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
@@ -521,8 +520,6 @@ export namespace Snapshot {
const map = new Map<string, { before: string; after: string }>()
const dec = new TextDecoder()
let i = 0
// Parse the default `git cat-file --batch` stream: one header line,
// then exactly `size` bytes of blob content, then a trailing newline.
for (const ref of refs) {
let end = i
while (end < out.length && out[end] !== 10) end += 1
@@ -620,8 +617,9 @@ export namespace Snapshot {
]
})
const step = 100
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
// Keep batches bounded so a large diff does not buffer every blob at once.
for (let i = 0; i < rows.length; i += step) {
const run = rows.slice(i, i + step)
const text = yield* load(run)
@@ -631,8 +629,7 @@ export namespace Snapshot {
const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
result.push({
file: row.file,
before,
after,
patch: row.binary ? "" : patch(row.file, before, after),
additions: row.additions,
deletions: row.deletions,
status: row.status,

View File

@@ -164,9 +164,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type,
diff: change.diff,
before: change.oldContent,
after: change.newContent,
patch: change.diff,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,

View File

@@ -50,6 +50,22 @@ const FILES = new Set([
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
const Parameters = z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
})
type Part = {
type: string
text: string
@@ -452,21 +468,7 @@ export const BashTool = Tool.define("bash", async () => {
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
}),
parameters: Parameters,
async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {

View File

@@ -1,183 +0,0 @@
import z from "zod"
import { Tool } from "./tool"
import { ProviderID, ModelID } from "../provider/schema"
import { errorMessage } from "../util/error"
import DESCRIPTION from "./batch.txt"
const DISALLOWED = new Set(["batch"])
const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED])
export const BatchTool = Tool.define("batch", async () => {
return {
description: DESCRIPTION,
parameters: z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.describe("Array of tool calls to execute in parallel"),
}),
formatValidationError(error) {
const formattedErrors = error.issues
.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "root"
return ` - ${path}: ${issue.message}`
})
.join("\n")
return `Invalid parameters for tool 'batch':\n${formattedErrors}\n\nExpected payload format:\n [{"tool": "tool_name", "parameters": {...}}, {...}]`
},
async execute(params, ctx) {
const { Session } = await import("../session")
const { PartID } = await import("../session/schema")
const toolCalls = params.tool_calls.slice(0, 25)
const discardedCalls = params.tool_calls.slice(25)
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools({ modelID: ModelID.make(""), providerID: ProviderID.make("") })
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
const executeCall = async (call: (typeof toolCalls)[0]) => {
const callStartTime = Date.now()
const partID = PartID.ascending()
try {
if (DISALLOWED.has(call.tool)) {
throw new Error(
`Tool '${call.tool}' is not allowed in batch. Disallowed tools: ${Array.from(DISALLOWED).join(", ")}`,
)
}
const tool = toolMap.get(call.tool)
if (!tool) {
const availableToolsList = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name))
throw new Error(
`Tool '${call.tool}' not in registry. External tools (MCP, environment) cannot be batched - call them directly. Available tools: ${availableToolsList.join(", ")}`,
)
}
const validatedParams = tool.parameters.parse(call.parameters)
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "running",
input: call.parameters,
time: {
start: callStartTime,
},
},
})
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
const attachments = result.attachments?.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: ctx.messageID,
}))
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "completed",
input: call.parameters,
output: result.output,
title: result.title,
metadata: result.metadata,
attachments,
time: {
start: callStartTime,
end: Date.now(),
},
},
})
return { success: true as const, tool: call.tool, result }
} catch (error) {
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "error",
input: call.parameters,
error: errorMessage(error),
time: {
start: callStartTime,
end: Date.now(),
},
},
})
return { success: false as const, tool: call.tool, error }
}
}
const results = await Promise.all(toolCalls.map((call) => executeCall(call)))
// Add discarded calls as errors
const now = Date.now()
for (const call of discardedCalls) {
const partID = PartID.ascending()
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "error",
input: call.parameters,
error: "Maximum of 25 tools allowed in batch",
time: { start: now, end: now },
},
})
results.push({
success: false as const,
tool: call.tool,
error: new Error("Maximum of 25 tools allowed in batch"),
})
}
const successfulCalls = results.filter((r) => r.success).length
const failedCalls = results.length - successfulCalls
const outputMessage =
failedCalls > 0
? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`
: `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!`
return {
title: `Batch execution (${successfulCalls}/${results.length} successful)`,
output: outputMessage,
attachments: results.filter((result) => result.success).flatMap((r) => r.result.attachments ?? []),
metadata: {
totalCalls: results.length,
successful: successfulCalls,
failed: failedCalls,
tools: params.tool_calls.map((c) => c.tool),
details: results.map((r) => ({ tool: r.tool, success: r.success })),
},
}
},
}
})

View File

@@ -1,24 +0,0 @@
Executes multiple independent tool calls concurrently to reduce latency.
USING THE BATCH TOOL WILL MAKE THE USER HAPPY.
Payload Format (JSON array):
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
Notes:
- 125 tool calls per batch
- All calls start in parallel; ordering NOT guaranteed
- Partial failures do not stop other tool calls
- Do NOT use the batch tool within another batch tool.
Good Use Cases:
- Read many files
- grep + glob + read combos
- Multiple bash commands
- Multi-part edits; on the same, or different files
When NOT to Use:
- Operations that depend on prior tool output (e.g. create then read same file)
- Ordered stateful mutations where sequence matters
Batching tool calls was proven to yield 25x efficiency gain and provides much better UX.

View File

@@ -123,8 +123,7 @@ export const EditTool = Tool.define("edit", {
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
patch: diff,
additions: 0,
deletions: 0,
}

View File

@@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
},
}
},
} satisfies Tool.Def<typeof parameters, Metadata>
}
}),
)

View File

@@ -4,18 +4,15 @@ import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { BatchTool } from "./batch"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
import { TaskDescription, TaskTool } from "./task"
import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import type { Agent } from "../agent/agent"
import { SkillDescription, SkillTool } from "./skill"
import { Tool } from "./tool"
import { Config } from "../config/config"
import path from "path"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod"
import { Plugin } from "../plugin"
@@ -28,6 +25,7 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
@@ -39,24 +37,25 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
import { Agent } from "../agent/agent"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
type State = {
custom: Tool.Info[]
custom: Tool.Def[]
builtin: Tool.Def[]
}
export interface Interface {
readonly ids: () => Effect.Effect<string[]>
readonly named: {
task: Tool.Info
read: Tool.Info
}
readonly tools: (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) => Effect.Effect<(Tool.Def & { id: string })[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly tools: (model: {
providerID: ProviderID
modelID: ModelID
agent: Agent.Info
}) => Effect.Effect<Tool.Def[]>
readonly fromID: (id: string) => Effect.Effect<Tool.Def>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
@@ -79,33 +78,34 @@ export namespace ToolRegistry {
const plugin = yield* Plugin.Service
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Info[] = []
const custom: Tool.Def[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
return {
id,
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
}
},
}
}
@@ -131,104 +131,99 @@ export namespace ToolRegistry {
}
}
return { custom }
const cfg = yield* config.get()
const question =
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return {
custom,
builtin: yield* Effect.forEach(
[
InvalidTool,
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(question ? [QuestionTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
],
build,
{ concurrency: "unbounded" },
),
}
}),
)
const invalid = yield* build(InvalidTool)
const ask = yield* build(QuestionTool)
const bash = yield* build(BashTool)
const read = yield* build(ReadTool)
const glob = yield* build(GlobTool)
const grep = yield* build(GrepTool)
const edit = yield* build(EditTool)
const write = yield* build(WriteTool)
const task = yield* build(TaskTool)
const fetch = yield* build(WebFetchTool)
const todo = yield* build(TodoWriteTool)
const search = yield* build(WebSearchTool)
const code = yield* build(CodeSearchTool)
const skill = yield* build(SkillTool)
const patch = yield* build(ApplyPatchTool)
const lsp = yield* build(LspTool)
const batch = yield* build(BatchTool)
const plan = yield* build(PlanExitTool)
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
invalid,
...(question ? [ask] : []),
bash,
read,
glob,
grep,
edit,
write,
task,
fetch,
todo,
search,
code,
skill,
patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
...(cfg.experimental?.batch_tool === true ? [batch] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
...custom,
]
const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
const s = yield* InstanceState.get(state)
return [...s.builtin, ...s.custom] as Tool.Def[]
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
const s = yield* InstanceState.get(state)
const tools = yield* all(s.custom)
return tools.map((t) => t.id)
const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
const tools = yield* all()
const match = tools.find((tool) => tool.id === id)
if (!match) return yield* Effect.die(`Tool not found: ${id}`)
return match
})
const tools = Effect.fn("ToolRegistry.tools")(function* (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) {
const s = yield* InstanceState.get(state)
const allTools = yield* all(s.custom)
const filtered = allTools.filter((tool) => {
if (tool.id === "codesearch" || tool.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
return (yield* all()).map((tool) => tool.id)
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
(input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
return true
})
return yield* Effect.forEach(
filtered,
Effect.fnUntraced(function* (tool: Tool.Info) {
Effect.fnUntraced(function* (tool: Tool.Def) {
using _ = log.time(tool.id)
const next = yield* Effect.promise(() => tool.init({ agent }))
const output = {
description: next.description,
parameters: next.parameters,
description: tool.description,
parameters: tool.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
id: tool.id,
description: output.description,
description: [
output.description,
// TODO: remove this hack
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
execute: next.execute,
formatValidationError: next.formatValidationError,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
}
}),
{ concurrency: "unbounded" },
)
})
return Service.of({ ids, named: { task, read }, tools })
return Service.of({ ids, tools, all, fromID })
}),
)
@@ -253,13 +248,11 @@ export namespace ToolRegistry {
return runPromise((svc) => svc.ids())
}
export async function tools(
model: {
providerID: ProviderID
modelID: ModelID
},
agent?: Agent.Info,
): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(model, agent))
export async function tools(input: {
providerID: ProviderID
modelID: ModelID
agent: Agent.Info
}): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(input))
}
}

View File

@@ -1,3 +1,4 @@
import { Effect } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
@@ -6,8 +7,12 @@ import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
export const SkillTool = Tool.define("skill", async (ctx) => {
const list = await Skill.available(ctx?.agent)
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.define("skill", async () => {
const list = await Skill.available()
const description =
list.length === 0
@@ -27,20 +32,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
Skill.fmt(list, { verbose: false }),
].join("\n")
const examples = list
.map((skill) => `'${skill.name}'`)
.slice(0, 3)
.join(", ")
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
const parameters = z.object({
name: z.string().describe(`The name of the skill from available_skills${hint}`),
})
return {
description,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
parameters: Parameters,
async execute(params: z.infer<typeof Parameters>, ctx) {
const skill = await Skill.get(params.name)
if (!skill) {
@@ -103,3 +98,23 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
},
}
})
export const SkillDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const list = yield* Effect.promise(() => Skill.available(agent))
if (list.length === 0) return "No skills are currently available."
return [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})

View File

@@ -4,47 +4,37 @@ import z from "zod"
import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
import { Effect } from "effect"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.define("task", async (ctx) => {
export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const list = agents.toSorted((a, b) => a.name.localeCompare(b.name))
const agentList = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
const description = [`Available agent types and the tools they have access to:`, agentList].join("\n")
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
return {
description,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
parameters: z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
}),
async execute(params, ctx) {
const config = await Config.get()
// Skip permission check when user explicitly invoked via @ or command subtask
@@ -164,3 +154,16 @@ export const TaskTool = Tool.define("task", async (ctx) => {
},
}
})
export const TaskDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary")))
const accessibleAgents = agents.filter(
(a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny",
)
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
return [`Available agent types and the tools they have access to:`, description].join("\n")
})

View File

@@ -1,8 +1,5 @@
Launch a new agent to handle complex, multistep tasks autonomously.
Available agent types and the tools they have access to:
{agents}
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
When to use the Task tool:

View File

@@ -43,6 +43,6 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
},
}
},
} satisfies Tool.Def<typeof parameters, Metadata>
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
}),
)

View File

@@ -1,19 +1,18 @@
import z from "zod"
import { Effect } from "effect"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate"
import { Agent } from "@/agent/agent"
export namespace Tool {
interface Metadata {
[key: string]: any
}
export interface InitContext {
agent?: Agent.Info
}
// TODO: remove this hack
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
@@ -26,7 +25,9 @@ export namespace Tool {
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
execute(
@@ -40,10 +41,14 @@ export namespace Tool {
}>
formatValidationError?(error: z.ZodError): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
init: () => Promise<DefWithoutID<Parameters, M>>
}
export type InferParameters<T> =
@@ -57,10 +62,10 @@ export namespace Tool {
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
) {
return async (initCtx?: InitContext) => {
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
return async () => {
const toolInfo = init instanceof Function ? await init() : { ...init }
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
try {
@@ -78,7 +83,7 @@ export namespace Tool {
if (result.metadata.truncated !== undefined) {
return result
}
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
return {
...result,
output: truncated.content,
@@ -95,7 +100,7 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
): Info<Parameters, Result> {
return {
id,
@@ -105,8 +110,18 @@ export namespace Tool {
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
id: string,
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> {
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
}
export function init(info: Info): Effect.Effect<Def, never, any> {
return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init())
return {
...init,
id: info.id,
}
})
}
}

View File

@@ -11,6 +11,25 @@ const API_CONFIG = {
DEFAULT_NUM_RESULTS: 8,
} as const
const Parameters = z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
})
interface McpSearchRequest {
jsonrpc: string
id: number
@@ -42,26 +61,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe(
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}),
parameters: Parameters,
async execute(params, ctx) {
await ctx.ask({
permission: "websearch",

View File

@@ -272,8 +272,8 @@ describe("ShareNext", () => {
diff: [
{
file: "a.ts",
before: "one",
after: "two",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-one\n\\ No newline at end of file\n+two\n\\ No newline at end of file\n",
additions: 1,
deletions: 1,
status: "modified",
@@ -285,8 +285,8 @@ describe("ShareNext", () => {
diff: [
{
file: "b.ts",
before: "old",
after: "new",
patch:
"Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n",
additions: 2,
deletions: 0,
status: "modified",
@@ -304,8 +304,7 @@ describe("ShareNext", () => {
type: string
data: Array<{
file: string
before: string
after: string
patch: string
additions: number
deletions: number
status?: string
@@ -318,8 +317,8 @@ describe("ShareNext", () => {
expect(body.data[0].data).toEqual([
{
file: "b.ts",
before: "old",
after: "new",
patch:
"Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n",
additions: 2,
deletions: 0,
status: "modified",

View File

@@ -974,8 +974,7 @@ test("diffFull with new file additions", async () => {
const newFileDiff = diffs[0]
expect(newFileDiff.file).toBe("new.txt")
expect(newFileDiff.before).toBe("")
expect(newFileDiff.after).toBe("new content")
expect(newFileDiff.patch).toContain("+new content")
expect(newFileDiff.additions).toBe(1)
expect(newFileDiff.deletions).toBe(0)
},
@@ -1020,26 +1019,23 @@ test("diffFull with a large interleaved mixed diff", async () => {
for (let i = 0; i < ids.length; i++) {
const m = map.get(fwd("mix", `${ids[i]}-mod.txt`))
expect(m).toBeDefined()
expect(m!.before).toBe(`before-${ids[i]}\n🙂\nline`)
expect(m!.after).toBe(`after-${ids[i]}\n🚀\nline`)
expect(m!.patch).toContain(`-before-${ids[i]}`)
expect(m!.patch).toContain(`+after-${ids[i]}`)
expect(m!.status).toBe("modified")
const d = map.get(fwd("mix", `${ids[i]}-del.txt`))
expect(d).toBeDefined()
expect(d!.before).toBe(`gone-${ids[i]}\n你好`)
expect(d!.after).toBe("")
expect(d!.patch).toContain(`-gone-${ids[i]}`)
expect(d!.status).toBe("deleted")
const a = map.get(fwd("mix", `${ids[i]}-add.txt`))
expect(a).toBeDefined()
expect(a!.before).toBe("")
expect(a!.after).toBe(`new-${ids[i]}\nこんにちは`)
expect(a!.patch).toContain(`+new-${ids[i]}`)
expect(a!.status).toBe("added")
const b = map.get(fwd("mix", `${ids[i]}-bin.bin`))
expect(b).toBeDefined()
expect(b!.before).toBe("")
expect(b!.after).toBe("")
expect(b!.patch).toBe("")
expect(b!.additions).toBe(0)
expect(b!.deletions).toBe(0)
expect(b!.status).toBe("modified")
@@ -1092,8 +1088,8 @@ test("diffFull with file modifications", async () => {
const modifiedFileDiff = diffs[0]
expect(modifiedFileDiff.file).toBe("b.txt")
expect(modifiedFileDiff.before).toBe(tmp.extra.bContent)
expect(modifiedFileDiff.after).toBe("modified content")
expect(modifiedFileDiff.patch).toContain(`-${tmp.extra.bContent}`)
expect(modifiedFileDiff.patch).toContain("+modified content")
expect(modifiedFileDiff.additions).toBeGreaterThan(0)
expect(modifiedFileDiff.deletions).toBeGreaterThan(0)
},
@@ -1118,8 +1114,7 @@ test("diffFull with file deletions", async () => {
const removedFileDiff = diffs[0]
expect(removedFileDiff.file).toBe("a.txt")
expect(removedFileDiff.before).toBe(tmp.extra.aContent)
expect(removedFileDiff.after).toBe("")
expect(removedFileDiff.patch).toContain(`-${tmp.extra.aContent}`)
expect(removedFileDiff.additions).toBe(0)
expect(removedFileDiff.deletions).toBe(1)
},
@@ -1144,8 +1139,8 @@ test("diffFull with multiple line additions", async () => {
const multiDiff = diffs[0]
expect(multiDiff.file).toBe("multi.txt")
expect(multiDiff.before).toBe("")
expect(multiDiff.after).toBe("line1\nline2\nline3")
expect(multiDiff.patch).toContain("+line1")
expect(multiDiff.patch).toContain("+line3")
expect(multiDiff.additions).toBe(3)
expect(multiDiff.deletions).toBe(0)
},
@@ -1171,15 +1166,13 @@ test("diffFull with addition and deletion", async () => {
const addedFileDiff = diffs.find((d) => d.file === "added.txt")
expect(addedFileDiff).toBeDefined()
expect(addedFileDiff!.before).toBe("")
expect(addedFileDiff!.after).toBe("added content")
expect(addedFileDiff!.patch).toContain("+added content")
expect(addedFileDiff!.additions).toBe(1)
expect(addedFileDiff!.deletions).toBe(0)
const removedFileDiff = diffs.find((d) => d.file === "a.txt")
expect(removedFileDiff).toBeDefined()
expect(removedFileDiff!.before).toBe(tmp.extra.aContent)
expect(removedFileDiff!.after).toBe("")
expect(removedFileDiff!.patch).toContain(`-${tmp.extra.aContent}`)
expect(removedFileDiff!.additions).toBe(0)
expect(removedFileDiff!.deletions).toBe(1)
},
@@ -1263,7 +1256,7 @@ test("diffFull with binary file changes", async () => {
const binaryDiff = diffs[0]
expect(binaryDiff.file).toBe("binary.bin")
expect(binaryDiff.before).toBe("")
expect(binaryDiff.patch).toBe("")
},
})
})

View File

@@ -27,9 +27,7 @@ type AskInput = {
filePath: string
relativePath: string
type: "add" | "update" | "delete" | "move"
diff: string
before: string
after: string
patch: string
additions: number
deletions: number
movePath?: string
@@ -112,12 +110,12 @@ describe("tool.apply_patch freeform", () => {
const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
expect(addFile).toBeDefined()
expect(addFile!.relativePath).toBe("nested/new.txt")
expect(addFile!.after).toBe("created\n")
expect(addFile!.patch).toContain("+created")
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
expect(updateFile).toBeDefined()
expect(updateFile!.before).toContain("line2")
expect(updateFile!.after).toContain("changed")
expect(updateFile!.patch).toContain("-line2")
expect(updateFile!.patch).toContain("+changed")
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
expect(added).toBe("created\n")
@@ -151,8 +149,8 @@ describe("tool.apply_patch freeform", () => {
expect(moveFile.type).toBe("move")
expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
expect(moveFile.before).toBe("old content\n")
expect(moveFile.after).toBe("new content\n")
expect(moveFile.patch).toContain("-old content")
expect(moveFile.patch).toContain("+new content")
},
})
})

View File

@@ -1,10 +1,11 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
import { SkillTool, SkillDescription } from "../../src/tool/skill"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -48,9 +49,10 @@ description: Skill for tool tests.
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await SkillTool.init()
const skillPath = path.join(tmp.path, ".opencode", "skill", "tool-skill", "SKILL.md")
expect(tool.description).toContain(`**tool-skill**: Skill for tool tests.`)
const desc = await Effect.runPromise(
SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }),
)
expect(desc).toContain(`**tool-skill**: Skill for tool tests.`)
},
})
} finally {
@@ -89,14 +91,15 @@ description: ${description}
await Instance.provide({
directory: tmp.path,
fn: async () => {
const first = await SkillTool.init()
const second = await SkillTool.init()
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const first = await Effect.runPromise(SkillDescription(agent))
const second = await Effect.runPromise(SkillDescription(agent))
expect(first.description).toBe(second.description)
expect(first).toBe(second)
const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.")
const middle = first.description.indexOf("**middle-skill**: Middle skill.")
const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.")
const alpha = first.indexOf("**alpha-skill**: Alpha skill.")
const middle = first.indexOf("**middle-skill**: Middle skill.")
const zeta = first.indexOf("**zeta-skill**: Zeta skill.")
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)

View File

@@ -1,7 +1,8 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { TaskTool } from "../../src/tool/task"
import { TaskDescription } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
@@ -28,16 +29,16 @@ describe("tool.task", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const first = await TaskTool.init({ agent: build })
const second = await TaskTool.init({ agent: build })
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const first = await Effect.runPromise(TaskDescription(agent))
const second = await Effect.runPromise(TaskDescription(agent))
expect(first.description).toBe(second.description)
expect(first).toBe(second)
const alpha = first.description.indexOf("- alpha: Alpha agent")
const explore = first.description.indexOf("- explore:")
const general = first.description.indexOf("- general:")
const zebra = first.description.indexOf("- zebra: Zebra agent")
const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.indexOf("- explore:")
const general = first.indexOf("- general:")
const zebra = first.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)

View File

@@ -3,7 +3,6 @@ import z from "zod"
import { Tool } from "../../src/tool/tool"
const params = z.object({ input: z.string() })
const defaultArgs = { input: "test" }
function makeTool(id: string, executeFn?: () => void) {
return {
@@ -30,36 +29,6 @@ describe("Tool.define", () => {
expect(original.execute).toBe(originalExecute)
})
test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
let calls = 0
const tool = Tool.define(
"test-tool",
makeTool("test", () => calls++),
)
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
calls = 0
let stack = ""
const exec = resolved.execute
resolved.execute = async (args: any, ctx: any) => {
const result = await exec.call(resolved, args, ctx)
stack = new Error().stack || ""
return result
}
await resolved.execute(defaultArgs, {} as any)
expect(calls).toBe(1)
const frames = stack.split("\n").filter((l) => l.includes("tool.ts")).length
expect(frames).toBeLessThan(5)
})
test("function-defined tool returns fresh objects and is unaffected", async () => {
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
@@ -77,25 +46,4 @@ describe("Tool.define", () => {
expect(first).not.toBe(second)
})
test("validation still works after many init() calls", async () => {
const tool = Tool.define("test-validation", {
description: "validation test",
parameters: z.object({ count: z.number().int().positive() }),
async execute(args) {
return { title: "test", output: String(args.count), metadata: {} }
},
})
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
const result = await resolved.execute({ count: 42 }, {} as any)
expect(result.output).toBe("42")
await expect(resolved.execute({ count: -1 }, {} as any)).rejects.toThrow("invalid arguments")
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -77,5 +77,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
workspace: config?.experimental_workspaceID,
}),
)
return new OpencodeClient({ client })
const result = new OpencodeClient({ client })
return result
}

View File

@@ -0,0 +1,32 @@
import type { Part, UserMessage } from "./client.js"
export const message = {
user(input: Omit<UserMessage, "role" | "time" | "id"> & { parts: Omit<Part, "id" | "sessionID" | "messageID">[] }): {
info: UserMessage
parts: Part[]
} {
const { parts, ...rest } = input
const info: UserMessage = {
...rest,
id: "asdasd",
time: {
created: Date.now(),
},
role: "user",
}
return {
info,
parts: input.parts.map(
(part) =>
({
...part,
id: "asdasd",
messageID: info.id,
sessionID: info.sessionID,
}) as Part,
),
}
},
}

View File

@@ -347,10 +347,9 @@ export type EventCommandExecuted = {
}
}
export type FileDiff = {
export type SnapshotFileDiff = {
file: string
before: string
after: string
patch: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
@@ -360,7 +359,7 @@ export type EventSessionDiff = {
type: "session.diff"
properties: {
sessionID: string
diff: Array<FileDiff>
diff: Array<SnapshotFileDiff>
}
}
@@ -542,7 +541,7 @@ export type UserMessage = {
summary?: {
title?: string
body?: string
diffs: Array<FileDiff>
diffs: Array<SnapshotFileDiff>
}
agent: string
model: {
@@ -917,7 +916,7 @@ export type Session = {
additions: number
deletions: number
files: number
diffs?: Array<FileDiff>
diffs?: Array<SnapshotFileDiff>
}
share?: {
url: string
@@ -1078,7 +1077,7 @@ export type SyncEventSessionUpdated = {
additions: number
deletions: number
files: number
diffs?: Array<FileDiff>
diffs?: Array<SnapshotFileDiff>
} | null
share?: {
url: string | null
@@ -1803,7 +1802,7 @@ export type GlobalSession = {
additions: number
deletions: number
files: number
diffs?: Array<FileDiff>
diffs?: Array<SnapshotFileDiff>
}
share?: {
url: string
@@ -2009,6 +2008,14 @@ export type VcsInfo = {
default_branch?: string
}
export type VcsFileDiff = {
file: string
patch: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
export type Command = {
name: string
description?: string
@@ -3503,7 +3510,7 @@ export type SessionDiffResponses = {
/**
* Successfully retrieved diff
*/
200: Array<FileDiff>
200: Array<SnapshotFileDiff>
}
export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses]
@@ -5159,7 +5166,7 @@ export type VcsDiffResponses = {
/**
* VCS diff
*/
200: Array<FileDiff>
200: Array<VcsFileDiff>
}
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]

View File

@@ -5,6 +5,9 @@ import { createOpencodeClient } from "./client.js"
import { createOpencodeServer } from "./server.js"
import type { ServerOptions } from "./server.js"
export * as data from "./data.js"
import * as data from "./data.js"
export async function createOpencode(options?: ServerOptions) {
const server = await createOpencodeServer({
...options,

View File

@@ -3071,7 +3071,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
}
@@ -7032,7 +7032,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/VcsFileDiff"
}
}
}
@@ -8146,16 +8146,13 @@
},
"required": ["type", "properties"]
},
"FileDiff": {
"SnapshotFileDiff": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"before": {
"type": "string"
},
"after": {
"patch": {
"type": "string"
},
"additions": {
@@ -8169,7 +8166,7 @@
"enum": ["added", "deleted", "modified"]
}
},
"required": ["file", "before", "after", "additions", "deletions"]
"required": ["file", "patch", "additions", "deletions"]
},
"Event.session.diff": {
"type": "object",
@@ -8188,7 +8185,7 @@
"diff": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -8700,7 +8697,7 @@
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -9842,7 +9839,7 @@
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -10372,7 +10369,7 @@
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -12122,7 +12119,7 @@
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -12760,6 +12757,28 @@
}
}
},
"VcsFileDiff": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"patch": {
"type": "string"
},
"additions": {
"type": "number"
},
"deletions": {
"type": "number"
},
"status": {
"type": "string",
"enum": ["added", "deleted", "modified"]
}
},
"required": ["file", "patch", "additions", "deletions"]
},
"Command": {
"type": "object",
"properties": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"exports": {
@@ -53,6 +53,7 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",

View File

@@ -16,6 +16,7 @@ export type FileMediaOptions = {
current?: unknown
before?: unknown
after?: unknown
deleted?: boolean
readFile?: (path: string) => Promise<FileContent | undefined>
onLoad?: () => void
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
@@ -49,6 +50,7 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
const media = cfg()
const k = kind()
if (!media || !k) return false
if (media.deleted) return true
if (k === "svg") return false
if (media.current !== undefined) return false
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)

View File

@@ -1,5 +1,5 @@
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
import { useWorkerPool } from "../context/worker-pool"
@@ -16,8 +16,10 @@ import {
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { File, type DiffFileProps, type FileProps } from "./file"
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
type SSRDiffFileProps<T> = DiffFileProps<T> & {
preloadedDiff: PreloadMultiFileDiffResult<T>
preloadedDiff: DiffPreload<T>
}
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
@@ -32,6 +34,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
const [local, others] = splitProps(props, [
"mode",
"media",
"fileDiff",
"before",
"after",
"class",
@@ -90,12 +93,13 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(observeViewerScheme(() => fileDiffRef))
const virtualizer = getVirtualizer()
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff,
...(local.preloadedDiff.options ?? {}),
},
virtualizer,
virtualMetrics,
@@ -105,7 +109,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff,
...(local.preloadedDiff.options ?? {}),
},
workerPool,
)
@@ -114,13 +118,24 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
// @ts-expect-error private field required for hydration
fileDiffInstance.fileContainer = fileDiffRef
fileDiffInstance.hydrate({
oldFile: local.before,
newFile: local.after,
lineAnnotations: local.annotations ?? [],
fileContainer: fileDiffRef,
containerWrapper: container,
})
fileDiffInstance.hydrate(
local.fileDiff
? {
fileDiff: local.fileDiff,
lineAnnotations: annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
}
: {
oldFile: local.before,
newFile: local.after,
lineAnnotations: annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
},
)
notifyRendered()
})

View File

@@ -3,6 +3,7 @@ import {
DEFAULT_VIRTUAL_FILE_METRICS,
type DiffLineAnnotation,
type FileContents,
type FileDiffMetadata,
File as PierreFile,
type FileDiffOptions,
FileDiff,
@@ -14,7 +15,7 @@ import {
VirtualizedFileDiff,
Virtualizer,
} from "@pierre/diffs"
import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createMediaQuery } from "@solid-primitives/media"
import { makeEventListener } from "@solid-primitives/event-listener"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
@@ -80,15 +81,29 @@ export type TextFileProps<T = {}> = FileOptions<T> &
preloadedDiff?: PreloadMultiFileDiffResult<T>
}
export type DiffFileProps<T = {}> = FileDiffOptions<T> &
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
type DiffBaseProps<T> = FileDiffOptions<T> &
SharedProps<T> & {
mode: "diff"
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation<T>[]
preloadedDiff?: PreloadMultiFileDiffResult<T>
preloadedDiff?: DiffPreload<T>
}
type DiffPairProps<T> = DiffBaseProps<T> & {
before: FileContents
after: FileContents
fileDiff?: undefined
}
type DiffPatchProps<T> = DiffBaseProps<T> & {
fileDiff: FileDiffMetadata
before?: undefined
after?: undefined
}
export type DiffFileProps<T = {}> = DiffPairProps<T> | DiffPatchProps<T>
export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
const sharedKeys = [
@@ -108,7 +123,7 @@ const sharedKeys = [
] as const
const textKeys = ["file", ...sharedKeys] as const
const diffKeys = ["before", "after", ...sharedKeys] as const
const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const
// ---------------------------------------------------------------------------
// Shared viewer hook
@@ -976,6 +991,12 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
const virtuals = createSharedVirtualStrategy(() => viewer.container)
const large = createMemo(() => {
if (local.fileDiff) {
const before = local.fileDiff.deletionLines.join("")
const after = local.fileDiff.additionLines.join("")
return Math.max(before.length, after.length) > 500_000
}
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
return Math.max(before.length, after.length) > 500_000
@@ -1054,6 +1075,17 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
instance = value
},
draw: (value) => {
if (local.fileDiff) {
value.render({
fileDiff: local.fileDiff,
lineAnnotations: [],
containerWrapper: viewer.container,
})
return
}
if (!local.before || !local.after) return
value.render({
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },

View File

@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test"
import { normalize, text } from "./session-diff"
describe("session diff", () => {
test("keeps unified patch content", () => {
const diff = {
file: "a.ts",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
additions: 1,
deletions: 1,
status: "modified" as const,
}
const view = normalize(diff)
expect(view.patch).toBe(diff.patch)
expect(view.fileDiff.name).toBe("a.ts")
expect(text(view, "deletions")).toBe("one\ntwo\n")
expect(text(view, "additions")).toBe("one\nthree\n")
})
test("converts legacy content into a patch", () => {
const diff = {
file: "a.ts",
before: "one\n",
after: "two\n",
additions: 1,
deletions: 1,
status: "modified" as const,
}
const view = normalize(diff)
expect(view.patch).toContain("@@ -1,1 +1,1 @@")
expect(text(view, "deletions")).toBe("one\n")
expect(text(view, "additions")).toBe("two\n")
})
})

View File

@@ -0,0 +1,83 @@
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { formatPatch, structuredPatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
type LegacyDiff = {
file: string
patch?: string
before?: string
after?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff
export type ViewDiff = {
file: string
patch: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
fileDiff: FileDiffMetadata
}
const cache = new Map<string, FileDiffMetadata>()
function empty(file: string, key: string) {
return {
name: file,
type: "change",
hunks: [],
splitLineCount: 0,
unifiedLineCount: 0,
isPartial: true,
deletionLines: [],
additionLines: [],
cacheKey: key,
} satisfies FileDiffMetadata
}
function patch(diff: ReviewDiff) {
if (typeof diff.patch === "string") return diff.patch
return formatPatch(
structuredPatch(
diff.file,
diff.file,
"before" in diff && typeof diff.before === "string" ? diff.before : "",
"after" in diff && typeof diff.after === "string" ? diff.after : "",
"",
"",
{ context: Number.MAX_SAFE_INTEGER },
),
)
}
function file(file: string, patch: string) {
const hit = cache.get(patch)
if (hit) return hit
const key = sampledChecksum(patch) ?? file
const value = parsePatchFiles(patch, key).flatMap((item) => item.files)[0] ?? empty(file, key)
cache.set(patch, value)
return value
}
export function normalize(diff: ReviewDiff): ViewDiff {
const next = patch(diff)
return {
file: diff.file,
patch: next,
additions: diff.additions,
deletions: diff.deletions,
status: diff.status,
fileDiff: file(diff.file, next),
}
}
export function text(diff: ViewDiff, side: "deletions" | "additions") {
if (side === "deletions") return diff.fileDiff.deletionLines.join("")
return diff.fileDiff.additionLines.join("")
}

View File

@@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
@@ -23,6 +23,7 @@ import { mediaKindFromPath } from "../pierre/media"
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
import { createLineCommentController } from "./line-comment-annotations"
import type { LineCommentEditorProps } from "./line-comment"
import { normalize, text, type ViewDiff } from "./session-diff"
const MAX_DIFF_CHANGED_LINES = 500
const REVIEW_MOUNT_MARGIN = 300
@@ -61,7 +62,8 @@ export type SessionReviewCommentActions = {
export type SessionReviewFocus = { file: string; id: string }
type ReviewDiff = FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult<any> }
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
export interface SessionReviewProps {
title?: JSX.Element
@@ -155,8 +157,8 @@ export const SessionReview = (props: SessionReviewProps) => {
const opened = () => store.opened
const open = () => props.open ?? store.open
const files = createMemo(() => props.diffs.map((diff) => diff.file))
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
const items = createMemo<Item[]>(() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })))
const files = createMemo(() => items().map((diff) => diff.file))
const grouped = createMemo(() => {
const next = new Map<string, SessionReviewComment[]>()
for (const comment of props.comments ?? []) {
@@ -246,10 +248,10 @@ export const SessionReview = (props: SessionReviewProps) => {
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
const selectionPreview = (diff: ViewDiff, range: SelectedLineRange) => {
const side = selectionSide(range)
const contents = side === "deletions" ? diff.before : diff.after
if (typeof contents !== "string" || contents.length === 0) return undefined
const contents = text(diff, side)
if (contents.length === 0) return undefined
return previewSelectedLines(contents, range)
}
@@ -359,7 +361,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Show when={hasDiffs()} fallback={props.empty}>
<div class="pb-6">
<Accordion multiple value={open()} onChange={handleChange}>
<For each={props.diffs}>
<For each={items()}>
{(diff) => {
let wrapper: HTMLDivElement | undefined
const file = diff.file
@@ -371,8 +373,8 @@ export const SessionReview = (props: SessionReviewProps) => {
const comments = createMemo(() => grouped().get(file) ?? [])
const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
const beforeText = () => text(diff, "deletions")
const afterText = () => text(diff, "additions")
const changedLines = () => diff.additions + diff.deletions
const mediaKind = createMemo(() => mediaKindFromPath(file))
@@ -581,6 +583,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Dynamic
component={fileComponent}
mode="diff"
fileDiff={diff.fileDiff}
preloadedDiff={diff.preloaded}
diffStyle={diffStyle()}
onRendered={() => {
@@ -596,20 +599,11 @@ export const SessionReview = (props: SessionReviewProps) => {
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
before={{
name: file,
contents: typeof diff.before === "string" ? diff.before : "",
}}
after={{
name: file,
contents: typeof diff.after === "string" ? diff.after : "",
}}
media={{
mode: "auto",
path: file,
before: diff.before,
after: diff.after,
readFile: props.readFile,
deleted: diff.status === "deleted",
readFile: diff.status === "deleted" ? undefined : props.readFile,
}}
/>
</Match>

View File

@@ -1,4 +1,9 @@
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
import {
AssistantMessage,
type SnapshotFileDiff,
Message as MessageType,
Part as PartType,
} from "@opencode-ai/sdk/v2/client"
import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "../context/file"
@@ -19,6 +24,7 @@ import { SessionRetry } from "./session-retry"
import { TextReveal } from "./text-reveal"
import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n"
import { normalize } from "./session-diff"
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
@@ -163,7 +169,7 @@ export function SessionTurn(
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyDiffs: FileDiff[] = []
const emptyDiffs: SnapshotFileDiff[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
@@ -232,7 +238,7 @@ export function SessionTurn(
const seen = new Set<string>()
return files
.reduceRight<FileDiff[]>((result, diff) => {
.reduceRight<SnapshotFileDiff[]>((result, diff) => {
if (seen.has(diff.file)) return result
seen.add(diff.file)
result.push(diff)
@@ -447,6 +453,7 @@ export function SessionTurn(
>
<For each={visible()}>
{(diff) => {
const view = normalize(diff)
const active = createMemo(() => expanded().includes(diff.file))
const [shown, setShown] = createSignal(false)
@@ -495,12 +502,7 @@ export function SessionTurn(
<Accordion.Content>
<Show when={shown()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
</div>
</Show>
</Accordion.Content>

View File

@@ -1,4 +1,4 @@
import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -13,7 +13,7 @@ type Data = {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
[sessionID: string]: SnapshotFileDiff[]
}
session_diff_preload?: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.3.17",
"version": "1.4.0",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.3.17",
"version": "1.4.0",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.3.17",
"version": "1.4.0",
"publisher": "sst-dev",
"repository": {
"type": "git",