|
|
@@ -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"],
|
|
|
- ])
|
|
|
- }),
|
|
|
- )
|
|
|
-
|
|
|
- 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"],
|
|
|
+ ["Initial context\n\nBuild 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")
|
|
|
}),
|
|
|
)
|
|
|
|