cli/run: switch to global event stream (#26383)

This commit is contained in:
Simon Klee
2026-05-08 19:44:31 +02:00
committed by GitHub
parent 3052a79b32
commit 4d43d584fe
5 changed files with 723 additions and 106 deletions

View File

@@ -487,6 +487,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
const handle = await mod.createSessionTransport({
sdk: ctx.sdk,
directory: ctx.directory,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,

View File

@@ -1,8 +1,9 @@
// SDK event subscription and prompt turn coordination.
// Global event subscription and prompt turn coordination.
//
// Creates a long-lived event stream subscription and feeds every event
// through the session-data reducer. The reducer produces scrollback commits
// and footer patches, which get forwarded to the footer through stream.ts.
// Creates a long-lived global event stream subscription and feeds relevant
// events for the current session tree through the reducers. The reducers
// produce scrollback commits and footer patches, which get forwarded to the
// footer through stream.ts.
//
// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the
// SDK, arms a deferred Wait, and resolves when the session becomes idle.
@@ -14,7 +15,7 @@
// The tick counter prevents stale idle events from resolving the wrong turn.
// We also re-check live session status before resolving an idle event so a
// delayed idle from an older turn cannot complete a newer busy turn.
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
import { makeRuntime } from "@/effect/run-service"
import {
@@ -62,6 +63,7 @@ type Trace = {
type StreamInput = {
sdk: OpencodeClient
directory?: string
sessionID: string
thinking: boolean
limits: () => Record<string, number>
@@ -151,6 +153,40 @@ function isEvent(value: unknown): value is Event {
return typeof type === "string" && !!properties && typeof properties === "object"
}
function isGlobalEvent(value: unknown): value is GlobalEvent {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false
}
const payload = Reflect.get(value, "payload")
return !!payload && typeof payload === "object"
}
function globalPayloadEvent(value: unknown): Event | undefined {
if (!isGlobalEvent(value)) {
return undefined
}
const payload = value.payload
if (payload.type === "sync") {
return undefined
}
return isEvent(payload) ? payload : undefined
}
function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean {
if (!directory || !isGlobalEvent(value)) {
return false
}
if (value.directory !== directory) {
return false
}
return value.payload.type === "server.instance.disposed"
}
function active(event: Event, sessionID: string): boolean {
if (sid(event) !== sessionID) {
return false
@@ -371,7 +407,7 @@ function createLayer(input: StreamInput) {
const events = yield* Scope.provide(scope)(
Effect.acquireRelease(
Effect.promise(() =>
input.sdk.event.subscribe(undefined, {
input.sdk.global.event({
signal: abort.signal,
}),
),
@@ -397,7 +433,6 @@ function createLayer(input: StreamInput) {
blockers: new Map(),
}
const recovering = new Set<string>()
const currentSubagentState = () => {
if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) {
state.selectedSubagent = undefined
@@ -526,6 +561,38 @@ function createLayer(input: StreamInput) {
Effect.orElseSucceed(() => []),
)
const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(
function* (sessions: string[]) {
yield* Effect.forEach(
sessions,
(sessionID) =>
messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe(
Effect.tap((messagesList) =>
Effect.sync(() => {
if (
!bootstrapSubagentCalls({
data: state.subagent,
sessionID,
messages: messagesList,
thinking: input.thinking,
limits: input.limits(),
})
) {
return
}
syncFooter([], undefined, currentSubagentState())
}),
),
),
{
concurrency: 4,
discard: true,
},
)
},
)
const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
const [messagesList, children, permissions, questions] = yield* Effect.all(
[
@@ -566,33 +633,6 @@ function createLayer(input: StreamInput) {
questions,
})
const sessions = [
...new Set(
listSubagentPermissions(state.subagent)
.filter((item) => item.tool && item.metadata?.input === undefined)
.map((item) => item.sessionID),
),
]
yield* Effect.forEach(
sessions,
(sessionID) =>
messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe(
Effect.tap((messagesList) =>
Effect.sync(() => {
bootstrapSubagentCalls({
data: state.subagent,
sessionID,
messages: messagesList,
})
}),
),
),
{
concurrency: "unbounded",
discard: true,
},
)
for (const request of [
...state.data.permissions,
...listSubagentPermissions(state.subagent),
@@ -605,6 +645,13 @@ function createLayer(input: StreamInput) {
const snapshot = currentSubagentState()
traceTabs(input.trace, [], snapshot.tabs)
syncFooter([], undefined, snapshot)
const sessions = [...state.subagent.tabs.keys()]
if (sessions.length === 0) {
return
}
yield* bootstrapSubagentHistory(sessions).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
})
const idle = Effect.fn("RunStreamTransport.idle")((fallback: boolean) =>
@@ -700,11 +747,22 @@ function createLayer(input: StreamInput) {
return
}
if (!isEvent(item)) {
if (isMatchingDisposeEvent(item, input.directory)) {
yield* fail(new Error("instance disposed"))
yield* closeScope()
return
}
const event = globalPayloadEvent(item)
if (!event) {
return
}
const sessionID = sid(event)
if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) {
return
}
const event = item
input.trace?.write("recv.event", event)
trackBlocker(event)
@@ -754,7 +812,7 @@ function createLayer(input: StreamInput) {
Effect.ensuring(
Effect.gen(function* () {
if (!abort.signal.aborted && !state.fault) {
yield* fail(new Error("session event stream closed"))
yield* fail(new Error("global event stream closed"))
}
closeStream()
}),

View File

@@ -1,4 +1,4 @@
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "@/util/locale"
import {
bootstrapSessionData,
@@ -22,6 +22,10 @@ type SessionMessage = {
parts: Part[]
}
type BootstrapChildMessage = SessionMessage & {
info: Message
}
type Frame = {
key: string
commit: StreamCommit
@@ -513,6 +517,70 @@ function applyChildEvent(input: {
return changed || queueChanged(input.detail.data, before)
}
function bootstrapChildEvent(input: {
detail: DetailState
event: Event
thinking: boolean
limits: Record<string, number>
}) {
const out = reduceSessionData({
data: input.detail.data,
event: input.event,
sessionID: input.detail.sessionID,
thinking: input.thinking,
limits: input.limits,
})
return appendCommits(input.detail, out.commits)
}
function bootstrapChildMessages(input: {
detail: DetailState
messages: BootstrapChildMessage[]
thinking: boolean
limits: Record<string, number>
}) {
let changed = false
for (const message of input.messages) {
changed =
bootstrapChildEvent({
detail: input.detail,
event: {
id: `bootstrap:message:${message.info.id}`,
type: "message.updated",
properties: {
sessionID: input.detail.sessionID,
info: message.info,
},
},
thinking: input.thinking,
limits: input.limits,
}) || changed
for (const part of message.parts) {
changed =
bootstrapChildEvent({
detail: input.detail,
event: {
id: `bootstrap:part:${part.id}`,
type: "message.part.updated",
properties: {
sessionID: input.detail.sessionID,
part,
time: 0,
},
},
thinking: input.thinking,
limits: input.limits,
}) || changed
}
}
compactDetail(input.detail)
return changed
}
function knownSession(data: SubagentData, sessionID: string) {
return data.tabs.has(sessionID)
}
@@ -634,7 +702,13 @@ export function bootstrapSubagentData(input: BootstrapSubagentInput) {
return changed
}
export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) {
export function bootstrapSubagentCalls(input: {
data: SubagentData
sessionID: string
messages: BootstrapChildMessage[]
thinking: boolean
limits: Record<string, number>
}) {
if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
return false
}
@@ -648,9 +722,14 @@ export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: s
permissions: detail.data.permissions,
questions: detail.data.questions,
})
compactDetail(detail)
const changed = bootstrapChildMessages({
detail,
messages: input.messages,
thinking: input.thinking,
limits: input.limits,
})
return beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before)
}
export function clearFinishedSubagents(data: SubagentData) {