mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-08 06:54:53 +00:00
Compare commits
4 Commits
production
...
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98325dcdc6 | ||
|
|
0788a535e2 | ||
|
|
b7fab49b64 | ||
|
|
463318486f |
36
bun.lock
36
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
136
packages/opencode/specs/v2/message-shape.md
Normal file
136
packages/opencode/specs/v2/message-shape.md
Normal 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.
|
||||
@@ -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>>) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -59,7 +59,7 @@ export namespace ShareNext {
|
||||
}
|
||||
| {
|
||||
type: "session_diff"
|
||||
data: SDK.FileDiff[]
|
||||
data: SDK.SnapshotFileDiff[]
|
||||
}
|
||||
| {
|
||||
type: "model"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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:
|
||||
- 1–25 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 2–5x efficiency gain and provides much better UX.
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
|
||||
},
|
||||
}
|
||||
},
|
||||
} satisfies Tool.Def<typeof parameters, Metadata>
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
32
packages/sdk/js/src/v2/data.ts
Normal file
32
packages/sdk/js/src/v2/data.ts
Normal 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,
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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) },
|
||||
|
||||
37
packages/ui/src/components/session-diff.test.ts
Normal file
37
packages/ui/src/components/session-diff.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
83
packages/ui/src/components/session-diff.ts
Normal file
83
packages/ui/src/components/session-diff.ts
Normal 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("")
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>[]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user