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 {