From e0c04110032dfdc82dff2139575fb93ef56780f0 Mon Sep 17 00:00:00 2001 From: ReStranger Date: Tue, 23 Jun 2026 19:10:55 +0300 Subject: [PATCH 01/11] fix: Skip bun version check for nix version (#33166) Signed-off-by: ReStranger Co-authored-by: dbeley <6568955+dbeley@users.noreply.github.com> --- nix/opencode.nix | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/nix/opencode.nix b/nix/opencode.nix index 82a7b54c40..a22f7d3d24 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -27,6 +27,13 @@ stdenvNoCC.mkDerivation (finalAttrs: { writableTmpDirAsHomeHook ]; + postPatch = '' + # NOTE: Relax Bun version check to be a warning instead of an error + substituteInPlace packages/script/src/index.ts \ + --replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \ + 'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}' + ''; + configurePhase = '' runHook preConfigure From a3825286cf75938cd568ee5ab1a24549a2fd3c84 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:40:08 -0500 Subject: [PATCH 02/11] test(core): avoid models cache recovery race (#33525) --- packages/core/test/models.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 31a3e57c10..6419d7ba1b 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -157,15 +157,12 @@ describe("ModelsDev Service", () => { Effect.gen(function* () { yield* writeCacheText("{") const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const context = yield* Layer.build(buildLayer(state)) const result = yield* Effect.acquireUseRelease( Effect.sync(() => { Flag.OPENCODE_DISABLE_MODELS_FETCH = false }), - () => - provided( - state, - ModelsDev.Service.use((s) => s.get()), - ), + () => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)), () => Effect.sync(() => { Flag.OPENCODE_DISABLE_MODELS_FETCH = true From 8e2d422ffe56f3b2eb52e3f7195a2f9722a9fc46 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 23 Jun 2026 22:15:00 +0530 Subject: [PATCH 03/11] fix(acp): preserve resource text source (#33524) --- packages/opencode/src/acp/content.ts | 16 ++++++++++++++-- packages/opencode/test/acp/content.test.ts | 18 +++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/acp/content.ts b/packages/opencode/src/acp/content.ts index 5f149d85f0..98060a3e2e 100644 --- a/packages/opencode/src/acp/content.ts +++ b/packages/opencode/src/acp/content.ts @@ -1,6 +1,6 @@ import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk" import path from "node:path" -import { pathToFileURL } from "node:url" +import { fileURLToPath, pathToFileURL } from "node:url" import { SessionV1 } from "@opencode-ai/core/v1/session" export type PromptPart = SessionV1.TextPartInput | SessionV1.FilePartInput @@ -76,7 +76,19 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] { case "resource": if ("text" in block.resource) { - return [{ type: "text", text: block.resource.text }] + try { + const parsed = new URL(block.resource.uri) + if (parsed.protocol === "file:") { + const line = parsed.hash.match(/^#L(\d+)/)?.[1] + return [ + { + type: "text", + text: `[${fileURLToPath(parsed)}${line ? `:${line}` : ""}]\n${block.resource.text}`, + }, + ] + } + } catch {} + return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }] } if (block.resource.mimeType) { return [ diff --git a/packages/opencode/test/acp/content.test.ts b/packages/opencode/test/acp/content.test.ts index 90f62f9d18..adf9ec2ef5 100644 --- a/packages/opencode/test/acp/content.test.ts +++ b/packages/opencode/test/acp/content.test.ts @@ -99,17 +99,29 @@ describe("acp content conversion", () => { ]) }) - test("resource with text becomes a text part", () => { + test("resource with text becomes a sourced text part", () => { expect( contentBlockToParts({ type: "resource", resource: { - uri: "file:///tmp/context.txt", + uri: "file:///tmp/context.txt#L12-L14", mimeType: "text/plain", text: "context", }, }), - ).toEqual([{ type: "text", text: "context" }]) + ).toEqual([{ type: "text", text: "[/tmp/context.txt:12]\ncontext" }]) + }) + + test("resource with text uses URI fallback for non-file resources", () => { + expect( + contentBlockToParts({ + type: "resource", + resource: { + uri: "mcp://server/context", + text: "context", + }, + }), + ).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }]) }) test("resource with blob and mimeType becomes a data URL file part", () => { From 976e5d421f08e22e4a7d58dbb7d1791ffa40f977 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:20:30 -0500 Subject: [PATCH 04/11] ci: route support questions (#33527) Co-authored-by: Test --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/ISSUE_TEMPLATE/question.yml | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/question.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 52eec90991..9501a1be65 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,4 +2,4 @@ blank_issues_enabled: false contact_links: - name: 💬 Discord Community url: https://discord.gg/opencode - about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question. + about: For support, troubleshooting, how-to questions, and real-time discussion. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml deleted file mode 100644 index 8930ba693c..0000000000 --- a/.github/ISSUE_TEMPLATE/question.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: Question -description: Ask a question -body: - - type: textarea - id: question - attributes: - label: Question - description: What's your question? - validations: - required: true From a131811cdc095340f9554ed82860c3f95decc9f9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:28:26 -0500 Subject: [PATCH 05/11] feat(mcp): use mcp__server__tool naming convention (#33533) --- packages/opencode/src/mcp/index.ts | 2 +- packages/opencode/test/mcp/lifecycle.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 12df017995..ed2cdf1f99 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -643,7 +643,7 @@ export const layer = Layer.effect( } const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const mcpTool of listed) { - const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name) + const key = "mcp__" + McpCatalog.sanitize(clientName) + "__" + McpCatalog.sanitize(mcpTool.name) result[key] = McpCatalog.convertTool(mcpTool, client, timeout) } } diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index aa72ac0324..e40bc51217 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -359,7 +359,7 @@ it.instance( command: ["echo", "test"], }) - expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) + expect(Object.keys(yield* mcp.tools())).toEqual(["mcp__paged-server__tool-one", "mcp__paged-server__tool-two"]) expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"]) expect(serverState.listToolsCalls).toBe(2) @@ -916,7 +916,7 @@ it.instance( expect(statusName(result.status, "tools-only-server")).toBe("connected") expect(serverState.listToolsCalls).toBe(1) - expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only-server_test_tool"]) + expect(Object.keys(yield* mcp.tools())).toEqual(["mcp__tools-only-server__test_tool"]) expect(yield* mcp.prompts()).toEqual({}) expect(yield* mcp.resources()).toEqual({}) expect(serverState.listPromptsCalls).toBe(0) @@ -1109,7 +1109,7 @@ it.instance( const keys = Object.keys(tools) // Server name dots should be replaced with underscores - expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true) + expect(keys.some((k) => k.startsWith("mcp__my_special-server__"))).toBe(true) // Tool name dots should be replaced with underscores expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true) expect(keys.length).toBe(2) From e04c5e72f7fabfdbf1d39ccdcdebd729e3979cce Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:50:19 +0800 Subject: [PATCH 06/11] fix(app): prompt persistence and draft sessions (#33528) --- packages/app/src/app.tsx | 4 +- packages/app/src/components/prompt-input.tsx | 2 +- .../components/prompt-input/submit.test.ts | 2 +- packages/app/src/components/titlebar.tsx | 37 ++++++---- packages/app/src/context/prompt.tsx | 15 ++-- packages/app/src/pages/layout-new.tsx | 4 +- .../composer/session-composer-region.tsx | 53 +++++++++++--- .../test-browser/prompt-persistence.test.ts | 69 +++++++++++++++++++ 8 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 packages/app/test-browser/prompt-persistence.test.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e2f6108fd2..97a1127558 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -185,7 +185,9 @@ function TargetDirectoryLayout(props: ParentProps) { if (!search.draftId) return undefined return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory }) - const directory = createMemo((prev) => prev ?? resolvedDirectory()) + const directory = createMemo((prev) => + search.draftId ? resolvedDirectory() : (prev ?? resolvedDirectory()), + ) const home = () => !params.serverKey && !search.draftId const targetDirectory = () => directory()! diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8d40953ff2..98e3931372 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1379,7 +1379,7 @@ export const PromptInput: Component = (props) => { const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const [promptReady] = createResource( - () => prompt.ready().promise, + () => prompt.ready.promise, (p) => p, ) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 7b412cd245..94a2488044 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -27,7 +27,7 @@ let variant: string | undefined const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const prompt = { - ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }), + ready: Object.assign(() => true, { promise: Promise.resolve(true) }), current: () => promptValue, cursor: () => 0, dirty: () => true, diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 580f6caec1..f019e839be 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" -import { base64Encode } from "@opencode-ai/core/util/encode" import { projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { makeEventListener } from "@solid-primitives/event-listener" @@ -264,15 +263,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const serverSdk = useServerSDK() const navigate = useNavigate() const layout = useLayout() - - const newSessionHref = () => { - if (params.dir) return `/${params.dir}/session` - - const project = layout.projects.list()[0] - if (!project) return "/" - - return `/${base64Encode(project.worktree)}/session` - } + const global = useGlobal() const tabs = useTabs() const tabsStore = tabs.store @@ -337,7 +328,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { tabsStoreActions.removeSessions(detail) }) - const openNewTab = () => navigate(newSessionHref()) + const openNewTab = () => { + const route = layout.route() + const activeSession = session() + if (route.type === "session" && activeSession) { + tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "") + return + } + + const current = layout.projects.list()[0] + if (current) { + tabs.newDraft({ server: server.key, directory: current.worktree }, "") + return + } + + const fallback = global.servers.list().flatMap((conn) => { + const project = global.createServerCtx(conn).projects.list()[0] + return project ? [{ server: ServerConnection.key(conn), project }] : [] + })[0] + if (!fallback) return + + tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "") + } const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() }) command.register("titlebar-home", () => [ @@ -592,8 +604,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { size="large" class="shrink-0" icon={} - as="a" - href={newSessionHref()} + onClick={openNewTab} aria-label={language.t("command.session.new")} /> diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 62818550da..49206583ad 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,7 +1,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useParams, useSearchParams } from "@solidjs/router" -import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" @@ -181,7 +181,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) { return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) } -function createPromptSession(serverScope: ServerScope, scope: Scope) { +export function createPromptSession(serverScope: ServerScope, scope: Scope) { const [store, setStore, _, ready] = persisted( promptTarget(serverScope, scope), createStore(promptStore()), @@ -190,6 +190,12 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) { return { ready, ...createPromptStateValue(store, setStore) } } +export function createPromptReady(session: Accessor) { + return Object.defineProperty(() => session().ready(), "promise", { + get: () => session().ready.promise, + }) as (() => boolean) & { readonly promise: Promise | undefined } +} + function promptStore(): PromptStore { return { prompt: clonePrompt(DEFAULT_PROMPT), @@ -247,7 +253,7 @@ export function createPromptState() { const [store, setStore] = createStore(promptStore()) const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) return { - ready: () => ready, + ready, ...createPromptStateValue(store, setStore), } } @@ -308,9 +314,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }), ) const pick = (scope?: Scope) => (scope ? load(scope) : session()) + const ready = createPromptReady(session) return { - ready: () => session().ready, + ready, current: () => session().current(), cursor: () => session().cursor(), dirty: () => session().dirty(), diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index 2f8793d8dd..91bf7ea0e4 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -1,4 +1,4 @@ -import { createEffect, type ParentProps } from "solid-js" +import { createEffect, Suspense, type ParentProps } from "solid-js" import { useNavigate } from "@solidjs/router" import { DebugBar } from "@/components/debug-bar" import { HelpButton } from "@/components/help-button" @@ -28,7 +28,7 @@ export default function NewLayout(props: ParentProps) {
- {props.children} + {props.children}
{import.meta.env.DEV && } diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 3fbde9ef3e..023ddfff9e 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo, onCleanup } from "solid-js" +import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useNavigate, useSearchParams } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" @@ -25,11 +25,12 @@ import { pathKey } from "@/utils/path-key" import { useLocal } from "@/context/local" import { useProviders } from "@/hooks/use-providers" import { useSettings } from "@/context/settings" -import { useServer } from "@/context/server" -import { useTabs } from "@/context/tabs" +import { ServerConnection, useServer } from "@/context/server" +import { type DraftTab, useTabs } from "@/context/tabs" import { useDirectoryPicker } from "@/components/directory-picker" import { base64Encode } from "@opencode-ai/core/util/encode" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" +import { useGlobal } from "@/context/global" export function SessionComposerRegion(props: { state: SessionComposerState @@ -73,26 +74,52 @@ export function SessionComposerRegion(props: { const settings = useSettings() const server = useServer() const tabs = useTabs() + const global = useGlobal() const pickDirectory = useDirectoryPicker() const [search] = useSearchParams<{ draftId?: string }>() const view = layout.view(route.sessionKey) + const draft = createMemo(() => { + if (!search.draftId) return + return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) + }) + const projectServer = createMemo(() => { + if (!search.draftId) return server.current + const target = draft()?.server + if (!target) return + return server.list.find((conn) => ServerConnection.key(conn) === target) + }) + const projectServerCtx = createMemo(() => { + const conn = projectServer() + if (conn) return global.createServerCtx(conn) + }) + const projects = createMemo(() => + search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(), + ) + const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory))) const globalProvidersQuery = createQuery(() => queryOptions().providers(null)) const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory))) const selectProject = (worktree: string) => { - layout.projects.open(worktree) - server.projects.touch(worktree) + const conn = projectServer() + const target = projectServerCtx() if (search.draftId) { - tabs.updateDraft(search.draftId, { server: server.key, directory: worktree }) + if (!conn || !target) return + target.projects.open(worktree) + target.projects.touch(worktree) + tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree }) return } + + layout.projects.open(worktree) + server.projects.touch(worktree) navigate(`/${base64Encode(worktree)}/session`) } const addProject = (title: string) => { - if (!server.current) return + const conn = projectServer() + if (!conn) return pickDirectory({ - server: server.current, + server: conn, title, onSelect: (result) => { const directory = Array.isArray(result) ? result[0] : result @@ -115,7 +142,7 @@ export function SessionComposerRegion(props: { loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, }, projects: { - available: layout.projects.list(), + available: projects(), directory: sdk().directory, select: selectProject, add: addProject, @@ -216,6 +243,12 @@ export function SessionComposerRegion(props: { update() }) + const ready = Promise.resolve() + const [promptReadyResource] = createResource( + () => prompt.ready.promise ?? ready, + (promise) => promise.then(() => true), + ) + return (
diff --git a/packages/app/test-browser/prompt-persistence.test.ts b/packages/app/test-browser/prompt-persistence.test.ts new file mode 100644 index 0000000000..9ca170f1d7 --- /dev/null +++ b/packages/app/test-browser/prompt-persistence.test.ts @@ -0,0 +1,69 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" +import type { AsyncStorage } from "@solid-primitives/storage" +import { createEffect, createRoot } from "solid-js" +import { ServerScope } from "@/utils/server-scope" + +let Prompt: typeof import("@/context/prompt") +let read: ((value: string | null) => void) | undefined + +const storage: AsyncStorage = { + getItem: () => new Promise((resolve) => (read = resolve)), + setItem: async () => undefined, + removeItem: async () => undefined, + clear: async () => undefined, + key: async () => null, + getLength: async () => 0, + length: Promise.resolve(0), +} + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useParams: () => ({}), + useSearchParams: () => [{}], + })) + mock.module("@opencode-ai/ui/context", () => ({ + createSimpleContext: () => ({ + use: () => undefined, + provider: () => undefined, + }), + })) + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ platform: "desktop", storage: () => storage }), + })) + + Prompt = await import("@/context/prompt") +}) + +describe("prompt persistence", () => { + test("waits for an async draft to hydrate before reporting ready", async () => { + await new Promise((resolve, reject) => { + createRoot((dispose) => { + const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" }) + const ready = Prompt.createPromptReady(() => session) + + expect(ready()).toBe(false) + expect(session.current()[0]).toMatchObject({ type: "text", content: "" }) + + read?.( + JSON.stringify({ + prompt: [{ type: "text", content: "persisted draft", start: 0, end: 15 }], + cursor: 15, + context: { items: [] }, + }), + ) + + createEffect(() => { + if (!ready()) return + try { + expect(session.current()[0]).toMatchObject({ type: "text", content: "persisted draft" }) + dispose() + resolve() + } catch (error) { + dispose() + reject(error) + } + }) + }) + }) + }) +}) From 2ba18b84a5853ff42a51848b81af2fe65f025e32 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:54:43 +0800 Subject: [PATCH 07/11] fix(app): use correct server sdk for titlebar session lookup (#33536) --- packages/app/src/components/titlebar.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index f019e839be..8e266ad841 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -524,10 +524,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { } const [session] = createResource( - () => tab.sessionId, - (sessionID) => - serverSdk() - .client.session.get({ sessionID }) + () => { + const id = tab.sessionId + const conn = server.list.find((s) => ServerConnection.key(s) === tab.server) + if (!conn) return null + const { sdk } = global.createServerCtx(conn) + return { id, sdk } + }, + ({ id, sdk }) => + sdk.client.session + .get({ sessionID: id }) .then((x) => x.data) .catch(() => undefined), ) From 5152150bfe02642e22bdb2c9d06ff91fb5f4f96c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:55:34 -0500 Subject: [PATCH 08/11] fix(opencode): make ACP resource text sourcing cross-platform (#33534) --- packages/opencode/src/acp/content.ts | 9 ++++- packages/opencode/test/acp/content.test.ts | 42 ++++++++++++++++------ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/acp/content.ts b/packages/opencode/src/acp/content.ts index 98060a3e2e..9207dd7c9d 100644 --- a/packages/opencode/src/acp/content.ts +++ b/packages/opencode/src/acp/content.ts @@ -80,10 +80,17 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] { const parsed = new URL(block.resource.uri) if (parsed.protocol === "file:") { const line = parsed.hash.match(/^#L(\d+)/)?.[1] + let filepath: string + try { + filepath = fileURLToPath(parsed) + } catch { + filepath = decodeURIComponent(parsed.pathname) + } + if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/") return [ { type: "text", - text: `[${fileURLToPath(parsed)}${line ? `:${line}` : ""}]\n${block.resource.text}`, + text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}`, }, ] } diff --git a/packages/opencode/test/acp/content.test.ts b/packages/opencode/test/acp/content.test.ts index adf9ec2ef5..ab5ffdb94a 100644 --- a/packages/opencode/test/acp/content.test.ts +++ b/packages/opencode/test/acp/content.test.ts @@ -100,16 +100,21 @@ describe("acp content conversion", () => { }) test("resource with text becomes a sourced text part", () => { - expect( - contentBlockToParts({ - type: "resource", - resource: { - uri: "file:///tmp/context.txt#L12-L14", - mimeType: "text/plain", - text: "context", - }, - }), - ).toEqual([{ type: "text", text: "[/tmp/context.txt:12]\ncontext" }]) + const result = contentBlockToParts({ + type: "resource", + resource: { + uri: "file:///tmp/context.txt#L12-L14", + mimeType: "text/plain", + text: "context", + }, + }) + expect(result).toHaveLength(1) + expect(result[0]?.type).toBe("text") + if (result[0]?.type === "text") { + expect(result[0].text.endsWith("\ncontext")).toBe(true) + expect(result[0].text.includes("context.txt")).toBe(true) + expect(result[0].text.includes("12")).toBe(true) + } }) test("resource with text uses URI fallback for non-file resources", () => { @@ -124,6 +129,23 @@ describe("acp content conversion", () => { ).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }]) }) + test("resource with text includes file path", () => { + const result = contentBlockToParts({ + type: "resource", + resource: { + uri: "file:///tmp/context.txt", + mimeType: "text/plain", + text: "context", + }, + }) + expect(result).toHaveLength(1) + expect(result[0]?.type).toBe("text") + if (result[0]?.type === "text") { + expect(result[0].text.endsWith("\ncontext")).toBe(true) + expect(result[0].text.includes("context.txt")).toBe(true) + } + }) + test("resource with blob and mimeType becomes a data URL file part", () => { expect( contentBlockToParts({ From dcf7b4e7924b51d8092684e6f24b2c997e14eb71 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 23 Jun 2026 20:51:56 +0200 Subject: [PATCH 09/11] fix(opencode): handle snapshot paths from subdirectories (#33506) --- packages/opencode/src/snapshot/index.ts | 29 ++++-- .../opencode/test/snapshot/snapshot.test.ts | 93 +++++++++++++++++++ 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index fd25437bb0..604e046d95 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -82,7 +82,9 @@ export const layer: Layer.Layer ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] - const feed = (list: string[]) => list.join("\0") + "\0" + const encodeNulTerminatedPaths = (files: string[]) => files.join("\0") + "\0" + const encodeTopLevelLiteralPathspecs = (files: string[]) => + encodeNulTerminatedPaths(files.map((file) => `:(top,literal)${file}`)) const git = Effect.fnUntraced( function* (cmd: string[], opts?: { cwd?: string; env?: Record; stdin?: string }) { @@ -107,6 +109,8 @@ export const layer: Layer.Layer() + // check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix. + const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item)) const check = yield* git( [ ...quote, @@ -120,12 +124,17 @@ export const layer: Layer.Layer() - return new Set(check.text.split("\0").filter(Boolean)) + return new Set( + check.text + .split("\0") + .filter(Boolean) + .map((item) => (item.startsWith("./:") ? item.slice(2) : item)), + ) }) const drop = Effect.fnUntraced(function* (files: string[]) { @@ -136,8 +145,8 @@ export const layer: Layer.Layer fs - .stat(path.join(state.directory, item)) + .stat(path.join(state.worktree, item)) .pipe(Effect.catch(() => Effect.void)) .pipe( Effect.map((stat) => { diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 208bc0e169..21ec0872b8 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -16,6 +16,8 @@ import { import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer)) +// Windows forbids both * and : in directory names. +const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -448,6 +450,97 @@ it.live( }), ) +it.live( + "subdirectory snapshots include scoped changes only", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const frontend = path.join(dir, "frontend") + yield* write(`${frontend}/tracked.txt`, "initial") + yield* write(`${frontend}/deleted.txt`, "initial") + yield* write(`${dir}/backend/tracked.txt`, "initial") + yield* write(`${dir}/backend/deleted.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${frontend}/tracked.txt`, "changed") + yield* write(`${frontend}/untracked.txt`, "new") + yield* rm(`${frontend}/deleted.txt`) + yield* write(`${dir}/backend/tracked.txt`, "changed") + yield* rm(`${dir}/backend/deleted.txt`) + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(frontend, "tracked.txt")) + expect(patch.files).toContain(fwd(frontend, "untracked.txt")) + expect(patch.files).toContain(fwd(frontend, "deleted.txt")) + expect(patch.files).not.toContain(fwd(dir, "backend", "tracked.txt")) + expect(patch.files).not.toContain(fwd(dir, "backend", "deleted.txt")) + expect(diff).not.toContain("backend/tracked.txt") + expect(diff).not.toContain("backend/deleted.txt") + }).pipe(provideInstance(frontend)) + }), +) + +nonWindowsIt( + "subdirectory snapshots treat wildcard characters literally", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const subdir = path.join(dir, "src*") + yield* write(`${subdir}/file.txt`, "initial") + yield* write(`${subdir}/later-ignored.txt`, "initial") + yield* write(`${dir}/srca/file.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/file.txt`, "changed") + yield* write(`${subdir}/later-ignored.txt`, "changed") + yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n") + yield* write(`${dir}/srca/file.txt`, "changed") + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(subdir, "file.txt")) + expect(patch.files).toContain(fwd(subdir, ".gitignore")) + expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt")) + expect(patch.files).not.toContain(fwd(dir, "srca", "file.txt")) + expect(diff).toContain("src*/later-ignored.txt") + expect(diff).toContain("deleted file mode") + expect(diff).not.toContain("srca/file.txt") + }).pipe(provideInstance(subdir)) + }), +) + +nonWindowsIt( + "subdirectory snapshots treat leading colons literally", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + const subdir = path.join(dir, ":src") + yield* write(`${subdir}/kept.txt`, "initial") + yield* write(`${subdir}/later-ignored.txt`, "initial") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${subdir}/kept.txt`, "changed") + yield* write(`${subdir}/later-ignored.txt`, "changed") + yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n") + const patch = yield* snapshot.patch(before!) + const diff = yield* snapshot.diff(before!) + expect(patch.files).toContain(fwd(subdir, "kept.txt")) + expect(patch.files).toContain(fwd(subdir, ".gitignore")) + expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt")) + expect(diff).toContain(":src/later-ignored.txt") + expect(diff).toContain("deleted file mode") + }).pipe(provideInstance(subdir)) + }), +) + it.instance( "gitignore changes", withTrackedSnapshot(({ tmp, snapshot, before }) => From c6cc13e18341badce7c0c33efd2690d2b95438ae Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 01:59:12 +0530 Subject: [PATCH 10/11] feat(mcp): add resource template listing (#33546) --- packages/opencode/src/mcp/catalog.ts | 8 ++ packages/opencode/src/mcp/index.ts | 15 +++ packages/opencode/src/session/tools.ts | 106 ++++++++++++++++-- packages/opencode/test/mcp/lifecycle.test.ts | 28 +++++ packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + 6 files changed, 151 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index b09adb2211..0cd2238edf 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -129,6 +129,14 @@ export function resources(client: Client, timeout?: number) { ) } +export function resourceTemplates(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resourceTemplates, + ) +} + function listTools(client: Client, timeout: number) { return Effect.tryPromise({ try: () => diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index ed2cdf1f99..7970b9ba29 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -125,6 +125,7 @@ const pendingOAuthTransports = new Map() // Prompt cache types type PromptInfo = Awaited>["prompts"][number] type ResourceInfo = Awaited>["resources"][number] +type ResourceTemplateInfo = Awaited>["resourceTemplates"][number] type McpEntry = NonNullable[string] function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info { @@ -162,6 +163,9 @@ export interface Interface { readonly tools: () => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: (clientName?: string) => Effect.Effect> + readonly resourceTemplates: ( + clientName?: string, + ) => Effect.Effect> readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record | Status }> readonly connect: (name: string) => Effect.Effect readonly disconnect: (name: string) => Effect.Effect @@ -690,6 +694,16 @@ export const layer = Layer.effect( ) }) + const resourceTemplates = Effect.fn("MCP.resourceTemplates")(function* (clientName?: string) { + return yield* collectFromConnected( + yield* InstanceState.get(state), + McpCatalog.resourceTemplates, + "resource templates", + (template) => template.uriTemplate, + clientName, + ) + }) + const withClient = Effect.fnUntraced(function* ( clientName: string, fn: (client: MCPClient, timeout?: number) => Promise, @@ -931,6 +945,7 @@ export const layer = Layer.effect( tools, prompts, resources, + resourceTemplates, add, connect, disconnect, diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 371a6c9a39..484a3466a6 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -22,8 +22,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" -const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources" -const READ_MCP_RESOURCE_TOOL = "read_mcp_resource" +const MCP_RESOURCE_TOOLS = { + list: "list_mcp_resources", + listTemplates: "list_mcp_resource_templates", + read: "read_mcp_resource", +} as const const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024 const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([ "application/pdf", @@ -130,7 +133,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { (client) => !!client.getServerCapabilities()?.resources, ) if (hasMcpResourceServer) { - tools[LIST_MCP_RESOURCES_TOOL] = tool({ + tools[MCP_RESOURCE_TOOLS.list] = tool({ description: "Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.", inputSchema: jsonSchema( @@ -167,7 +170,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { : resourceServers.map((server) => `mcp:${server}:*`) yield* plugin.trigger( "tool.execute.before", - { tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) yield* ctx.ask({ @@ -200,7 +203,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } yield* plugin.trigger( "tool.execute.after", - { tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, output, ) if (opts.abortSignal?.aborted) { @@ -212,7 +215,89 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }, }) - tools[READ_MCP_RESOURCE_TOOL] = tool({ + tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({ + description: + "Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.", + inputSchema: jsonSchema( + ProviderTransform.schema(input.model, { + type: "object", + properties: { + server: { + type: "string", + description: "Optional MCP server name. When omitted, lists resource templates from every connected server.", + }, + }, + additionalProperties: false, + }), + ), + execute(args, opts) { + return run.promise( + Effect.gen(function* () { + const parsed = parseListMcpResourcesArgs(args) + const ctx = context(toRecord(args), opts) + const clients = yield* mcp.clients() + const resourceServers = Object.entries(clients) + .filter((entry) => !!entry[1].getServerCapabilities()?.resources) + .map((entry) => entry[0]) + .sort((a, b) => a.localeCompare(b)) + if (parsed.server && !resourceServers.includes(parsed.server)) { + throw new Error( + resourceServers.length === 0 + ? `MCP server "${parsed.server}" does not support resources` + : `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`, + ) + } + const permissionPatterns = parsed.server + ? [`mcp:${parsed.server}:*`] + : resourceServers.map((server) => `mcp:${server}:*`) + yield* plugin.trigger( + "tool.execute.before", + { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* ctx.ask({ + permission: "read", + metadata: parsed.server ? { server: parsed.server } : {}, + patterns: permissionPatterns, + always: permissionPatterns, + }) + + const templates = Object.values(yield* mcp.resourceTemplates(parsed.server)) + const filtered = templates + .filter((template) => !parsed.server || template.client === parsed.server) + .toSorted((a, b) => + (a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare( + b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate, + ), + ) + const content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2) + const truncated = yield* truncate.output(content, {}, input.agent) + const output = { + title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates", + metadata: { + count: filtered.length, + servers: resourceServers, + ...(parsed.server ? { server: parsed.server } : {}), + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + output: truncated.content, + } + yield* plugin.trigger( + "tool.execute.after", + { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + output, + ) + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + }), + ) + }, + }) + + tools[MCP_RESOURCE_TOOLS.read] = tool({ description: "Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.", inputSchema: jsonSchema( @@ -247,7 +332,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } yield* plugin.trigger( "tool.execute.before", - { tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) yield* ctx.ask({ @@ -282,7 +367,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } yield* plugin.trigger( "tool.execute.after", - { tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, output, ) if (opts.abortSignal?.aborted) { @@ -432,6 +517,11 @@ function formatMcpResource(resource: MCP.Resource) { return { ...result, server: resource.client } } +function formatMcpResourceTemplate(template: Record & { client: string }) { + const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client")) + return { ...result, server: template.client } +} + function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) { const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord) const text: string[] = [] diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index e40bc51217..9d19bf79af 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -17,6 +17,7 @@ interface MockClientState { listToolsCalls: number listPromptsCalls: number listResourcesCalls: number + listResourceTemplatesCalls: number getPromptTimeout?: number readResourceTimeout?: number requestCalls: number @@ -26,6 +27,7 @@ interface MockClientState { listResourcesShouldFail: boolean prompts: Array<{ name: string; description?: string }> resources: Array<{ name: string; uri: string; description?: string }> + resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }> toolPages: Record< string, { @@ -38,6 +40,10 @@ interface MockClientState { string, { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } > + resourceTemplatePages: Record< + string, + { resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string } + > closed: boolean clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } } requestHandlers: Map Promise> @@ -67,6 +73,7 @@ function getOrCreateClientState(name?: string): MockClientState { listToolsCalls: 0, listPromptsCalls: 0, listResourcesCalls: 0, + listResourceTemplatesCalls: 0, requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", @@ -74,9 +81,11 @@ function getOrCreateClientState(name?: string): MockClientState { listResourcesShouldFail: false, prompts: [], resources: [], + resourceTemplates: [], toolPages: {}, promptPages: {}, resourcePages: {}, + resourceTemplatePages: {}, closed: false, requestHandlers: new Map(), notificationHandlers: new Map(), @@ -224,6 +233,13 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { resources: this._state?.resources ?? [] } } + async listResourceTemplates(params?: { cursor?: string }) { + if (this._state) this._state.listResourceTemplatesCalls++ + const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")] + if (page) return page + return { resourceTemplates: this._state?.resourceTemplates ?? [] } + } + async getPrompt(_params: unknown, options?: { timeout?: number }) { if (this._state) this._state.getPromptTimeout = options?.timeout return { messages: [] } @@ -353,6 +369,13 @@ it.instance( initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] }, } + serverState.resourceTemplatePages = { + initial: { + resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }], + nextCursor: "resource-templates-2", + }, + "resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] }, + } yield* mcp.add("paged-server", { type: "local", @@ -362,9 +385,14 @@ it.instance( expect(Object.keys(yield* mcp.tools())).toEqual(["mcp__paged-server__tool-one", "mcp__paged-server__tool-two"]) expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"]) + expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([ + "paged-server:test://one/{id}", + "paged-server:test://two/{id}", + ]) expect(serverState.listToolsCalls).toBe(2) expect(serverState.listPromptsCalls).toBe(2) expect(serverState.listResourcesCalls).toBe(2) + expect(serverState.listResourceTemplatesCalls).toBe(2) }), ), { config: { mcp: {} } }, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 0a3af92a00..ca1683e901 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -116,6 +116,7 @@ const mcp = Layer.succeed( tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 98233f3527..9f5b36f353 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -40,6 +40,7 @@ const mcp = Layer.succeed( tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, From d2305d4a76e3b9ccbf0b98f2d137e49714a90dab Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 20:31:13 +0000 Subject: [PATCH 11/11] chore: generate --- packages/opencode/src/session/tools.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 484a3466a6..376ba8f2b8 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -224,7 +224,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { properties: { server: { type: "string", - description: "Optional MCP server name. When omitted, lists resource templates from every connected server.", + description: + "Optional MCP server name. When omitted, lists resource templates from every connected server.", }, }, additionalProperties: false,