Skip to content

Commit f640975

Browse files
authored
fix: restore instance context in prompt runs (#22498)
1 parent f9d99f0 commit f640975

4 files changed

Lines changed: 103 additions & 14 deletions

File tree

packages/opencode/src/effect/app-runtime.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Layer, ManagedRuntime } from "effect"
2-
import { memoMap } from "./run-service"
2+
import { attach, memoMap } from "./run-service"
33
import { Observability } from "./oltp"
44

55
import { AppFileSystem } from "@/filesystem"
@@ -97,4 +97,25 @@ export const AppLayer = Layer.mergeAll(
9797
SessionShare.defaultLayer,
9898
)
9999

100-
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
100+
const rt = ManagedRuntime.make(AppLayer, { memoMap })
101+
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
102+
const wrap = (effect: Parameters<typeof rt.runSync>[0]) => attach(effect as never) as never
103+
104+
export const AppRuntime: Runtime = {
105+
runSync(effect) {
106+
return rt.runSync(wrap(effect))
107+
},
108+
runPromise(effect, options) {
109+
return rt.runPromise(wrap(effect), options)
110+
},
111+
runPromiseExit(effect, options) {
112+
return rt.runPromiseExit(wrap(effect), options)
113+
},
114+
runFork(effect) {
115+
return rt.runFork(wrap(effect))
116+
},
117+
runCallback(effect) {
118+
return rt.runCallback(wrap(effect))
119+
},
120+
dispose: () => rt.dispose(),
121+
}

packages/opencode/src/session/prompt.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,21 @@ export namespace SessionPrompt {
104104
const summary = yield* SessionSummary.Service
105105
const sys = yield* SystemPrompt.Service
106106
const llm = yield* LLM.Service
107-
const ctx = yield* Effect.context()
108-
109-
const run = {
110-
promise: <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(ctx)(effect),
111-
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runForkWith(ctx)(effect),
112-
}
107+
const runner = Effect.fn("SessionPrompt.runner")(function* () {
108+
const ctx = yield* Effect.context()
109+
return {
110+
promise: <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(ctx)(effect),
111+
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runForkWith(ctx)(effect),
112+
}
113+
})
114+
const ops = Effect.fn("SessionPrompt.ops")(function* () {
115+
const run = yield* runner()
116+
return {
117+
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
118+
resolvePromptParts: (template: string) => resolvePromptParts(template),
119+
prompt: (input: PromptInput) => prompt(input),
120+
} satisfies TaskPromptOps
121+
})
113122

114123
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
115124
yield* elog.info("cancel", { sessionID })
@@ -359,6 +368,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
359368
}) {
360369
using _ = log.time("resolveTools")
361370
const tools: Record<string, AITool> = {}
371+
const run = yield* runner()
372+
const promptOps = yield* ops()
362373

363374
const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
364375
sessionID: input.session.id,
@@ -528,6 +539,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
528539
}) {
529540
const { task, model, lastUser, sessionID, session, msgs } = input
530541
const ctx = yield* InstanceState.context
542+
const promptOps = yield* ops()
531543
const { task: taskTool } = yield* registry.named()
532544
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
533545
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
@@ -712,6 +724,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
712724

713725
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
714726
const ctx = yield* InstanceState.context
727+
const run = yield* runner()
715728
const session = yield* sessions.get(input.sessionID)
716729
if (session.revert) {
717730
yield* revert.cleanup(session)
@@ -1659,12 +1672,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
16591672
return result
16601673
})
16611674

1662-
const promptOps: TaskPromptOps = {
1663-
cancel: (sessionID) => run.fork(cancel(sessionID)),
1664-
resolvePromptParts: (template) => resolvePromptParts(template),
1665-
prompt: (input) => prompt(input),
1666-
}
1667-
16681675
return Service.of({
16691676
cancel,
16701677
prompt,

packages/opencode/test/effect/app-runtime-logger.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { expect, test } from "bun:test"
22
import { Context, Effect, Layer, Logger } from "effect"
33
import { AppRuntime } from "../../src/effect/app-runtime"
4+
import { InstanceRef } from "../../src/effect/instance-ref"
45
import { EffectLogger } from "../../src/effect/logger"
56
import { makeRuntime } from "../../src/effect/run-service"
7+
import { Instance } from "../../src/project/instance"
8+
import { tmpdir } from "../fixture/fixture"
69

710
function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
811
return {
@@ -40,3 +43,19 @@ test("AppRuntime also installs EffectLogger through Observability.layer", async
4043
expect(current.effectLogger).toBe(true)
4144
expect(current.defaultLogger).toBe(false)
4245
})
46+
47+
test("AppRuntime attaches InstanceRef from ALS", async () => {
48+
await using tmp = await tmpdir({ git: true })
49+
50+
const dir = await Instance.provide({
51+
directory: tmp.path,
52+
fn: () =>
53+
AppRuntime.runPromise(
54+
Effect.gen(function* () {
55+
return (yield* InstanceRef)?.directory
56+
}),
57+
),
58+
})
59+
60+
expect(dir).toBe(tmp.path)
61+
})

packages/opencode/test/session/prompt-effect.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,48 @@ it.live("loop continues when finish is tool-calls", () =>
483483
),
484484
)
485485

486+
it.live("glob tool keeps instance context during prompt runs", () =>
487+
provideTmpdirServer(
488+
({ dir, llm }) =>
489+
Effect.gen(function* () {
490+
const prompt = yield* SessionPrompt.Service
491+
const sessions = yield* Session.Service
492+
const session = yield* sessions.create({
493+
title: "Glob context",
494+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
495+
})
496+
const file = path.join(dir, "probe.txt")
497+
yield* Effect.promise(() => Bun.write(file, "probe"))
498+
499+
yield* prompt.prompt({
500+
sessionID: session.id,
501+
agent: "build",
502+
noReply: true,
503+
parts: [{ type: "text", text: "find text files" }],
504+
})
505+
yield* llm.tool("glob", { pattern: "**/*.txt" })
506+
yield* llm.text("done")
507+
508+
const result = yield* prompt.loop({ sessionID: session.id })
509+
expect(result.info.role).toBe("assistant")
510+
511+
const msgs = yield* MessageV2.filterCompactedEffect(session.id)
512+
const tool = msgs
513+
.flatMap((msg) => msg.parts)
514+
.find(
515+
(part): part is CompletedToolPart =>
516+
part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
517+
)
518+
if (!tool) return
519+
520+
expect(tool.state.output).toContain(file)
521+
expect(tool.state.output).not.toContain("No context found for instance")
522+
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
523+
}),
524+
{ git: true, config: providerCfg },
525+
),
526+
)
527+
486528
it.live("loop continues when finish is stop but assistant has tool parts", () =>
487529
provideTmpdirServer(
488530
Effect.fnUntraced(function* ({ llm }) {

0 commit comments

Comments
 (0)