feat(session): define explicit fork boundaries

This commit is contained in:
Dax Raad 2026-07-29 09:49:35 -04:00
commit fc11ed3838
29 changed files with 821 additions and 386 deletions

View file

@ -199,7 +199,7 @@ describe("Session.create", () => {
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id })
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const parentContext = yield* session.context(parent.id)
const forkContext = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
@ -252,6 +252,17 @@ describe("Session.create", () => {
}),
)
it.effect("rejects forking an empty session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const parent = yield* session.create({ location })
expect(
yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.ForkEmptyError", sessionID: parent.id })
}),
)
it.effect("forks before the selected boundary message", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@ -286,16 +297,27 @@ describe("Session.create", () => {
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
})
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
const beforeFirst = yield* session.fork({ sessionID: parent.id, messageID: first.id })
const complete = yield* session.fork({ sessionID: parent.id })
const forked = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: second.id },
})
const beforeFirst = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: first.id },
})
const complete = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const context = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id })
expect(forked.fork).toEqual({
sessionID: parent.id,
boundary: { type: "before", messageID: second.id },
})
expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id)
expect(history[0]).toMatchObject({ data: { from: second.id } })
expect(history[0]).toMatchObject({
data: { boundary: { type: "before", messageID: second.id } },
})
expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
expect(yield* session.context(beforeFirst.id)).toEqual([])
expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })

View file

@ -1107,7 +1107,7 @@ describe("SessionRunnerLLM", () => {
systemBaseline = "Latest context"
yield* runPrompt(session, "Third")
const forked = yield* session.fork({ sessionID, messageID: second.id })
const forked = yield* session.fork({ sessionID, boundary: { type: "before", messageID: second.id } })
expect(
yield* (yield* Database.Service).db
.select()
@ -1115,14 +1115,13 @@ describe("SessionRunnerLLM", () => {
.where(eq(InstructionStateTable.session_id, forked.id))
.get(),
).toMatchObject({
initial_values: { "test/context": Instructions.hash("Initial context") },
initial_values: { "test/context": Instructions.hash("Changed context") },
current_values: { "test/context": Instructions.hash("Changed context") },
})
yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false })
yield* session.resume(forked.id)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
expect(systemTexts(requests.at(-1)!)).toContain("Latest context")
const { db } = yield* Database.Service
@ -1151,19 +1150,22 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("caps nested fork instruction ancestry at the selected message", () =>
it.effect("keeps nested forks self-contained", () =>
Effect.gen(function* () {
const session = yield* setup
yield* runPrompt(session, "First")
systemBaseline = "Changed context"
const second = yield* runPrompt(session, "Second")
const child = yield* session.fork({ sessionID, messageID: second.id })
const child = yield* session.fork({ sessionID, boundary: { type: "before", messageID: second.id } })
const inheritedFirst = (yield* session.messages({ sessionID: child.id })).find(
(message) => message.type === "user" && message.text === "First",
)
if (!inheritedFirst) return yield* Effect.die(new Error("Nested fork boundary message not found"))
const grandchild = yield* session.fork({ sessionID: child.id, messageID: inheritedFirst.id })
const grandchild = yield* session.fork({
sessionID: child.id,
boundary: { type: "before", messageID: inheritedFirst.id },
})
expect(
yield* (yield* Database.Service).db
@ -1172,8 +1174,8 @@ describe("SessionRunnerLLM", () => {
.where(eq(InstructionStateTable.session_id, grandchild.id))
.get(),
).toMatchObject({
initial_values: { "test/context": Instructions.hash("Initial context") },
current_values: { "test/context": Instructions.hash("Initial context") },
initial_values: { "test/context": Instructions.hash("Changed context") },
current_values: { "test/context": Instructions.hash("Changed context") },
})
return undefined
}),