From 522322980e2d5053b37cd19ef370b73af5be32a3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 31 May 2026 17:21:06 +0530 Subject: [PATCH] propagate parent session status context --- packages/opencode/src/session/processor.ts | 23 ++++++++----- packages/opencode/src/session/prompt.ts | 24 ++++++++++--- packages/opencode/src/session/run-state.ts | 22 ++++++++---- packages/opencode/src/session/status.ts | 16 +++++++-- packages/opencode/test/session/prompt.test.ts | 34 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 ++ 6 files changed, 98 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f124f7eea47..20027f1e69b 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -57,6 +57,7 @@ type Input = { assistantMessage: SessionLegacy.Assistant sessionID: SessionID model: Provider.Model + statusContext?: SessionStatus.SetContext } export interface Interface { @@ -777,7 +778,7 @@ export const layer = Layer.effect( sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, }) - yield* status.set(ctx.sessionID, { type: "idle" }) + yield* status.set(ctx.sessionID, { type: "idle" }, ctx.statusContext) }) const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { @@ -789,7 +790,7 @@ export const layer = Layer.effect( yield* Effect.gen(function* () { ctx.currentText = undefined ctx.reasoningMap = {} - yield* status.set(ctx.sessionID, { type: "busy" }) + yield* status.set(ctx.sessionID, { type: "busy" }, ctx.statusContext) const stream = llm.stream(streamInput) yield* stream.pipe( @@ -829,13 +830,17 @@ export const layer = Layer.effect( : Effect.void return event.pipe( Effect.andThen( - status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - action: info.action, - next: info.next, - }), + status.set( + ctx.sessionID, + { + type: "retry", + attempt: info.attempt, + message: info.message, + action: info.action, + next: info.next, + }, + ctx.statusContext, + ), ), ) }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 36e394f0aa2..9b7ec9006fb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -140,7 +140,8 @@ export const layer = Layer.effect( const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* elog.info("cancel", { sessionID }) - yield* state.cancel(sessionID) + const session = yield* sessions.get(sessionID).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + yield* state.cancel(sessionID, session?.parentID ? { parentID: session.parentID } : undefined) }) const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { @@ -1251,9 +1252,10 @@ export const layer = Layer.effect( let structured: unknown let step = 0 const session = yield* sessions.get(sessionID).pipe(Effect.orDie) + const statusContext = session.parentID ? { parentID: session.parentID } : {} while (true) { - yield* status.set(sessionID, { type: "busy" }) + yield* status.set(sessionID, { type: "busy" }, statusContext) yield* slog.info("loop", { step }) let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe( @@ -1381,6 +1383,7 @@ export const layer = Layer.effect( assistantMessage: msg, sessionID, model, + statusContext, }) .pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant)) @@ -1504,7 +1507,13 @@ export const layer = Layer.effect( const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")( function* (input: LoopInput) { - return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + return yield* state.ensureRunning( + input.sessionID, + session.parentID ? { parentID: session.parentID } : {}, + lastAssistant(input.sessionID), + runLoop(input.sessionID), + ) }, ) @@ -1512,7 +1521,14 @@ export const layer = Layer.effect( "SessionPrompt.shell", )(function* (input: ShellInput) { const ready = yield* Latch.make() - return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + return yield* state.startShell( + input.sessionID, + session.parentID ? { parentID: session.parentID } : {}, + lastAssistant(input.sessionID), + shellImpl(input, ready), + ready, + ) }) const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 1b92dce6828..e71dec10fd5 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -10,14 +10,16 @@ import { SessionStatus } from "./status" export interface Interface { readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect - readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly cancel: (sessionID: SessionID, statusContext?: SessionStatus.SetContext) => Effect.Effect readonly ensureRunning: ( sessionID: SessionID, + statusContext: SessionStatus.SetContext, onInterrupt: Effect.Effect, work: Effect.Effect, ) => Effect.Effect readonly startShell: ( sessionID: SessionID, + statusContext: SessionStatus.SetContext, onInterrupt: Effect.Effect, work: Effect.Effect, ready?: Latch.Latch, @@ -51,6 +53,7 @@ export const layer = Layer.effect( const runner = Effect.fn("SessionRunState.runner")(function* ( sessionID: SessionID, + statusContext: SessionStatus.SetContext, onInterrupt: Effect.Effect, ) { const data = yield* InstanceState.get(state) @@ -59,9 +62,9 @@ export const layer = Layer.effect( const next = Runner.make(data.scope, { onIdle: Effect.gen(function* () { data.runners.delete(sessionID) - yield* status.set(sessionID, { type: "idle" }) + yield* status.set(sessionID, { type: "idle" }, statusContext) }), - onBusy: status.set(sessionID, { type: "busy" }), + onBusy: status.set(sessionID, { type: "busy" }, statusContext), onInterrupt, }) data.runners.set(sessionID, next) @@ -74,12 +77,15 @@ export const layer = Layer.effect( if (existing?.busy) yield* busyError(sessionID) }) - const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) { + const cancel = Effect.fn("SessionRunState.cancel")(function* ( + sessionID: SessionID, + statusContext?: SessionStatus.SetContext, + ) { yield* cancelBackgroundJobs(background, sessionID) const data = yield* InstanceState.get(state) const existing = data.runners.get(sessionID) if (!existing || !existing.busy) { - yield* status.set(sessionID, { type: "idle" }) + yield* status.set(sessionID, { type: "idle" }, statusContext) return } yield* existing.cancel @@ -87,19 +93,21 @@ export const layer = Layer.effect( const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* ( sessionID: SessionID, + statusContext: SessionStatus.SetContext, onInterrupt: Effect.Effect, work: Effect.Effect, ) { - return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work) + return yield* (yield* runner(sessionID, statusContext, onInterrupt)).ensureRunning(work) }) const startShell = Effect.fn("SessionRunState.startShell")(function* ( sessionID: SessionID, + statusContext: SessionStatus.SetContext, onInterrupt: Effect.Effect, work: Effect.Effect, ready?: Latch.Latch, ) { - return yield* (yield* runner(sessionID, onInterrupt)) + return yield* (yield* runner(sessionID, statusContext, onInterrupt)) .startShell(work, ready) .pipe(Effect.catchTag("RunnerBusy", () => Effect.fail(busyError(sessionID)))) }) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index a7a6c5f87ef..a36c5bcc5ff 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -31,12 +31,18 @@ export const Info = Schema.Union([ ]).annotate({ identifier: "SessionStatus" }) export type Info = Schema.Schema.Type +export const SetContext = Schema.Struct({ + parentID: Schema.optional(SessionID), +}) +export type SetContext = Schema.Schema.Type + export const Event = { Status: EventV2.define({ type: "session.status", schema: { sessionID: SessionID, status: Info, + parentID: Schema.optional(SessionID), }, }), // deprecated @@ -51,7 +57,7 @@ export const Event = { export interface Interface { readonly get: (sessionID: SessionID) => Effect.Effect readonly list: () => Effect.Effect> - readonly set: (sessionID: SessionID, status: Info) => Effect.Effect + readonly set: (sessionID: SessionID, status: Info, context?: SetContext) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionStatus") {} @@ -74,9 +80,13 @@ export const layer = Layer.effect( return new Map(yield* InstanceState.get(state)) }) - const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) { + const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info, context?: SetContext) { const data = yield* InstanceState.get(state) - yield* events.publish(Event.Status, { sessionID, status }) + yield* events.publish(Event.Status, { + sessionID, + status, + ...(context?.parentID ? { parentID: context.parentID } : {}), + }) if (status.type === "idle") { yield* events.publish(Event.Idle, { sessionID }) data.delete(sessionID) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f04925b9825..00fa11df796 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -867,6 +867,40 @@ it.instance( 3_000, ) +it.instance( + "loop includes parentID on child session status events", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const events = yield* EventV2Bridge.Service + const ready = yield* Deferred.make() + + yield* llm.hang + + const parent = yield* sessions.create({}) + const child = yield* sessions.create({ parentID: parent.id }) + yield* user(child.id, "hi") + + const off = yield* events.listen((evt) => { + if (evt.type !== SessionStatus.Event.Status.type) return Effect.void + const data = evt.data as typeof SessionStatus.Event.Status.data.Type + if (data.sessionID !== child.id || data.status.type !== "busy") return Effect.void + if (data.parentID !== parent.id) return Effect.void + Deferred.doneUnsafe(ready, Effect.void) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) + + const fiber = yield* prompt.loop({ sessionID: child.id }).pipe(Effect.forkChild) + yield* Deferred.await(ready).pipe(Effect.timeout("2 seconds")) + yield* prompt.cancel(child.id) + yield* Fiber.await(fiber) + }), + 3_000, +) + // Cancel semantics it.instance( diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3be97a5cf90..c622666d51c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1188,6 +1188,7 @@ export type GlobalEvent = { properties: { sessionID: string status: SessionStatus + parentID?: string } } | { @@ -4275,6 +4276,7 @@ export type EventSessionStatus = { properties: { sessionID: string status: SessionStatus + parentID?: string } }