diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 636f9a535c..73ceacb99d 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -370,7 +370,11 @@ export const layer = Layer.effect( prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( Effect.gen(function* () { - yield* result.get(input.sessionID) + const session = yield* result.get(input.sessionID) + // A staged revert must be committed before admitting new input so the prompt + // continues from the reverted boundary rather than stale post-boundary history. + if (session.revert) + yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) const prompt = resolvePrompt(input.prompt) const messageID = input.id ?? SessionMessage.ID.create() const delivery = input.delivery ?? "steer" diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 18017e5052..abcdf70c4a 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -1,10 +1,12 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Fiber, Layer, Stream } from "effect" +import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { SessionEvent } from "@opencode-ai/core/session/event" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -110,6 +112,21 @@ const eventCount = (type: string) => ), ) +const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const assistantRow = (id: SessionMessage.ID, seq: number) => { + const { id: _, type, ...data } = encodeMessage( + SessionMessage.Assistant.make({ + id, + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [], + time: { created: DateTime.makeUnsafe(0) }, + }), + ) + return { id, session_id: sessionID, type, seq, time_created: 0, data } +} + describe("SessionV2.prompt", () => { it.effect("exposes the execution registry", () => Effect.gen(function* () { @@ -201,6 +218,39 @@ describe("SessionV2.prompt", () => { }), ) + it.effect("commits a staged revert before admitting a new prompt", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + + const boundary = yield* session.prompt({ + sessionID, + prompt: Prompt.make({ text: "boundary" }), + resume: false, + }) + yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) + const stale = SessionMessage.ID.make("msg_stale_assistant") + yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie) + yield* events.publish(SessionEvent.RevertEvent.Staged, { + sessionID, + timestamp: yield* DateTime.now, + revert: { messageID: boundary.id, files: [] }, + }) + expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id) + + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "after revert" }), resume: false }) + + expect((yield* session.get(sessionID)).revert).toBeUndefined() + expect( + (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all().pipe(Effect.orDie)).map( + (row) => row.id, + ), + ).not.toContain(stale) + }), + ) + it.effect("resolves attachment MIME before admission", () => Effect.gen(function* () { yield* setup