Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CompactClient, WarpGrepClient } from "@morphllm/morphsdk";
import { CompactClient } from "@morphllm/morphsdk/tools/compact";
import { WarpGrepClient } from "@morphllm/morphsdk/tools/warp-grep/client";

// These are internal to the plugin but duplicated here for testing.
const EXISTING_CODE_MARKER = "// ... existing code ...";
Expand Down Expand Up @@ -116,6 +117,40 @@ describe("packaged tool-selection instructions", () => {
expect(content).toContain("MORPH_COMPACT_TOKEN_LIMIT");
expect(content).toContain("opencode.json");
});

test("source uses narrow Morph SDK entrypoints", () => {
const content = readFileSync(join(import.meta.dir, "index.ts"), "utf-8");

expect(content).toContain('@morphllm/morphsdk/tools/fastapply');
expect(content).toContain('@morphllm/morphsdk/tools/warp-grep/client');
expect(content).toContain('@morphllm/morphsdk/tools/compact');
expect(content).not.toContain(
'import { MorphClient, WarpGrepClient, CompactClient } from "@morphllm/morphsdk";',
);
});

test("build pipeline bundles morphsdk compatibility layer", () => {
const pkg = JSON.parse(
readFileSync(join(import.meta.dir, "package.json"), "utf-8"),
) as {
scripts?: Record<string, string>;
};
const tsupConfig = readFileSync(
join(import.meta.dir, "tsup.config.ts"),
"utf-8",
);

expect(pkg.scripts?.build).toBe("tsup --config tsup.config.ts");
expect(tsupConfig).toContain("noExternal");
expect(tsupConfig).toMatch(/@morphllm\\\/morphsdk/);
});

test("local WarpGrep uses non-streaming execution for stable final results", () => {
const content = readFileSync(join(import.meta.dir, "index.ts"), "utf-8");

expect(content).toContain("const result = await warpGrep!.execute({");
expect(content).not.toContain("streamSteps: true");
});
});

describe("normalizeCodeEditInput", () => {
Expand Down Expand Up @@ -995,7 +1030,7 @@ describe("ToolContext path resolution", () => {
describe("formatWarpGrepResult edge cases", () => {
async function executeSearch(fakeResult: unknown): Promise<string> {
const original = WarpGrepClient.prototype.execute;
WarpGrepClient.prototype.execute = function* () {
WarpGrepClient.prototype.execute = async function () {
return fakeResult;
} as any;

Expand Down
64 changes: 16 additions & 48 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
* OpenCode Morph Plugin v2
*
* Integrates Morph SDK for fast apply, WarpGrep codebase search, and shell env.
* Uses MorphClient for shared config (API key, timeout, retries) across all tools.
* Uses narrow Morph SDK entrypoints so OpenCode does not eagerly load unrelated
* ESM/CJS edges during plugin startup.
*
* @see https://docs.morphllm.com/quickstart
*/

import { type Plugin, tool } from "@opencode-ai/plugin";
import { MorphClient, WarpGrepClient, CompactClient } from "@morphllm/morphsdk";
import { applyEdit } from "@morphllm/morphsdk/tools/fastapply";
import { WarpGrepClient } from "@morphllm/morphsdk/tools/warp-grep/client";
import { CompactClient } from "@morphllm/morphsdk/tools/compact";
import type { WarpGrepResult, CompactResult } from "@morphllm/morphsdk";
import type { Part, TextPart, ToolPart, Message } from "@opencode-ai/sdk";
import { isAbsolute, resolve as resolvePath } from "node:path";
Expand Down Expand Up @@ -74,17 +77,6 @@ const PLUGIN_VERSION = "2.0.0";
const EXISTING_CODE_MARKER = "// ... existing code ...";
const MORPH_ROUTING_HINT_HEADER = "Morph plugin routing hints:";

/**
* Shared MorphClient — FastApply uses morph.fastApply.applyEdit()
* with MORPH_API_URL passed as per-call override.
*/
const morph = MORPH_API_KEY
? new MorphClient({
apiKey: MORPH_API_KEY,
timeout: MORPH_TIMEOUT,
})
: null;

/**
* Separate WarpGrep client with its own timeout (typically longer than fast apply).
*/
Expand Down Expand Up @@ -693,20 +685,9 @@ const MorphPlugin: Plugin = async ({ directory, worktree, client }) => {
} catch {}
};

if (!MORPH_API_KEY) {
await log(
"warn",
"MORPH_API_KEY not set - morph tools will be disabled",
);
} else {
const features = [
MORPH_EDIT_ENABLED && "edit",
MORPH_WARPGREP_ENABLED && "warpgrep",
MORPH_WARPGREP_GITHUB_ENABLED && "warpgrep-github",
MORPH_COMPACT_ENABLED && "compact",
].filter(Boolean);
await log("info", `Plugin v${PLUGIN_VERSION} loaded [${features.join(", ")}]`);
}
// Avoid networked startup logging so short-lived OpenCode commands like
// `opencode debug config` can load the plugin without leaving a pending
// app.log request alive in the host process.

// Build tool map conditionally based on feature flags
const tools: Record<string, ReturnType<typeof tool>> = {};
Expand Down Expand Up @@ -841,15 +822,17 @@ If you truly want to replace the entire file, use the 'write' tool instead.`;

// Call Morph SDK to merge the edit
const startTime = Date.now();
const result = await morph!.fastApply.applyEdit(
const result = await applyEdit(
{
originalCode,
codeEdit: normalizedCodeEdit,
instruction: instructions,
filepath: target_filepath,
},
{
morphApiKey: MORPH_API_KEY,
morphApiUrl: MORPH_API_URL,
timeout: MORPH_TIMEOUT,
generateUdiff: true,
},
);
Expand Down Expand Up @@ -967,37 +950,22 @@ Get your API key at: https://morphllm.com/dashboard/api-keys`;
const startTime = Date.now();

try {
const generator = warpGrep!.execute({
// The current Morph SDK streaming branch can return an unusable final
// result in OpenCode even when a direct execute() succeeds. Prefer the
// non-streaming path here so the tool remains reliably usable.
const result = await warpGrep!.execute({
searchTerm: args.search_term,
repoRoot: resolveSessionRepoRoot(
directory,
worktree,
),
streamSteps: true,
});

let turnCount = 0;
let result: WarpGrepResult;

for (;;) {
const { value, done } = await generator.next();
if (done) {
result = value;
break;
}
turnCount = value.turn;
await log(
"debug",
`WarpGrep turn ${value.turn}: ${value.toolCalls?.map((tc: { name: string }) => tc.name).join(", ") ?? "..."}`,
);
}

const duration = Date.now() - startTime;
const contextCount = result.contexts?.length ?? 0;

await log(
"info",
`WarpGrep: ${contextCount} contexts in ${turnCount} turns (${duration}ms)`,
`WarpGrep: ${contextCount} contexts (${duration}ms)`,
);

return formatWarpGrepResult(result);
Expand Down
Loading