From 514e2ed0ae5d3339789b54d4277d36b48533ba80 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Fri, 21 Aug 2026 12:01:38 -0300 Subject: [PATCH] feat(opencode): V2 text.complete alongside V1 trigger Adds `SessionEvent.Text.Complete` (live waterfall event, type `session.next.text.complete`) carrying `{ sessionID, messageID, partID, text }` and wires `EventV2Bridge.waterfall` into the text-complete dispatch next to the existing V1 `plugin.trigger('experimental.text.complete', ...)`. The waterfall currently has a single listener that delegates to the V1 trigger so existing plugins keep working. Listeners return `{ text }` to transform the final text; throwing short-circuits the text part. Same shim pattern as #70 #72 #75 #76 #77 #78 #79. --- packages/opencode/src/session/processor.ts | 14 ++++++++++++-- packages/schema/src/session-event.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f5a8b74d76e6..3d1df77c0e9c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -23,6 +23,7 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionEvent } from "@opencode-ai/core/session/event" import { Database } from "@opencode-ai/core/database/database" import { Usage, type LLMEvent } from "@opencode-ai/llm" @@ -514,7 +515,7 @@ const layer = Layer.effect( if (!ctx.currentText) return // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.currentText.text = ctx.currentText.text - ctx.currentText.text = (yield* plugin.trigger( + const textCompleteV1 = yield* plugin.trigger( "experimental.text.complete", { sessionID: ctx.sessionID, @@ -522,7 +523,16 @@ const layer = Layer.effect( partID: ctx.currentText.id, }, { text: ctx.currentText.text }, - )).text + ) + const textCompleteV2 = yield* events.waterfall(SessionEvent.Text.Complete, [ + (_e, next) => + Effect.gen(function* () { + const downstream = yield* next() + const downstreamText = (downstream as { text?: string } | undefined)?.text + return { text: downstreamText ?? textCompleteV1.text } + }), + ]) + ctx.currentText.text = (textCompleteV2 as { text: string }).text { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 4155781ed5fa..ff292a27f72e 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -270,6 +270,24 @@ export namespace Text { }, }) export type Ended = typeof Ended.Type + + /** + * Live waterfall fired when a text part's stream completes, before the + * final value is persisted. Listeners return `{ text }` to transform the + * final text or throw to veto. This is the V2 replacement for the legacy + * V1 `experimental.text.complete` hook and gives plugins proper + * `next()`-based mutation semantics. + */ + export const Complete = Event.define({ + type: "session.next.text.complete", + schema: { + ...Base, + messageID: SessionMessage.ID, + partID: Schema.String, + text: Schema.String, + }, + }) + export type Complete = typeof Complete.Type } export namespace Reasoning {