Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/turn-lifecycle-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode": minor
---

Add observable start and end lifecycle events around each agent turn.
25 changes: 23 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,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"
Expand Down Expand Up @@ -1086,6 +1086,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: yield* DateTime.now })
.pipe(Effect.ignore)

while (true) {
yield* status.set(sessionID, { type: "busy" })
yield* Effect.logInfo("loop", { "session.id": sessionID, step })
Expand Down Expand Up @@ -1358,7 +1363,23 @@ const layer = Layer.effect(
const loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts> = 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<SessionV1.WithParts, Session.BusyError> = Effect.fn(
Expand Down
69 changes: 69 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/schema/src/session-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading