Ver código fonte

feat(tui): allow backgrounding synchronous subagents (#30488)

Kit Langton 2 meses atrás
pai
commit
3003867c25
26 arquivos alterados com 527 adições e 35 exclusões
  1. 85 5
      packages/core/src/background-job.ts
  2. 2 1
      packages/core/test/fixture/tmpdir.ts
  3. 2 0
      packages/opencode/src/background/job.ts
  4. 2 0
      packages/opencode/src/cli/cmd/run.ts
  5. 4 0
      packages/opencode/src/cli/cmd/run/footer.ts
  6. 36 0
      packages/opencode/src/cli/cmd/run/footer.view.tsx
  7. 4 0
      packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
  8. 9 0
      packages/opencode/src/cli/cmd/run/runtime.ts
  9. 2 0
      packages/opencode/src/cli/cmd/run/subagent-data.ts
  10. 2 0
      packages/opencode/src/cli/cmd/run/types.ts
  11. 2 0
      packages/opencode/src/cli/cmd/tui/config/keybind.ts
  12. 46 0
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  13. 15 0
      packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts
  14. 21 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts
  15. 2 0
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  16. 3 1
      packages/opencode/src/server/shared/workspace-routing.ts
  17. 0 1
      packages/opencode/src/session/run-state.ts
  18. 41 18
      packages/opencode/src/tool/task.ts
  19. 50 3
      packages/opencode/test/background/job.test.ts
  20. 3 1
      packages/opencode/test/cli/run/footer.view.test.tsx
  21. 11 0
      packages/opencode/test/server/httpapi-exercise/index.ts
  22. 19 0
      packages/opencode/test/server/session-actions.test.ts
  23. 5 0
      packages/opencode/test/server/workspace-routing.test.ts
  24. 91 5
      packages/opencode/test/tool/task.test.ts
  25. 38 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  26. 32 0
      packages/sdk/js/src/v2/gen/types.gen.ts

+ 85 - 5
packages/core/src/background-job.ts

@@ -25,6 +25,9 @@ type Active = {
   pending: number
   next: number
   output?: { sequence: number; text: string }
+  tail: Deferred.Deferred<void>
+  promoted: Deferred.Deferred<Info>
+  onPromote?: Effect.Effect<void>
 }
 
 type State = {
@@ -38,15 +41,31 @@ type FinishResult = {
   scope?: Scope.Closeable
 }
 
+type PromoteResult = {
+  info?: Info
+  promoted?: Deferred.Deferred<Info>
+  onPromote?: Effect.Effect<void>
+}
+
 type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
 
-type ExtendResult = { extended: false } | { extended: true; scope: Scope.Closeable; token: object; sequence: number }
+type ExtendResult =
+  | { extended: false }
+  | {
+      extended: true
+      previous: Deferred.Deferred<void>
+      scope: Scope.Closeable
+      tail: Deferred.Deferred<void>
+      token: object
+      sequence: number
+    }
 
 export type StartInput = {
   id?: string
   type: string
   title?: string
   metadata?: Record<string, unknown>
+  onPromote?: Effect.Effect<void>
   run: Effect.Effect<string, unknown>
 }
 
@@ -71,6 +90,8 @@ export interface Interface {
   readonly start: (input: StartInput) => Effect.Effect<Info>
   readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
   readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
+  readonly waitForPromotion: (id: string) => Effect.Effect<Info>
+  readonly promote: (id: string) => Effect.Effect<Info | undefined>
   readonly cancel: (id: string) => Effect.Effect<Info | undefined>
 }
 
@@ -128,6 +149,7 @@ export const make = Effect.gen(function* () {
           : "error"
       const next = {
         ...job,
+        onPromote: undefined,
         pending: 0,
         output,
         info: {
@@ -182,6 +204,8 @@ export const make = Effect.gen(function* () {
         const id = input.id ?? Identifier.ascending("job")
         const started_at = yield* Clock.currentTimeMillis
         const done = yield* Deferred.make<Info>()
+        const promoted = yield* Deferred.make<Info>()
+        const tail = yield* Deferred.make<void>()
         const result = yield* SynchronizedRef.modifyEffect(
           state.jobs,
           Effect.fnUntraced(function* (jobs) {
@@ -205,6 +229,9 @@ export const make = Effect.gen(function* () {
               token,
               pending: 1,
               next: 1,
+              tail,
+              promoted,
+              onPromote: input.onPromote,
             }
             return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
               StartResult,
@@ -212,7 +239,14 @@ export const make = Effect.gen(function* () {
             ]
           }),
         )
-        if ("scope" in result) yield* fork(result.scope, id, result.token, 0, restore(input.run))
+        if ("scope" in result)
+          yield* fork(
+            result.scope,
+            id,
+            result.token,
+            0,
+            restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))),
+          )
         return result.info
       }),
     )
@@ -221,23 +255,34 @@ export const make = Effect.gen(function* () {
   const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
     return yield* Effect.uninterruptibleMask((restore) =>
       Effect.gen(function* () {
+        const tail = yield* Deferred.make<void>()
         const result = yield* SynchronizedRef.modify(
           state.jobs,
           (jobs): readonly [ExtendResult, Map<string, Active>] => {
             const job = jobs.get(input.id)
             if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
             return [
-              { extended: true, scope: job.scope, token: job.token, sequence: job.next },
+              { extended: true, previous: job.tail, scope: job.scope, tail, token: job.token, sequence: job.next },
               new Map(jobs).set(input.id, {
                 ...job,
                 pending: job.pending + 1,
                 next: job.next + 1,
+                tail,
               }),
             ]
           },
         )
         if (!result.extended) return false
-        yield* fork(result.scope, input.id, result.token, result.sequence, restore(input.run))
+        yield* fork(
+          result.scope,
+          input.id,
+          result.token,
+          result.sequence,
+          Deferred.await(result.previous).pipe(
+            Effect.andThen(restore(input.run)),
+            Effect.ensuring(Deferred.succeed(result.tail, undefined)),
+          ),
+        )
         return true
       }),
     )
@@ -254,6 +299,40 @@ export const make = Effect.gen(function* () {
     return { info: snapshot(job), timedOut: true }
   })
 
+  const waitForPromotion: Interface["waitForPromotion"] = Effect.fn("BackgroundJob.waitForPromotion")(function* (id) {
+    const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
+    if (!job || job.info.status !== "running") return yield* Effect.never
+    if (job.info.metadata?.background === true) return snapshot(job)
+    return yield* Deferred.await(job.promoted)
+  })
+
+  const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) {
+    const result = yield* SynchronizedRef.modifyEffect(
+      state.jobs,
+      Effect.fnUntraced(function* (jobs) {
+        const job = jobs.get(id)
+        if (!job || job.info.status !== "running") return [{}, jobs] as readonly [PromoteResult, Map<string, Active>]
+        if (job.info.metadata?.background === true)
+          return [{ info: snapshot(job) }, jobs] as readonly [PromoteResult, Map<string, Active>]
+        const next = {
+          ...job,
+          onPromote: undefined,
+          info: {
+            ...job.info,
+            metadata: { ...job.info.metadata, background: true },
+          },
+        }
+        return [
+          { info: snapshot(next), onPromote: job.onPromote, promoted: job.promoted },
+          new Map(jobs).set(id, next),
+        ] as readonly [PromoteResult, Map<string, Active>]
+      }),
+    )
+    if (result.info && result.promoted) yield* Deferred.succeed(result.promoted, result.info).pipe(Effect.ignore)
+    if (result.onPromote) yield* result.onPromote.pipe(Effect.ignore)
+    return result.info
+  })
+
   const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
     const completed_at = yield* Clock.currentTimeMillis
     const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
@@ -262,6 +341,7 @@ export const make = Effect.gen(function* () {
       if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
       const next = {
         ...job,
+        onPromote: undefined,
         pending: 0,
         info: {
           ...job.info,
@@ -276,7 +356,7 @@ export const make = Effect.gen(function* () {
     return result.info
   })
 
-  return Service.of({ list, get, start, extend, wait, cancel })
+  return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel })
 })
 
 export const layer = Layer.effect(Service, make)

+ 2 - 1
packages/core/test/fixture/tmpdir.ts

@@ -12,12 +12,13 @@ export const tmpdir = async () => {
   }
 }
 
-async function remove(dir: string, retries = 10): Promise<void> {
+async function remove(dir: string, retries = 30): Promise<void> {
   try {
     await fs.rm(dir, { recursive: true, force: true })
   } catch (error) {
     if (retries === 0 || !error || typeof error !== "object" || !("code" in error) || error.code !== "EBUSY")
       throw error
+    Bun.gc(true)
     await Bun.sleep(100)
     return remove(dir, retries - 1)
   }

+ 2 - 0
packages/opencode/src/background/job.ts

@@ -24,6 +24,8 @@ export const layer = Layer.effect(
       start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)),
       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)),
+      promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)),
       cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)),
     })
   }),

