Skip to content

Commit 9ff999c

Browse files
authored
tool/lsp: include request details in permission metadata (anomalyco#24139)
1 parent 4877ecc commit 9ff999c

2 files changed

Lines changed: 182 additions & 4 deletions

File tree

packages/opencode/src/tool/lsp.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ export const LspTool = Tool.define(
3636
Effect.gen(function* () {
3737
const lsp = yield* LSP.Service
3838
const fs = yield* AppFileSystem.Service
39-
4039
return {
4140
description: DESCRIPTION,
4241
parameters: Parameters,
@@ -47,12 +46,29 @@ export const LspTool = Tool.define(
4746
Effect.gen(function* () {
4847
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
4948
yield* assertExternalDirectoryEffect(ctx, file)
50-
yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
49+
const meta =
50+
args.operation === "workspaceSymbol"
51+
? { operation: args.operation }
52+
: args.operation === "documentSymbol"
53+
? { operation: args.operation, filePath: file }
54+
: { operation: args.operation, filePath: file, line: args.line, character: args.character }
55+
yield* ctx.ask({
56+
permission: "lsp",
57+
patterns: ["*"],
58+
always: ["*"],
59+
metadata: meta,
60+
})
5161

5262
const uri = pathToFileURL(file).href
5363
const position = { file, line: args.line - 1, character: args.character - 1 }
5464
const relPath = path.relative(Instance.worktree, file)
55-
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
65+
const detail =
66+
args.operation === "workspaceSymbol"
67+
? ""
68+
: args.operation === "documentSymbol"
69+
? relPath
70+
: `${relPath}:${args.line}:${args.character}`
71+
const title = detail ? `${args.operation} ${detail}` : args.operation
5672

5773
const exists = yield* fs.existsSafe(file)
5874
if (!exists) throw new Error(`File not found: ${file}`)
@@ -90,7 +106,7 @@ export const LspTool = Tool.define(
90106
metadata: { result },
91107
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
92108
}
93-
}),
109+
}).pipe(Effect.orDie),
94110
}
95111
}),
96112
)
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { afterEach, describe, expect } from "bun:test"
2+
import { Effect, Layer } from "effect"
3+
import path from "path"
4+
import { Agent } from "../../src/agent/agent"
5+
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
6+
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
7+
import { LSP } from "../../src/lsp"
8+
import { Permission } from "../../src/permission"
9+
import { Instance } from "../../src/project/instance"
10+
import { MessageID, SessionID } from "../../src/session/schema"
11+
import { Tool, Truncate } from "../../src/tool"
12+
import { LspTool } from "../../src/tool/lsp"
13+
import { provideTmpdirInstance } from "../fixture/fixture"
14+
import { testEffect } from "../lib/effect"
15+
16+
afterEach(async () => {
17+
await Instance.disposeAll()
18+
})
19+
20+
const ctx = {
21+
sessionID: SessionID.make("ses_test"),
22+
messageID: MessageID.make(""),
23+
callID: "",
24+
agent: "build",
25+
abort: AbortSignal.any([]),
26+
messages: [],
27+
metadata: () => Effect.void,
28+
ask: () => Effect.void,
29+
}
30+
31+
const lsp = Layer.succeed(
32+
LSP.Service,
33+
LSP.Service.of({
34+
init: () => Effect.void,
35+
status: () => Effect.succeed([]),
36+
hasClients: () => Effect.succeed(true),
37+
touchFile: () => Effect.void,
38+
diagnostics: () => Effect.succeed({}),
39+
hover: () => Effect.succeed([]),
40+
definition: () => Effect.succeed([]),
41+
references: () => Effect.succeed([]),
42+
implementation: () => Effect.succeed([]),
43+
documentSymbol: () => Effect.succeed([]),
44+
workspaceSymbol: () => Effect.succeed([]),
45+
prepareCallHierarchy: () => Effect.succeed([]),
46+
incomingCalls: () => Effect.succeed([]),
47+
outgoingCalls: () => Effect.succeed([]),
48+
}),
49+
)
50+
51+
const it = testEffect(
52+
Layer.mergeAll(
53+
Agent.defaultLayer,
54+
AppFileSystem.defaultLayer,
55+
CrossSpawnSpawner.defaultLayer,
56+
Truncate.defaultLayer,
57+
lsp,
58+
),
59+
)
60+
61+
const init = Effect.fn("LspToolTest.init")(function* () {
62+
const info = yield* LspTool
63+
return yield* info.init()
64+
})
65+
66+
const run = Effect.fn("LspToolTest.run")(function* (
67+
args: Tool.InferParameters<typeof LspTool>,
68+
next: Tool.Context = ctx,
69+
) {
70+
const tool = yield* init()
71+
return yield* tool.execute(args, next)
72+
})
73+
74+
const put = Effect.fn("LspToolTest.put")(function* (file: string) {
75+
const fs = yield* AppFileSystem.Service
76+
yield* fs.writeWithDirs(file, "export const x = 1\n")
77+
})
78+
79+
const asks = () => {
80+
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
81+
return {
82+
items,
83+
next: {
84+
...ctx,
85+
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
86+
Effect.sync(() => {
87+
items.push(req)
88+
}),
89+
},
90+
}
91+
}
92+
93+
describe("tool.lsp", () => {
94+
describe("permission metadata", () => {
95+
it.live("keeps cursor details for position-based operations", () =>
96+
provideTmpdirInstance(
97+
(dir) =>
98+
Effect.gen(function* () {
99+
const file = path.join(dir, "test.ts")
100+
yield* put(file)
101+
102+
const { items, next } = asks()
103+
const result = yield* run({ operation: "goToDefinition", filePath: file, line: 3, character: 7 }, next)
104+
const req = items.find((item) => item.permission === "lsp")
105+
106+
expect(req).toBeDefined()
107+
expect(req!.metadata).toEqual({
108+
operation: "goToDefinition",
109+
filePath: file,
110+
line: 3,
111+
character: 7,
112+
})
113+
expect(result.title).toBe("goToDefinition test.ts:3:7")
114+
}),
115+
{ git: true },
116+
),
117+
)
118+
119+
it.live("omits cursor details for documentSymbol", () =>
120+
provideTmpdirInstance(
121+
(dir) =>
122+
Effect.gen(function* () {
123+
const file = path.join(dir, "test.ts")
124+
yield* put(file)
125+
126+
const { items, next } = asks()
127+
const result = yield* run({ operation: "documentSymbol", filePath: file, line: 3, character: 7 }, next)
128+
const req = items.find((item) => item.permission === "lsp")
129+
130+
expect(req).toBeDefined()
131+
expect(req!.metadata).toEqual({
132+
operation: "documentSymbol",
133+
filePath: file,
134+
})
135+
expect(result.title).toBe("documentSymbol test.ts")
136+
}),
137+
{ git: true },
138+
),
139+
)
140+
141+
it.live("omits file and cursor details for workspaceSymbol", () =>
142+
provideTmpdirInstance(
143+
(dir) =>
144+
Effect.gen(function* () {
145+
const file = path.join(dir, "test.ts")
146+
yield* put(file)
147+
148+
const { items, next } = asks()
149+
const result = yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }, next)
150+
const req = items.find((item) => item.permission === "lsp")
151+
152+
expect(req).toBeDefined()
153+
expect(req!.metadata).toEqual({
154+
operation: "workspaceSymbol",
155+
})
156+
expect(result.title).toBe("workspaceSymbol")
157+
}),
158+
{ git: true },
159+
),
160+
)
161+
})
162+
})

0 commit comments

Comments
 (0)