mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(plugin): expose experimental terminal reads (#45792)
This commit is contained in:
@@ -1639,6 +1639,23 @@ export interface PtyApi<E = never> {
|
||||
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadInput = { readonly sessionID: Session.ID; readonly lines?: number | undefined }
|
||||
export type ExperimentalPersistentPtyReadOutput = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly title: string
|
||||
readonly cwd: string
|
||||
readonly foregroundProcess: string | null
|
||||
readonly screen: {
|
||||
readonly text: string
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
} | null
|
||||
export type ExperimentalPersistentPtyReadOperation<E = never> = (
|
||||
input: ExperimentalPersistentPtyReadInput,
|
||||
) => Effect.Effect<ExperimentalPersistentPtyReadOutput, E>
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID }
|
||||
export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{
|
||||
readonly id: Pty.ID
|
||||
@@ -1787,6 +1804,7 @@ export type ExperimentalPersistentPtyConnectTokenOperation<E = never> = (
|
||||
|
||||
export interface ExperimentalApi<E = never> {
|
||||
readonly persistentPty: {
|
||||
readonly read: ExperimentalPersistentPtyReadOperation<E>
|
||||
readonly list: ExperimentalPersistentPtyListOperation<E>
|
||||
readonly create: ExperimentalPersistentPtyCreateOperation<E>
|
||||
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
|
||||
|
||||
@@ -198,6 +198,8 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyReadInput,
|
||||
ExperimentalPersistentPtyReadOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
@@ -1234,6 +1236,15 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
|
||||
connect: { token: EndpointPtyConnectToken(raw) },
|
||||
})
|
||||
|
||||
const EndpointExperimentalPersistentPtyRead =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyReadInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyReadOutput>()(
|
||||
raw["persistentPty.read"]({ params: { sessionID: input["sessionID"] }, query: { lines: input["lines"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyList =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyListInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyListOutput>()(
|
||||
@@ -1322,6 +1333,7 @@ const EndpointExperimentalPersistentPtyConnectToken =
|
||||
|
||||
const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
|
||||
persistentPty: {
|
||||
read: EndpointExperimentalPersistentPtyRead(raw),
|
||||
list: EndpointExperimentalPersistentPtyList(raw),
|
||||
create: EndpointExperimentalPersistentPtyCreate(raw),
|
||||
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
|
||||
|
||||
@@ -194,6 +194,8 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ExperimentalPersistentPtyReadInput,
|
||||
ExperimentalPersistentPtyReadOutput,
|
||||
ExperimentalPersistentPtyListInput,
|
||||
ExperimentalPersistentPtyListOutput,
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
@@ -1684,6 +1686,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
experimental: {
|
||||
persistentPty: {
|
||||
read: (input: ExperimentalPersistentPtyReadInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyReadOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal/read`,
|
||||
query: { lines: input["lines"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
list: (input: ExperimentalPersistentPtyListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyListOutput }>(
|
||||
{
|
||||
|
||||
@@ -378,6 +378,14 @@ export type SessionStatus =
|
||||
|
||||
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
|
||||
|
||||
export type PersistentPtyReadResult = {
|
||||
ptyID: string
|
||||
title: string
|
||||
cwd: string
|
||||
foregroundProcess: string | null
|
||||
screen: { text: string; cols: number; rows: number; cursor: { x: number; y: number } }
|
||||
}
|
||||
|
||||
export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number }
|
||||
|
||||
export type ShellInfo1 = {
|
||||
@@ -5769,6 +5777,13 @@ export type PtyConnectTokenOutput = {
|
||||
data: PtyTicketConnectToken
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly lines?: { readonly lines?: number | undefined }["lines"]
|
||||
}
|
||||
|
||||
export type ExperimentalPersistentPtyReadOutput = { data: PersistentPtyReadResult | null }["data"]
|
||||
|
||||
export type ExperimentalPersistentPtyListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
|
||||
|
||||
@@ -50,6 +50,8 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
|
||||
expect(client.experimental.persistentPty.read).toBeFunction()
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
|
||||
@@ -59,6 +59,13 @@ export const WireResponse = Schema.Union([
|
||||
cursor_x: Schema.Number,
|
||||
cursor_y: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("rows"),
|
||||
terminal: WireTerminal,
|
||||
lines: Schema.Array(Schema.String),
|
||||
cursor_x: Schema.Number,
|
||||
cursor_y: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("attached"),
|
||||
terminal: WireTerminal,
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Added, Handoff, Removed } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
@@ -103,6 +103,7 @@ export interface Interface {
|
||||
data: Uint8Array,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
|
||||
readonly read: (sessionID: Session.ID, lines?: number) => Effect.Effect<ReadResult | null, UnavailableError>
|
||||
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly shutdown: () => Effect.Effect<void, UnavailableError>
|
||||
readonly handoff: () => Effect.Effect<Handoff | null, UnavailableError>
|
||||
@@ -140,6 +141,8 @@ export const configured = (options: Options = {}) =>
|
||||
options.handoff,
|
||||
).pipe(Effect.mapError(unavailable))
|
||||
const removing = new Set<Pty.ID>()
|
||||
// Controller activity selects a terminal; observer reads and pane visibility do not.
|
||||
const current = new Map<Session.ID, Pty.ID>()
|
||||
|
||||
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
|
||||
const response = yield* optionalRequest(daemon, { op: "list" })
|
||||
@@ -207,7 +210,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "resize",
|
||||
id: fromID(id),
|
||||
@@ -216,6 +219,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
current.set(terminal.sessionID, id)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -225,7 +229,7 @@ export const configured = (options: Options = {}) =>
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
yield* get(id)
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "control",
|
||||
id: fromID(id),
|
||||
@@ -234,6 +238,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
current.set(terminal.sessionID, id)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -244,7 +249,7 @@ export const configured = (options: Options = {}) =>
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
yield* get(id)
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "input",
|
||||
id: fromID(id),
|
||||
@@ -254,6 +259,7 @@ export const configured = (options: Options = {}) =>
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
current.set(terminal.sessionID, id)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -269,16 +275,46 @@ export const configured = (options: Options = {}) =>
|
||||
}
|
||||
})
|
||||
|
||||
const read = Effect.fn("PersistentPty.read")(function* (sessionID: Session.ID, lines?: number) {
|
||||
if (lines !== undefined && !Schema.is(ReadLines)(lines))
|
||||
return yield* new UnavailableError({ message: "lines must be an integer between 1 and 65535" })
|
||||
const id = current.get(sessionID)
|
||||
if (!id) return null
|
||||
const terminal = yield* get(id).pipe(Effect.catchTag("PersistentPty.NotFoundError", () => Effect.succeed(null)))
|
||||
if (!terminal || terminal.sessionID !== sessionID) {
|
||||
if (current.get(sessionID) === id) current.delete(sessionID)
|
||||
return null
|
||||
}
|
||||
// Let the daemon choose the live height in the same snapshot when lines is omitted.
|
||||
const response = yield* request(daemon, { op: "read_rows", id: fromID(id), rows: lines })
|
||||
if (response.type !== "rows") return yield* unexpected(response)
|
||||
const info = toInfo(response.terminal)
|
||||
return {
|
||||
ptyID: info.id,
|
||||
title: info.title,
|
||||
cwd: info.cwd,
|
||||
foregroundProcess: info.foregroundProcess,
|
||||
screen: {
|
||||
text: response.lines.join("\n"),
|
||||
cols: info.size.cols,
|
||||
rows: info.size.rows,
|
||||
cursor: { x: response.cursor_x, y: response.cursor_y },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
if (current.get(terminal.sessionID) === id) current.delete(terminal.sessionID)
|
||||
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
|
||||
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
|
||||
current.clear()
|
||||
if (!response) return
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
})
|
||||
@@ -321,6 +357,7 @@ export const configured = (options: Options = {}) =>
|
||||
},
|
||||
})
|
||||
.pipe(Effect.mapError(unavailable))
|
||||
if (attachment.role === "controller") current.set(Session.ID.make(attachment.terminal.group_id), id)
|
||||
return {
|
||||
info: toInfo(attachment.terminal),
|
||||
role: attachment.role,
|
||||
@@ -340,6 +377,7 @@ export const configured = (options: Options = {}) =>
|
||||
control,
|
||||
input,
|
||||
snapshot,
|
||||
read,
|
||||
remove,
|
||||
shutdown,
|
||||
handoff,
|
||||
|
||||
@@ -193,6 +193,11 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
event: {
|
||||
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||
},
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: (input) => runtime.persistentPty.read(input.sessionID, input.lines),
|
||||
},
|
||||
},
|
||||
generate: {
|
||||
text: (input) => generate.text(input).pipe(Effect.map((text) => ({ text }))),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PersistentPty } from "../persistent-pty.js"
|
||||
import { Session } from "../session.js"
|
||||
|
||||
export interface Interface {
|
||||
@@ -29,6 +30,7 @@ export interface Interface {
|
||||
| "context"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
|
||||
readonly persistentPty: Pick<PersistentPty.Interface, "read">
|
||||
readonly location: {
|
||||
readonly agent: {
|
||||
readonly list: (
|
||||
@@ -90,6 +92,9 @@ export const layerWithCell = (cell: Cell) =>
|
||||
completeBackground: (notificationID) =>
|
||||
require(cell, (runtime) => runtime.job.completeBackground(notificationID)),
|
||||
},
|
||||
persistentPty: {
|
||||
read: (sessionID, lines) => require(cell, (runtime) => runtime.persistentPty.read(sessionID, lines)),
|
||||
},
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
|
||||
@@ -107,9 +112,11 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
const sessions = yield* Session.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const runtime: Interface = {
|
||||
session: sessions,
|
||||
job: jobs,
|
||||
persistentPty,
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) =>
|
||||
@@ -162,7 +169,7 @@ export const providerNodeWithCell = (cell: Cell) =>
|
||||
makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayerWithCell(cell),
|
||||
deps: [node, Session.node, Job.node, LocationServiceMap.node],
|
||||
deps: [node, Session.node, Job.node, LocationServiceMap.node, PersistentPty.node],
|
||||
})
|
||||
|
||||
export const providerNode = providerNodeWithCell(defaultCell)
|
||||
|
||||
@@ -9,12 +9,14 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
@@ -25,6 +27,54 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
|
||||
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
|
||||
|
||||
describe("Plugin", () => {
|
||||
it.effect("routes experimental terminal reads through the runtime cell without wrapping results", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const cell = PluginRuntime.makeCell()
|
||||
const host = yield* PluginHost.make(plugins).pipe(Effect.provide(PluginRuntime.layerWithCell(cell)))
|
||||
const sessionID = Session.ID.make("ses_terminal")
|
||||
const pending = host.experimental.terminal.read({ sessionID, lines: 3 })
|
||||
const seen: unknown[] = []
|
||||
const terminal = {
|
||||
ptyID: Pty.ID.make("pty_terminal"),
|
||||
title: "Build",
|
||||
cwd: "/workspace",
|
||||
foregroundProcess: null,
|
||||
screen: { text: "one\ntwo\nthree", cols: 80, rows: 2, cursor: { x: 3, y: 1 } },
|
||||
}
|
||||
const error = new PersistentPty.UnavailableError({ message: "terminal daemon unavailable" })
|
||||
cell.runtime = {
|
||||
...runtime,
|
||||
persistentPty: {
|
||||
read: (id, lines) => {
|
||||
seen.push({ sessionID: id, lines })
|
||||
if (id === Session.ID.make("ses_failure")) return Effect.fail(error)
|
||||
return Effect.succeed(id === sessionID ? terminal : null)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(Object.keys(host.experimental)).toEqual(["terminal"])
|
||||
expect(Object.keys(host.experimental.terminal)).toEqual(["read"])
|
||||
expect(yield* pending).toBe(terminal)
|
||||
expect(yield* host.experimental.terminal.read({ sessionID })).toBe(terminal)
|
||||
expect(yield* host.experimental.terminal.read({ sessionID: Session.ID.make("ses_empty") })).toBeNull()
|
||||
expect(
|
||||
yield* host.experimental.terminal.read({ sessionID: Session.ID.make("ses_failure") }).pipe(Effect.flip),
|
||||
).toBe(error)
|
||||
expect(seen).toEqual([
|
||||
{ sessionID, lines: 3 },
|
||||
{ sessionID, lines: undefined },
|
||||
{ sessionID: Session.ID.make("ses_empty"), lines: undefined },
|
||||
{ sessionID: Session.ID.make("ses_failure"), lines: undefined },
|
||||
])
|
||||
|
||||
cell.runtime = undefined
|
||||
expect(Exit.isFailure(yield* pending.pipe(Effect.exit))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes the current location to activated plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const cell = PluginRuntime.makeCell()
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Global.node,
|
||||
Bus.node,
|
||||
PersistentPty.node,
|
||||
PluginRuntime.node,
|
||||
PluginRuntime.providerNodeWithCell(cell),
|
||||
]),
|
||||
[
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Watcher.node, Watcher.configured({ enabled: false })],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
|
||||
[PersistentPty.node, PersistentPty.configured()],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("Plugin runtime terminal reads", () => {
|
||||
it.live("shares the configured global PTY service and validates lines before an empty selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const sessionID = Session.ID.make("ses_no_terminal")
|
||||
|
||||
expect(cell.runtime?.persistentPty).toBe(persistentPty)
|
||||
expect(yield* runtime.persistentPty.read(sessionID)).toBeNull()
|
||||
expect(yield* runtime.persistentPty.read(sessionID, 1)).toBeNull()
|
||||
expect(yield* runtime.persistentPty.read(sessionID, 65535)).toBeNull()
|
||||
yield* Effect.forEach([0, -1, 1.5, 65536, NaN, Infinity], (lines) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* runtime.persistentPty.read(sessionID, lines).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(PersistentPty.UnavailableError)
|
||||
expect(error.message).toContain("lines")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -58,6 +58,11 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
event: overrides.event ?? {
|
||||
subscribe: () => Stream.empty,
|
||||
},
|
||||
experimental: overrides.experimental ?? {
|
||||
terminal: {
|
||||
read: () => Effect.die("unused experimental.terminal.read"),
|
||||
},
|
||||
},
|
||||
generate: overrides.generate ?? {
|
||||
text: () => Effect.die("unused generate.text"),
|
||||
},
|
||||
|
||||
@@ -22,6 +22,8 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -30,6 +32,84 @@ import { host as testHost } from "./host"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it.effect("validates and forwards experimental terminal reads through the protocol schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: unknown[] = []
|
||||
const terminal = PersistentPty.ReadResult.make({
|
||||
ptyID: Pty.ID.make("pty_terminal"),
|
||||
title: "Build",
|
||||
cwd: "/workspace",
|
||||
foregroundProcess: "bun",
|
||||
screen: { text: "one\ntwo\nthree", cols: 80, rows: 2, cursor: { x: 3, y: 1 } },
|
||||
})
|
||||
const host = testHost({
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: (input) => {
|
||||
seen.push(input)
|
||||
return Effect.succeed(terminal)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-terminal-read",
|
||||
setup: async (ctx) => {
|
||||
expect(Object.keys(ctx.experimental)).toEqual(["terminal"])
|
||||
expect(Object.keys(ctx.experimental.terminal)).toEqual(["read"])
|
||||
for (const lines of [0, -1, 1.5, 65536, NaN, Infinity, "3"]) {
|
||||
await expect(
|
||||
Reflect.apply(ctx.experimental.terminal.read, undefined, [{ sessionID: "ses_terminal", lines }]),
|
||||
).rejects.toBeDefined()
|
||||
}
|
||||
await expect(Reflect.apply(ctx.experimental.terminal.read, undefined, [{ lines: 3 }])).rejects.toBeDefined()
|
||||
expect(seen).toEqual([])
|
||||
expect(await ctx.experimental.terminal.read({ sessionID: "ses_terminal" })).toEqual(terminal)
|
||||
expect(await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 3 })).toEqual(terminal)
|
||||
await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 1 })
|
||||
await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 65535 })
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ sessionID: Session.ID.make("ses_terminal") },
|
||||
{ sessionID: Session.ID.make("ses_terminal"), lines: 3 },
|
||||
{ sessionID: Session.ID.make("ses_terminal"), lines: 1 },
|
||||
{ sessionID: Session.ID.make("ses_terminal"), lines: 65535 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves null terminal reads and rejects daemon failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const host = testHost({
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: (input) =>
|
||||
input.sessionID === Session.ID.make("ses_failure")
|
||||
? Effect.fail(new Error("terminal daemon unavailable"))
|
||||
: Effect.succeed(null),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-terminal-null",
|
||||
setup: async (ctx) => {
|
||||
expect(await ctx.experimental.terminal.read({ sessionID: "ses_empty" })).toBeNull()
|
||||
await expect(ctx.experimental.terminal.read({ sessionID: "ses_failure" })).rejects.toThrow(
|
||||
"terminal daemon unavailable",
|
||||
)
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes the host location including workspace and project metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -9,6 +9,7 @@ export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
|
||||
import type { ExperimentalApi, GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Location } from "@opencode-ai/schema/location"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
@@ -30,6 +30,9 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly experimental: {
|
||||
readonly terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read">
|
||||
}
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly generate: GenerateApi<unknown>
|
||||
|
||||
@@ -77,6 +77,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
)
|
||||
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
|
||||
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
|
||||
const ExperimentalEndpoints = ClientApi.groups["server.experimental"].endpoints
|
||||
const GenerateEndpoints = ClientApi.groups["server.generate"].endpoints
|
||||
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
|
||||
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
|
||||
@@ -188,6 +189,11 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
),
|
||||
},
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: adaptApiMethod(ExperimentalEndpoints["persistentPty.read"], host.experimental.terminal.read),
|
||||
},
|
||||
},
|
||||
generate: {
|
||||
text: adaptApiMethod(GenerateEndpoints["generate.text"], host.generate.text),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Location } from "@opencode-ai/schema/location"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
@@ -29,6 +30,9 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly experimental: {
|
||||
readonly terminal: Pick<OpenCodeClient["experimental"]["persistentPty"], "read">
|
||||
}
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly generate: GenerateApi
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
@@ -30,6 +31,7 @@ test.each([
|
||||
expect(entrypoint.Location).toBe(Location)
|
||||
expect(entrypoint.Mcp).toBe(Mcp)
|
||||
expect(entrypoint.Model).toBe(Model)
|
||||
expect(entrypoint.PersistentPty).toBe(PersistentPty)
|
||||
expect(entrypoint.Provider).toBe(Provider)
|
||||
expect(entrypoint.Reference).toBe(Reference)
|
||||
expect(entrypoint.Skill).toBe(Skill)
|
||||
@@ -44,6 +46,7 @@ test.each([
|
||||
"Location",
|
||||
"Mcp",
|
||||
"Model",
|
||||
"PersistentPty",
|
||||
"Plugin",
|
||||
"Provider",
|
||||
"Reference",
|
||||
|
||||
@@ -19,6 +19,22 @@ const errors = [InvalidRequestError, ServiceUnavailableError] as const
|
||||
const terminalErrors = [PtyNotFoundError, ServiceUnavailableError] as const
|
||||
|
||||
export const PersistentPtyGroup = HttpApiGroup.make("server.experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.read", "/api/experimental/session/:sessionID/terminal/read", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: {
|
||||
lines: Schema.NumberFromString.pipe(Schema.decodeTo(PersistentPty.ReadLines), Schema.optional),
|
||||
},
|
||||
success: Schema.Struct({ data: Schema.NullOr(PersistentPty.ReadResult) }),
|
||||
error: [ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
summary: "Read the session's most recently controlled terminal",
|
||||
description:
|
||||
"Read the last physical rows without changing selection or taking control. Omitted lines uses the live terminal height; larger counts include retained history. Blank rows are preserved. Screen dimensions and cursor remain relative to the live screen. Returns null when no current terminal exists. Selection is server-local and resets on restart. Experimental: may change without compatibility guarantees.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("persistentPty.list", "/api/experimental/session/:sessionID/terminal", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -47,6 +47,24 @@ export const Snapshot = Schema.Struct({
|
||||
}).annotate({ identifier: "PersistentPty.Snapshot" })
|
||||
export interface Snapshot extends Schema.Schema.Type<typeof Snapshot> {}
|
||||
|
||||
export const ReadLines = PositiveInt.check(Schema.isLessThanOrEqualTo(65535)).annotate({
|
||||
identifier: "PersistentPty.ReadLines",
|
||||
})
|
||||
|
||||
export const ReadResult = Schema.Struct({
|
||||
ptyID: Pty.ID,
|
||||
title: Schema.String,
|
||||
cwd: Schema.String,
|
||||
foregroundProcess: Schema.NullOr(Schema.String),
|
||||
screen: Schema.Struct({
|
||||
text: Schema.String,
|
||||
cols: PositiveInt,
|
||||
rows: PositiveInt,
|
||||
cursor: Snapshot.fields.cursor,
|
||||
}),
|
||||
}).annotate({ identifier: "PersistentPty.ReadResult" })
|
||||
export interface ReadResult extends Schema.Schema.Type<typeof ReadResult> {}
|
||||
|
||||
export const Added = ephemeral({ type: "persistent-pty.added", schema: { sessionID: Session.ID, terminal: Info } })
|
||||
export const Removed = ephemeral({ type: "persistent-pty.removed", schema: { sessionID: Session.ID, ptyID: Pty.ID } })
|
||||
export const Event = { Added, Removed, Definitions: inventory(Added, Removed) }
|
||||
|
||||
@@ -20,6 +20,12 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
const pty = yield* PersistentPty.Service
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"persistentPty.read",
|
||||
Effect.fn(function* (ctx) {
|
||||
return { data: yield* pty.read(ctx.params.sessionID, ctx.query.lines).pipe(mapUnavailable) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"persistentPty.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -7,12 +7,137 @@ import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Exit, Schema, Scope } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { OpenCode } from "../../client/src/promise/index"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
const binary = process.env.OPENCODE_PTY_BIN ?? "/root/projects/opencode-pty/target/debug/opencode-pty"
|
||||
const smoke = existsSync(binary) ? it.live : it.live.skip
|
||||
|
||||
smoke(
|
||||
"reads the latest controlled terminal with optional physical line counts through the SDK",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* testDirectory("xdg")
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: fixture.database },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const client = OpenCode.make({ baseUrl: base, headers: { authorization: `Basic ${btoa("opencode:secret")}` } })
|
||||
const sessionID = "ses_terminal_read"
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
|
||||
expect(existsSync(fixture.directory)).toBeFalse()
|
||||
yield* Effect.promise(async () => {
|
||||
for (const lines of ["0", "-1", "1.5", "65536", "nope"]) {
|
||||
const response = await fetch(`${base}/api/experimental/session/${sessionID}/terminal/read?lines=${lines}`, {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
await response.arrayBuffer()
|
||||
}
|
||||
})
|
||||
expect(existsSync(fixture.directory)).toBeFalse()
|
||||
const first = yield* Effect.promise(() =>
|
||||
client.experimental.persistentPty.create({
|
||||
sessionID,
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "stty -echo; seq 1 20; exec cat"],
|
||||
cwd: fixture.root,
|
||||
title: "first",
|
||||
env: {},
|
||||
size: { cols: 40, rows: 4 },
|
||||
}),
|
||||
)
|
||||
const second = yield* Effect.promise(() =>
|
||||
client.experimental.persistentPty.create({
|
||||
sessionID,
|
||||
command: "/bin/sh",
|
||||
args: ["-c", "printf second; exec cat"],
|
||||
cwd: fixture.root,
|
||||
title: "second",
|
||||
env: {},
|
||||
}),
|
||||
)
|
||||
expect(yield* waitForText(base, first.id, "20")).toContain("1\n2\n")
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
|
||||
const controller = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => openTerminalSocket(base, first.id, "read-first")),
|
||||
(connection) => Effect.sync(() => connection.socket.close()),
|
||||
)
|
||||
const observer = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => openTerminalSocket(base, second.id, "read-second", "observer")),
|
||||
(connection) => Effect.sync(() => connection.socket.close()),
|
||||
)
|
||||
const current = yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))
|
||||
if (!current) throw new Error("Expected the controller's terminal to be selected")
|
||||
expect(current).toEqual({
|
||||
ptyID: first.id,
|
||||
title: "first",
|
||||
cwd: fixture.root,
|
||||
foregroundProcess: current.foregroundProcess,
|
||||
screen: { text: "18\n19\n20\n", cols: 40, rows: 4, cursor: { x: 0, y: 3 } },
|
||||
})
|
||||
expect(current.foregroundProcess === null || typeof current.foregroundProcess === "string").toBeTrue()
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID: "ses_other" }))).toBeNull()
|
||||
yield* Effect.promise(() => client.experimental.persistentPty.snapshot({ ptyID: second.id }))
|
||||
for (const lines of [2, 6, 65535]) {
|
||||
const value = yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID, lines }))
|
||||
expect(value?.ptyID).toBe(first.id)
|
||||
expect(value?.screen.rows).toBe(4)
|
||||
const expected = Array.from({ length: 20 }, (_, index) => String(index + 1))
|
||||
.concat("")
|
||||
.slice(-lines)
|
||||
expect(value?.screen.text.split("\n")).toEqual(expected)
|
||||
}
|
||||
observer.socket.send(controlFrame(30, 3))
|
||||
expect((yield* Effect.promise(() => waitForRead(client, sessionID, second.id))).screen.rows).toBe(3)
|
||||
yield* Effect.promise(() =>
|
||||
client.experimental.persistentPty.update({
|
||||
ptyID: first.id,
|
||||
attachmentID: "read-first",
|
||||
size: { cols: 42, rows: 5 },
|
||||
}),
|
||||
)
|
||||
expect((yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID })))?.ptyID).toBe(first.id)
|
||||
observer.socket.send(controlFrame(30, 3))
|
||||
yield* Effect.promise(() => waitForRead(client, sessionID, second.id))
|
||||
controller.socket.send(inputFrame(40, 4, "typed\n"))
|
||||
yield* Effect.promise(() => waitForSocketOutput([controller], "typed"))
|
||||
expect((yield* Effect.promise(() => waitForRead(client, sessionID, first.id))).screen.text).toContain("typed")
|
||||
const takeover = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => openTerminalSocket(base, second.id, "read-takeover")),
|
||||
(connection) => Effect.sync(() => connection.socket.close()),
|
||||
)
|
||||
expect((yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID })))?.ptyID).toBe(
|
||||
second.id,
|
||||
)
|
||||
takeover.socket.close()
|
||||
yield* Effect.promise(() => client.experimental.persistentPty.remove({ ptyID: first.id }))
|
||||
expect((yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID })))?.ptyID).toBe(
|
||||
second.id,
|
||||
)
|
||||
yield* Effect.promise(() => client.experimental.persistentPty.remove({ ptyID: second.id }))
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
|
||||
yield* Effect.promise(() => client.experimental.persistentPty.shutdown())
|
||||
expect(yield* Effect.promise(() => client.experimental.persistentPty.read({ sessionID }))).toBeNull()
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
|
||||
async function waitForRead(client: ReturnType<typeof OpenCode.make>, sessionID: string, ptyID: string) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const result = await client.experimental.persistentPty.read({ sessionID })
|
||||
if (result?.ptyID === ptyID) return result
|
||||
await Bun.sleep(20)
|
||||
}
|
||||
throw new Error(`Terminal ${ptyID} did not become current for ${sessionID}`)
|
||||
}
|
||||
|
||||
smoke(
|
||||
"creates two persistent terminals for one session through the client API",
|
||||
() =>
|
||||
@@ -216,10 +341,18 @@ smoke(
|
||||
})).data,
|
||||
)
|
||||
expect(yield* waitForText(base, first.id, "before-restart")).toContain("before-restart")
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.promise(() => openTerminalSocket(base, first.id, "before-restart")),
|
||||
(connection) => Effect.sync(() => connection.socket.close()),
|
||||
)
|
||||
expect((yield* request(base, "GET", `/api/experimental/session/${sessionID}/terminal/read`)).data).toMatchObject({
|
||||
ptyID: first.id,
|
||||
})
|
||||
|
||||
const independent = yield* ServerProcess.start<never, never>(options)
|
||||
const otherBase = HttpServer.formatAddress(independent.address)
|
||||
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
|
||||
expect((yield* request(otherBase, "GET", `/api/experimental/session/${sessionID}/terminal/read`)).data).toBeNull()
|
||||
|
||||
const handoff = Schema.decodeUnknownSync(PersistentPty.Handoff)(
|
||||
(yield* request(base, "POST", "/api/experimental/persistent-pty/handoff")).handoff,
|
||||
@@ -240,6 +373,9 @@ smoke(
|
||||
(yield* request(replacementBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
|
||||
).toMatchObject([{ id: first.id, pid: first.pid }])
|
||||
expect(yield* waitForText(replacementBase, first.id, "before-restart")).toContain("before-restart")
|
||||
expect(
|
||||
(yield* request(replacementBase, "GET", `/api/experimental/session/${sessionID}/terminal/read`)).data,
|
||||
).toBeNull()
|
||||
|
||||
yield* Scope.close(replacementScope, Exit.void)
|
||||
yield* waitForExit(registration.pid)
|
||||
|
||||
Reference in New Issue
Block a user