+ 2 - 0
packages/opencode/src/cli/cmd/run.ts

@@ -816,6 +816,7 @@ export const RunCommand = effectCmd({
             initialInput,
             createSession: createFreshSession,
             thinking,
+            backgroundSubagents: flags.experimentalBackgroundSubagents,
             demo: args.demo,
           })
         } catch (error) {
@@ -849,6 +850,7 @@ export const RunCommand = effectCmd({
             files,
             initialInput,
             thinking,
+            backgroundSubagents: flags.experimentalBackgroundSubagents,
             demo: args.demo,
           })
         } catch (error) {

+ 4 - 0
packages/opencode/src/cli/cmd/run/footer.ts

@@ -84,6 +84,7 @@ type RunFooterOptions = {
   theme: RunTheme
   keymap: Keymap<Renderable, KeyEvent>
   tuiConfig: RunTuiConfig
+  backgroundSubagents: boolean
   diffStyle: RunDiffStyle
   onPermissionReply: (input: PermissionReply) => void | Promise<void>
   onQuestionReply: (input: QuestionReply) => void | Promise<void>
@@ -92,6 +93,7 @@ type RunFooterOptions = {
   onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
   onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
   onInterrupt?: () => void
+  onBackground?: () => void
   onExit?: () => void
   onSubagentSelect?: (sessionID: string | undefined) => void
   treeSitterClient?: TreeSitterClient
@@ -294,6 +296,7 @@ export class RunFooter implements FooterApi {
               theme: options.theme,
               diffStyle: options.diffStyle,
               tuiConfig: options.tuiConfig,
+              backgroundSubagents: options.backgroundSubagents,
               history: options.history,
               agent: options.agentLabel,
               onSubmit: footer.handlePrompt,
@@ -302,6 +305,7 @@ export class RunFooter implements FooterApi {
               onQuestionReject: footer.handleQuestionReject,
               onCycle: footer.handleCycle,
               onInterrupt: footer.handleInterrupt,
+              onBackground: options.onBackground,
               onInputClear: footer.handleInputClear,
               onExitRequest: footer.handleExit,
               onRequestExit: footer.setRequestExitHandler,

+ 36 - 0
packages/opencode/src/cli/cmd/run/footer.view.tsx

@@ -86,6 +86,7 @@ type RunFooterViewProps = {
   theme?: RunTheme
   diffStyle?: RunDiffStyle
   tuiConfig: RunTuiConfig
+  backgroundSubagents: boolean
   history?: RunPrompt[]
   agent: string
   onSubmit: (input: RunPrompt) => boolean
@@ -94,6 +95,7 @@ type RunFooterViewProps = {
   onQuestionReject: (input: QuestionReject) => void | Promise<void>
   onCycle: () => void
   onInterrupt: () => boolean
+  onBackground?: () => void
   onInputClear: () => void
   onExitRequest?: () => boolean
   onRequestExit?: (fn: (() => boolean) | undefined) => void
@@ -158,6 +160,9 @@ export function RunFooterView(props: RunFooterViewProps) {
       label: count === 1 ? "agent" : "agents",
     }
   })
+  const foregroundSubagents = createMemo(
+    () => props.backgroundSubagents && tabs().some((item) => item.status === "running" && !item.background),
+  )
   const queuedIndicator = createMemo(() => {
     const count = queuedPrompts().length
     if (count === 0) return
@@ -214,6 +219,15 @@ export function RunFooterView(props: RunFooterViewProps) {
         props.tuiConfig,
       ) ?? "",
   )
+  const backgroundShortcut = useKeymapSelector(
+    (keymap: OpenTuiKeymap) =>
+      formatKeyBindings(
+        keymap
+          .getCommandBindings({ visibility: "registered", commands: ["session.background"] })
+          .get("session.background"),
+        props.tuiConfig,
+      ) ?? "",
+  )
   const hints = createMemo(() => hintFlags(term().width))
   const busy = createMemo(() => props.state().phase === "running")
   const armed = createMemo(() => props.state().interrupt > 0)
@@ -375,6 +389,21 @@ export function RunFooterView(props: RunFooterViewProps) {
     ],
   }))
 
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(),
+    priority: 1,
+    commands: [
+      {
+        name: "session.background",
+        title: "Background subagents",
+        category: "Session",
+        run: () => props.onBackground?.(),
+      },
+    ],
+    bindings: props.tuiConfig.keybinds.get("session.background"),
+  }))
+
   useBindings(() => ({
     mode: OPENCODE_BASE_MODE,
     enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
@@ -774,6 +803,13 @@ export function RunFooterView(props: RunFooterViewProps) {
                             </text>
                           )}
                         </Show>
+                        <Show when={foregroundSubagents() && backgroundShortcut()}>
+                          <text id="run-direct-footer-background-label" fg={theme().text} wrapMode="none" truncate>
+                            <span style={{ fg: theme().highlight }}>• </span>
+                            <span style={{ fg: theme().highlight }}>{backgroundShortcut()}</span>{" "}
+                            <span style={{ fg: theme().muted }}>background</span>
+                          </text>
+                        </Show>
                         <Show when={queuedIndicator()}>
                           {(info) => (
                             <text id="run-direct-footer-queued-label" fg={theme().text} wrapMode="none" truncate>

+ 4 - 0
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

@@ -63,6 +63,7 @@ export type LifecycleInput = {
   model: RunInput["model"]
   variant: string | undefined
   tuiConfig: RunTuiConfig
+  backgroundSubagents: boolean
   onPermissionReply: (input: PermissionReply) => void | Promise<void>
   onQuestionReply: (input: QuestionReply) => void | Promise<void>
   onQuestionReject: (input: QuestionReject) => void | Promise<void>
@@ -70,6 +71,7 @@ export type LifecycleInput = {
   onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
   onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
   onInterrupt?: () => void
+  onBackground?: () => void
   onSubagentSelect?: (sessionID: string | undefined) => void
 }
 
@@ -237,6 +239,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
           wrote,
           keymap,
           tuiConfig: input.tuiConfig,
+          backgroundSubagents: input.backgroundSubagents,
           diffStyle: input.tuiConfig.diff_style ?? "auto",
           onPermissionReply: input.onPermissionReply,
           onQuestionReply: input.onQuestionReply,
@@ -245,6 +248,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
           onModelSelect: input.onModelSelect,
           onVariantSelect: input.onVariantSelect,
           onInterrupt: input.onInterrupt,
+          onBackground: input.onBackground,
           onSubagentSelect: input.onSubagentSelect,
         })
 

+ 9 - 0
packages/opencode/src/cli/cmd/run/runtime.ts

@@ -52,6 +52,7 @@ type RunRuntimeInput = {
   files: RunInput["files"]
   initialInput?: string
   thinking: boolean
+  backgroundSubagents: boolean
   replay?: boolean
   replayLimit?: number
   demo?: RunInput["demo"]
@@ -70,6 +71,7 @@ type RunLocalInput = {
   files: RunInput["files"]
   initialInput?: string
   thinking: boolean
+  backgroundSubagents: boolean
   replay?: boolean
   replayLimit?: number
   demo?: RunInput["demo"]
@@ -253,6 +255,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
         model: state.model,
         variant: state.activeVariant,
         tuiConfig,
+        backgroundSubagents: input.backgroundSubagents,
         onPermissionReply: async (next) => {
           if (state.demo?.permission(next)) {
             return
@@ -372,6 +375,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
               state.aborting = false
             })
         },
+        onBackground: () => {
+          if (!hasSession(input, state)) return
+          void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {})
+        },
         onSubagentSelect: (sessionID) => {
           state.selectSubagent?.(sessionID)
           log?.write("subagent.select", {
@@ -794,6 +801,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
         files: input.files,
         initialInput: input.initialInput,
         thinking: input.thinking,
+        backgroundSubagents: input.backgroundSubagents,
         replay: input.replay,
         replayLimit: input.replayLimit,
         demo: input.demo,
@@ -848,6 +856,7 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
         files: input.files,
         initialInput: input.initialInput,
         thinking: input.thinking,
+        backgroundSubagents: input.backgroundSubagents,
         replay: input.replay,
         replayLimit: input.replayLimit,
         demo: input.demo,

+ 2 - 0
packages/opencode/src/cli/cmd/run/subagent-data.ts

@@ -83,6 +83,7 @@ export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubag
     a.label === b.label &&
     a.description === b.description &&
     a.status === b.status &&
+    a.background === b.background &&
     a.title === b.title &&
     a.toolCalls === b.toolCalls &&
     a.lastUpdatedAt === b.lastUpdatedAt
@@ -303,6 +304,7 @@ function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
     label,
     description,
     status,
+    background: metadata(part, "background") === true,
     title: stateTitle(part),
     toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
     lastUpdatedAt: stateUpdatedAt(part),

+ 2 - 0
packages/opencode/src/cli/cmd/run/types.ts

@@ -68,6 +68,7 @@ export type RunInput = {
   files: RunFilePart[]
   initialInput?: string
   thinking: boolean
+  backgroundSubagents: boolean
   demo?: boolean
 }
 
@@ -184,6 +185,7 @@ export type FooterSubagentTab = {
   label: string
   description: string
   status: "running" | "completed" | "error"
+  background?: boolean
   title?: string
   toolCalls?: number
   lastUpdatedAt: number

+ 2 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -92,6 +92,7 @@ export const Definitions = {
   session_share: keybind("none", "Share current session"),
   session_unshare: keybind("none", "Unshare current session"),
   session_interrupt: keybind("escape", "Interrupt current session"),
+  session_background: keybind("ctrl+b", "Background synchronous subagents"),
   session_compact: keybind("<leader>c", "Compact the session"),
   session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
   session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
@@ -291,6 +292,7 @@ export const CommandMap = {
   session_share: "session.share",
   session_unshare: "session.unshare",
   session_interrupt: "session.interrupt",
+  session_background: "session.background",
   session_compact: "session.compact",
   session_toggle_timestamps: "session.toggle.timestamps",
   session_toggle_generic_tool_output: "session.toggle.generic_tool_output",

+ 46 - 0
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -198,6 +198,17 @@ export function Session() {
       .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
   })
   const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
+  const foregroundTasks = createMemo(() =>
+    messages().flatMap((message) =>
+      (sync.data.part[message.id] ?? []).filter(
+        (part): part is ToolPart =>
+          part.type === "tool" &&
+          part.tool === "task" &&
+          part.state.status === "running" &&
+          part.state.metadata?.background !== true,
+      ),
+    ),
+  )
   const permissions = createMemo(() => {
     if (session()?.parentID) return []
     return children().flatMap((x) => sync.data.permission[x.id] ?? [])
@@ -1008,6 +1019,20 @@ export function Session() {
         dialog.clear()
       },
     },
+    {
+      title: "Background subagents",
+      value: "session.background",
+      category: "Session",
+      hidden: true,
+      enabled: foregroundTasks().length > 0,
+      run: () => {
+        void sdk.client.experimental.session.background({
+          sessionID: route.sessionID,
+          workspace: project.workspace.current(),
+        })
+        dialog.clear()
+      },
+    },
     {
       title: "Go to child session",
       value: "session.child.first",
@@ -1088,6 +1113,13 @@ export function Session() {
     bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
   }))
 
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: foregroundTasks().length > 0,
+    priority: 1,
+    bindings: tuiConfig.keybinds.get("session.background"),
+  }))
+
   const revertInfo = createMemo(() => session()?.revert)
   const revertMessageID = createMemo(() => revertInfo()?.messageID)
 
@@ -1453,6 +1485,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
   })
 
   const childShortcut = useCommandShortcut("session.child.first")
+  const backgroundShortcut = useCommandShortcut("session.background")
 
   return (
     <>
@@ -1476,6 +1509,19 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
           <text fg={theme.text}>
             {childShortcut()}
             <span style={{ fg: theme.textMuted }}> view subagents</span>
+            <Show
+              when={props.parts.some(
+                (x) =>
+                  x.type === "tool" &&
+                  x.tool === "task" &&
+                  x.state.status === "running" &&
+                  x.state.metadata?.background !== true,
+              )}
+            >
+              <span style={{ fg: theme.textMuted }}> · </span>
+              {backgroundShortcut()}
+              <span style={{ fg: theme.textMuted }}> background</span>
+            </Show>
           </text>
         </box>
       </Show>

+ 15 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts

@@ -2,6 +2,7 @@ import { AccountID, OrgID } from "@/account/schema"
 import { MCP } from "@/mcp"
 
 import { Session } from "@/session/session"
+import { SessionID } from "@/session/schema"
 import { Worktree } from "@/worktree"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
 import { Schema } from "effect"
@@ -91,6 +92,7 @@ export const ExperimentalPaths = {
   worktree: "/experimental/worktree",
   worktreeReset: "/experimental/worktree/reset",
   session: "/experimental/session",
+  sessionBackground: "/experimental/session/:sessionID/background",
   resource: "/experimental/resource",
 } as const
 
@@ -215,6 +217,19 @@ export const ExperimentalApi = HttpApi.make("experimental")
               "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
           }),
         ),
+        HttpApiEndpoint.post("sessionBackground", ExperimentalPaths.sessionBackground, {
+          params: { sessionID: SessionID },
+          query: WorkspaceRoutingQuery,
+          success: described(Schema.Boolean, "Backgrounded subagents"),
+          error: HttpApiError.BadRequest,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "experimental.session.background",
+            summary: "Background subagents",
+            description:
+              "Detach any synchronous subagents currently blocking the session and continue them in the background.",
+          }),
+        ),
         HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
           query: WorkspaceRoutingQuery,
           success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),

+ 21 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts

@@ -1,10 +1,13 @@
 import { Account } from "@/account/account"
 import { Agent } from "@/agent/agent"
+import { BackgroundJob } from "@/background/job"
 import { Config } from "@/config/config"
 import { InstanceState } from "@/effect/instance-state"
+import { RuntimeFlags } from "@/effect/runtime-flags"
 import { MCP } from "@/mcp"
 import { Project } from "@/project/project"
 import { Session } from "@/session/session"
+import type { SessionID } from "@/session/schema"
 import { ToolJsonSchema } from "@/tool/json-schema"
 import { ToolRegistry } from "@/tool/registry"
 import { Worktree } from "@/worktree"
@@ -30,6 +33,8 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
     const registry = yield* ToolRegistry.Service
     const worktreeSvc = yield* Worktree.Service
     const sessions = yield* Session.Service
+    const background = yield* BackgroundJob.Service
+    const flags = yield* RuntimeFlags.Service
 
     const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
       const [state, groups] = yield* Effect.all(
@@ -146,6 +151,21 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
       })
     })
 
+    const sessionBackground = Effect.fn("ExperimentalHttpApi.sessionBackground")(function* (ctx: {
+      params: { sessionID: SessionID }
+    }) {
+      if (!flags.experimentalBackgroundSubagents) return false
+      const jobs = (yield* background.list()).filter(
+        (job) =>
+          job.type === "task" &&
+          job.status === "running" &&
+          job.metadata?.parentSessionId === ctx.params.sessionID &&
+          job.metadata.background !== true,
+      )
+      const promoted = yield* Effect.forEach(jobs, (job) => background.promote(job.id), { concurrency: "unbounded" })
+      return promoted.some((job) => job !== undefined)
+    })
+
     const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
       return yield* mcp.resources()
     })
@@ -161,6 +181,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
       .handle("worktreeRemove", worktreeRemove)
       .handle("worktreeReset", worktreeReset)
       .handle("session", session)
+      .handle("sessionBackground", sessionBackground)
       .handle("resource", resource)
   }),
 )

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -13,6 +13,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Account } from "@/account/account"
 import { Agent } from "@/agent/agent"
 import { Auth } from "@/auth"
+import { BackgroundJob } from "@/background/job"
 import { Config } from "@/config/config"
 import { Command } from "@/command"
 import * as Observability from "@opencode-ai/core/effect/observability"
@@ -214,6 +215,7 @@ export function createRoutes(
       Account.defaultLayer,
       Agent.defaultLayer,
       Auth.defaultLayer,
+      BackgroundJob.defaultLayer,
       Command.defaultLayer,
       Config.defaultLayer,
       Format.defaultLayer,

+ 3 - 1
packages/opencode/src/server/shared/workspace-routing.ts

@@ -20,7 +20,9 @@ export function isLocalWorkspaceRoute(method: string, path: string) {
 export function getWorkspaceRouteSessionID(url: URL) {
   if (url.pathname === "/session/status") return null
 
-  const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
+  const id =
+    url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] ??
+    url.pathname.match(/^\/experimental\/session\/([^/]+)\/background$/)?.[1]
   if (!id) return null
 
   return SessionID.make(id)

+ 0 - 1
packages/opencode/src/session/run-state.ts

@@ -4,7 +4,6 @@ import { Runner } from "@/effect/runner"
 import { BackgroundJob } from "@/background/job"
 import { Effect, Latch, Layer, Scope, Context } from "effect"
 import { Session } from "./session"
-import { MessageV2 } from "./message-v2"
 import { SessionID } from "./schema"
 import { SessionStatus } from "./status"
 

+ 41 - 18
packages/opencode/src/tool/task.ts

@@ -220,6 +220,17 @@ export const TaskTool = Tool.define(
           .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
       })
 
+      const notify = Effect.fn("TaskTool.notifyBackgroundResult")(function* (jobID: string) {
+        yield* background.wait({ id: jobID }).pipe(
+          Effect.flatMap((result) => {
+            if (result.info?.status === "completed") return inject("completed", result.info.output ?? "")
+            if (result.info?.status === "error") return inject("error", result.info.error ?? "")
+            return Effect.void
+          }),
+          Effect.forkIn(scope, { startImmediately: true }),
+        )
+      })
+
       if (yield* background.extend({ id: nextSession.id, run: runTask() })) {
         return {
           title: params.description,
@@ -237,27 +248,27 @@ export const TaskTool = Tool.define(
         }
       }
 
-      if (runInBackground) {
-        const info = yield* background.start({
-          id: nextSession.id,
-          type: id,
-          title: params.description,
-          metadata,
-          run: runTask(),
-        })
-        yield* background.wait({ id: info.id }).pipe(
-          Effect.flatMap((result) => {
-            if (result.info?.status === "completed") return inject("completed", result.info.output ?? "")
-            if (result.info?.status === "error") return inject("error", result.info.error ?? "")
-            return Effect.void
+      const info = yield* background.start({
+        id: nextSession.id,
+        type: id,
+        title: params.description,
+        metadata,
+        onPromote: Effect.all([
+          ctx.metadata({
+            title: params.description,
+            metadata: { ...metadata, background: true, jobId: nextSession.id },
           }),
-          Effect.forkIn(scope, { startImmediately: true }),
-        )
+          notify(nextSession.id),
+        ]),
+        run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))),
+      })
 
+      function backgroundResult() {
         return {
           title: params.description,
           metadata: {
             ...metadata,
+            background: true,
             jobId: info.id,
           },
           output: renderOutput({
@@ -269,6 +280,11 @@ export const TaskTool = Tool.define(
         }
       }
 
+      if (runInBackground) {
+        yield* notify(info.id)
+        return backgroundResult()
+      }
+
       const runCancel = yield* EffectBridge.make()
       const cancel = ops.cancel(nextSession.id)
 
@@ -282,16 +298,23 @@ export const TaskTool = Tool.define(
         }),
         () =>
           Effect.gen(function* () {
-            const text = yield* runTask()
+            const result = yield* Effect.raceFirst(
+              background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)),
+              background.waitForPromotion(nextSession.id),
+            )
+            if (result?.metadata?.background === true) return backgroundResult()
+            if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed"))
+            if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled"))
             return {
               title: params.description,
               metadata,
-              output: renderOutput({ sessionID: nextSession.id, state: "completed", text }),
+              output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }),
             }
           }),
         (_, exit) =>
           Effect.gen(function* () {
-            if (Exit.hasInterrupts(exit)) yield* cancel
+            if (Exit.hasInterrupts(exit))
+              yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true })
           }).pipe(
             Effect.ensuring(
               Effect.sync(() => {

+ 50 - 3
packages/opencode/test/background/job.test.ts

@@ -99,6 +99,31 @@ describe("background.job", () => {
     }),
   )
 
+  it.instance("runs extensions after earlier work completes", () =>
+    Effect.gen(function* () {
+      const jobs = yield* BackgroundJob.Service
+      const first = yield* Deferred.make<void>()
+      const order: string[] = []
+      const job = yield* jobs.start({
+        type: "test",
+        run: Effect.sync(() => order.push("start")).pipe(Effect.andThen(Deferred.await(first)), Effect.as("first")),
+      })
+
+      expect(
+        yield* jobs.extend({
+          id: job.id,
+          run: Effect.sync(() => order.push("extend")).pipe(Effect.as("second")),
+        }),
+      ).toBe(true)
+      yield* Effect.yieldNow
+      expect(order).toEqual(["start"])
+
+      yield* Deferred.succeed(first, undefined)
+      expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("second")
+      expect(order).toEqual(["start", "extend"])
+    }),
+  )
+
   it.instance("rejects extensions after a job completes", () =>
     Effect.gen(function* () {
       const jobs = yield* BackgroundJob.Service
@@ -160,25 +185,47 @@ describe("background.job", () => {
     Effect.gen(function* () {
       const jobs = yield* BackgroundJob.Service
       const interrupted = yield* Deferred.make<void>()
-      const extendedInterrupted = yield* Deferred.make<void>()
       const job = yield* jobs.start({
         type: "test",
         run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
       })
       yield* jobs.extend({
         id: job.id,
-        run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(extendedInterrupted, undefined))),
+        run: Effect.never,
       })
 
       const cancelled = yield* jobs.cancel(job.id)
 
       expect(cancelled?.status).toBe("cancelled")
       yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
-      yield* Deferred.await(extendedInterrupted).pipe(Effect.timeout("1 second"))
       expect((yield* jobs.get(job.id))?.status).toBe("cancelled")
     }),
   )
 
+  it.instance("promotes running jobs without interrupting them", () =>
+    Effect.gen(function* () {
+      const jobs = yield* BackgroundJob.Service
+      const latch = yield* Deferred.make<void>()
+      const promoted = yield* Deferred.make<void>()
+      const job = yield* jobs.start({
+        type: "test",
+        metadata: { parentSessionId: "parent" },
+        onPromote: Deferred.succeed(promoted, undefined).pipe(Effect.asVoid),
+        run: Deferred.await(latch).pipe(Effect.as("done")),
+      })
+
+      const info = yield* jobs.promote(job.id)
+
+      expect(info?.status).toBe("running")
+      expect(info?.metadata?.background).toBe(true)
+      yield* Deferred.await(promoted)
+      expect((yield* jobs.get(job.id))?.status).toBe("running")
+
+      yield* Deferred.succeed(latch, undefined)
+      expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done")
+    }),
+  )
+
   it.instance("returns immutable snapshots", () =>
     Effect.gen(function* () {
       const jobs = yield* BackgroundJob.Service

+ 3 - 1
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -185,6 +185,7 @@ async function renderFooter(
           subagent={subagents}
           theme={RUN_THEME_FALLBACK}
           tuiConfig={config}
+          backgroundSubagents={true}
           agent="opencode"
           onSubmit={input.onSubmit ?? (() => true)}
           onPermissionReply={() => {}}
@@ -613,6 +614,7 @@ test("direct footer shows editable prompts and additional queued work while runn
           ]}
           theme={RUN_THEME_FALLBACK}
           tuiConfig={tuiConfig}
+          backgroundSubagents={true}
           agent="opencode"
           onSubmit={() => true}
           onPermissionReply={() => {}}
@@ -647,7 +649,7 @@ test("direct footer shows editable prompts and additional queued work while runn
 
   try {
     await app.renderOnce()
-    expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • 1 queued ctrl+x q")
+    expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • ctrl+b background • 1 queued ctrl+x q")
     expect(app.captureCharFrame()).toContain("2 queued")
     expect(app.captureCharFrame()).not.toContain("to view")
     expect(app.captureCharFrame()).not.toContain("edit/remove")

+ 11 - 0
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -568,6 +568,17 @@ const scenarios: Scenario[] = [
     .get("/experimental/session", "experimental.session.list")
     .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
     .json(200, array),
+  http.protected
+    .post("/experimental/session/{sessionID}/background", "experimental.session.background")
+    .mutating()
+    .seeded((ctx) => ctx.session({ title: "Background route owner" }))
+    .at((ctx) => ({
+      path: route("/experimental/session/{sessionID}/background", { sessionID: ctx.state.id }),
+      headers: ctx.headers(),
+    }))
+    .json(200, (body) => {
+      check(body === false, "background route should be a no-op without running subagents")
+    }),
   http.protected.get("/experimental/resource", "experimental.resource.list").json(),
   http.protected
     .post("/sync/history", "sync.history.list")

+ 19 - 0
packages/opencode/test/server/session-actions.test.ts

@@ -90,4 +90,23 @@ describe("session action routes", () => {
       }),
     { git: true },
   )
+
+  it.instance(
+    "experimental background route is a no-op without synchronous subagents",
+    () =>
+      Effect.gen(function* () {
+        const test = yield* TestInstance
+        const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) =>
+          SessionNs.use.remove(created.id).pipe(Effect.ignore),
+        )
+
+        const res = yield* requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, {
+          method: "POST",
+        })
+
+        expect(res.status).toBe(200)
+        expect(yield* res.json).toBe(false)
+      }),
+    { git: true },
+  )
 })

+ 5 - 0
packages/opencode/test/server/workspace-routing.test.ts

@@ -41,6 +41,11 @@ describe("getWorkspaceRouteSessionID", () => {
     expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_xyz"))
   })
 
+  test("extracts session ID from experimental background path", () => {
+    const url = new URL("http://localhost/experimental/session/ses_bg/background")
+    expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_bg"))
+  })
+
   test("returns null for /session/status", () => {
     const url = new URL("http://localhost/session/status")
     expect(getWorkspaceRouteSessionID(url)).toBeNull()

+ 91 - 5
packages/opencode/test/tool/task.test.ts

@@ -1,14 +1,13 @@
 import { afterEach, describe, expect } from "bun:test"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { Database } from "@opencode-ai/core/database/database"
-import { Effect, Exit, Fiber, Layer } from "effect"
+import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
 import { Agent } from "../../src/agent/agent"
 import { BackgroundJob } from "@/background/job"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { Config } from "@/config/config"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Session } from "@/session/session"
-import { MessageV2 } from "../../src/session/message-v2"
 import type { SessionPrompt } from "../../src/session/prompt"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
 import { SessionRunState } from "@/session/run-state"
@@ -484,6 +483,72 @@ describe("tool.task", () => {
     }),
   )
 
+  it.instance("promotes a running foreground task without restarting it", () =>
+    Effect.gen(function* () {
+      const jobs = yield* BackgroundJob.Service
+      const { chat, assistant } = yield* seed()
+      const tool = yield* TaskTool
+      const def = yield* tool.init()
+      const ready = yield* Deferred.make<void>()
+      const done = yield* Deferred.make<void>()
+      const injected = yield* Deferred.make<SessionPrompt.PromptInput>()
+      let runs = 0
+      const promptOps: TaskPromptOps = {
+        cancel: () => Effect.void,
+        resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
+        prompt: (input) => {
+          if (input.sessionID === chat.id) {
+            return Deferred.succeed(injected, input).pipe(Effect.as(reply(input, "injected")))
+          }
+          return Effect.gen(function* () {
+            runs += 1
+            yield* Deferred.succeed(ready, undefined)
+            yield* Deferred.await(done)
+            return reply(input, "background done")
+          })
+        },
+      }
+
+      const fiber = yield* def
+        .execute(
+          {
+            description: "inspect bug",
+            prompt: "look into the cache key path",
+            subagent_type: "general",
+          },
+          {
+            sessionID: chat.id,
+            messageID: assistant.id,
+            agent: "build",
+            abort: new AbortController().signal,
+            extra: { promptOps },
+            messages: [],
+            metadata: () => Effect.void,
+            ask: () => Effect.void,
+          },
+        )
+        .pipe(Effect.forkChild)
+
+      yield* Deferred.await(ready)
+      const job = (yield* jobs.list())[0]
+      expect(job).toBeDefined()
+      if (!job) throw new Error("task job not found")
+      expect(job.metadata?.parentSessionId).toBe(chat.id)
+      yield* jobs.promote(job.id)
+
+      const result = yield* Fiber.join(fiber)
+      expect(result.metadata.background).toBe(true)
+      expect(result.output).toContain(`state="running"`)
+      expect((yield* jobs.get(result.metadata.sessionId))?.status).toBe("running")
+      expect(runs).toBe(1)
+
+      yield* Deferred.succeed(done, undefined)
+      expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.output).toBe("background done")
+      expect((yield* Deferred.await(injected)).parts[0]?.type).toBe("text")
+      expect(runs).toBe(1)
+    }),
+  )
+
   background.instance("execute launches background tasks without waiting for completion", () =>
     Effect.gen(function* () {
       const jobs = yield* BackgroundJob.Service
@@ -576,14 +641,14 @@ describe("tool.task", () => {
         context,
       )
 
-      expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
-        { type: "text", text: "also inspect cancellation" },
-      ])
       expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
       expect(result.metadata.background).toBe(true)
       expect(result.output).toContain("Background task updated")
       first.resolve()
       expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running")
+      expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
+        { type: "text", text: "also inspect cancellation" },
+      ])
 
       second.resolve()
       const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
@@ -784,6 +849,27 @@ describe("tool.task", () => {
     }),
   )
 
+  it.instance("cancelling a child run cancels its own pre-runner task job", () =>
+    Effect.gen(function* () {
+      const jobs = yield* BackgroundJob.Service
+      const runState = yield* SessionRunState.Service
+      const sessions = yield* Session.Service
+      const { chat } = yield* seed()
+      const child = yield* sessions.create({ parentID: chat.id, title: "child" })
+
+      yield* jobs.start({
+        id: child.id,
+        type: "task",
+        metadata: { parentSessionId: chat.id, sessionId: child.id },
+        run: Effect.never,
+      })
+
+      yield* runState.cancel(child.id)
+
+      expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
+    }),
+  )
+
   it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
     Effect.gen(function* () {
       const jobs = yield* BackgroundJob.Service

+ 38 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -44,6 +44,8 @@ import type {
   ExperimentalProjectCopyRemoveResponses,
   ExperimentalResourceListErrors,
   ExperimentalResourceListResponses,
+  ExperimentalSessionBackgroundErrors,
+  ExperimentalSessionBackgroundResponses,
   ExperimentalSessionListErrors,
   ExperimentalSessionListResponses,
   ExperimentalWorkspaceAdapterListErrors,
@@ -733,6 +735,42 @@ export class Session extends HeyApiClient {
       ...params,
     })
   }
+
+  /**
+   * Background subagents
+   *
+   * Detach any synchronous subagents currently blocking the session and continue them in the background.
+   */
+  public background<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "sessionID" },
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<
+      ExperimentalSessionBackgroundResponses,
+      ExperimentalSessionBackgroundErrors,
+      ThrowOnError
+    >({
+      url: "/experimental/session/{sessionID}/background",
+      ...options,
+      ...params,
+    })
+  }
 }
 
 export class Resource extends HeyApiClient {

+ 32 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -5850,6 +5850,38 @@ export type ExperimentalSessionListResponses = {
 
 export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses]
 
+export type ExperimentalSessionBackgroundData = {
+  body?: never
+  path: {
+    sessionID: string
+  }
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/experimental/session/{sessionID}/background"
+}
+
+export type ExperimentalSessionBackgroundErrors = {
+  /**
+   * BadRequest | InvalidRequestError
+   */
+  400: EffectHttpApiErrorBadRequest | InvalidRequestError
+}
+
+export type ExperimentalSessionBackgroundError =
+  ExperimentalSessionBackgroundErrors[keyof ExperimentalSessionBackgroundErrors]
+
+export type ExperimentalSessionBackgroundResponses = {
+  /**
+   * Backgrounded subagents
+   */
+  200: boolean
+}
+
+export type ExperimentalSessionBackgroundResponse =
+  ExperimentalSessionBackgroundResponses[keyof ExperimentalSessionBackgroundResponses]
+
 export type ExperimentalResourceListData = {
   body?: never
   path?: never