Skip to content
Closed
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
50 changes: 49 additions & 1 deletion packages/core/src/background-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { makeGlobalNode } from "./effect/app-node"

export type Status = "running" | "completed" | "error" | "cancelled"

export type MessagePayload = {
childSessionID: string
parentSessionID: string
body: string
expectReply: boolean
}

export type Info = {
id: string
type: string
Expand All @@ -28,6 +35,7 @@ type Active = {
output?: { sequence: number; text: string }
tail: Deferred.Deferred<void>
promoted: Deferred.Deferred<Info>
messaged: Deferred.Deferred<MessagePayload>
onPromote?: Effect.Effect<void>
}

Expand All @@ -48,6 +56,11 @@ type PromoteResult = {
onPromote?: Effect.Effect<void>
}

type MessageResult = {
info?: Info
messaged?: Deferred.Deferred<MessagePayload>
}

type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }

type ExtendResult =
Expand Down Expand Up @@ -92,6 +105,8 @@ export interface Interface {
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
readonly waitForPromotion: (id: string) => Effect.Effect<Info>
readonly message: (id: string, payload: MessagePayload) => Effect.Effect<Info | undefined>
readonly waitForMessage: (id: string) => Effect.Effect<MessagePayload>
readonly promote: (id: string) => Effect.Effect<Info | undefined>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
}
Expand Down Expand Up @@ -206,6 +221,7 @@ export const make = Effect.gen(function* () {
const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>()
const promoted = yield* Deferred.make<Info>()
const messaged = yield* Deferred.make<MessagePayload>()
const tail = yield* Deferred.make<void>()
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Expand All @@ -232,6 +248,7 @@ export const make = Effect.gen(function* () {
next: 1,
tail,
promoted,
messaged,
onPromote: input.onPromote,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
Expand Down Expand Up @@ -307,6 +324,37 @@ export const make = Effect.gen(function* () {
return yield* Deferred.await(job.promoted)
})

const message: Interface["message"] = Effect.fn("BackgroundJob.message")(function* (id, payload) {
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs) {
const job = jobs.get(id)
if (!job || job.info.status !== "running")
return [{} as MessageResult, jobs] as readonly [MessageResult, Map<string, Active>]
const next = {
...job,
info: { ...job.info, metadata: { ...job.info.metadata, messaged: true } },
}
return [
{ info: snapshot(next), messaged: job.messaged },
new Map(jobs).set(id, next),
] as readonly [MessageResult, Map<string, Active>]
}),
)
if (result.info && result.messaged) yield* Deferred.succeed(result.messaged, payload).pipe(Effect.ignore)
return result.info
}),
)
})

const waitForMessage: Interface["waitForMessage"] = Effect.fn("BackgroundJob.waitForMessage")(function* (id) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
if (!job || job.info.status !== "running") return yield* Effect.never
return yield* Deferred.await(job.messaged)
})

const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) {
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Expand Down Expand Up @@ -357,7 +405,7 @@ export const make = Effect.gen(function* () {
return result.info
})

return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel })
return Service.of({ list, get, start, extend, wait, waitForPromotion, message, waitForMessage, promote, cancel })
})

const layer = Layer.effect(Service, make)
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import { LayerNode } from "./effect/layer-node"
import * as Path from "effect/Path"
import * as PlatformError from "effect/PlatformError"
import * as Predicate from "effect/Predicate"
Expand Down Expand Up @@ -497,11 +498,12 @@ export const make = Effect.gen(function* () {
return makeSpawner(spawnCommand)
})

const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
ChildProcessSpawner,
make,
)

export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
export const defaultLayer = layer

export * as CrossSpawnSpawner from "./cross-spawn-spawner"
4 changes: 4 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ export const Info = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
subagent_interrupt: Schema.optional(Schema.Boolean).annotate({
description:
"Enable the subagent interrupt HTTP endpoint and TUI esc-with-reason UX. Server-controlled; reflects the OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT runtime flag.",
}),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/v1/config/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const InputObject = Schema.StructWithRest(
external_directory: Schema.optional(Rule),
todowrite: Schema.optional(Action),
question: Schema.optional(Action),
interrupt: Schema.optional(Action),
message: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
Expand Down
30 changes: 29 additions & 1 deletion packages/core/test/background-job.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { BackgroundJob } from "@opencode-ai/core/background-job"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Deferred, Effect, Exit, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { it } from "./lib/effect"

const jobsLayer = LayerNode.compile(BackgroundJob.node)
Expand Down Expand Up @@ -103,4 +103,32 @@ describe("BackgroundJob", () => {
expect((yield* jobs.get(job.id))?.status).toBe("running")
}),
)

it.live("message - resolves waitForMessage and marks metadata.messaged", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const started = yield* jobs.start({
id: "ses_child_msg",
type: "task",
run: Effect.never,
})
expect(started.status).toBe("running")

const fiber = yield* Effect.forkChild(jobs.waitForMessage("ses_child_msg"))
const payload = {
childSessionID: "ses_child_msg",
parentSessionID: "ses_parent",
body: "need a decision",
expectReply: true,
}
const info = yield* jobs.message("ses_child_msg", payload)
expect(info?.metadata?.messaged).toBe(true)
expect(info?.metadata?.background).toBeUndefined()

const received = yield* Fiber.join(fiber)
expect(received).toEqual(payload)

yield* jobs.cancel("ses_child_msg")
}).pipe(Effect.provide(jobsLayer)),
)
})
3 changes: 2 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ag

export const use = serviceUse(Service)

const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
Expand Down Expand Up @@ -449,5 +449,6 @@ export const node = LayerNode.make({
layer: layer,
deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, locationServiceMapNode],
})
export const defaultLayer = layer

export * as Agent from "./agent"
3 changes: 3 additions & 0 deletions packages/opencode/src/background/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
type ExtendInput,
type Info,
type Interface,
type MessagePayload,
type StartInput,
type Status,
type WaitInput,
Expand All @@ -26,6 +27,8 @@ const layer = Layer.effect(
extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)),
wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)),
waitForPromotion: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForPromotion(id)),
message: (id, payload) => InstanceState.useEffect(state, (jobs) => jobs.message(id, payload)),
waitForMessage: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForMessage(id)),
promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)),
cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)),
})
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ function writableGlobal(info: Info) {
return next
}

const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
Expand Down Expand Up @@ -677,5 +677,6 @@ export const node = LayerNode.make({
layer: layer,
deps: [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient],
})
export const defaultLayer = layer

export * as Config from "./config"
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionPrompt } from "@/session/prompt"
import { Interrupt } from "@/session/interrupt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp/lsp"
Expand Down Expand Up @@ -88,6 +89,7 @@ export const AppLayer = AppNodeBuilderV1.build(
SessionRevert.node,
SessionSummary.node,
SessionPrompt.node,
Interrupt.node,
Instruction.node,
LLM.node,
LSP.node,
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalSubagentInterrupt: enabledByExperimental("OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT"),
experimentalAgentMessaging: enabledByExperimental("OPENCODE_EXPERIMENTAL_AGENT_MESSAGING"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/event-v2-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,6 @@ const layer = Layer.effect(
)

export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2.node] })
export const defaultLayer = layer

export * as EventV2Bridge from "./event-v2-bridge"
Loading
Loading