From f6b780dbecdd852fccd10c40d147bcc1f58a16ba Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Jul 2026 11:47:44 -0400 Subject: [PATCH 1/3] feat(core): add transient session generation --- packages/core/src/location-services.ts | 2 + packages/core/src/session.ts | 15 + packages/core/src/session/context.ts | 22 +- packages/core/src/session/generate.ts | 49 ++++ packages/core/src/session/history.ts | 37 ++- .../core/src/session/instruction-state.ts | 81 +++++- packages/core/src/session/model-request.ts | 62 +++- packages/core/test/instruction-state.test.ts | 128 ++++++++ packages/core/test/session-generate.test.ts | 273 ++++++++++++++++++ plans/session-generate.md | 78 ++--- 10 files changed, 668 insertions(+), 79 deletions(-) create mode 100644 packages/core/src/session/generate.ts create mode 100644 packages/core/test/session-generate.test.ts diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 1af575595b54..6d614a412b74 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -39,6 +39,7 @@ import { InstructionDiscovery } from "./instruction-discovery" import { InstructionBuiltIns } from "./instructions/builtins" import { InstructionEntry } from "./session/instruction-entry" import { SessionInstructions } from "./session/instructions" +import { SessionGenerate } from "./session/generate" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" @@ -81,6 +82,7 @@ const locationServiceNodes = [ Form.node, QuestionV2.node, Generate.node, + SessionGenerate.node, ReadToolFileSystem.node, McpTool.node, SessionInstructions.node, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 34ff7ecfa920..ca7ae184737e 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -33,6 +33,7 @@ import { LocationServiceMap } from "./location-service-map" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionPending } from "./session/pending" +import type { Error as SessionGenerateError } from "./session/generate" import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" import { Session } from "@opencode-ai/schema/session" @@ -175,6 +176,7 @@ export type Error = | CommandV2.NotFoundError | CommandV2.EvaluationError | MessageNotFoundError + | SessionGenerateError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect<{ @@ -243,6 +245,11 @@ export interface Interface { delivery?: SessionPending.Delivery resume?: boolean }) => Effect.Effect + /** Generates text from current Session context without admitting input or mutating history. */ + readonly generate: (input: { + sessionID: SessionSchema.ID + prompt: string + }) => Effect.Effect readonly command: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -564,6 +571,14 @@ const layer = Layer.effect( }), ), ), + generate: Effect.fn("V2Session.generate")(function* (input) { + const session = yield* result.get(input.sessionID) + // PermissionV2 imports SessionV2 while the Location graph imports PermissionV2. + // Load the Location implementation lazily to avoid closing that module cycle. + const sessionGenerate = yield* Effect.promise(() => import("./session/generate")) + const generate = yield* sessionGenerate.Service.pipe(Effect.provide(locations.get(session.location))) + return yield* generate.generate(input) + }), command: Effect.fn("V2Session.command")(function* (input) { const session = yield* result.get(input.sessionID) const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location))) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 9bdf354731a0..eed584db4c29 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -44,6 +44,13 @@ export interface Interface { readonly select: (sessionID: SessionSchema.ID) => Effect.Effect /** Resolves the model and active history for that selection. */ readonly load: (selection: Selection) => Effect.Effect + /** Resolves current model context without committing instruction changes or promoting input. */ + readonly loadForGenerate: ( + selection: Selection, + ) => Effect.Effect< + Loaded & { readonly instructionDelta: string }, + Instructions.InitializationBlocked | SessionRunnerModel.Error + > } /** Location-scoped model-context loader for durable Session Steps. */ @@ -100,7 +107,20 @@ const layer = Layer.effect( } }) - return Service.of({ select, load }) + const loadForGenerate = Effect.fn("SessionContext.loadForGenerate")(function* (selection: Selection) { + const model = yield* models.resolve(selection.session) + const history = yield* SessionHistory.entriesForGenerate(db, selection.session.id, selection.instructions) + return { + session: selection.session, + agent: selection.agent, + model, + initial: history.initial, + messages: history.entries.map((entry) => entry.message), + instructionDelta: history.delta, + } + }) + + return Service.of({ select, load, loadForGenerate }) }), ) diff --git a/packages/core/src/session/generate.ts b/packages/core/src/session/generate.ts new file mode 100644 index 000000000000..c7da22c52dce --- /dev/null +++ b/packages/core/src/session/generate.ts @@ -0,0 +1,49 @@ +export * as SessionGenerate from "./generate" + +import { LLMClient, LLMError } from "@opencode-ai/ai" +import { Context, Effect, Layer } from "effect" +import { llmClient } from "../effect/app-node-platform" +import { makeLocationNode } from "../effect/app-node" +import { Instructions } from "../instructions" +import { AgentNotFoundError } from "./error" +import { SessionContext } from "./context" +import { SessionModelRequest } from "./model-request" +import { SessionRunnerModel } from "./runner/model" +import { SessionSchema } from "./schema" + +export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | LLMError + +export interface Interface { + /** Generates text from current Session context without mutating the Session. */ + readonly generate: (input: { + readonly sessionID: SessionSchema.ID + readonly prompt: string + }) => Effect.Effect +} + +/** Location-scoped transient generation from Session context. */ +export class Service extends Context.Service()("@opencode/v2/SessionGenerate") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const context = yield* SessionContext.Service + const requests = yield* SessionModelRequest.Service + const llm = yield* LLMClient.Service + + return Service.of({ + generate: Effect.fn("SessionGenerate.generate")(function* (input) { + const selection = yield* context.select(input.sessionID) + const loaded = yield* context.loadForGenerate(selection) + const request = yield* requests.prepareGenerate({ context: loaded, prompt: input.prompt }) + return (yield* llm.generate(request)).text + }), + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [SessionContext.node, SessionModelRequest.node, llmClient], +}) diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index c41671271f2e..10b0761b657c 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -60,6 +60,13 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) => ), ) +const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)) + return yield* Effect.forEach(rows, (row) => + decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))), + ) +}) + export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { return yield* Effect.forEach( yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)), @@ -75,10 +82,7 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun return yield* db .transaction(() => Effect.gen(function* () { - const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)) - const messages = yield* Effect.forEach(rows, (row) => - decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))), - ) + const messages = yield* messageEntries(db, sessionID) const assembled = yield* InstructionState.assemble(db, sessionID, instructions) return { initial: assembled.initial, @@ -89,6 +93,31 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun .pipe(Effect.orDie) }) +export const entriesForGenerate = Effect.fn("SessionHistory.entriesForGenerate")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + instructions: Instructions.Instructions, +) { + return yield* db + .transaction(() => + Effect.gen(function* () { + const messages = yield* messageEntries(db, sessionID) + // Do not append a transient user message after an unresolved assistant tool call. + const unsettled = messages.findIndex( + (entry) => entry.message.type === "assistant" && entry.message.finish === undefined, + ) + const settled = unsettled === -1 ? messages : messages.slice(0, unsettled) + const assembled = yield* InstructionState.assembleForGenerate(db, sessionID, instructions) + return { + initial: assembled.initial, + entries: [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq), + delta: assembled.delta, + } + }), + ) + .pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error)))) +}) + /** Returns the session's sole user message, or `undefined` once a second one exists. */ export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* ( db: DatabaseService, diff --git a/packages/core/src/session/instruction-state.ts b/packages/core/src/session/instruction-state.ts index eb50d16a2a30..54a32da56cfe 100644 --- a/packages/core/src/session/instruction-state.ts +++ b/packages/core/src/session/instruction-state.ts @@ -128,13 +128,7 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* ( yield* reset(db, sessionID) return undefined } - const state = { - session_id: sessionID, - epoch_start: folded.epochStart, - through_seq: folded.throughSeq, - initial_values: folded.initial, - current_values: folded.current, - } + const state = foldedState(sessionID, folded) yield* db .insert(InstructionStateTable) .values(state) @@ -152,13 +146,12 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* ( return state }) -export const assemble = Effect.fn("InstructionState.assemble")(function* ( +const assembleState = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, instructions: Instructions.Instructions, + state: typeof InstructionStateTable.$inferSelect, ) { - const state = yield* find(db, sessionID) - if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`)) const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start) const updates = rows.map((row) => ({ row, @@ -188,7 +181,41 @@ export const assemble = Effect.fn("InstructionState.assemble")(function* ( }) values = Instructions.applyDelta(values, delta) } - return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result } + return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result, current: values } +}) + +export const assemble = Effect.fn("InstructionState.assemble")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + instructions: Instructions.Instructions, +) { + const state = yield* find(db, sessionID) + if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`)) + const assembled = yield* assembleState(db, sessionID, instructions, state) + return { initial: assembled.initial, updates: assembled.updates } +}) + +export const assembleForGenerate = Effect.fn("InstructionState.assembleForGenerate")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + instructions: Instructions.Instructions, +) { + const state = yield* readState(db, sessionID) + const observed = yield* Instructions.read(instructions) + const admission = yield* Instructions.diff(observed, state?.current_values) + const blobs = new Map( + Object.entries(admission.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]), + ) + if (!state) { + const values = dereference(Instructions.applyHashDelta({}, admission.delta), blobs) + return { initial: Instructions.renderInitial(instructions, values), updates: [], delta: "" } + } + const assembled = yield* assembleState(db, sessionID, instructions, state) + return { + initial: assembled.initial, + updates: assembled.updates, + delta: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(admission.delta, blobs)), + } }) const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { @@ -203,7 +230,25 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const stored = yield* find(db, sessionID) if (!stored) return yield* rebuild(db, sessionID) - const latest = yield* db + const latest = yield* latestRelevantSequence(db, sessionID) + if (!latest || latest.seq <= stored.through_seq) return stored + return yield* rebuild(db, sessionID) +}) + +const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const stored = yield* find(db, sessionID) + if (!stored) { + const folded = fold(yield* instructionEvents(db, sessionID)) + return folded ? foldedState(sessionID, folded) : undefined + } + const latest = yield* latestRelevantSequence(db, sessionID) + if (!latest || latest.seq <= stored.through_seq) return stored + const folded = fold(yield* instructionEvents(db, sessionID)) + return folded ? foldedState(sessionID, folded) : undefined +}) + +const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return yield* db .select({ seq: EventTable.seq }) .from(EventTable) .where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes))) @@ -211,8 +256,6 @@ const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sess .limit(1) .get() .pipe(Effect.orDie) - if (!latest || latest.seq <= stored.through_seq) return stored - return yield* rebuild(db, sessionID) }) const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly>) { @@ -364,3 +407,13 @@ function fold(rows: ReadonlyArray) { : { epochStart: row.seq, throughSeq: row.seq, initial: current, current } }, undefined) } + +function foldedState(sessionID: SessionSchema.ID, folded: NonNullable>) { + return { + session_id: sessionID, + epoch_start: folded.epochStart, + through_seq: folded.throughSeq, + initial_values: folded.initial, + current_values: folded.current, + } +} diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 936c30591a91..33bdc939079f 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -26,6 +26,11 @@ interface PrepareInput { readonly step: number } +interface GenerateInput { + readonly context: SessionContext.Loaded & { readonly instructionDelta: string } + readonly prompt: string +} + /** * Builds an outbound model request and captures the tool-call capability that * must remain paired with it. It does not execute the request or mutate @@ -34,6 +39,8 @@ interface PrepareInput { export interface Interface { /** Builds one outbound model request and its matching tool-call capability. */ readonly prepare: (input: PrepareInput) => Effect.Effect + /** Builds one tool-free outbound request from a read-only Session snapshot. */ + readonly prepareGenerate: (input: GenerateInput) => Effect.Effect } /** Location-scoped outbound model-request preparation. */ @@ -45,20 +52,19 @@ const layer = Layer.effect( const hooks = yield* PluginHooks.Service const registry = yield* ToolRegistry.Service - const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { - const session = input.context.session - const agent = input.context.agent - const resolved = input.context.model + const build = Effect.fn("SessionModelRequest.build")(function* ( + context: SessionContext.Loaded, + messages: Array, + executableTools: ToolRegistry.Materialization | undefined, + ) { + const session = context.session + const agent = context.agent + const resolved = context.model const model = resolved.model - const providerMetadataKey = model.route.providerMetadataKey ?? model.provider - const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps - const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id - const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial] + const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, context.initial] .filter((part) => part.length > 0) .map(SystemPart.make) - const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) - const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history const toolDefinitions = executableTools?.definitions ?? [] const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool])) // Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit. @@ -87,7 +93,7 @@ const layer = Layer.effect( system: contextEvent.system, messages: contextEvent.messages, tools: hookedTools, - toolChoice: stepLimitReached ? "none" : undefined, + toolChoice: executableTools ? undefined : "none", }) const resolveToolCall = (name: string): ToolCallResolution => { if (!executableTools) @@ -108,7 +114,39 @@ const layer = Layer.effect( } }) - return Service.of({ prepare }) + const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { + const model = input.context.model.model + const providerMetadataKey = model.route.providerMetadataKey ?? model.provider + const stepLimitReached = + input.context.agent.info.steps !== undefined && input.step >= input.context.agent.info.steps + const executableTools = stepLimitReached + ? undefined + : yield* registry.materialize(input.context.agent.info.permissions) + const history = toLLMMessages(input.context.messages, input.context.model.ref, providerMetadataKey) + return yield* build( + input.context, + stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history, + executableTools, + ) + }) + + const prepareGenerate = Effect.fn("SessionModelRequest.prepareGenerate")(function* (input: GenerateInput) { + const model = input.context.model.model + const providerMetadataKey = model.route.providerMetadataKey ?? model.provider + const history = toLLMMessages(input.context.messages, input.context.model.ref, providerMetadataKey) + const prepared = yield* build( + input.context, + [ + ...history, + ...(input.context.instructionDelta ? [Message.system(input.context.instructionDelta)] : []), + Message.user(input.prompt), + ], + undefined, + ) + return prepared.request + }) + + return Service.of({ prepare, prepareGenerate }) }), ) diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts index 03a98c7dc5b5..d0049be1efd1 100644 --- a/packages/core/test/instruction-state.test.ts +++ b/packages/core/test/instruction-state.test.ts @@ -227,6 +227,134 @@ describe("InstructionState", () => { }), ) + it.effect("assembles a fresh private update without repairing a missing cache", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate") + const { db, events } = yield* setup(sessionID) + let value = "Initial context" + const instructions = source( + "test/context", + Effect.sync(() => value), + ) + yield* InstructionState.prepare(db, events, instructions, sessionID) + yield* db + .delete(InstructionStateTable) + .where(eq(InstructionStateTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) + value = "Changed context" + const beforeEvents = yield* instructionEvents(db, sessionID) + const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) + + const assembled = yield* InstructionState.assembleForGenerate(db, sessionID, instructions) + + expect(assembled).toEqual({ initial: "Initial context", updates: [], delta: "Changed context" }) + expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) + expect( + yield* db + .select() + .from(InstructionStateTable) + .where(eq(InstructionStateTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie), + ).toBeUndefined() + }), + ) + + it.effect("reads through a stale cache without repairing it", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale") + const { db, events } = yield* setup(sessionID) + let value = "Initial context" + const instructions = source( + "test/context", + Effect.sync(() => value), + ) + yield* InstructionState.prepare(db, events, instructions, sessionID) + value = "Committed update" + yield* InstructionState.prepare(db, events, instructions, sessionID) + yield* db + .update(InstructionStateTable) + .set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } }) + .where(eq(InstructionStateTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) + value = "Private update" + const beforeEvents = yield* instructionEvents(db, sessionID) + const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) + const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie) + + const assembled = yield* InstructionState.assembleForGenerate(db, sessionID, instructions) + + expect(assembled.initial).toBe("Initial context") + expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"]) + expect(assembled.delta).toBe("Private update") + expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState) + }), + ) + + it.effect("assembles initial instructions without persisting a baseline", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial") + const { db } = yield* setup(sessionID) + const instructions = source("test/context", Effect.succeed("Initial context")) + + expect(yield* InstructionState.assembleForGenerate(db, sessionID, instructions)).toEqual({ + initial: "Initial context", + updates: [], + delta: "", + }) + expect(yield* instructionEvents(db, sessionID)).toEqual([]) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([]) + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined() + }), + ) + + it.effect("retains a committed value when fresh instructions are unavailable", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate_unavailable") + const { db, events } = yield* setup(sessionID) + let value: string | Instructions.Unavailable = "Committed context" + const instructions = source( + "test/context", + Effect.sync(() => value), + ) + yield* InstructionState.prepare(db, events, instructions, sessionID) + value = Instructions.unavailable + const beforeEvents = yield* instructionEvents(db, sessionID) + const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) + const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie) + + expect(yield* InstructionState.assembleForGenerate(db, sessionID, instructions)).toEqual({ + initial: "Committed context", + updates: [], + delta: "", + }) + expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState) + }), + ) + + it.effect("blocks an unavailable initial instruction without persisting a baseline", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate_blocked") + const { db } = yield* setup(sessionID) + const instructions = source("test/context", Effect.succeed(Instructions.unavailable)) + + const error = yield* InstructionState.assembleForGenerate(db, sessionID, instructions).pipe(Effect.flip) + + expect(error).toBeInstanceOf(Instructions.InitializationBlocked) + expect(error.keys).toEqual([Instructions.Key.make("test/context")]) + expect(yield* instructionEvents(db, sessionID)).toEqual([]) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([]) + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined() + }), + ) + it.effect("keeps prepare equivalent to observe followed by commit", () => Effect.gen(function* () { const observedSessionID = SessionSchema.ID.make("ses_instruction_composed") diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts new file mode 100644 index 000000000000..853284309cba --- /dev/null +++ b/packages/core/test/session-generate.test.ts @@ -0,0 +1,273 @@ +import { expect } from "bun:test" +import { LLMClient, LLMEvent, LLMResponse, Model, type LLMRequest } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { llmClient } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" +import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" +import { Instructions } from "@opencode-ai/core/instructions" +import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins" +import { Location } from "@opencode-ai/core/location" +import { McpInstructions } from "@opencode-ai/core/mcp/instructions" +import { ModelV2 } from "@opencode-ai/core/model" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionGenerate } from "@opencode-ai/core/session/generate" +import { InstructionState } from "@opencode-ai/core/session/instruction-state" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { + InstructionBlobTable, + InstructionStateTable, + SessionMessageTable, + SessionPendingTable, + SessionTable, +} from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SkillInstructions } from "@opencode-ai/core/skill/instructions" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { asc, eq } from "drizzle-orm" +import { Effect, Layer, Schema, Stream } from "effect" +import { testEffect } from "./lib/effect" + +const requests: LLMRequest[] = [] +let instruction: string | Instructions.Unavailable = "Initial context" +const sessionID = SessionSchema.ID.make("ses_generate_test") + +const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route }) +const client = Layer.mock(LLMClient.Service)({ + prepare: () => Effect.die(new Error("unused")), + stream: () => Stream.die(new Error("unused")), + generate: (request) => + Effect.sync(() => { + requests.push(request) + const response = LLMResponse.fromEvents([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "generate" }), + LLMEvent.textDelta({ id: "generate", text: "Transient answer" }), + LLMEvent.textEnd({ id: "generate" }), + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }), + LLMEvent.finish({ reason: "stop" }), + ]) + if (!response) throw new Error("Incomplete generate response") + return response + }), +}) +const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) +const builtins = Layer.mock(InstructionBuiltIns.Service, { + load: () => + Effect.succeed( + Instructions.make({ + key: Instructions.Key.make("test/context"), + codec: Schema.toCodecJson(Schema.String), + read: Effect.sync(() => instruction), + render: { + initial: String, + changed: (_previous, current) => current, + }, + }), + ), +}) +const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) +const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) +const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) +const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) +const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void }) +const tools = Layer.mock(ToolRegistry.Service, { + register: () => Effect.die(new Error("unused")), + materialize: () => Effect.die(new Error("transient generation must not materialize tools")), +}) + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionProjector.node, + SessionStore.node, + AgentV2.node, + InstructionBuiltIns.node, + SessionGenerate.node, + ]), + [ + [llmClient, client], + [SessionRunnerModel.node, models], + [InstructionBuiltIns.node, builtins], + [InstructionDiscovery.node, discovery], + [SkillInstructions.node, skills], + [ReferenceInstructions.node, references], + [McpInstructions.node, mcp], + [PluginSupervisor.node, plugins], + [ToolRegistry.node, tools], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + ], + ), +) + +const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID) => + Effect.all({ + sequence: EventV2.latestSequence(db, sessionID), + events: db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, sessionID)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie), + messages: db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, sessionID)) + .orderBy(asc(SessionMessageTable.seq)) + .all() + .pipe(Effect.orDie), + pending: db + .select() + .from(SessionPendingTable) + .where(eq(SessionPendingTable.session_id, sessionID)) + .orderBy(asc(SessionPendingTable.admitted_seq)) + .all() + .pipe(Effect.orDie), + instructions: db + .select() + .from(InstructionStateTable) + .where(eq(InstructionStateTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie), + blobs: db.select().from(InstructionBlobTable).orderBy(asc(InstructionBlobTable.hash)).all().pipe(Effect.orDie), + session: db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie), + }) + +const userTexts = (request: LLMRequest) => + request.messages.flatMap((message) => + message.role === "user" + ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) + : [], + ) + +const setup = Effect.gen(function* () { + const { db } = yield* Database.Service + const events = yield* EventV2.Service + const agents = yield* AgentV2.Service + const instructionBuiltIns = yield* InstructionBuiltIns.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("build"), (agent) => { + agent.mode = "primary" + }), + ) + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "generate-test", + directory: "/project", + title: "Generate test", + version: "test", + agent: AgentV2.ID.make("build"), + }) + .run() + .pipe(Effect.orDie) + return { db, events, instructions: yield* instructionBuiltIns.load(sessionID) } +}) + +it.effect("generates from fresh settled Session context without durable mutation", () => + Effect.gen(function* () { + requests.length = 0 + instruction = "Initial context" + const { db, events, instructions } = yield* setup + yield* InstructionState.prepare(db, events, instructions, sessionID) + const existing = SessionMessage.ID.create() + yield* events.publish(SessionEvent.InputAdmitted, { + sessionID, + inputID: existing, + input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" }, + }) + yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing }) + const activeAssistant = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { + sessionID, + assistantMessageID: activeAssistant, + agent: AgentV2.ID.make("build"), + model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") }, + }) + yield* events.publish(SessionEvent.Tool.Input.Started, { + sessionID, + assistantMessageID: activeAssistant, + callID: "active-call", + name: "echo", + }) + yield* events.publish(SessionEvent.Tool.Input.Ended, { + sessionID, + assistantMessageID: activeAssistant, + callID: "active-call", + text: "{}", + }) + yield* events.publish(SessionEvent.Tool.Called, { + sessionID, + assistantMessageID: activeAssistant, + callID: "active-call", + input: {}, + executed: false, + }) + yield* events.publish(SessionEvent.InputAdmitted, { + sessionID, + inputID: SessionMessage.ID.create(), + input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" }, + }) + instruction = "Changed context" + const before = yield* durableState(db, sessionID) + + const generate = yield* SessionGenerate.Service + const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" }) + + expect(result).toBe("Transient answer") + expect(requests).toHaveLength(1) + expect(requests[0]?.model).toBe(model) + expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context") + expect( + requests[0]?.messages.flatMap((message) => + message.role === "system" + ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) + : [], + ), + ).toEqual(["Changed context"]) + expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"]) + expect(requests[0]?.messages.some((message) => message.role === "assistant")).toBe(false) + expect(requests[0]?.tools).toEqual([]) + expect(requests[0]?.toolChoice).toMatchObject({ type: "none" }) + expect(yield* durableState(db, sessionID)).toEqual(before) + }), +) + +it.effect("blocks unavailable initial instructions before generation", () => + Effect.gen(function* () { + requests.length = 0 + instruction = Instructions.unavailable + const { db } = yield* setup + const before = yield* durableState(db, sessionID) + const generate = yield* SessionGenerate.Service + + const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(Instructions.InitializationBlocked) + expect(requests).toEqual([]) + expect(yield* durableState(db, sessionID)).toEqual(before) + }), +) diff --git a/plans/session-generate.md b/plans/session-generate.md index e7bb955b009b..fd8aa375a94b 100644 --- a/plans/session-generate.md +++ b/plans/session-generate.md @@ -1,19 +1,16 @@ # Session-Aware One-Shot Generation Plan -Status: **Proposed** +Status: **In progress** ## Decision Add a Session operation that prepares one request from the Session's active model context, appends a transient prompt, executes exactly one Physical Attempt, returns the assistant text, and leaves the Session unchanged: ```ts -const { text } = await client.session.generate({ - sessionID, - text: "Summarize where we left off.", -}) +const text = yield * session.generate({ sessionID, prompt: "Summarize where we left off." }) ``` -The operation belongs to Session because its meaning depends on Session History, selected agent and model, instructions, tools, provider transforms, hooks, and prompt-cache identity. The existing stateless `Generate.text` module remains unaware of Session. +The operation belongs to Session because its meaning depends on Session History, the selected agent and model, instructions, provider transforms, hooks, and prompt-cache identity. The existing stateless `Generate.text` module remains unaware of Session. This feature should deepen the Session request-preparation module rather than add a second approximation of the runner. The durable runner and `session.generate` must share preparation, then diverge before provider output acquires durable consequences. @@ -38,14 +35,14 @@ The desired architecture makes request preparation independently callable while session.prompt -> SessionAdmission -> SessionExecution - -> SessionContext.snapshot + -> SessionContext.select/load -> SessionModelRequest.prepare -> LLMClient.stream -> SessionSettlement session.generate - -> SessionContext.snapshot - -> SessionModelRequest.prepare + -> SessionContext.select/loadForGenerate + -> SessionModelRequest.prepareGenerate -> LLMClient.generate -> return text ``` @@ -63,22 +60,22 @@ Durability becomes a property of admission and settlement, not request preparati ## The Core Seam -The first extraction should be one internal Location-scoped module with a small interface. Names are provisional; behavior is not. +The Location-scoped request module exposes two concrete methods rather than a generic operation protocol: ```ts interface SessionModelRequest { - readonly prepare: (input: { - snapshot: SessionContext.Snapshot - operation: { type: "step"; current: number; maximum?: number } | { type: "generate"; prompt: Message.User } - }) => Effect + readonly prepare: (input: { context: SessionContext.Loaded; step: number }) => Effect + readonly prepareGenerate: (input: { + context: SessionContext.Loaded & { instructionDelta: string } + prompt: string + }) => Effect } ``` ```ts type PreparedSessionModelRequest = { request: LLM.Request - snapshot: SessionContext.Snapshot - executableTools?: MaterializedTools + resolveToolCall: (name: string) => ToolCallResolution } ``` @@ -91,18 +88,11 @@ type PreparedSessionModelRequest = { - active-history selection after the latest completed compaction; - conversion to provider messages; - provider system prompts and model headers; -- tool permission filtering and definition materialization; +- tool permission filtering and definition materialization for durable Steps; - provider transforms and Session context hooks; - Session-based prompt-cache identity. -The operation determines tool capability and request shape: - -- `step` advertises tools and returns the execution capability used by durable settlement, except when the agent's Step limit disables tools. -- `generate` advertises the same definitions but returns no execution capability. - -`session.generate` keeps normal tool choice so providers retain the normal request and cache shape. Several protocols omit tool definitions entirely when `toolChoice` is `none`, so that setting cannot satisfy cache-shape parity. A generated tool call is collected but never executed or continued. - -Avoid a broad bag of booleans or independently selectable tool modes. The operation discriminant derives valid tool materialization and request behavior. Admission, compaction, attempts, and settlement remain separate modules rather than modes hidden inside preparation. +Durable `prepare` advertises permitted tools and returns the capability used by settlement, except when the agent's Step limit disables tools. `prepareGenerate` does not materialize or advertise tools and sets tool choice to `none`, independently of agent permissions. Avoid a generic `step | generate | compaction` operation union or independently selectable behavior flags; the two concrete methods encode the only supported request shapes. ## Read-Only Session Context @@ -117,17 +107,19 @@ commit the canonical model's instruction state durable Both a durable Step and `session.generate` resolve and assemble instructions. Only durable execution commits instruction-state changes. A transient request must not make the false durable claim that the canonical Session model saw an instruction update. -The context snapshot should be loaded from one consistent database view and carry a revision, likely the latest aggregate or projected sequence used to assemble it. A concurrent durable Step may advance the Session after that point; the transient request continues against its immutable snapshot. +The active history and committed instruction state are loaded from one consistent database view. A concurrent durable Step may advance the Session afterward; the already prepared request remains immutable. No reusable snapshot or revision protocol is needed for this operation. Pending inputs remain excluded. They are not Session History until promotion, and `session.generate` must not alter admission order or expose queued work early. +If the Session currently has an unsettled assistant message, generation stops history at that boundary. In particular, it never appends the transient user prompt after an unresolved tool call. + ## One Physical Attempt Without Settlement Use the existing `LLMClient` interface directly. The durable runner consumes `llm.stream(prepared.request)`, while `session.generate` calls `llm.generate(prepared.request)` to collect the same event stream into its existing `LLMResponse` model. No additional provider-attempt module is needed. `LLMClient.generate` collects exactly one provider stream. It does not retry as a new logical Step, execute tools, continue after tool calls, publish Session events, capture filesystem snapshots, or update Session usage. -The initial public result exposes only `{ text }`. Keeping richer evidence internal avoids prematurely committing the public contract while allowing tests to verify tool-call and finish behavior. +The internal result is the collected text string. Keeping richer evidence internal avoids prematurely committing a public transport contract. If a provider returns tool calls, collection records and ignores them. No tool hook or execution path runs. Assistant text, including an empty string, remains a successful result. Empty text is required for cache-warming calls. @@ -147,32 +139,22 @@ The first contract should state: The operation does not acquire ownership of the durable Session Drain and does not fail merely because the Session is running. This keeps transient generation independent from durable scheduling. -The prepared result carries the captured revision internally. A later recap integration can suppress stale output when the Session advances while generation runs. Returning the revision publicly can follow if more consumers need compare-and-display behavior. +A later recap integration can suppress stale output by comparing the Session aggregate sequence captured by that caller. Core does not expose a generic revision protocol before a concrete consumer needs one. ## Hooks Follow The Stage They Affect -The existing Session context hook should run because it participates in normal request preparation. Its event should eventually identify the operation: - -```ts -type SessionModelRequestOperation = { type: "step"; step: number } | { type: "generate" } | { type: "compaction" } -``` - -Request preparation and observation hooks run for `session.generate`. Admission, projection, Session settlement, and tool-execution hooks do not run because those stages do not occur. +The existing Session context hook runs because it participates in normal request preparation. Admission, projection, Session settlement, and tool-execution hooks do not run because those stages do not occur. Add operation metadata only when a concrete hook consumer needs it; do not introduce a speculative operation union. The no-mutation guarantee covers OpenCode's durable Session state. Arbitrary plugin hooks may still perform external side effects. -## Public Contract +## Internal Contract Start with the smallest useful interface: ```ts type SessionGenerateInput = { sessionID: SessionID - text: string -} - -type SessionGenerateOutput = { - text: string + prompt: string } ``` @@ -180,9 +162,9 @@ The operation: 1. Resolves the Session or returns the normal Session-not-found error. 2. Captures its latest committed active model context. -3. Uses the selected agent, model, instructions, provider configuration, tools, transforms, hooks, and Session cache key. -4. Appends `text` only to the in-memory provider request. -5. Executes exactly one Physical Attempt with normal tool definitions and no executable tool capability. +3. Uses the selected agent, model, instructions, provider configuration, transforms, hooks, and Session cache key. +4. Appends `prompt` only to the in-memory provider request. +5. Executes exactly one Physical Attempt with tools disabled. 6. Returns collected assistant text, including an empty string. 7. Does not admit input, publish Session events, execute tools, initiate compaction, update usage, or mutate Session projections. @@ -229,7 +211,7 @@ promote -> snapshot -> compact if required -> prepare -> attempt -> settle ### 5. Add The Core `Session.generate` Operation -Use read-only snapshot and request preparation, append one transient user message, select advertise-only tools, collect one attempt, and return text. Add tests proving that messages, pending inputs, instruction state, Session events, snapshots, and usage remain unchanged. +Use read-only context and request preparation, append one transient user message, disable tools, collect one attempt, and return text. Add tests proving that messages, pending inputs, instruction state, Session events, and usage remain unchanged. Test concurrent Session advancement with deterministic synchronization around request dispatch. The generated request should retain its captured context while the source Session advances independently. @@ -248,11 +230,11 @@ The implementation is complete when tests establish these laws: 1. **Request equivalence:** the durable runner's prepared request remains equivalent before and after extraction. 2. **Transcript immutability:** `session.generate` leaves Session messages and pending inputs unchanged. 3. **Instruction immutability:** transient generation does not advance instruction state or publish instruction events. -4. **Single attempt:** one call produces exactly one `llm.stream` invocation and no continuation. -5. **No tool execution:** advertised tool calls never reach tool settlement or tool hooks. +4. **Single attempt:** one call produces exactly one `llm.generate` invocation and no continuation. +5. **No tools:** transient requests advertise no tools and never reach tool settlement or tool hooks. 6. **Cache identity:** normal Steps and transient generation use the same Session-derived prompt-cache key. 7. **Hook parity:** Session request hooks see and may transform transient requests through the same preparation seam. -8. **Empty success:** a provider response with no assistant text returns `{ text: "" }`. +8. **Empty success:** a provider response with no assistant text returns `""`. 9. **Snapshot isolation:** concurrent Session advancement does not change an already prepared transient request. 10. **No accounting mutation:** transient usage does not alter durable Session cost or token totals. From 92763d0ec0a3748c372411bad6459704b25ec838 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Jul 2026 12:08:36 -0400 Subject: [PATCH 2/3] refactor(core): deepen transient session generation --- packages/core/src/session/context.ts | 22 +------ packages/core/src/session/generate.ts | 46 ++++++++++++-- packages/core/src/session/history.ts | 19 +++--- .../core/src/session/instruction-state.ts | 49 ++++++++------- packages/core/src/session/model-request.ts | 62 ++++--------------- packages/core/test/instruction-state.test.ts | 23 ++++--- packages/core/test/session-generate.test.ts | 45 +++++++++++--- plans/session-generate.md | 18 +++--- 8 files changed, 149 insertions(+), 135 deletions(-) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index eed584db4c29..9bdf354731a0 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -44,13 +44,6 @@ export interface Interface { readonly select: (sessionID: SessionSchema.ID) => Effect.Effect /** Resolves the model and active history for that selection. */ readonly load: (selection: Selection) => Effect.Effect - /** Resolves current model context without committing instruction changes or promoting input. */ - readonly loadForGenerate: ( - selection: Selection, - ) => Effect.Effect< - Loaded & { readonly instructionDelta: string }, - Instructions.InitializationBlocked | SessionRunnerModel.Error - > } /** Location-scoped model-context loader for durable Session Steps. */ @@ -107,20 +100,7 @@ const layer = Layer.effect( } }) - const loadForGenerate = Effect.fn("SessionContext.loadForGenerate")(function* (selection: Selection) { - const model = yield* models.resolve(selection.session) - const history = yield* SessionHistory.entriesForGenerate(db, selection.session.id, selection.instructions) - return { - session: selection.session, - agent: selection.agent, - model, - initial: history.initial, - messages: history.entries.map((entry) => entry.message), - instructionDelta: history.delta, - } - }) - - return Service.of({ select, load, loadForGenerate }) + return Service.of({ select, load }) }), ) diff --git a/packages/core/src/session/generate.ts b/packages/core/src/session/generate.ts index c7da22c52dce..dc44b1b0386e 100644 --- a/packages/core/src/session/generate.ts +++ b/packages/core/src/session/generate.ts @@ -1,14 +1,19 @@ export * as SessionGenerate from "./generate" -import { LLMClient, LLMError } from "@opencode-ai/ai" +import { LLM, LLMClient, LLMError, Message, SystemPart } from "@opencode-ai/ai" import { Context, Effect, Layer } from "effect" +import { Database } from "../database/database" import { llmClient } from "../effect/app-node-platform" import { makeLocationNode } from "../effect/app-node" import { Instructions } from "../instructions" +import { PluginHooks } from "../plugin/hooks" import { AgentNotFoundError } from "./error" import { SessionContext } from "./context" -import { SessionModelRequest } from "./model-request" +import { SessionHistory } from "./history" +import { SessionModelHeaders } from "./model-headers" import { SessionRunnerModel } from "./runner/model" +import PROMPT_DEFAULT from "./runner/prompt/base.txt" +import { toLLMMessages } from "./runner/to-llm-message" import { SessionSchema } from "./schema" export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | LLMError @@ -28,14 +33,43 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const context = yield* SessionContext.Service - const requests = yield* SessionModelRequest.Service + const database = yield* Database.Service + const hooks = yield* PluginHooks.Service const llm = yield* LLMClient.Service + const models = yield* SessionRunnerModel.Service return Service.of({ generate: Effect.fn("SessionGenerate.generate")(function* (input) { const selection = yield* context.select(input.sessionID) - const loaded = yield* context.loadForGenerate(selection) - const request = yield* requests.prepareGenerate({ context: loaded, prompt: input.prompt }) + const model = yield* models.resolve(selection.session) + const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions) + const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider + const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id) + ? selection.session.id.slice(4) + : selection.session.id + const contextEvent = yield* hooks.trigger("session", "context", { + sessionID: selection.session.id, + agent: selection.agent.id, + model: model.ref, + system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial] + .filter((part) => part.length > 0) + .map(SystemPart.make), + messages: [ + ...toLLMMessages(history.messages, model.ref, providerMetadataKey), + ...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []), + Message.user(input.prompt), + ], + tools: {}, + }) + const request = LLM.request({ + model: model.model, + http: { headers: SessionModelHeaders.make(selection.session) }, + providerOptions: { openai: { promptCacheKey } }, + system: contextEvent.system, + messages: contextEvent.messages, + tools: [], + toolChoice: "none", + }) return (yield* llm.generate(request)).text }), }) @@ -45,5 +79,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [SessionContext.node, SessionModelRequest.node, llmClient], + deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient], }) diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index 10b0761b657c..8cc4ceaed0a5 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -68,10 +68,7 @@ const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, session }) export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { - return yield* Effect.forEach( - yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)), - decodeMessageRow, - ) + return (yield* messageEntries(db, sessionID)).map((entry) => entry.message) }) export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* ( @@ -93,25 +90,27 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun .pipe(Effect.orDie) }) -export const entriesForGenerate = Effect.fn("SessionHistory.entriesForGenerate")(function* ( +export const preview = Effect.fn("SessionHistory.preview")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, instructions: Instructions.Instructions, ) { + const observed = yield* Instructions.read(instructions) return yield* db .transaction(() => Effect.gen(function* () { const messages = yield* messageEntries(db, sessionID) - // Do not append a transient user message after an unresolved assistant tool call. + // An active assistant may contain an unresolved tool call, so only preview the settled prefix. const unsettled = messages.findIndex( - (entry) => entry.message.type === "assistant" && entry.message.finish === undefined, + (entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined, ) const settled = unsettled === -1 ? messages : messages.slice(0, unsettled) - const assembled = yield* InstructionState.assembleForGenerate(db, sessionID, instructions) + const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed) + const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq) return { initial: assembled.initial, - entries: [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq), - delta: assembled.delta, + messages: entries.map((entry) => entry.message), + instructionUpdate: assembled.update, } }), ) diff --git a/packages/core/src/session/instruction-state.ts b/packages/core/src/session/instruction-state.ts index 54a32da56cfe..1d6127b6deea 100644 --- a/packages/core/src/session/instruction-state.ts +++ b/packages/core/src/session/instruction-state.ts @@ -29,12 +29,11 @@ export const observe = Effect.fn("InstructionState.observe")(function* ( const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], { concurrency: "unbounded", }) - const admission = yield* Instructions.diff(observed, stored?.current_values) + const result = yield* observeAgainst(observed, stored?.current_values) return { sessionID, initial: !stored, - current: Instructions.applyHashDelta(stored?.current_values ?? {}, admission.delta), - ...admission, + ...result, } }) @@ -123,22 +122,21 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, ) { - const folded = fold(yield* instructionEvents(db, sessionID)) - if (!folded) { + const state = yield* stateFromEvents(db, sessionID) + if (!state) { yield* reset(db, sessionID) return undefined } - const state = foldedState(sessionID, folded) yield* db .insert(InstructionStateTable) .values(state) .onConflictDoUpdate({ target: InstructionStateTable.session_id, set: { - epoch_start: folded.epochStart, - through_seq: folded.throughSeq, - initial_values: folded.initial, - current_values: folded.current, + epoch_start: state.epoch_start, + through_seq: state.through_seq, + initial_values: state.initial_values, + current_values: state.current_values, }, }) .run() @@ -195,26 +193,34 @@ export const assemble = Effect.fn("InstructionState.assemble")(function* ( return { initial: assembled.initial, updates: assembled.updates } }) -export const assembleForGenerate = Effect.fn("InstructionState.assembleForGenerate")(function* ( +export const preview = Effect.fn("InstructionState.preview")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, instructions: Instructions.Instructions, + observed: Instructions.ReadResult, ) { const state = yield* readState(db, sessionID) - const observed = yield* Instructions.read(instructions) - const admission = yield* Instructions.diff(observed, state?.current_values) + const result = yield* observeAgainst(observed, state?.current_values) const blobs = new Map( - Object.entries(admission.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]), + Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]), ) if (!state) { - const values = dereference(Instructions.applyHashDelta({}, admission.delta), blobs) - return { initial: Instructions.renderInitial(instructions, values), updates: [], delta: "" } + const values = dereference(result.current, blobs) + return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" } } const assembled = yield* assembleState(db, sessionID, instructions, state) return { initial: assembled.initial, updates: assembled.updates, - delta: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(admission.delta, blobs)), + update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)), + } +}) + +const observeAgainst = Effect.fnUntraced(function* (observed: Instructions.ReadResult, previous?: Instructions.Values) { + const admission = yield* Instructions.diff(observed, previous) + return { + current: Instructions.applyHashDelta(previous ?? {}, admission.delta), + ...admission, } }) @@ -237,12 +243,13 @@ const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sess const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const stored = yield* find(db, sessionID) - if (!stored) { - const folded = fold(yield* instructionEvents(db, sessionID)) - return folded ? foldedState(sessionID, folded) : undefined - } + if (!stored) return yield* stateFromEvents(db, sessionID) const latest = yield* latestRelevantSequence(db, sessionID) if (!latest || latest.seq <= stored.through_seq) return stored + return yield* stateFromEvents(db, sessionID) +}) + +const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const folded = fold(yield* instructionEvents(db, sessionID)) return folded ? foldedState(sessionID, folded) : undefined }) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 33bdc939079f..936c30591a91 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -26,11 +26,6 @@ interface PrepareInput { readonly step: number } -interface GenerateInput { - readonly context: SessionContext.Loaded & { readonly instructionDelta: string } - readonly prompt: string -} - /** * Builds an outbound model request and captures the tool-call capability that * must remain paired with it. It does not execute the request or mutate @@ -39,8 +34,6 @@ interface GenerateInput { export interface Interface { /** Builds one outbound model request and its matching tool-call capability. */ readonly prepare: (input: PrepareInput) => Effect.Effect - /** Builds one tool-free outbound request from a read-only Session snapshot. */ - readonly prepareGenerate: (input: GenerateInput) => Effect.Effect } /** Location-scoped outbound model-request preparation. */ @@ -52,19 +45,20 @@ const layer = Layer.effect( const hooks = yield* PluginHooks.Service const registry = yield* ToolRegistry.Service - const build = Effect.fn("SessionModelRequest.build")(function* ( - context: SessionContext.Loaded, - messages: Array, - executableTools: ToolRegistry.Materialization | undefined, - ) { - const session = context.session - const agent = context.agent - const resolved = context.model + const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { + const session = input.context.session + const agent = input.context.agent + const resolved = input.context.model const model = resolved.model + const providerMetadataKey = model.route.providerMetadataKey ?? model.provider + const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps + const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id - const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, context.initial] + const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial] .filter((part) => part.length > 0) .map(SystemPart.make) + const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) + const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history const toolDefinitions = executableTools?.definitions ?? [] const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool])) // Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit. @@ -93,7 +87,7 @@ const layer = Layer.effect( system: contextEvent.system, messages: contextEvent.messages, tools: hookedTools, - toolChoice: executableTools ? undefined : "none", + toolChoice: stepLimitReached ? "none" : undefined, }) const resolveToolCall = (name: string): ToolCallResolution => { if (!executableTools) @@ -114,39 +108,7 @@ const layer = Layer.effect( } }) - const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { - const model = input.context.model.model - const providerMetadataKey = model.route.providerMetadataKey ?? model.provider - const stepLimitReached = - input.context.agent.info.steps !== undefined && input.step >= input.context.agent.info.steps - const executableTools = stepLimitReached - ? undefined - : yield* registry.materialize(input.context.agent.info.permissions) - const history = toLLMMessages(input.context.messages, input.context.model.ref, providerMetadataKey) - return yield* build( - input.context, - stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history, - executableTools, - ) - }) - - const prepareGenerate = Effect.fn("SessionModelRequest.prepareGenerate")(function* (input: GenerateInput) { - const model = input.context.model.model - const providerMetadataKey = model.route.providerMetadataKey ?? model.provider - const history = toLLMMessages(input.context.messages, input.context.model.ref, providerMetadataKey) - const prepared = yield* build( - input.context, - [ - ...history, - ...(input.context.instructionDelta ? [Message.system(input.context.instructionDelta)] : []), - Message.user(input.prompt), - ], - undefined, - ) - return prepared.request - }) - - return Service.of({ prepare, prepareGenerate }) + return Service.of({ prepare }) }), ) diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts index d0049be1efd1..6d7d4d78d7d7 100644 --- a/packages/core/test/instruction-state.test.ts +++ b/packages/core/test/instruction-state.test.ts @@ -63,6 +63,11 @@ const instructionEvents = (db: Database.Interface["db"], sessionID: SessionSchem .all() .pipe(Effect.orDie) +const preview = (db: Database.Interface["db"], sessionID: SessionSchema.ID, instructions: Instructions.Instructions) => + Instructions.read(instructions).pipe( + Effect.flatMap((observed) => InstructionState.preview(db, sessionID, instructions, observed)), + ) + describe("InstructionState", () => { it.effect("observes each source once without publishing events or inserting blobs", () => Effect.gen(function* () { @@ -246,9 +251,9 @@ describe("InstructionState", () => { const beforeEvents = yield* instructionEvents(db, sessionID) const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) - const assembled = yield* InstructionState.assembleForGenerate(db, sessionID, instructions) + const assembled = yield* preview(db, sessionID, instructions) - expect(assembled).toEqual({ initial: "Initial context", updates: [], delta: "Changed context" }) + expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" }) expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) expect( @@ -285,11 +290,11 @@ describe("InstructionState", () => { const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie) - const assembled = yield* InstructionState.assembleForGenerate(db, sessionID, instructions) + const assembled = yield* preview(db, sessionID, instructions) expect(assembled.initial).toBe("Initial context") expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"]) - expect(assembled.delta).toBe("Private update") + expect(assembled.update).toBe("Private update") expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState) @@ -302,10 +307,10 @@ describe("InstructionState", () => { const { db } = yield* setup(sessionID) const instructions = source("test/context", Effect.succeed("Initial context")) - expect(yield* InstructionState.assembleForGenerate(db, sessionID, instructions)).toEqual({ + expect(yield* preview(db, sessionID, instructions)).toEqual({ initial: "Initial context", updates: [], - delta: "", + update: "", }) expect(yield* instructionEvents(db, sessionID)).toEqual([]) expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([]) @@ -328,10 +333,10 @@ describe("InstructionState", () => { const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie) - expect(yield* InstructionState.assembleForGenerate(db, sessionID, instructions)).toEqual({ + expect(yield* preview(db, sessionID, instructions)).toEqual({ initial: "Committed context", updates: [], - delta: "", + update: "", }) expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) @@ -345,7 +350,7 @@ describe("InstructionState", () => { const { db } = yield* setup(sessionID) const instructions = source("test/context", Effect.succeed(Instructions.unavailable)) - const error = yield* InstructionState.assembleForGenerate(db, sessionID, instructions).pipe(Effect.flip) + const error = yield* preview(db, sessionID, instructions).pipe(Effect.flip) expect(error).toBeInstanceOf(Instructions.InitializationBlocked) expect(error.keys).toEqual([Instructions.Key.make("test/context")]) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 853284309cba..c6a97755960a 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test" -import { LLMClient, LLMEvent, LLMResponse, Model, type LLMRequest } from "@opencode-ai/ai" +import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" import { AgentV2 } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" @@ -35,8 +35,8 @@ import { } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { SkillInstructions } from "@opencode-ai/core/skill/instructions" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" -import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { asc, eq } from "drizzle-orm" import { Effect, Layer, Schema, Stream } from "effect" import { testEffect } from "./lib/effect" @@ -84,10 +84,6 @@ const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succee const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void }) -const tools = Layer.mock(ToolRegistry.Service, { - register: () => Effect.die(new Error("unused")), - materialize: () => Effect.die(new Error("transient generation must not materialize tools")), -}) const it = testEffect( AppNodeBuilder.build( @@ -98,6 +94,7 @@ const it = testEffect( SessionStore.node, AgentV2.node, InstructionBuiltIns.node, + PluginHooks.node, SessionGenerate.node, ]), [ @@ -109,7 +106,6 @@ const it = testEffect( [ReferenceInstructions.node, references], [McpInstructions.node, mcp], [PluginSupervisor.node, plugins], - [ToolRegistry.node, tools], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], ], ), @@ -200,6 +196,24 @@ it.effect("generates from fresh settled Session context without durable mutation input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" }, }) yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing }) + const settledAssistant = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { + sessionID, + assistantMessageID: settledAssistant, + agent: AgentV2.ID.make("build"), + model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") }, + }) + yield* events.publish(SessionEvent.Text.Started, { + sessionID, + assistantMessageID: settledAssistant, + ordinal: 0, + }) + yield* events.publish(SessionEvent.Text.Ended, { + sessionID, + assistantMessageID: settledAssistant, + ordinal: 0, + text: "Settled partial answer", + }) const activeAssistant = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { sessionID, @@ -233,6 +247,12 @@ it.effect("generates from fresh settled Session context without durable mutation }) instruction = "Changed context" const before = yield* durableState(db, sessionID) + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "context", (event) => + Effect.sync(() => { + event.system = [SystemPart.make("Hooked system"), ...event.system] + }), + ) const generate = yield* SessionGenerate.Service const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" }) @@ -240,7 +260,10 @@ it.effect("generates from fresh settled Session context without durable mutation expect(result).toBe("Transient answer") expect(requests).toHaveLength(1) expect(requests[0]?.model).toBe(model) + expect(requests[0]?.system[0]?.text).toBe("Hooked system") expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context") + expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID }) + expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } }) expect( requests[0]?.messages.flatMap((message) => message.role === "system" @@ -249,7 +272,13 @@ it.effect("generates from fresh settled Session context without durable mutation ), ).toEqual(["Changed context"]) expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"]) - expect(requests[0]?.messages.some((message) => message.role === "assistant")).toBe(false) + expect( + requests[0]?.messages.flatMap((message) => + message.role === "assistant" + ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) + : [], + ), + ).toEqual(["Settled partial answer"]) expect(requests[0]?.tools).toEqual([]) expect(requests[0]?.toolChoice).toMatchObject({ type: "none" }) expect(yield* durableState(db, sessionID)).toEqual(before) diff --git a/plans/session-generate.md b/plans/session-generate.md index fd8aa375a94b..2f6607596cca 100644 --- a/plans/session-generate.md +++ b/plans/session-generate.md @@ -41,8 +41,9 @@ session.prompt -> SessionSettlement session.generate - -> SessionContext.select/loadForGenerate - -> SessionModelRequest.prepareGenerate + -> SessionContext.select + -> SessionHistory.preview + -> SessionGenerate request construction -> LLMClient.generate -> return text ``` @@ -51,7 +52,8 @@ The modules have distinct jobs: - `SessionAdmission` records and promotes durable input. - `SessionContext` resolves a read-only, internally consistent view of what the selected agent would see. -- `SessionModelRequest` converts that view into the provider request used by a Physical Attempt. +- `SessionModelRequest` converts durable Step context into a provider request paired with tool capability. +- `SessionGenerate` owns the one tool-free transient request shape rather than threading generation through the durable modules. - `LLMClient` executes one provider request without deciding what becomes durable. - `SessionSettlement` gives streamed provider events their durable Session meaning, executes local tools, records usage, and decides continuation. - `SessionCompaction` replaces oversized active history and remains a durable Session operation. @@ -60,15 +62,11 @@ Durability becomes a property of admission and settlement, not request preparati ## The Core Seam -The Location-scoped request module exposes two concrete methods rather than a generic operation protocol: +The Location-scoped request module keeps its durable Step interface: ```ts interface SessionModelRequest { readonly prepare: (input: { context: SessionContext.Loaded; step: number }) => Effect - readonly prepareGenerate: (input: { - context: SessionContext.Loaded & { instructionDelta: string } - prompt: string - }) => Effect } ``` @@ -88,11 +86,11 @@ type PreparedSessionModelRequest = { - active-history selection after the latest completed compaction; - conversion to provider messages; - provider system prompts and model headers; -- tool permission filtering and definition materialization for durable Steps; +- tool permission filtering and definition materialization; - provider transforms and Session context hooks; - Session-based prompt-cache identity. -Durable `prepare` advertises permitted tools and returns the capability used by settlement, except when the agent's Step limit disables tools. `prepareGenerate` does not materialize or advertise tools and sets tool choice to `none`, independently of agent permissions. Avoid a generic `step | generate | compaction` operation union or independently selectable behavior flags; the two concrete methods encode the only supported request shapes. +Durable `prepare` advertises permitted tools and returns the capability used by settlement, except when the agent's Step limit disables tools. `SessionGenerate` constructs its request with no tool definitions and tool choice `none`, independently of agent permissions. Avoid a generic `step | generate | compaction` operation union or independently selectable behavior flags; the two operations own their concrete request shapes. ## Read-Only Session Context From 6600f7deeca7e9c27e58d5ae37d32a5869150587 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Jul 2026 13:44:37 -0400 Subject: [PATCH 3/3] refactor(core): split session generate node --- packages/core/src/location-services.ts | 4 +- packages/core/src/session.ts | 11 ++- packages/core/src/session/generate-node.ts | 69 +++++++++++++++++++ packages/core/src/session/generate.ts | 74 ++------------------- packages/core/test/session-generate.test.ts | 3 +- 5 files changed, 83 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/session/generate-node.ts diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 6d614a412b74..47e3f6dd2fbc 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -39,7 +39,7 @@ import { InstructionDiscovery } from "./instruction-discovery" import { InstructionBuiltIns } from "./instructions/builtins" import { InstructionEntry } from "./session/instruction-entry" import { SessionInstructions } from "./session/instructions" -import { SessionGenerate } from "./session/generate" +import { SessionGenerateNode } from "./session/generate-node" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" @@ -82,7 +82,7 @@ const locationServiceNodes = [ Form.node, QuestionV2.node, Generate.node, - SessionGenerate.node, + SessionGenerateNode.node, ReadToolFileSystem.node, McpTool.node, SessionInstructions.node, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index ca7ae184737e..edf31d55945c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -33,7 +33,7 @@ import { LocationServiceMap } from "./location-service-map" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionPending } from "./session/pending" -import type { Error as SessionGenerateError } from "./session/generate" +import { SessionGenerate } from "./session/generate" import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" import { Session } from "@opencode-ai/schema/session" @@ -176,7 +176,7 @@ export type Error = | CommandV2.NotFoundError | CommandV2.EvaluationError | MessageNotFoundError - | SessionGenerateError + | SessionGenerate.Error export interface Interface { readonly list: (input?: ListInput) => Effect.Effect<{ @@ -249,7 +249,7 @@ export interface Interface { readonly generate: (input: { sessionID: SessionSchema.ID prompt: string - }) => Effect.Effect + }) => Effect.Effect readonly command: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -573,10 +573,7 @@ const layer = Layer.effect( ), generate: Effect.fn("V2Session.generate")(function* (input) { const session = yield* result.get(input.sessionID) - // PermissionV2 imports SessionV2 while the Location graph imports PermissionV2. - // Load the Location implementation lazily to avoid closing that module cycle. - const sessionGenerate = yield* Effect.promise(() => import("./session/generate")) - const generate = yield* sessionGenerate.Service.pipe(Effect.provide(locations.get(session.location))) + const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location))) return yield* generate.generate(input) }), command: Effect.fn("V2Session.command")(function* (input) { diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts new file mode 100644 index 000000000000..7d7e88e90f6a --- /dev/null +++ b/packages/core/src/session/generate-node.ts @@ -0,0 +1,69 @@ +export * as SessionGenerateNode from "./generate-node" + +import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai" +import { Effect, Layer } from "effect" +import { Database } from "../database/database" +import { makeLocationNode } from "../effect/app-node" +import { llmClient } from "../effect/app-node-platform" +import { PluginHooks } from "../plugin/hooks" +import { SessionContext } from "./context" +import { SessionGenerate } from "./generate" +import { SessionHistory } from "./history" +import { SessionModelHeaders } from "./model-headers" +import { SessionRunnerModel } from "./runner/model" +import PROMPT_DEFAULT from "./runner/prompt/base.txt" +import { toLLMMessages } from "./runner/to-llm-message" + +const layer = Layer.effect( + SessionGenerate.Service, + Effect.gen(function* () { + const context = yield* SessionContext.Service + const database = yield* Database.Service + const hooks = yield* PluginHooks.Service + const llm = yield* LLMClient.Service + const models = yield* SessionRunnerModel.Service + + return SessionGenerate.Service.of({ + generate: Effect.fn("SessionGenerate.generate")(function* (input) { + const selection = yield* context.select(input.sessionID) + const model = yield* models.resolve(selection.session) + const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions) + const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider + const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id) + ? selection.session.id.slice(4) + : selection.session.id + const contextEvent = yield* hooks.trigger("session", "context", { + sessionID: selection.session.id, + agent: selection.agent.id, + model: model.ref, + system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial] + .filter((part) => part.length > 0) + .map(SystemPart.make), + messages: [ + ...toLLMMessages(history.messages, model.ref, providerMetadataKey), + ...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []), + Message.user(input.prompt), + ], + tools: {}, + }) + return (yield* llm.generate( + LLM.request({ + model: model.model, + http: { headers: SessionModelHeaders.make(selection.session) }, + providerOptions: { openai: { promptCacheKey } }, + system: contextEvent.system, + messages: contextEvent.messages, + tools: [], + toolChoice: "none", + }), + )).text + }), + }) + }), +) + +export const node = makeLocationNode({ + service: SessionGenerate.Service, + layer, + deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient], +}) diff --git a/packages/core/src/session/generate.ts b/packages/core/src/session/generate.ts index dc44b1b0386e..381c3345185a 100644 --- a/packages/core/src/session/generate.ts +++ b/packages/core/src/session/generate.ts @@ -1,20 +1,11 @@ export * as SessionGenerate from "./generate" -import { LLM, LLMClient, LLMError, Message, SystemPart } from "@opencode-ai/ai" -import { Context, Effect, Layer } from "effect" -import { Database } from "../database/database" -import { llmClient } from "../effect/app-node-platform" -import { makeLocationNode } from "../effect/app-node" -import { Instructions } from "../instructions" -import { PluginHooks } from "../plugin/hooks" -import { AgentNotFoundError } from "./error" -import { SessionContext } from "./context" -import { SessionHistory } from "./history" -import { SessionModelHeaders } from "./model-headers" -import { SessionRunnerModel } from "./runner/model" -import PROMPT_DEFAULT from "./runner/prompt/base.txt" -import { toLLMMessages } from "./runner/to-llm-message" -import { SessionSchema } from "./schema" +import type { LLMError } from "@opencode-ai/ai" +import { Context, type Effect } from "effect" +import type { Instructions } from "../instructions" +import type { AgentNotFoundError } from "./error" +import type { SessionRunnerModel } from "./runner/model" +import type { SessionSchema } from "./schema" export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | LLMError @@ -28,56 +19,3 @@ export interface Interface { /** Location-scoped transient generation from Session context. */ export class Service extends Context.Service()("@opencode/v2/SessionGenerate") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const context = yield* SessionContext.Service - const database = yield* Database.Service - const hooks = yield* PluginHooks.Service - const llm = yield* LLMClient.Service - const models = yield* SessionRunnerModel.Service - - return Service.of({ - generate: Effect.fn("SessionGenerate.generate")(function* (input) { - const selection = yield* context.select(input.sessionID) - const model = yield* models.resolve(selection.session) - const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions) - const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider - const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id) - ? selection.session.id.slice(4) - : selection.session.id - const contextEvent = yield* hooks.trigger("session", "context", { - sessionID: selection.session.id, - agent: selection.agent.id, - model: model.ref, - system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial] - .filter((part) => part.length > 0) - .map(SystemPart.make), - messages: [ - ...toLLMMessages(history.messages, model.ref, providerMetadataKey), - ...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []), - Message.user(input.prompt), - ], - tools: {}, - }) - const request = LLM.request({ - model: model.model, - http: { headers: SessionModelHeaders.make(selection.session) }, - providerOptions: { openai: { promptCacheKey } }, - system: contextEvent.system, - messages: contextEvent.messages, - tools: [], - toolChoice: "none", - }) - return (yield* llm.generate(request)).text - }), - }) - }), -) - -export const node = makeLocationNode({ - service: Service, - layer, - deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient], -}) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index c6a97755960a..a66ac6a69e85 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -21,6 +21,7 @@ import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionGenerate } from "@opencode-ai/core/session/generate" +import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node" import { InstructionState } from "@opencode-ai/core/session/instruction-state" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -95,7 +96,7 @@ const it = testEffect( AgentV2.node, InstructionBuiltIns.node, PluginHooks.node, - SessionGenerate.node, + SessionGenerateNode.node, ]), [ [llmClient, client],