From 522322980e2d5053b37cd19ef370b73af5be32a3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 31 May 2026 17:21:06 +0530 Subject: [PATCH 1/3] 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 f124f7eea4..20027f1e69 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 36e394f0aa..9b7ec9006f 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 1b92dce682..e71dec10fd 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 a7a6c5f87e..a36c5bcc5f 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 f04925b982..00fa11df79 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 3be97a5cf9..c622666d51 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 } } From fd14c00481e19de5e4ea970551b2019d8d22301b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 31 May 2026 19:03:31 +0530 Subject: [PATCH 2/3] revert(session): remove threaded 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, 23 insertions(+), 98 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20027f1e69..f124f7eea4 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -57,7 +57,6 @@ type Input = { assistantMessage: SessionLegacy.Assistant sessionID: SessionID model: Provider.Model - statusContext?: SessionStatus.SetContext } export interface Interface { @@ -778,7 +777,7 @@ export const layer = Layer.effect( sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, }) - yield* status.set(ctx.sessionID, { type: "idle" }, ctx.statusContext) + yield* status.set(ctx.sessionID, { type: "idle" }) }) const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { @@ -790,7 +789,7 @@ export const layer = Layer.effect( yield* Effect.gen(function* () { ctx.currentText = undefined ctx.reasoningMap = {} - yield* status.set(ctx.sessionID, { type: "busy" }, ctx.statusContext) + yield* status.set(ctx.sessionID, { type: "busy" }) const stream = llm.stream(streamInput) yield* stream.pipe( @@ -830,17 +829,13 @@ 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, - }, - ctx.statusContext, - ), + status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + action: info.action, + next: info.next, + }), ), ) }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9b7ec9006f..36e394f0aa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -140,8 +140,7 @@ export const layer = Layer.effect( const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* elog.info("cancel", { sessionID }) - const session = yield* sessions.get(sessionID).pipe(Effect.catchCause(() => Effect.succeed(undefined))) - yield* state.cancel(sessionID, session?.parentID ? { parentID: session.parentID } : undefined) + yield* state.cancel(sessionID) }) const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { @@ -1252,10 +1251,9 @@ 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" }, statusContext) + yield* status.set(sessionID, { type: "busy" }) yield* slog.info("loop", { step }) let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe( @@ -1383,7 +1381,6 @@ export const layer = Layer.effect( assistantMessage: msg, sessionID, model, - statusContext, }) .pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant)) @@ -1507,13 +1504,7 @@ export const layer = Layer.effect( const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")( function* (input: LoopInput) { - 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), - ) + return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID)) }, ) @@ -1521,14 +1512,7 @@ export const layer = Layer.effect( "SessionPrompt.shell", )(function* (input: ShellInput) { const ready = yield* Latch.make() - 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, - ) + return yield* state.startShell(input.sessionID, 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 e71dec10fd..1b92dce682 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -10,16 +10,14 @@ import { SessionStatus } from "./status" export interface Interface { readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect - readonly cancel: (sessionID: SessionID, statusContext?: SessionStatus.SetContext) => Effect.Effect + readonly cancel: (sessionID: SessionID) => 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, @@ -53,7 +51,6 @@ 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) @@ -62,9 +59,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" }, statusContext) + yield* status.set(sessionID, { type: "idle" }) }), - onBusy: status.set(sessionID, { type: "busy" }, statusContext), + onBusy: status.set(sessionID, { type: "busy" }), onInterrupt, }) data.runners.set(sessionID, next) @@ -77,15 +74,12 @@ export const layer = Layer.effect( if (existing?.busy) yield* busyError(sessionID) }) - const cancel = Effect.fn("SessionRunState.cancel")(function* ( - sessionID: SessionID, - statusContext?: SessionStatus.SetContext, - ) { + const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) { 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" }, statusContext) + yield* status.set(sessionID, { type: "idle" }) return } yield* existing.cancel @@ -93,21 +87,19 @@ 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, statusContext, onInterrupt)).ensureRunning(work) + return yield* (yield* runner(sessionID, 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, statusContext, onInterrupt)) + return yield* (yield* runner(sessionID, 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 a36c5bcc5f..a7a6c5f87e 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -31,18 +31,12 @@ 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 @@ -57,7 +51,7 @@ export const Event = { export interface Interface { readonly get: (sessionID: SessionID) => Effect.Effect readonly list: () => Effect.Effect> - readonly set: (sessionID: SessionID, status: Info, context?: SetContext) => Effect.Effect + readonly set: (sessionID: SessionID, status: Info) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionStatus") {} @@ -80,13 +74,9 @@ export const layer = Layer.effect( return new Map(yield* InstanceState.get(state)) }) - const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info, context?: SetContext) { + const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) { const data = yield* InstanceState.get(state) - yield* events.publish(Event.Status, { - sessionID, - status, - ...(context?.parentID ? { parentID: context.parentID } : {}), - }) + yield* events.publish(Event.Status, { sessionID, status }) 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 00fa11df79..f04925b982 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -867,40 +867,6 @@ 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 c622666d51..3be97a5cf9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1188,7 +1188,6 @@ export type GlobalEvent = { properties: { sessionID: string status: SessionStatus - parentID?: string } } | { @@ -4276,7 +4275,6 @@ export type EventSessionStatus = { properties: { sessionID: string status: SessionStatus - parentID?: string } } From c7faad0f8fdbcc098509daa6e72702eb7d106cde Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 31 May 2026 19:03:23 +0530 Subject: [PATCH 3/3] fix(session): enrich status events with parent id --- packages/opencode/src/session/status.ts | 13 +++++- .../opencode/test/session/compaction.test.ts | 2 +- .../test/session/processor-effect.test.ts | 16 +++++--- packages/opencode/test/session/prompt.test.ts | 40 ++++++++++++++++++- .../test/session/snapshot-tool-race.test.ts | 5 ++- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index a7a6c5f87e..d831202190 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -4,6 +4,8 @@ import { NonNegativeInt } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" +import { NotFoundError } from "@/storage/storage" +import { Session } from "./session" export const Info = Schema.Union([ Schema.Struct({ @@ -37,6 +39,7 @@ export const Event = { schema: { sessionID: SessionID, status: Info, + parentID: Schema.optional(SessionID), }, }), // deprecated @@ -60,6 +63,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service + const sessions = yield* Session.Service const state = yield* InstanceState.make( Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map())), @@ -76,7 +80,12 @@ export const layer = Layer.effect( const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) { const data = yield* InstanceState.get(state) - yield* events.publish(Event.Status, { sessionID, status }) + const session = yield* sessions.get(sessionID).pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.void)) + yield* events.publish(Event.Status, { + sessionID, + status, + ...(session?.parentID ? { parentID: session.parentID } : {}), + }) if (status.type === "idle") { yield* events.publish(Event.Idle, { sessionID }) data.delete(sessionID) @@ -89,6 +98,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) +export const defaultLayer = layer.pipe(Layer.provide(Session.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer)) export * as SessionStatus from "./status" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 9bff89c348..6981d98232 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -269,7 +269,7 @@ function withCompaction(options?: CompactionProcessOptions) { function compactionProcessLayer(options?: CompactionProcessOptions) { const events = EventV2Bridge.defaultLayer - const status = SessionStatus.layer.pipe(Layer.provide(events)) + const status = SessionStatus.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provide(events)) const processor = options?.llm ? SessionProcessorModule.SessionProcessor.layer.pipe( Layer.provide(summary), diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index e68ad962fe..72b941f00b 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -171,7 +171,10 @@ const assistant = Effect.fn("TestSession.assistant")(function* ( return msg }) -const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) +const status = SessionStatus.layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), +) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) const deps = Layer.mergeAll( Session.defaultLayer, @@ -588,15 +591,18 @@ it.live("session.processor effect tests publish retry status updates", () => yield* llm.error(503, { error: "boom" }) yield* llm.text("") - const chat = yield* session.create({}) + const root = yield* session.create({}) + const chat = yield* session.create({ parentID: root.id }) const parent = yield* user(chat.id, "retry") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const mdl = yield* provider.getModel(ref.providerID, ref.modelID) - const states: number[] = [] + const states: Array<{ attempt: number; parentID?: SessionID }> = [] 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 === chat.id && data.status.type === "retry") states.push(data.status.attempt) + if (data.sessionID === chat.id && data.status.type === "retry") { + states.push({ attempt: data.status.attempt, parentID: data.parentID }) + } return Effect.void }) const handle = yield* processors.create({ @@ -626,7 +632,7 @@ it.live("session.processor effect tests publish retry status updates", () => expect(value).toBe("continue") expect(yield* llm.calls).toBe(2) - expect(states).toStrictEqual([1]) + expect(states).toStrictEqual([{ attempt: 1, parentID: root.id }]) }), { config: (url) => providerCfg(url) }, ), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f04925b982..1fbf96c725 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -153,7 +153,10 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) +const status = SessionStatus.layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), +) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) @@ -867,6 +870,41 @@ it.instance( 3_000, ) +it.instance( + "loop status events identify child sessions through cancellation", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const events = yield* EventV2Bridge.Service + const statuses: Array = [] + + 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((event) => { + if (event.type !== SessionStatus.Event.Status.type) return Effect.void + const data = event.data as typeof SessionStatus.Event.Status.data.Type + if (data.sessionID === child.id) statuses.push(data) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) + + const fiber = yield* prompt.loop({ sessionID: child.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* prompt.cancel(child.id) + yield* Fiber.await(fiber) + + expect(statuses.some((event) => event.status.type === "idle")).toBe(true) + expect(statuses.every((event) => event.parentID === parent.id)).toBe(true) + }), + 3_000, +) + // Cancel semantics it.instance( diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index b5fed974a0..3215636173 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -109,7 +109,10 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) +const status = SessionStatus.layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), +) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)