From 5eb3c1ebdad58404fca6290e4d4590cca284bff5 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 00:30:02 +0800 Subject: [PATCH 1/5] fix(shell): preserve cwd after login startup --- packages/opencode/src/session/prompt.ts | 10 +++--- .../test/session/prompt-effect.test.ts | 34 ++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ccfc912d9..c2f721f2e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -958,6 +958,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shellName = ( process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) ).toLowerCase() + const cwd = ctx.directory const invocations: Record = { nu: { args: ["-c", input.command] }, fish: { args: ["-c", input.command] }, @@ -966,12 +967,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the "-l", "-c", ` - __oc_cwd=$PWD [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - cd "$__oc_cwd" + cd -- ${JSON.stringify(cwd)} eval ${JSON.stringify(input.command)} `, + "pawwork", ], }, bash: { @@ -979,12 +980,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the "-l", "-c", ` - __oc_cwd=$PWD shopt -s expand_aliases [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - cd "$__oc_cwd" + cd -- ${JSON.stringify(cwd)} eval ${JSON.stringify(input.command)} `, + "pawwork", ], }, cmd: { args: ["/c", input.command] }, @@ -994,7 +995,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const args = (invocations[shellName] ?? invocations[""]).args - const cwd = ctx.directory const shellEnv = yield* restore( plugin.trigger( "shell.env", diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 537ac7a77..7a28f4e71 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -73,10 +73,14 @@ function defer() { } function withSh(fx: () => Effect.Effect) { + return withShell("/bin/sh", fx) +} + +function withShell(shell: string, fx: () => Effect.Effect) { return Effect.acquireUseRelease( Effect.sync(() => { const prev = process.env.SHELL - process.env.SHELL = "/bin/sh" + process.env.SHELL = shell Shell.preferred.reset() return prev }), @@ -1269,6 +1273,34 @@ unix("shell completes a fast command on the preferred shell", () => ), ) +unix("shell commands can change directory after login startup", () => + withShell("/bin/bash", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + const parent = path.dirname(dir) + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: 'printf "argc:%s\\n" "$#"; cd .. && pwd', + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.output).toContain("argc:0") + expect(tool.state.output).toContain(parent) + expect(tool.state.metadata.output).toContain("argc:0") + expect(tool.state.metadata.output).toContain(parent) + yield* run.assertNotBusy(chat.id) + }), + { git: true, config: cfg }, + ), + ), +) + unix("shell lists files from the project directory", () => provideTmpdirInstance( (dir) => From e58be71146ada86a0cb8e7ca27fa1590ee36cca2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 00:32:50 +0800 Subject: [PATCH 2/5] fix(lsp): pass workspace symbol query --- packages/opencode/src/tool/lsp.ts | 10 +- packages/opencode/src/tool/lsp.txt | 7 +- .../__snapshots__/parameters.test.ts.snap | 4 + packages/opencode/test/tool/lsp.test.ts | 92 +++++++++++++++++++ 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/test/tool/lsp.test.ts diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index ddc8f03a3..84a8f9c5c 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -29,6 +29,9 @@ export const Parameters = Schema.Struct({ character: Schema.Number.check(Schema.isInt()) .check(Schema.isGreaterThanOrEqualTo(1)) .annotate({ description: "The character offset (1-based, as shown in editors)" }), + query: Schema.optional(Schema.String).annotate({ + description: "Search query for workspaceSymbol. Empty string requests all symbols.", + }), }) export const LspTool = Tool.define( @@ -39,10 +42,7 @@ export const LspTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: ( - args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number }, - ctx: Tool.Context, - ) => + execute: (args: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) yield* assertExternalDirectoryEffect(ctx, file) @@ -89,7 +89,7 @@ export const LspTool = Tool.define( case "documentSymbol": return lsp.documentSymbol(uri) case "workspaceSymbol": - return lsp.workspaceSymbol("") + return lsp.workspaceSymbol(args.query ?? "") case "goToImplementation": return lsp.implementation(position) case "prepareCallHierarchy": diff --git a/packages/opencode/src/tool/lsp.txt b/packages/opencode/src/tool/lsp.txt index 5a50a571b..5975118eb 100644 --- a/packages/opencode/src/tool/lsp.txt +++ b/packages/opencode/src/tool/lsp.txt @@ -5,7 +5,7 @@ Supported operations: - findReferences: Find all references to a symbol - hover: Get hover information (documentation, type info) for a symbol - documentSymbol: Get all symbols (functions, classes, variables) in a document -- workspaceSymbol: Search for symbols across the entire workspace +- workspaceSymbol: List project-wide symbols matching a query string - goToImplementation: Find implementations of an interface or abstract method - prepareCallHierarchy: Get call hierarchy item at a position (functions/methods) - incomingCalls: Find all functions/methods that call the function at a position @@ -16,4 +16,9 @@ All operations require: - line: The line number (1-based, as shown in editors) - character: The character offset (1-based, as shown in editors) +workspaceSymbol also accepts: +- query: A query string to filter symbols by. Empty string requests all symbols. + +For workspaceSymbol, filePath is not sent in the LSP workspace/symbol request. It is used by PawWork to select and start the matching LSP server. + Note: LSP servers must be configured for the file type. If no server is available, an error will be returned. diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index eb3fe6cce..b20665b34 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -209,6 +209,10 @@ exports[`tool parameters JSON Schema (wire shape) lsp 1`] = ` ], "type": "string", }, + "query": { + "description": "Search query for workspaceSymbol. Empty string requests all symbols.", + "type": "string", + }, }, "required": [ "operation", diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts new file mode 100644 index 000000000..55f95fef7 --- /dev/null +++ b/packages/opencode/test/tool/lsp.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { LSP } from "../../src/lsp" +import { Instance } from "../../src/project/instance" +import { MessageID, SessionID } from "../../src/session/schema" +import { LspTool } from "../../src/tool/lsp" +import { Truncate } from "../../src/tool/truncate" +import type * as Tool from "../../src/tool/tool" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await Instance.disposeAll() +}) + +const workspaceSymbolQueries: string[] = [] + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(true), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed([]), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: (query) => + Effect.sync(() => { + workspaceSymbolQueries.push(query) + return [] + }), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + shutdownAll: () => Effect.void, + invalidate: () => Effect.void, + }), +) + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + lsp, + Truncate.defaultLayer, + ), +) + +const ctx: Tool.Context = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const run = Effect.fn("LspToolTest.run")(function* (args: Tool.InferParameters) { + const info = yield* LspTool + const tool = yield* info.init() + return yield* tool.execute(args, ctx) +}) + +describe("tool.lsp", () => { + it.live( + "passes workspaceSymbol query to LSP", + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const file = `${dir}/test.ts` + yield* Effect.promise(() => Bun.write(file, "export function TestSymbol() {}\n")) + workspaceSymbolQueries.length = 0 + + yield* provideInstance(dir)( + Effect.gen(function* () { + yield* run({ operation: "workspaceSymbol", filePath: file, line: 1, character: 1, query: "TestSymbol" }) + yield* run({ operation: "workspaceSymbol", filePath: file, line: 1, character: 1 }) + }), + ) + + expect(workspaceSymbolQueries).toEqual(["TestSymbol", ""]) + }), + ) +}) From 667d6dd9846f8c007e98c05fae8b1a41550b8dc0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 00:34:35 +0800 Subject: [PATCH 3/5] fix(session): harden shell cancellation --- packages/opencode/src/effect/runner.ts | 26 ++++++++++---- packages/opencode/src/session/prompt.ts | 22 ++++++------ packages/opencode/test/effect/runner.test.ts | 38 +++++++++++++++++++- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 9fa55230c..89c3a844a 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -19,6 +19,7 @@ interface RunHandle { interface ShellHandle { id: number ready: Deferred.Deferred + cancelled: Deferred.Deferred fiber: Fiber.Fiber } @@ -99,7 +100,12 @@ export const make = ( }), ).pipe(Effect.flatten) - const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) + const stopShell = (shell: ShellHandle) => + Effect.gen(function* () { + yield* awaitShellReady(shell) + yield* Deferred.succeed(shell.cancelled, undefined).pipe(Effect.asVoid) + yield* Fiber.interrupt(shell.fiber) + }) const awaitShellReady = (shell: ShellHandle) => Deferred.await(shell.ready).pipe(Effect.raceFirst(Fiber.await(shell.fiber).pipe(Effect.asVoid)), Effect.ignore) @@ -149,18 +155,28 @@ export const make = ( } yield* busy const id = next() + const cancelled = yield* Deferred.make() const ready = options?.ready ?? (yield* Deferred.make().pipe( Effect.tap((ready) => Deferred.succeed(ready, undefined)), )) const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, ready, fiber } satisfies ShellHandle + const shell = { id, ready, cancelled, fiber } satisfies ShellHandle return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) if (Exit.isSuccess(exit)) return exit.value - if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt + if ( + Cause.hasInterruptsOnly(exit.cause) || + ((yield* Deferred.isDone(cancelled)) && + Cause.hasInterrupts(exit.cause) && + !Cause.hasFails(exit.cause) && + !Cause.hasDies(exit.cause)) + ) { + if (onInterrupt) return yield* onInterrupt + return yield* Effect.die(new Cancelled()) + } return yield* Effect.failCause(exit.cause) }), { _tag: "Shell", shell }, @@ -184,7 +200,6 @@ export const make = ( case "Shell": return [ Effect.gen(function* () { - yield* awaitShellReady(st.shell) yield* stopShell(st.shell) yield* idleIfCurrent() }), @@ -193,9 +208,8 @@ export const make = ( case "ShellThenRun": return [ Effect.gen(function* () { - yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) - yield* awaitShellReady(st.shell) yield* stopShell(st.shell) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) yield* idleIfCurrent() }), { _tag: "Idle" } as const, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c2f721f2e..30a918f0e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -887,10 +887,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, ready: Deferred.Deferred) { let output = "" let aborted = false - const { run, msg, part, cmd, finish } = yield* Effect.uninterruptibleMask((restore) => + const { msg, part, cmd, finish } = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const ctx = yield* InstanceState.context - const run = yield* runner() const session = yield* sessions.get(input.sessionID) if (session.revert) { yield* revert.cleanup(session) @@ -1034,35 +1033,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) - return { run, msg, part, cmd, finish } + return { msg, part, cmd, finish } }), ) const exit = yield* Effect.gen(function* () { const handle = yield* spawner.spawn(cmd) yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) => - Effect.sync(() => { + Effect.gen(function* () { output += chunk if (part.state.status === "running") { part.state.metadata = { ...part.state.metadata, output, description: "" } - void run.fork(sessions.updatePart(part)) + yield* sessions.updatePart(part) } }), ) yield* handle.exitCode }).pipe( Effect.scoped, - Effect.onInterrupt(() => - Effect.sync(() => { - aborted = true - }), - ), Effect.orDie, - Effect.ensuring(finish), Effect.exit, ) - if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) { + if (Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) && !Cause.hasDies(exit.cause)) { + aborted = true + } + yield* finish + + if (Exit.isFailure(exit) && !aborted && !Cause.hasInterruptsOnly(exit.cause)) { return yield* Effect.failCause(exit.cause) } diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index deebdcaa3..7f2661ed4 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" import { Runner } from "../../src/effect" import { it } from "../lib/effect" @@ -334,6 +334,42 @@ describe("Runner", () => { }), ) + it.live( + "cancel does not mask shell defects", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s, { onInterrupt: Effect.succeed("interrupted") }) + + const sh = yield* runner + .startShell(Effect.never.pipe(Effect.ensuring(Effect.die("boom")), Effect.as("ignored"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* runner.cancel + expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true) + }), + ) + + it.live( + "cancel does not mask shell typed failures", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s, { onInterrupt: Effect.succeed("interrupted") }) + + const sh = yield* runner + .startShell(Effect.never.pipe(Effect.onInterrupt(() => Effect.fail("boom")), Effect.as("ignored"))) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* runner.cancel + const exit = yield* Fiber.await(sh) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.hasFails(exit.cause)).toBe(true) + } + }), + ) + // --- shell→run handoff --- it.live( From 65710169838ba5ca9a39f02ef1ee8f661ba596e2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 00:35:20 +0800 Subject: [PATCH 4/5] test(session): cover optional field omission --- .../test/server/global-session-list.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 7a8dba2d3..253fca982 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -221,6 +221,43 @@ describe("session.listGlobal", () => { }), ) + it.live( + "session routes omit undefined optional fields", + Effect.promise(async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await svc.create({ title: "route-optional-fields" }) + + const app = Server.Default().app + const response = await app.request(`/session?directory=${encodeURIComponent(tmp.path)}&roots=true&limit=1`) + expect(response.status).toBe(200) + const body = (await response.json()) as Array> + expect(body).toHaveLength(1) + const item = body[0] + + expect(Object.hasOwn(item, "parentID")).toBe(false) + expect(Object.hasOwn(item, "workspaceID")).toBe(false) + expect(Object.hasOwn(item, "summary")).toBe(false) + expect(Object.hasOwn(item, "share")).toBe(false) + expect(Object.hasOwn(item, "permission")).toBe(false) + expect(Object.hasOwn(item, "revert")).toBe(false) + expect(Object.hasOwn(item.time, "compacting")).toBe(false) + expect(Object.hasOwn(item.time, "archived")).toBe(false) + + const global = await app.request( + `/experimental/session?directory=${encodeURIComponent(tmp.path)}&roots=true&limit=1`, + ) + expect(global.status).toBe(200) + const globalBody = (await global.json()) as Array> + expect(globalBody).toHaveLength(1) + expect(Object.hasOwn(globalBody[0].project, "name")).toBe(false) + }, + }) + }), + ) + test("experimental route round-trips created-order cursor", async () => { await using tmp = await tmpdir({ git: true }) const originalNow = Date.now From b36ec850bccc0792559ab6ac60b320155cf8e7b8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 29 Apr 2026 00:35:49 +0800 Subject: [PATCH 5/5] fix(session): remove compaction summary dividers --- packages/opencode/src/session/compaction.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 0e20d4f75..bb8e08f09 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -37,8 +37,8 @@ const PRUNE_PROTECTED_TOOLS = ["skill"] const DEFAULT_TAIL_TURNS = 2 const MIN_PRESERVE_RECENT_TOKENS = 2_000 const MAX_PRESERVE_RECENT_TOKENS = 8_000 -const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged: ---- +const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside