diff --git a/bun.lock b/bun.lock index 1c6bcd4716..df1485f400 100644 --- a/bun.lock +++ b/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": { @@ -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", @@ -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=="], diff --git a/nix/hashes.json b/nix/hashes.json index 0b8e34e786..f592339c2d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-r1+AehuOGIOaaxfXkQGracT/6OdFRn5Ub8s7H+MeKFY=", - "aarch64-linux": "sha256-WkMSRF/ZJLyzxNBjpiMR459C9G0NVOEw31tm8roPneA=", - "aarch64-darwin": "sha256-Z127cxFpTl8Ml7PB3CG9TcCU08oYCPuk0FECK2MQ2CI=", - "x86_64-darwin": "sha256-pkRoFtnVjyl+5fm+rrFyRnEwvptxylnFxPAcEv4ZOCg=" + "x86_64-linux": "sha256-85wpU1oCWbthPleNIOj5d5AOuuYZ6rM7gMLZR6YJ2WU=", + "aarch64-linux": "sha256-C3A56SDQGJquCpIRj2JhIzr4A7N4cc9lxtEjl8bXDeM=", + "aarch64-darwin": "sha256-/Ij3qhGRrcLlMfl9uEacDNnGK5URxhctuQFBW4Njrog=", + "x86_64-darwin": "sha256-10sOPuN4eZ75orw4FI8ztCq1+AKS2e8aAfg3Z6Yn56w=" } } diff --git a/package.json b/package.json index 4ce36d17ec..d4713f95da 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 4af6365535..01248e20e8 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -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 } diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 8e11110e3d..472ac219e9 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -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 - session_diff: Record + session_diff: Record todo: Record message: Record part: Record @@ -64,7 +64,7 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 0177ebbe13..6f4d81062b 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -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 - session_diff: Record + session_diff: Record todo: Record message: Record part: Record diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 1d6e550f8e..b0f340a902 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -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[] diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 0c67647261..cf50fbe908 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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 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(() => { 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(() => { @@ -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() { ) + const createGit = (input: { emptyClass: string }) => ( +
+
+
{language.t("session.review.noVcs.createGit.title")}
+
+ {language.t("session.review.noVcs.createGit.description")} +
+
+ +
+ ) + 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
{language.t("session.review.loadingChanges")}
- } - - if (sessionEmptyKey() === "session.review.noVcs") { - return ( -
-
-
{language.t("session.review.noVcs.createGit.title")}
-
- {language.t("session.review.noVcs.createGit.description")} -
-
- -
- ) - } - return (
{reviewEmptyText()}
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index b681286450..71dfe375e0 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -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["view"]> diffStyle: DiffStyle onDiffStyleChange?: (style: DiffStyle) => void diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 86f932ea25..cddbea84d6 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -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 diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index c6291b75d2..18fcd7a071 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -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(), + data: z.custom(), }), z.object({ type: z.literal("model"), diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index e755ea75a1..edeeaf1ad5 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -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 diff --git a/packages/opencode/specs/v2.md b/packages/opencode/specs/v2/keymappings.md similarity index 89% rename from packages/opencode/specs/v2.md rename to packages/opencode/specs/v2/keymappings.md index 66b4d2dea4..5b23db7954 100644 --- a/packages/opencode/specs/v2.md +++ b/packages/opencode/specs/v2/keymappings.md @@ -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 `abc`. Commands don't define their binding, but have an id that a key can be mapped to like diff --git a/packages/opencode/specs/v2/message-shape.md b/packages/opencode/specs/v2/message-shape.md new file mode 100644 index 0000000000..965498f190 --- /dev/null +++ b/packages/opencode/specs/v2/message-shape.md @@ -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 + } +} + +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 + } +} + +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. diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 7f451e98c0..458f925474 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -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>) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 91baca52a2..396d756301 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2124,7 +2124,7 @@ function ApplyPatch(props: ToolProps) { } > - + diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 5142079b1d..ec6e415c82 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -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, ) { 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 + 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 + export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect readonly defaultBranch: () => Effect.Effect - readonly diff: (mode: Mode) => Effect.Effect + readonly diff: (mode: Mode) => Effect.Effect } interface State { diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts index 7cc7886b04..65ea2fac2e 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance.ts @@ -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()), }, }, }, diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index c673393d0e..763cdcf774 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -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, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b91dfded5e..c297339992 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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, { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 2eb9887ea4..0cd0055c85 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -59,7 +59,7 @@ export namespace ShareNext { } | { type: "session_diff" - data: SDK.FileDiff[] + data: SDK.SnapshotFileDiff[] } | { type: "model" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index a2ac3d351c..cde36dd52d 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -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 [ "", - ...list.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - " ", - ]), + ...list + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + " ", + ]), "", ].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) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 2db67695ff..569c834bf4 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -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 @@ -521,8 +520,6 @@ export namespace Snapshot { const map = new Map() 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, diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index c23c0dd3d0..30b2e91ace 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -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, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e50f09cc38..365fda3296 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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) { diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts deleted file mode 100644 index c79a530f71..0000000000 --- a/packages/opencode/src/tool/batch.ts +++ /dev/null @@ -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 })), - }, - } - }, - } -}) diff --git a/packages/opencode/src/tool/batch.txt b/packages/opencode/src/tool/batch.txt deleted file mode 100644 index 968a6c3f07..0000000000 --- a/packages/opencode/src/tool/batch.txt +++ /dev/null @@ -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. \ No newline at end of file diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 554d547d05..9505dd9eab 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -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, } diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index dd99688880..23c9b35c89 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect + } }), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9c045338ee..72911051e0 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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 - 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 + readonly tools: (model: { + providerID: ProviderID + modelID: ModelID + agent: Agent.Info + }) => Effect.Effect + readonly fromID: (id: string) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/ToolRegistry") {} @@ -79,33 +78,34 @@ export namespace ToolRegistry { const plugin = yield* Plugin.Service const build = (tool: T | Effect.Effect) => - Effect.isEffect(tool) ? tool : Effect.succeed(tool) + Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool) const state = yield* InstanceState.make( 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)) } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 17016b06f8..276f3931d0 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -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, ctx) { + parameters: Parameters, + async execute(params: z.infer, 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 `` 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") + }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index af130a70d9..07e779f5bd 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -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, 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") + }) diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 585cce8f9d..fba8470d1b 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -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: diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index d10e84931a..92318164c6 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -43,6 +43,6 @@ export const TodoWriteTool = Tool.defineEffect + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index a107dad7e8..6d129f4271 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -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 export type Context = { sessionID: SessionID @@ -26,7 +25,9 @@ export namespace Tool { metadata(input: { title?: string; metadata?: M }): void ask(input: Omit): Promise } + export interface Def { + id: string description: string parameters: Parameters execute( @@ -40,10 +41,14 @@ export namespace Tool { }> formatValidationError?(error: z.ZodError): string } + export type DefWithoutID = Omit< + Def, + "id" + > export interface Info { id: string - init: (ctx?: InitContext) => Promise> + init: () => Promise> } export type InferParameters = @@ -57,10 +62,10 @@ export namespace Tool { function wrap( id: string, - init: ((ctx?: InitContext) => Promise>) | Def, + init: (() => Promise>) | DefWithoutID, ) { - 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( id: string, - init: ((ctx?: InitContext) => Promise>) | Def, + init: (() => Promise>) | DefWithoutID, ): Info { return { id, @@ -105,8 +110,18 @@ export namespace Tool { export function defineEffect( id: string, - init: Effect.Effect<((ctx?: InitContext) => Promise>) | Def, never, R>, + init: Effect.Effect<(() => Promise>) | DefWithoutID, never, R>, ): Effect.Effect, never, R> { return Effect.map(init, (next) => ({ id, init: wrap(id, next) })) } + + export function init(info: Info): Effect.Effect { + return Effect.gen(function* () { + const init = yield* Effect.promise(() => info.init()) + return { + ...init, + id: info.id, + } + }) + } } diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index bf16428dfb..c0f1c8d105 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -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", diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 12d71f19a0..6619b3c605 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -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", diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index f53f1e8111..3cedfb941d 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -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("") }, }) }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 4e276517f1..19c8cfefd0 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -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") }, }) }) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index ffae223f98..e6269a4f38 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -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) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index aae48a30ab..fe936a242a 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -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) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 1503eed728..2ea6d56a51 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -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") - }) }) diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index e230a4b5dc..67fe1de32f 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -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 } diff --git a/packages/sdk/js/src/v2/data.ts b/packages/sdk/js/src/v2/data.ts new file mode 100644 index 0000000000..baae6f278d --- /dev/null +++ b/packages/sdk/js/src/v2/data.ts @@ -0,0 +1,32 @@ +import type { Part, UserMessage } from "./client.js" + +export const message = { + user(input: Omit & { parts: Omit[] }): { + 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, + ), + } + }, +} diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fc1616c4fd..0a9aa4358e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 + diff: Array } } @@ -542,7 +541,7 @@ export type UserMessage = { summary?: { title?: string body?: string - diffs: Array + diffs: Array } agent: string model: { @@ -917,7 +916,7 @@ export type Session = { additions: number deletions: number files: number - diffs?: Array + diffs?: Array } share?: { url: string @@ -1078,7 +1077,7 @@ export type SyncEventSessionUpdated = { additions: number deletions: number files: number - diffs?: Array + diffs?: Array } | null share?: { url: string | null @@ -1803,7 +1802,7 @@ export type GlobalSession = { additions: number deletions: number files: number - diffs?: Array + diffs?: Array } 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 + 200: Array } export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] @@ -5159,7 +5166,7 @@ export type VcsDiffResponses = { /** * VCS diff */ - 200: Array + 200: Array } export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] diff --git a/packages/sdk/js/src/v2/index.ts b/packages/sdk/js/src/v2/index.ts index d044f5ad66..d514784bc2 100644 --- a/packages/sdk/js/src/v2/index.ts +++ b/packages/sdk/js/src/v2/index.ts @@ -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, diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 5007e78e8b..207b400a7d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 64520f707c..d3e1cc9429 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/file-media.tsx b/packages/ui/src/components/file-media.tsx index 2fd54588a3..f066019d72 100644 --- a/packages/ui/src/components/file-media.tsx +++ b/packages/ui/src/components/file-media.tsx @@ -16,6 +16,7 @@ export type FileMediaOptions = { current?: unknown before?: unknown after?: unknown + deleted?: boolean readFile?: (path: string) => Promise 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) diff --git a/packages/ui/src/components/file-ssr.tsx b/packages/ui/src/components/file-ssr.tsx index 9526907831..fed5c89315 100644 --- a/packages/ui/src/components/file-ssr.tsx +++ b/packages/ui/src/components/file-ssr.tsx @@ -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 = PreloadMultiFileDiffResult | PreloadFileDiffResult + type SSRDiffFileProps = DiffFileProps & { - preloadedDiff: PreloadMultiFileDiffResult + preloadedDiff: DiffPreload } function DiffSSRViewer(props: SSRDiffFileProps) { @@ -32,6 +34,7 @@ function DiffSSRViewer(props: SSRDiffFileProps) { const [local, others] = splitProps(props, [ "mode", "media", + "fileDiff", "before", "after", "class", @@ -90,12 +93,13 @@ function DiffSSRViewer(props: SSRDiffFileProps) { onCleanup(observeViewerScheme(() => fileDiffRef)) const virtualizer = getVirtualizer() + const annotations = local.annotations ?? local.preloadedDiff.annotations ?? [] fileDiffInstance = virtualizer ? new VirtualizedFileDiff( { ...createDefaultOptions(props.diffStyle), ...others, - ...local.preloadedDiff, + ...(local.preloadedDiff.options ?? {}), }, virtualizer, virtualMetrics, @@ -105,7 +109,7 @@ function DiffSSRViewer(props: SSRDiffFileProps) { { ...createDefaultOptions(props.diffStyle), ...others, - ...local.preloadedDiff, + ...(local.preloadedDiff.options ?? {}), }, workerPool, ) @@ -114,13 +118,24 @@ function DiffSSRViewer(props: SSRDiffFileProps) { // @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() }) diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index fb488729e2..b78f0bae44 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -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 = FileOptions & preloadedDiff?: PreloadMultiFileDiffResult } -export type DiffFileProps = FileDiffOptions & +type DiffPreload = PreloadMultiFileDiffResult | PreloadFileDiffResult + +type DiffBaseProps = FileDiffOptions & SharedProps & { mode: "diff" - before: FileContents - after: FileContents annotations?: DiffLineAnnotation[] - preloadedDiff?: PreloadMultiFileDiffResult + preloadedDiff?: DiffPreload } +type DiffPairProps = DiffBaseProps & { + before: FileContents + after: FileContents + fileDiff?: undefined +} + +type DiffPatchProps = DiffBaseProps & { + fileDiff: FileDiffMetadata + before?: undefined + after?: undefined +} + +export type DiffFileProps = DiffPairProps | DiffPatchProps + export type FileProps = TextFileProps | DiffFileProps 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(props: DiffFileProps) { 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(props: DiffFileProps) { 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) }, diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts new file mode 100644 index 0000000000..463a729778 --- /dev/null +++ b/packages/ui/src/components/session-diff.test.ts @@ -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") + }) +}) diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts new file mode 100644 index 0000000000..cc2b1ce527 --- /dev/null +++ b/packages/ui/src/components/session-diff.ts @@ -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() + +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("") +} diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 3b582d66f9..90da853efc 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -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 } +type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } +type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } 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(() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded }))) + const files = createMemo(() => items().map((diff) => diff.file)) const grouped = createMemo(() => { const next = new Map() 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) => {
- + {(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) => { { @@ -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, }} /> diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index c20e5fb1ce..bb699a77e2 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -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 { 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() return files - .reduceRight((result, diff) => { + .reduceRight((result, diff) => { if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff) @@ -447,6 +453,7 @@ export function SessionTurn( > {(diff) => { + const view = normalize(diff) const active = createMemo(() => expanded().includes(diff.file)) const [shown, setShown] = createSignal(false) @@ -495,12 +502,7 @@ export function SessionTurn(
- +
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 93368c2a05..632bed0cfa 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -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[]