fix(core): harden v2 session context epochs
This commit is contained in:
parent
83916f667d
commit
03974ba0e6
33 changed files with 340 additions and 6461 deletions
|
|
@ -69,16 +69,15 @@ describe("DatabaseMigration", () => {
|
|||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_message'`),
|
||||
).toEqual({ name: "session_context_message" })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 34 })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 31 })
|
||||
expect(
|
||||
yield* db.all(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_context_message_session_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "event_aggregate_seq_idx" },
|
||||
{ name: "event_aggregate_type_seq_idx" },
|
||||
{ name: "session_input_session_admitted_seq_idx" },
|
||||
{ name: "session_context_message_session_seq_idx" },
|
||||
{ name: "session_input_session_pending_delivery_seq_idx" },
|
||||
{ name: "session_input_session_promoted_seq_idx" },
|
||||
{ name: "session_message_session_seq_idx" },
|
||||
|
|
|
|||
|
|
@ -90,10 +90,23 @@ describe("toLLMMessages", () => {
|
|||
test("maps hidden Session context updates into chronological system messages", () => {
|
||||
expect(
|
||||
toLLMMessages(
|
||||
[{ type: "system-context", parts: [{ key: SystemContext.Key.make("test/context"), text: "Updated context" }] }],
|
||||
[
|
||||
{
|
||||
type: "system-context",
|
||||
parts: [
|
||||
{ key: SystemContext.Key.make("test/context"), text: "Updated context" },
|
||||
{ key: SystemContext.Key.make("test/other"), text: "Other context" },
|
||||
],
|
||||
},
|
||||
],
|
||||
model,
|
||||
),
|
||||
).toEqual([Message.system("Updated context")])
|
||||
).toEqual([
|
||||
Message.system([
|
||||
{ type: "text", text: "Updated context" },
|
||||
{ type: "text", text: "Other context" },
|
||||
]),
|
||||
])
|
||||
})
|
||||
|
||||
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ let streamStarted: Deferred.Deferred<void> | undefined
|
|||
let streamFailure: LLMError | undefined
|
||||
let toolExecutionGate: Deferred.Deferred<void> | undefined
|
||||
let toolExecutionsStarted: Deferred.Deferred<void> | undefined
|
||||
let toolExecutionsReady = 5
|
||||
let activeToolExecutions = 0
|
||||
let maxActiveToolExecutions = 0
|
||||
const client = Layer.succeed(
|
||||
|
|
@ -85,6 +86,7 @@ const client = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
||||
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
|
||||
const authorizations: ToolRegistry.AuthorizeInput[] = []
|
||||
const executions: string[] = []
|
||||
const permission = Layer.succeed(
|
||||
|
|
@ -118,7 +120,7 @@ const echo = Layer.effectDiscard(
|
|||
executions.push(text)
|
||||
activeToolExecutions++
|
||||
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
||||
if (activeToolExecutions === 5 && toolExecutionsStarted) {
|
||||
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
|
||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||
}
|
||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||
|
|
@ -137,10 +139,13 @@ const echo = Layer.effectDiscard(
|
|||
}),
|
||||
),
|
||||
).pipe(Layer.provide(registry))
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||
const models = SessionRunnerModel.layerWith((session) =>
|
||||
Effect.succeed(session.model?.id === "replacement" ? replacementModel : model),
|
||||
)
|
||||
const systemContextKey = SystemContext.Key.make("test/context")
|
||||
let systemBaseline = "Initial context"
|
||||
let systemRemoved = false
|
||||
let systemUnavailable = false
|
||||
const systemContext = Layer.succeed(
|
||||
SessionSystemContext.Service,
|
||||
SessionSystemContext.Service.of({
|
||||
|
|
@ -148,15 +153,17 @@ const systemContext = Layer.succeed(
|
|||
Effect.sync(() => ({
|
||||
entries: systemRemoved
|
||||
? []
|
||||
: [
|
||||
{
|
||||
_tag: "Available" as const,
|
||||
key: systemContextKey,
|
||||
baseline: systemBaseline,
|
||||
update: systemBaseline,
|
||||
hash: Hash.sha256(systemBaseline),
|
||||
},
|
||||
],
|
||||
: systemUnavailable
|
||||
? [{ _tag: "Unavailable" as const, key: systemContextKey }]
|
||||
: [
|
||||
{
|
||||
_tag: "Available" as const,
|
||||
key: systemContextKey,
|
||||
baseline: systemBaseline,
|
||||
update: systemBaseline,
|
||||
hash: Hash.sha256(systemBaseline),
|
||||
},
|
||||
],
|
||||
})),
|
||||
}),
|
||||
)
|
||||
|
|
@ -229,6 +236,7 @@ const setup = Effect.gen(function* () {
|
|||
response = []
|
||||
systemBaseline = "Initial context"
|
||||
systemRemoved = false
|
||||
systemUnavailable = false
|
||||
responses = undefined
|
||||
streamFailure = undefined
|
||||
responseStream = undefined
|
||||
|
|
@ -236,6 +244,7 @@ const setup = Effect.gen(function* () {
|
|||
streamStarted = undefined
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
toolExecutionsReady = 5
|
||||
activeToolExecutions = 0
|
||||
maxActiveToolExecutions = 0
|
||||
yield* db
|
||||
|
|
@ -656,6 +665,105 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("defers replacement while admitted context is temporarily unavailable", () =>
|
||||
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 })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemUnavailable = true
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
systemUnavailable = false
|
||||
systemBaseline = "Replacement context"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context"],
|
||||
["Initial context"],
|
||||
["Replacement context"],
|
||||
])
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.replaced.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays retained context projections while replacement is pending", () =>
|
||||
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 })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
systemBaseline = "Changed context"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
|
||||
yield* replaySessionProjection(sessionID)
|
||||
systemBaseline = "Replacement context"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays retained context projections after multiple replacements", () =>
|
||||
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 })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemBaseline = "Replacement context 1"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemBaseline = "Replacement context 2"
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context 2"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
@ -863,6 +971,49 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("reloads a model switch before a tool-driven continuation turn", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
],
|
||||
]
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
const run = yield* Effect.forkChild(session.resume(sessionID))
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
["Initial context"],
|
||||
["Replacement context"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restores durable reasoning provider metadata in a second-turn request", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ describe("SystemContext", () => {
|
|||
"core/skills": Hash.sha256("Available skills: effect"),
|
||||
},
|
||||
})
|
||||
expect(SystemContext.render(refreshed.changes)).toBe("The current date is 2026-06-04.\n\nAvailable skills: effect")
|
||||
})
|
||||
|
||||
test("omits unavailable initial context and admits it after its first successful load", async () => {
|
||||
|
|
@ -112,6 +111,23 @@ describe("SystemContext", () => {
|
|||
expect(refreshed).toEqual({ changes: [], checkpoint: previous })
|
||||
})
|
||||
|
||||
test("blocks replacement while admitted context is unavailable", async () => {
|
||||
const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") }
|
||||
const snapshot = await Effect.runPromise(
|
||||
SystemContext.load(
|
||||
SystemContext.struct({
|
||||
remote: SystemContext.value({
|
||||
key: key("core/remote-instructions"),
|
||||
load: Effect.succeed(SystemContext.unavailable),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(SystemContext.replacementBlocked(snapshot, previous)).toBe(true)
|
||||
expect(SystemContext.replacementBlocked(snapshot, {})).toBe(false)
|
||||
})
|
||||
|
||||
test("emits tombstones and drops checkpoints for removed components", async () => {
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
|
|
@ -185,4 +201,13 @@ describe("SystemContext", () => {
|
|||
expect(() => decode("date")).toThrow()
|
||||
expect(() => decode("core/")).toThrow()
|
||||
})
|
||||
|
||||
test("requires namespaced checkpoint keys", () => {
|
||||
const decode = Schema.decodeUnknownSync(SystemContext.CheckpointSchema)
|
||||
const valid = JSON.parse('{"core/date":"hash"}')
|
||||
const invalid = JSON.parse('{"date":"hash"}')
|
||||
|
||||
expect(decode(valid)).toEqual(valid)
|
||||
expect(() => decode(invalid)).toThrow()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue