mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 19:03:33 +00:00
feat: background blocking tools
This commit is contained in:
parent
a10733dbf4
commit
e2bca216a2
15 changed files with 397 additions and 71 deletions
|
|
@ -237,12 +237,17 @@ type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["se
|
|||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_19Input = {
|
||||
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_19Request["params"]["messageID"]
|
||||
}
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_20Input = {
|
||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_20Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
|
|
@ -268,7 +273,8 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
|
|||
history: Endpoint4_16(raw),
|
||||
events: Endpoint4_17(raw),
|
||||
interrupt: Endpoint4_18(raw),
|
||||
message: Endpoint4_19(raw),
|
||||
background: Endpoint4_19(raw),
|
||||
message: Endpoint4_20(raw),
|
||||
})
|
||||
|
||||
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import type {
|
|||
SessionEventsOutput,
|
||||
SessionInterruptInput,
|
||||
SessionInterruptOutput,
|
||||
SessionBackgroundInput,
|
||||
SessionBackgroundOutput,
|
||||
SessionMessageInput,
|
||||
SessionMessageOutput,
|
||||
MessageListInput,
|
||||
|
|
@ -561,6 +563,17 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
),
|
||||
background: (input: SessionBackgroundInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionBackgroundOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/background`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
message: (input: SessionMessageInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionMessageOutput }>(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1775,6 +1775,10 @@ export type SessionInterruptInput = { readonly sessionID: { readonly sessionID:
|
|||
|
||||
export type SessionInterruptOutput = void
|
||||
|
||||
export type SessionBackgroundInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionBackgroundOutput = void
|
||||
|
||||
export type SessionMessageInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
|
||||
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
|||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
const BACKGROUND_STARTED =
|
||||
"The command is running in the background. You will be notified automatically when it completes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
"The command has not completed; it is now running in the background."
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
|
|
@ -185,75 +185,80 @@ export const Plugin = {
|
|||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
|
||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
|
||||
if (input.background === true) {
|
||||
const background = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
})
|
||||
const run = Effect.fn("ShellTool.run")(function* () {
|
||||
return yield* Effect.gen(function* () {
|
||||
const final = yield* shell.wait(background.id)
|
||||
const page = yield* shell.output(background.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout")
|
||||
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return `${body}${notice}`
|
||||
}).pipe(Effect.onInterrupt(() => shell.remove(background.id).pipe(Effect.ignore)))
|
||||
})
|
||||
|
||||
const info = yield* runtime.job.start({
|
||||
id: context.toolCallID,
|
||||
type: name,
|
||||
title: input.command,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
run: run(),
|
||||
})
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: background.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
})
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout") {
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
exit: final.exit,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return {
|
||||
exit: final.exit,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timeout: true,
|
||||
output: `${body}${notice}`,
|
||||
truncated,
|
||||
status: "completed" as const,
|
||||
}
|
||||
})
|
||||
|
||||
const run = settleShell().pipe(
|
||||
Effect.map((output) => output.output),
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
id: context.toolCallID,
|
||||
type: name,
|
||||
title: input.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
run,
|
||||
})
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
|
||||
return {
|
||||
exit: final.exit,
|
||||
output: `${body}${notice}`,
|
||||
truncated,
|
||||
status: "completed" as const,
|
||||
...(yield* settleShell()),
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from "fs/promises"
|
|||
import { realpathSync } from "node:fs"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
|
|
@ -454,6 +454,51 @@ describe("ShellTool", () => {
|
|||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("backgrounds a foreground command when the session is signaled", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe(
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> =>
|
||||
Effect.gen(function* () {
|
||||
const backgrounded = yield* jobs.backgroundAll({ sessionID })
|
||||
if (backgrounded.length > 0) return backgrounded
|
||||
if (remaining <= 0) return yield* Effect.fail(new Error("Timed out waiting for foreground shell job"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* backgroundWhenReady(remaining - 1)
|
||||
})
|
||||
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
|
||||
|
||||
const settled = yield* Fiber.join(waiting)
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
||||
expect(settled.output?.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("running in the background"),
|
||||
})
|
||||
expect(shellID).toStartWith("sh_")
|
||||
|
||||
const shell = yield* Shell.Service
|
||||
if (!shellID) return
|
||||
const id = ShellSchema.ID.make(shellID)
|
||||
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
|
||||
yield* shell.remove(id)
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps locked deferred parity TODOs visible", async () => {
|
||||
|
|
|
|||
|
|
@ -182,13 +182,18 @@ export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["param
|
|||
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
|
||||
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
export type Endpoint4_19Input = {
|
||||
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_19Request["params"]["messageID"]
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
export type Endpoint4_20Input = {
|
||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_20Request["params"]["messageID"]
|
||||
}
|
||||
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
|
||||
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
|
|
@ -210,6 +215,7 @@ export interface SessionApi<E = never> {
|
|||
readonly history: SessionHistoryOperation<E>
|
||||
readonly events: SessionEventsOperation<E>
|
||||
readonly interrupt: SessionInterruptOperation<E>
|
||||
readonly background: SessionBackgroundOperation<E>
|
||||
readonly message: SessionMessageOperation<E>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -411,6 +411,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.background", "/api/session/:sessionID/background", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.background",
|
||||
summary: "Background blocking session tools",
|
||||
description:
|
||||
"Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", {
|
||||
params: { sessionID: Session.ID, messageID: SessionMessage.ID },
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const CommandExecute = Event.define({
|
|||
"session.new",
|
||||
"session.share",
|
||||
"session.interrupt",
|
||||
"session.background",
|
||||
"session.compact",
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
|
|
|
|||
|
|
@ -309,6 +309,8 @@ import type {
|
|||
V2PermissionSavedListResponses,
|
||||
V2PermissionSavedRemoveErrors,
|
||||
V2PermissionSavedRemoveResponses,
|
||||
V2PluginListErrors,
|
||||
V2PluginListResponses,
|
||||
V2ProjectCopyCreateErrors,
|
||||
V2ProjectCopyCreateResponses,
|
||||
V2ProjectCopyRefreshErrors,
|
||||
|
|
@ -343,6 +345,8 @@ import type {
|
|||
V2ReferenceListResponses,
|
||||
V2SessionActiveErrors,
|
||||
V2SessionActiveResponses,
|
||||
V2SessionBackgroundErrors,
|
||||
V2SessionBackgroundResponses,
|
||||
V2SessionCompactErrors,
|
||||
V2SessionCompactResponses,
|
||||
V2SessionContextErrors,
|
||||
|
|
@ -5107,6 +5111,30 @@ export class Agent extends HeyApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class Plugin extends HeyApiClient {
|
||||
/**
|
||||
* List plugins
|
||||
*
|
||||
* Retrieve currently loaded plugins.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
|
||||
return (options?.client ?? this.client).get<V2PluginListResponses, V2PluginListErrors, ThrowOnError>({
|
||||
url: "/api/plugin",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Revert extends HeyApiClient {
|
||||
/**
|
||||
* Stage session revert
|
||||
|
|
@ -5926,6 +5954,27 @@ export class Session3 extends HeyApiClient {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Background blocking session tools
|
||||
*
|
||||
* Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.
|
||||
*/
|
||||
public background<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
|
||||
return (options?.client ?? this.client).post<V2SessionBackgroundResponses, V2SessionBackgroundErrors, ThrowOnError>(
|
||||
{
|
||||
url: "/api/session/{sessionID}/background",
|
||||
...options,
|
||||
...params,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session message
|
||||
*
|
||||
|
|
@ -7436,6 +7485,11 @@ export class V2 extends HeyApiClient {
|
|||
return (this._agent ??= new Agent({ client: this.client }))
|
||||
}
|
||||
|
||||
private _plugin?: Plugin
|
||||
get plugin(): Plugin {
|
||||
return (this._plugin ??= new Plugin({ client: this.client }))
|
||||
}
|
||||
|
||||
private _session?: Session3
|
||||
get session(): Session3 {
|
||||
return (this._session ??= new Session3({ client: this.client }))
|
||||
|
|
|
|||
|
|
@ -1502,6 +1502,7 @@ export type GlobalEvent = {
|
|||
| "session.new"
|
||||
| "session.share"
|
||||
| "session.interrupt"
|
||||
| "session.background"
|
||||
| "session.compact"
|
||||
| "session.page.up"
|
||||
| "session.page.down"
|
||||
|
|
@ -2707,6 +2708,7 @@ export type EventTuiCommandExecute = {
|
|||
| "session.new"
|
||||
| "session.share"
|
||||
| "session.interrupt"
|
||||
| "session.background"
|
||||
| "session.compact"
|
||||
| "session.page.up"
|
||||
| "session.page.down"
|
||||
|
|
@ -3134,6 +3136,7 @@ export type EventTuiCommandExecute2 = {
|
|||
| "session.new"
|
||||
| "session.share"
|
||||
| "session.interrupt"
|
||||
| "session.background"
|
||||
| "session.compact"
|
||||
| "session.page.up"
|
||||
| "session.page.down"
|
||||
|
|
@ -4111,6 +4114,10 @@ export type AgentV2Info = {
|
|||
permissions: PermissionV2Ruleset
|
||||
}
|
||||
|
||||
export type PluginInfo = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type SessionV2Info = {
|
||||
id: string
|
||||
parentID?: string
|
||||
|
|
@ -6171,6 +6178,7 @@ export type TuiCommandExecute = {
|
|||
| "session.new"
|
||||
| "session.share"
|
||||
| "session.interrupt"
|
||||
| "session.background"
|
||||
| "session.compact"
|
||||
| "session.page.up"
|
||||
| "session.page.down"
|
||||
|
|
@ -11817,6 +11825,43 @@ export type V2AgentListResponses = {
|
|||
|
||||
export type V2AgentListResponse = V2AgentListResponses[keyof V2AgentListResponses]
|
||||
|
||||
export type V2PluginListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
}
|
||||
url: "/api/plugin"
|
||||
}
|
||||
|
||||
export type V2PluginListErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
}
|
||||
|
||||
export type V2PluginListError = V2PluginListErrors[keyof V2PluginListErrors]
|
||||
|
||||
export type V2PluginListResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfo
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
}
|
||||
|
||||
export type V2PluginListResponse = V2PluginListResponses[keyof V2PluginListResponses]
|
||||
|
||||
export type V2SessionListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
|
@ -12570,6 +12615,41 @@ export type V2SessionInterruptResponses = {
|
|||
|
||||
export type V2SessionInterruptResponse = V2SessionInterruptResponses[keyof V2SessionInterruptResponses]
|
||||
|
||||
export type V2SessionBackgroundData = {
|
||||
body?: never
|
||||
path: {
|
||||
sessionID: string
|
||||
}
|
||||
query?: never
|
||||
url: "/api/session/{sessionID}/background"
|
||||
}
|
||||
|
||||
export type V2SessionBackgroundErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* SessionNotFoundError
|
||||
*/
|
||||
404: SessionNotFoundError
|
||||
}
|
||||
|
||||
export type V2SessionBackgroundError = V2SessionBackgroundErrors[keyof V2SessionBackgroundErrors]
|
||||
|
||||
export type V2SessionBackgroundResponses = {
|
||||
/**
|
||||
* <No Content>
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type V2SessionBackgroundResponse = V2SessionBackgroundResponses[keyof V2SessionBackgroundResponses]
|
||||
|
||||
export type V2SessionMessageData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { DateTime, Effect, Stream } from "effect"
|
|||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import {
|
||||
ConflictError,
|
||||
InvalidCursorError,
|
||||
|
|
@ -21,6 +22,7 @@ const DefaultSessionHistoryLimit = 50
|
|||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const jobs = yield* Job.Service
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
|
|
@ -481,6 +483,48 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.background",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.get(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const backgrounded = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID })
|
||||
if (backgrounded.length > 0)
|
||||
yield* session
|
||||
.synthetic({
|
||||
sessionID: ctx.params.sessionID,
|
||||
text: [
|
||||
"User requested that active blocking work be moved to the background.",
|
||||
"",
|
||||
"Backgrounded work:",
|
||||
...backgrounded.map(
|
||||
(job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`,
|
||||
),
|
||||
"",
|
||||
"The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.",
|
||||
].join("\n"),
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.message",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
|||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
|
|
@ -30,6 +31,7 @@ const applicationServices = LayerNode.group([
|
|||
EventV2.node,
|
||||
httpClient,
|
||||
ToolOutputStore.cleanupNode,
|
||||
Job.node,
|
||||
SessionV2.node,
|
||||
PluginRuntime.providerNode,
|
||||
PermissionSaved.node,
|
||||
|
|
|
|||
|
|
@ -435,6 +435,23 @@ export function Prompt(props: PromptProps) {
|
|||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Background blocking tools",
|
||||
name: "session.background",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
enabled: status() === "running",
|
||||
run: () => {
|
||||
if (auto()?.visible) return
|
||||
if (!input.focused) return
|
||||
if (!props.sessionID) return
|
||||
|
||||
void sdk.api.session.background({
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Open editor",
|
||||
category: "Session",
|
||||
|
|
@ -590,6 +607,7 @@ export function Prompt(props: PromptProps) {
|
|||
"prompt.stash.list",
|
||||
"prompt.skills",
|
||||
"session.interrupt",
|
||||
"session.background",
|
||||
"workspace.set",
|
||||
"session.move",
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -94,7 +94,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_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
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"),
|
||||
|
|
|
|||
|
|
@ -782,11 +782,14 @@ export function Session() {
|
|||
},
|
||||
},
|
||||
{
|
||||
title: "Background subagents",
|
||||
title: "Background blocking tools",
|
||||
value: "session.background",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => unavailable("Backgrounding subagents"),
|
||||
run: () => {
|
||||
void sdk.api.session.background({ sessionID: route.sessionID })
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle subagent picker",
|
||||
|
|
@ -920,6 +923,7 @@ export function Session() {
|
|||
/>
|
||||
)}
|
||||
</For>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
count={
|
||||
|
|
@ -1037,6 +1041,34 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
|
|||
)
|
||||
}
|
||||
|
||||
function BackgroundToolHint(props: { messages: SessionMessage[] }) {
|
||||
const { theme } = useTheme()
|
||||
const shortcut = useCommandShortcut("session.background")
|
||||
const visible = createMemo(() => {
|
||||
const current = props.messages.findLast(
|
||||
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
||||
)
|
||||
return (
|
||||
current?.content.some((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const display = toolDisplay(part.name)
|
||||
return display === "shell" || display === "subagent"
|
||||
}) ?? false
|
||||
)
|
||||
})
|
||||
return (
|
||||
<Show when={visible() && shortcut()}>
|
||||
{(value) => (
|
||||
<box marginTop={1} paddingLeft={3} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>
|
||||
Press <span style={{ fg: theme.text }}>{value()}</span> to move running work to the background
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionMessageView(props: { message: SessionMessage }) {
|
||||
return (
|
||||
<Switch>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue