From 67aeed381fa0a9ca990278347b817703c645cf34 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Fri, 21 Aug 2026 13:03:38 -0300 Subject: [PATCH 1/2] feat(opencode): turn lifecycle events (Turn.Started, Turn.End) Adds `SessionEvent.Turn.Started` and `SessionEvent.Turn.Ended` (live events, types `session.next.turn.started`/`session.next.turn.ended`) carrying `{ sessionID, timestamp }` (+ `finished` on Ended). Fires `Turn.Started` once per turn at the top of `runLoop` and `Turn.Ended` once when the loop exits. First two events of the agent-loop decomposition. They mark the natural lifecycle boundary of a turn (a user prompt may span multiple steps) and give V2 plugins / telemetry a clean entry/exit point without depending on the internal `while (true)` structure. Same shim pattern as the rest: V2 surface alongside the existing V1 flow, no breaking change. Next decomposition steps (separate PRs): `Step.Started`/`Step.Ended` per-step events, `LLM.Stream` already wired in schema, `Tools.Execute` event around the per-tool pipeline, and finally the per-step services replaceable via the plugin runtime. --- packages/opencode/src/session/prompt.ts | 15 +++++++++++++++ packages/schema/src/session-event.ts | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ade75c66c529..d9ae375a1114 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,5 +1,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { DateTime } from "effect" import path from "path" import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -1086,6 +1087,11 @@ const layer = Layer.effect( let step = 0 const session = yield* sessions.get(sessionID).pipe(Effect.orDie) + // Turn lifecycle: fire `Turn.Started` once per turn (before any step). + yield* events + .publish(SessionEvent.Turn.Started, { sessionID, timestamp: DateTime.makeUnsafe(Date.now()) }) + .pipe(Effect.ignore) + while (true) { yield* status.set(sessionID, { type: "busy" }) yield* Effect.logInfo("loop", { "session.id": sessionID, step }) @@ -1350,6 +1356,15 @@ const layer = Layer.effect( continue } + // Turn lifecycle: fire `Turn.Ended` once when the loop exits. + yield* events + .publish(SessionEvent.Turn.Ended, { + sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + finished: true, + }) + .pipe(Effect.ignore) + yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) return yield* lastAssistant(sessionID) }, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 9964b81f9083..c6aaa4046d5f 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -139,6 +139,31 @@ export const PromptAdmitted = Event.define({ }) export type PromptAdmitted = typeof PromptAdmitted.Type +export namespace Turn { + /** + * Live event fired once per turn, before any step runs. Marks the start of + * the agent loop processing a single user prompt (which may span multiple + * steps). Observation only — plugins use this for telemetry / turn-level + * bookkeeping. + */ + export const Started = Event.define({ + type: "session.next.turn.started", + schema: Base, + }) + export type Started = typeof Started.Type + + /** + * Live event fired once when a turn ends (loop exit, before the last + * assistant is returned). Marks the natural boundary for telemetry and + * for any plugin that wants to run a side effect at the end of a turn. + */ + export const Ended = Event.define({ + type: "session.next.turn.ended", + schema: { ...Base, finished: Schema.Boolean }, + }) + export type Ended = typeof Ended.Type +} + export const ContextUpdated = Event.define({ type: "session.next.context.updated", ...options, From 41de66555184dbea31aba5e8b27cb17bfb1b8890 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Fri, 21 Aug 2026 13:36:57 -0300 Subject: [PATCH 2/2] fix(opencode): settle turn lifecycle on every exit --- .changeset/turn-lifecycle-events.md | 5 ++ packages/opencode/src/session/prompt.ts | 32 +++++---- packages/opencode/test/session/prompt.test.ts | 69 +++++++++++++++++++ 3 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 .changeset/turn-lifecycle-events.md diff --git a/.changeset/turn-lifecycle-events.md b/.changeset/turn-lifecycle-events.md new file mode 100644 index 000000000000..02c24fa9d6c2 --- /dev/null +++ b/.changeset/turn-lifecycle-events.md @@ -0,0 +1,5 @@ +--- +"opencode": minor +--- + +Add observable start and end lifecycle events around each agent turn. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d9ae375a1114..5043eb479525 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" -import { DateTime } from "effect" import path from "path" import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -44,7 +43,7 @@ import { Truncate } from "@/tool/truncate" import { Image } from "@/image/image" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" +import { Cause, DateTime, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" @@ -1089,7 +1088,7 @@ const layer = Layer.effect( // Turn lifecycle: fire `Turn.Started` once per turn (before any step). yield* events - .publish(SessionEvent.Turn.Started, { sessionID, timestamp: DateTime.makeUnsafe(Date.now()) }) + .publish(SessionEvent.Turn.Started, { sessionID, timestamp: yield* DateTime.now }) .pipe(Effect.ignore) while (true) { @@ -1356,15 +1355,6 @@ const layer = Layer.effect( continue } - // Turn lifecycle: fire `Turn.Ended` once when the loop exits. - yield* events - .publish(SessionEvent.Turn.Ended, { - sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - finished: true, - }) - .pipe(Effect.ignore) - yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) return yield* lastAssistant(sessionID) }, @@ -1373,7 +1363,23 @@ 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)) + return yield* state.ensureRunning( + input.sessionID, + lastAssistant(input.sessionID), + runLoop(input.sessionID).pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + yield* events + .publish(SessionEvent.Turn.Ended, { + sessionID: input.sessionID, + timestamp: yield* DateTime.now, + finished: Exit.isSuccess(exit), + }) + .pipe(Effect.ignore) + }), + ), + ), + ) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn( diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5a0176abc9b0..d81d8dd0aaa9 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -40,6 +40,7 @@ import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionV2 } from "@opencode-ai/core/session" +import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" @@ -580,6 +581,74 @@ withMcpInstructions.instance( 15_000, ) +it.instance("loop emits successful turn lifecycle events", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const events = yield* EventV2Bridge.Service + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const lifecycle = new Array<{ type: string; finished?: boolean }>() + const off = yield* events.listen((event) => { + if (event.type === SessionEvent.Turn.Started.type) lifecycle.push({ type: event.type }) + if (event.type === SessionEvent.Turn.Ended.type) + lifecycle.push({ + type: event.type, + finished: (event.data as typeof SessionEvent.Turn.Ended.data.Type).finished, + }) + return Effect.void + }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.text("world") + + yield* prompt.loop({ sessionID: chat.id }) + yield* off + + expect(lifecycle).toEqual([ + { type: SessionEvent.Turn.Started.type }, + { type: SessionEvent.Turn.Ended.type, finished: true }, + ]) + }), +) + +it.instance("loop emits interrupted turn lifecycle events", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const events = yield* EventV2Bridge.Service + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const lifecycle = new Array<{ type: string; finished?: boolean }>() + const off = yield* events.listen((event) => { + if (event.type === SessionEvent.Turn.Started.type) lifecycle.push({ type: event.type }) + if (event.type === SessionEvent.Turn.Ended.type) + lifecycle.push({ + type: event.type, + finished: (event.data as typeof SessionEvent.Turn.Ended.data.Type).finished, + }) + return Effect.void + }) + yield* llm.hang + yield* user(chat.id, "hello") + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* awaitWithTimeout(llm.wait(1), "timed out waiting for turn request", "10 seconds") + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + yield* off + + expect(lifecycle).toEqual([ + { type: SessionEvent.Turn.Started.type }, + { type: SessionEvent.Turn.Ended.type, finished: false }, + ]) + }), +) + it.instance("legacy prompt emits message events without session.next events", () => Effect.gen(function* () { const events = yield* EventV2Bridge.Service