feat(tui): allow backgrounding synchronous subagents (#30488)
This commit is contained in:
parent
8c0edca175
commit
3003867c25
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
})
|
||||
}),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user