refactor(core): simplify session context epochs (#33378)
This commit is contained in:
parent
f9f2280452
commit
c6ee511485
16 changed files with 281 additions and 728 deletions
|
|
@ -71,9 +71,9 @@ describe("DatabaseMigration", () => {
|
|||
).toEqual({ name: "session_context_epoch" })
|
||||
expect(
|
||||
yield* db.get(
|
||||
sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`,
|
||||
sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
|
||||
),
|
||||
).toEqual({ name: "agent", dflt_value: "'build'" })
|
||||
).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
||||
expect(
|
||||
yield* db.all(
|
||||
|
|
|
|||
|
|
@ -360,12 +360,12 @@ const setupOverflowRecovery = Effect.gen(function* () {
|
|||
return session
|
||||
})
|
||||
|
||||
const userTexts = (request: LLMRequest) =>
|
||||
const messageTexts = (request: LLMRequest, role: "user" | "system") =>
|
||||
request.messages.flatMap((message) =>
|
||||
message.role === "user"
|
||||
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
: [],
|
||||
message.role === role ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) : [],
|
||||
)
|
||||
const userTexts = (request: LLMRequest) => messageTexts(request, "user")
|
||||
const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
|
||||
|
||||
const replaySessionProjection = (id: SessionV2.ID) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -746,39 +746,6 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("does not create a source Location epoch after a concurrent Session move", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
let moved = false
|
||||
systemLoadHook = Effect.suspend(() => {
|
||||
if (moved) return Effect.void
|
||||
moved = true
|
||||
return events
|
||||
.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
|
||||
expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true)
|
||||
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get(),
|
||||
).toBeUndefined()
|
||||
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reuses one durable baseline after the context producer changes", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
@ -890,7 +857,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("composes selected-agent skill guidance and replaces it after an agent switch", () =>
|
||||
it.effect("updates selected-agent skill guidance after an agent switch", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -913,12 +880,13 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context\n\nBuild skills"],
|
||||
["Initial context\n\nReviewer skills"],
|
||||
["Initial context\n\nBuild skills"],
|
||||
])
|
||||
expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries first-epoch preparation when the selected agent changes during observation", () =>
|
||||
it.effect("keeps the sampled agent when selection changes during observation", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -945,88 +913,12 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context\n\nReviewer skills"],
|
||||
["Initial context\n\nBuild skills"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("opens a queued activity once when the selected agent changes during observation", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
|
||||
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
|
||||
let switched = false
|
||||
systemLoadHook = Effect.suspend(() => {
|
||||
if (switched) return Effect.void
|
||||
switched = true
|
||||
return events
|
||||
.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "reviewer",
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "Queued" }),
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect((yield* session.context(sessionID)).filter((message) => message.type === "user")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries an agent switch before the final provider-dispatch boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
|
||||
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
|
||||
let switched = false
|
||||
modelResolveHook = Effect.suspend(() => {
|
||||
if (switched) return Effect.void
|
||||
switched = true
|
||||
return events
|
||||
.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "reviewer",
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context\n\nReviewer skills"],
|
||||
])
|
||||
expect(
|
||||
yield* db
|
||||
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual({ replacementSeq: null })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries a model switch before the final provider-dispatch boundary", () =>
|
||||
it.effect("keeps the sampled model when selection changes during model resolution", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -1049,145 +941,11 @@ describe("SessionRunnerLLM", () => {
|
|||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
expect(requests.map((request) => request.model)).toEqual([replacementModel])
|
||||
expect(requests.map((request) => request.model)).toEqual([model])
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fences an unchanged epoch read across an agent ABA replacement request", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
let switched = false
|
||||
systemLoadHook = Effect.suspend(() => {
|
||||
if (switched) return Effect.void
|
||||
switched = true
|
||||
return events
|
||||
.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: AgentV2.ID.make("reviewer"),
|
||||
})
|
||||
.pipe(
|
||||
Effect.andThen(
|
||||
events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
agent: AgentV2.defaultID,
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual({ replacementSeq: null })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects stale agent guidance when committing an existing-epoch replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: AgentV2.ID.make("reviewer"),
|
||||
})
|
||||
const context = (text: string) =>
|
||||
Effect.succeed(
|
||||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed(text),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
}),
|
||||
)
|
||||
const location = (yield* session.get(sessionID)).location
|
||||
|
||||
expect(
|
||||
yield* SessionContextEpoch.prepare(
|
||||
db,
|
||||
events,
|
||||
context("Stale build context"),
|
||||
sessionID,
|
||||
location,
|
||||
AgentV2.defaultID,
|
||||
).pipe(Effect.catchDefect(Effect.succeed)),
|
||||
).toBeInstanceOf(SessionContextEpoch.AgentMismatch)
|
||||
|
||||
expect(
|
||||
yield* SessionContextEpoch.prepare(
|
||||
db,
|
||||
events,
|
||||
context("Reviewer context"),
|
||||
sessionID,
|
||||
location,
|
||||
AgentV2.ID.make("reviewer"),
|
||||
),
|
||||
).toMatchObject({ baseline: "Reviewer context" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("blocks a cross-agent provider turn while replacement context is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
skillBaselines.set(AgentV2.defaultID, "Build skills")
|
||||
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: AgentV2.ID.make("reviewer"),
|
||||
})
|
||||
systemUnavailable = true
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
const blocked = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(blocked)).toBe(true)
|
||||
if (Exit.isFailure(blocked))
|
||||
expect(Cause.squash(blocked.cause)).toBeInstanceOf(SessionContextEpoch.AgentReplacementBlocked)
|
||||
expect(requests).toHaveLength(0)
|
||||
|
||||
systemUnavailable = false
|
||||
yield* session.resume(sessionID)
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context\n\nReviewer skills"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("admits removed context as a chronological System message", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
@ -1209,7 +967,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () =>
|
||||
it.effect("keeps the baseline and chronological System updates after a model switch", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -1235,24 +993,26 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context"],
|
||||
["Initial context"],
|
||||
["Replacement context"],
|
||||
["Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"])
|
||||
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"user",
|
||||
"system",
|
||||
"model-switched",
|
||||
"user",
|
||||
"system",
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(5)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defers replacement while admitted context is temporarily unavailable", () =>
|
||||
it.effect("preserves the baseline while context is temporarily unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -1279,81 +1039,12 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context"],
|
||||
["Initial context"],
|
||||
["Replacement context"],
|
||||
["Initial context"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advances a pending replacement to the latest invalidation boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
const latest = yield* SessionInput.latestSeq(db, sessionID)
|
||||
|
||||
expect(
|
||||
yield* db
|
||||
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual({ replacementSeq: latest })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries epoch preparation until observation-time invalidations settle", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
requests.length = 0
|
||||
systemBaseline = "Changed context"
|
||||
let invalidations = 0
|
||||
systemLoadHook = Effect.suspend(() => {
|
||||
if (invalidations === 4) return Effect.void
|
||||
invalidations++
|
||||
return events
|
||||
.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(invalidations),
|
||||
model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(invalidations).toBe(4)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
|
||||
it.effect("rebuilds the baseline directly after completed compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -1580,7 +1271,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves effective System updates while compaction replacement is blocked", () =>
|
||||
it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -1613,16 +1304,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
|
||||
expect(
|
||||
requests
|
||||
.at(-1)
|
||||
?.messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content[0]?.type === "text" &&
|
||||
message.content[0].text === "Changed context",
|
||||
),
|
||||
).toBe(true)
|
||||
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -1821,8 +1503,9 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context"],
|
||||
["Replacement context"],
|
||||
["Initial context"],
|
||||
])
|
||||
expect(systemTexts(requests[1]!)).toContain("Replacement context")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue