Skip to content
Merged
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
26 changes: 20 additions & 6 deletions packages/opencode/src/effect/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface RunHandle<A, E> {
interface ShellHandle<A, E> {
id: number
ready: Deferred.Deferred<void>
cancelled: Deferred.Deferred<void>
fiber: Fiber.Fiber<A, E>
}

Expand Down Expand Up @@ -99,7 +100,12 @@ export const make = <A, E = never>(
}),
).pipe(Effect.flatten)

const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
const stopShell = (shell: ShellHandle<A, E>) =>
Effect.gen(function* () {
yield* awaitShellReady(shell)
yield* Deferred.succeed(shell.cancelled, undefined).pipe(Effect.asVoid)
yield* Fiber.interrupt(shell.fiber)
})

const awaitShellReady = (shell: ShellHandle<A, E>) =>
Deferred.await(shell.ready).pipe(Effect.raceFirst(Fiber.await(shell.fiber).pipe(Effect.asVoid)), Effect.ignore)
Expand Down Expand Up @@ -149,18 +155,28 @@ export const make = <A, E = never>(
}
yield* busy
const id = next()
const cancelled = yield* Deferred.make<void>()
const ready =
options?.ready ??
(yield* Deferred.make<void>().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<A, E>
const shell = { id, ready, cancelled, fiber } satisfies ShellHandle<A, E>
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())
Comment thread
Astro-Han marked this conversation as resolved.
}
return yield* Effect.failCause(exit.cause)
}),
{ _tag: "Shell", shell },
Expand All @@ -184,7 +200,6 @@ export const make = <A, E = never>(
case "Shell":
return [
Effect.gen(function* () {
yield* awaitShellReady(st.shell)
yield* stopShell(st.shell)
yield* idleIfCurrent()
}),
Expand All @@ -193,9 +208,8 @@ export const make = <A, E = never>(
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,
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Goal
- [single-sentence task summary]

Expand Down Expand Up @@ -66,7 +66,7 @@ const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the se

## Relevant Files
- [file or directory path: why it matters, or "(none)"]
---
</template>

Rules:
- Keep every section, even when empty.
Expand Down
32 changes: 15 additions & 17 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) {
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)
Expand Down Expand Up @@ -958,6 +957,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<string, { args: string[] }> = {
nu: { args: ["-c", input.command] },
fish: { args: ["-c", input.command] },
Expand All @@ -966,25 +966,25 @@ 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: {
args: [
"-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] },
Expand All @@ -994,7 +994,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",
Expand Down Expand Up @@ -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)
}

Expand Down
10 changes: 5 additions & 5 deletions packages/opencode/src/tool/lsp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file)
Expand Down Expand Up @@ -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":
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/tool/lsp.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
38 changes: 37 additions & 1 deletion packages/opencode/test/effect/runner.test.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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<string>(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<string, string>(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(
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/test/server/global-session-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, any>>
expect(body).toHaveLength(1)
const item = body[0]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<Record<string, any>>
expect(globalBody).toHaveLength(1)
expect(Object.hasOwn(globalBody[0].project, "name")).toBe(false)
},
})
}),
)
Comment thread
Astro-Han marked this conversation as resolved.

test("experimental route round-trips created-order cursor", async () => {
await using tmp = await tmpdir({ git: true })
const originalNow = Date.now
Expand Down
34 changes: 33 additions & 1 deletion packages/opencode/test/session/prompt-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,14 @@ function defer<T>() {
}

function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
return withShell("/bin/sh", fx)
}

function withShell<A, E, R>(shell: string, fx: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const prev = process.env.SHELL
process.env.SHELL = "/bin/sh"
process.env.SHELL = shell
Shell.preferred.reset()
return prev
}),
Expand Down Expand Up @@ -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 },
),
),
)
Comment thread
Astro-Han marked this conversation as resolved.

unix("shell lists files from the project directory", () =>
provideTmpdirInstance(
(dir) =>
Expand Down
Loading
Loading