feat(core): add transient session generation (#37330)
This commit is contained in:
parent
103f764624
commit
e4a16830f1
9 changed files with 687 additions and 79 deletions
|
|
@ -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 { SessionGenerateNode } from "./session/generate-node"
|
||||
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,
|
||||
SessionGenerateNode.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
|
|
|
|||
|
|
@ -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 { SessionGenerate } from "./session/generate"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
|
@ -176,6 +177,7 @@ export type Error =
|
|||
| CommandV2.NotFoundError
|
||||
| CommandV2.EvaluationError
|
||||
| MessageNotFoundError
|
||||
| SessionGenerate.Error
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
|
|
@ -244,6 +246,11 @@ export interface Interface {
|
|||
delivery?: SessionPending.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
|
||||
/** Generates text from current Session context without admitting input or mutating history. */
|
||||
readonly generate: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: string
|
||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||
readonly command: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
|
|
@ -569,6 +576,11 @@ const layer = Layer.effect(
|
|||
}),
|
||||
),
|
||||
),
|
||||
generate: Effect.fn("V2Session.generate")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
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)))
|
||||
|
|
|
|||
69
packages/core/src/session/generate-node.ts
Normal file
69
packages/core/src/session/generate-node.ts
Normal file
|
|
@ -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],
|
||||
})
|
||||
21
packages/core/src/session/generate.ts
Normal file
21
packages/core/src/session/generate.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export * as SessionGenerate from "./generate"
|
||||
|
||||
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
|
||||
|
||||
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<string, Error>
|
||||
}
|
||||
|
||||
/** Location-scoped transient generation from Session context. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionGenerate") {}
|
||||
|
|
@ -60,13 +60,17 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
|||
),
|
||||
)
|
||||
|
||||
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,
|
||||
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* messageEntries(db, sessionID)).map((entry) => entry.message)
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
|
|
@ -75,10 +79,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 +90,33 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
|
|||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
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)
|
||||
// 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.time.completed === undefined,
|
||||
)
|
||||
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
|
||||
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,
|
||||
messages: entries.map((entry) => entry.message),
|
||||
instructionUpdate: assembled.update,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.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,
|
||||
|
|
|
|||
|
|
@ -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,28 +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 = {
|
||||
session_id: sessionID,
|
||||
epoch_start: folded.epochStart,
|
||||
through_seq: folded.throughSeq,
|
||||
initial_values: folded.initial,
|
||||
current_values: folded.current,
|
||||
}
|
||||
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()
|
||||
|
|
@ -152,13 +144,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 +179,49 @@ 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 preview = Effect.fn("InstructionState.preview")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.Instructions,
|
||||
observed: Instructions.ReadResult,
|
||||
) {
|
||||
const state = yield* readState(db, sessionID)
|
||||
const result = yield* observeAgainst(observed, state?.current_values)
|
||||
const blobs = new Map<Instructions.Hash, Schema.Json>(
|
||||
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
||||
)
|
||||
if (!state) {
|
||||
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,
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
|
|
@ -203,7 +236,26 @@ 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) 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
|
||||
})
|
||||
|
||||
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 +263,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<Record<string, Schema.Json>>) {
|
||||
|
|
@ -364,3 +414,13 @@ function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
|||
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
function foldedState(sessionID: SessionSchema.ID, folded: NonNullable<ReturnType<typeof fold>>) {
|
||||
return {
|
||||
session_id: sessionID,
|
||||
epoch_start: folded.epochStart,
|
||||
through_seq: folded.throughSeq,
|
||||
initial_values: folded.initial,
|
||||
current_values: folded.current,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
@ -227,6 +232,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* preview(db, sessionID, instructions)
|
||||
|
||||
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(
|
||||
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* preview(db, sessionID, instructions)
|
||||
|
||||
expect(assembled.initial).toBe("Initial context")
|
||||
expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed 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)
|
||||
}),
|
||||
)
|
||||
|
||||
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* preview(db, sessionID, instructions)).toEqual({
|
||||
initial: "Initial context",
|
||||
updates: [],
|
||||
update: "",
|
||||
})
|
||||
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* preview(db, sessionID, instructions)).toEqual({
|
||||
initial: "Committed context",
|
||||
updates: [],
|
||||
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("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* preview(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")
|
||||
|
|
|
|||
303
packages/core/test/session-generate.test.ts
Normal file
303
packages/core/test/session-generate.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { expect } from "bun:test"
|
||||
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"
|
||||
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 { 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"
|
||||
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 { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
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 it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
AgentV2.node,
|
||||
InstructionBuiltIns.node,
|
||||
PluginHooks.node,
|
||||
SessionGenerateNode.node,
|
||||
]),
|
||||
[
|
||||
[llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[InstructionBuiltIns.node, builtins],
|
||||
[InstructionDiscovery.node, discovery],
|
||||
[SkillInstructions.node, skills],
|
||||
[ReferenceInstructions.node, references],
|
||||
[McpInstructions.node, mcp],
|
||||
[PluginSupervisor.node, plugins],
|
||||
[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 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,
|
||||
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 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" })
|
||||
|
||||
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"
|
||||
? 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.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)
|
||||
}),
|
||||
)
|
||||
|
||||
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)
|
||||
}),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue