|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|
|
import * as DateTime from "effect/DateTime"
|
|
|
import * as FastCheck from "effect/testing/FastCheck"
|
|
|
import { SessionEntry } from "../../src/v2/session-entry"
|
|
|
+import { SessionEntryStepper } from "../../src/v2/session-entry-stepper"
|
|
|
import { SessionEvent } from "../../src/v2/session-event"
|
|
|
|
|
|
const time = (n: number) => DateTime.makeUnsafe(n)
|
|
|
@@ -29,8 +30,8 @@ function assistant() {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-function history() {
|
|
|
- const state: SessionEntry.History = {
|
|
|
+function memoryState() {
|
|
|
+ const state: SessionEntryStepper.MemoryState = {
|
|
|
entries: [],
|
|
|
pending: [],
|
|
|
}
|
|
|
@@ -38,51 +39,189 @@ function history() {
|
|
|
}
|
|
|
|
|
|
function active() {
|
|
|
- const state: SessionEntry.History = {
|
|
|
+ const state: SessionEntryStepper.MemoryState = {
|
|
|
entries: [assistant()],
|
|
|
pending: [],
|
|
|
}
|
|
|
return state
|
|
|
}
|
|
|
|
|
|
-function run(events: SessionEvent.Event[], state = history()) {
|
|
|
- return events.reduce<SessionEntry.History>((state, event) => SessionEntry.step(state, event), state)
|
|
|
+function run(events: SessionEvent.Event[], state = memoryState()) {
|
|
|
+ return events.reduce<SessionEntryStepper.MemoryState>((state, event) => SessionEntryStepper.step(state, event), state)
|
|
|
}
|
|
|
|
|
|
-function last(state: SessionEntry.History) {
|
|
|
+function last(state: SessionEntryStepper.MemoryState) {
|
|
|
const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
|
|
|
expect(entry?.type).toBe("assistant")
|
|
|
return entry?.type === "assistant" ? entry : undefined
|
|
|
}
|
|
|
|
|
|
-function texts_of(state: SessionEntry.History) {
|
|
|
+function texts_of(state: SessionEntryStepper.MemoryState) {
|
|
|
const entry = last(state)
|
|
|
if (!entry) return []
|
|
|
return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
|
|
|
}
|
|
|
|
|
|
-function reasons(state: SessionEntry.History) {
|
|
|
+function reasons(state: SessionEntryStepper.MemoryState) {
|
|
|
const entry = last(state)
|
|
|
if (!entry) return []
|
|
|
return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
|
|
|
}
|
|
|
|
|
|
-function tools(state: SessionEntry.History) {
|
|
|
+function tools(state: SessionEntryStepper.MemoryState) {
|
|
|
const entry = last(state)
|
|
|
if (!entry) return []
|
|
|
return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
|
|
|
}
|
|
|
|
|
|
-function tool(state: SessionEntry.History, callID: string) {
|
|
|
+function tool(state: SessionEntryStepper.MemoryState, callID: string) {
|
|
|
return tools(state).find((x) => x.callID === callID)
|
|
|
}
|
|
|
|
|
|
-describe("session-entry step", () => {
|
|
|
- describe("seeded pending assistant", () => {
|
|
|
+function adapterStore() {
|
|
|
+ return {
|
|
|
+ committed: [] as SessionEntry.Entry[],
|
|
|
+ deferred: [] as SessionEntry.Entry[],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function adapterFor(store: ReturnType<typeof adapterStore>): SessionEntryStepper.Adapter<typeof store> {
|
|
|
+ const activeAssistantIndex = () =>
|
|
|
+ store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
|
|
|
+
|
|
|
+ const getCurrentAssistant = () => {
|
|
|
+ const index = activeAssistantIndex()
|
|
|
+ if (index < 0) return
|
|
|
+ const assistant = store.committed[index]
|
|
|
+ return assistant?.type === "assistant" ? assistant : undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ getCurrentAssistant,
|
|
|
+ updateAssistant(assistant) {
|
|
|
+ const index = activeAssistantIndex()
|
|
|
+ if (index < 0) return
|
|
|
+ const current = store.committed[index]
|
|
|
+ if (current?.type !== "assistant") return
|
|
|
+ store.committed[index] = assistant
|
|
|
+ },
|
|
|
+ appendEntry(entry) {
|
|
|
+ store.committed.push(entry)
|
|
|
+ },
|
|
|
+ appendPending(entry) {
|
|
|
+ store.deferred.push(entry)
|
|
|
+ },
|
|
|
+ finish() {
|
|
|
+ return store
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+describe("session-entry-stepper", () => {
|
|
|
+ describe("stepWith", () => {
|
|
|
+ test("reduces through a custom adapter", () => {
|
|
|
+ const store = adapterStore()
|
|
|
+ store.committed.push(assistant())
|
|
|
+
|
|
|
+ SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) }))
|
|
|
+ SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) }))
|
|
|
+ SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }))
|
|
|
+ SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }))
|
|
|
+ SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) }))
|
|
|
+ SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }))
|
|
|
+ SessionEntryStepper.stepWith(
|
|
|
+ adapterFor(store),
|
|
|
+ SessionEvent.Step.Ended.create({
|
|
|
+ reason: "stop",
|
|
|
+ cost: 1,
|
|
|
+ tokens: {
|
|
|
+ input: 1,
|
|
|
+ output: 2,
|
|
|
+ reasoning: 3,
|
|
|
+ cache: {
|
|
|
+ read: 4,
|
|
|
+ write: 5,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ timestamp: time(7),
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(store.deferred).toHaveLength(1)
|
|
|
+ expect(store.deferred[0]?.type).toBe("user")
|
|
|
+ expect(store.committed).toHaveLength(1)
|
|
|
+ expect(store.committed[0]?.type).toBe("assistant")
|
|
|
+ if (store.committed[0]?.type !== "assistant") return
|
|
|
+
|
|
|
+ expect(store.committed[0].content).toEqual([
|
|
|
+ { type: "reasoning", text: "thought" },
|
|
|
+ { type: "text", text: "world" },
|
|
|
+ ])
|
|
|
+ expect(store.committed[0].time.completed).toEqual(time(7))
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe("memory", () => {
|
|
|
+ test("tracks and replaces the current assistant", () => {
|
|
|
+ const state = active()
|
|
|
+ const adapter = SessionEntryStepper.memory(state)
|
|
|
+ const current = adapter.getCurrentAssistant()
|
|
|
+
|
|
|
+ expect(current?.type).toBe("assistant")
|
|
|
+ if (!current) return
|
|
|
+
|
|
|
+ adapter.updateAssistant(
|
|
|
+ new SessionEntry.Assistant({
|
|
|
+ ...current,
|
|
|
+ content: [new SessionEntry.AssistantText({ type: "text", text: "done" })],
|
|
|
+ time: {
|
|
|
+ ...current.time,
|
|
|
+ completed: time(1),
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(adapter.getCurrentAssistant()).toBeUndefined()
|
|
|
+ expect(state.entries[0]?.type).toBe("assistant")
|
|
|
+ if (state.entries[0]?.type !== "assistant") return
|
|
|
+
|
|
|
+ expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }])
|
|
|
+ expect(state.entries[0].time.completed).toEqual(time(1))
|
|
|
+ })
|
|
|
+
|
|
|
+ test("appends committed and pending entries", () => {
|
|
|
+ const state = memoryState()
|
|
|
+ const adapter = SessionEntryStepper.memory(state)
|
|
|
+ const committed = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }))
|
|
|
+ const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) }))
|
|
|
+
|
|
|
+ adapter.appendEntry(committed)
|
|
|
+ adapter.appendPending(pending)
|
|
|
+
|
|
|
+ expect(state.entries).toEqual([committed])
|
|
|
+ expect(state.pending).toEqual([pending])
|
|
|
+ })
|
|
|
+
|
|
|
+ test("stepWith through memory records reasoning", () => {
|
|
|
+ const state = active()
|
|
|
+
|
|
|
+ SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), SessionEvent.Reasoning.Started.create({ timestamp: time(1) }))
|
|
|
+ SessionEntryStepper.stepWith(SessionEntryStepper.memory(state), SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }))
|
|
|
+ SessionEntryStepper.stepWith(
|
|
|
+ SessionEntryStepper.memory(state),
|
|
|
+ SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }),
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }])
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe("step", () => {
|
|
|
+ describe("seeded pending assistant", () => {
|
|
|
test("stores prompts in entries when no assistant is pending", () => {
|
|
|
FastCheck.assert(
|
|
|
FastCheck.property(word, (body) => {
|
|
|
- const next = SessionEntry.step(history(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
|
+ const next = SessionEntryStepper.step(memoryState(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
|
expect(next.entries).toHaveLength(1)
|
|
|
expect(next.entries[0]?.type).toBe("user")
|
|
|
if (next.entries[0]?.type !== "user") return
|
|
|
@@ -95,7 +234,7 @@ describe("session-entry step", () => {
|
|
|
test("stores prompts in pending when an assistant is pending", () => {
|
|
|
FastCheck.assert(
|
|
|
FastCheck.property(word, (body) => {
|
|
|
- const next = SessionEntry.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
|
+ const next = SessionEntryStepper.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
|
expect(next.pending).toHaveLength(1)
|
|
|
expect(next.pending[0]?.type).toBe("user")
|
|
|
if (next.pending[0]?.type !== "user") return
|
|
|
@@ -110,8 +249,8 @@ describe("session-entry step", () => {
|
|
|
FastCheck.property(texts, (parts) => {
|
|
|
const next = parts.reduce(
|
|
|
(state, part, i) =>
|
|
|
- SessionEntry.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })),
|
|
|
- SessionEntry.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
|
|
|
+ SessionEntryStepper.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })),
|
|
|
+ SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
|
|
|
)
|
|
|
|
|
|
expect(texts_of(next)).toEqual([
|
|
|
@@ -302,7 +441,7 @@ describe("session-entry step", () => {
|
|
|
},
|
|
|
timestamp: time(n),
|
|
|
})
|
|
|
- const next = SessionEntry.step(active(), event)
|
|
|
+ const next = SessionEntryStepper.step(active(), event)
|
|
|
const entry = last(next)
|
|
|
expect(entry).toBeDefined()
|
|
|
if (!entry) return
|
|
|
@@ -316,12 +455,12 @@ describe("session-entry step", () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- describe("known reducer gaps", () => {
|
|
|
+ describe("known reducer gaps", () => {
|
|
|
test("prompt appends immutably when no assistant is pending", () => {
|
|
|
FastCheck.assert(
|
|
|
FastCheck.property(word, (body) => {
|
|
|
- const old = history()
|
|
|
- const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
|
+ const old = memoryState()
|
|
|
+ const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
|
expect(old).not.toBe(next)
|
|
|
expect(old.entries).toHaveLength(0)
|
|
|
expect(next.entries).toHaveLength(1)
|
|
|
@@ -334,7 +473,7 @@ describe("session-entry step", () => {
|
|
|
FastCheck.assert(
|
|
|
FastCheck.property(word, (body) => {
|
|
|
const old = active()
|
|
|
- const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
|
+ const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
|
expect(old).not.toBe(next)
|
|
|
expect(old.pending).toHaveLength(0)
|
|
|
expect(next.pending).toHaveLength(1)
|
|
|
@@ -651,7 +790,7 @@ describe("session-entry step", () => {
|
|
|
test("records synthetic events", () => {
|
|
|
FastCheck.assert(
|
|
|
FastCheck.property(word, (body) => {
|
|
|
- const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
|
|
|
+ const next = SessionEntryStepper.step(memoryState(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
|
|
|
expect(next.entries).toHaveLength(1)
|
|
|
expect(next.entries[0]?.type).toBe("synthetic")
|
|
|
if (next.entries[0]?.type !== "synthetic") return
|
|
|
@@ -664,8 +803,8 @@ describe("session-entry step", () => {
|
|
|
test("records compaction events", () => {
|
|
|
FastCheck.assert(
|
|
|
FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
|
|
|
- const next = SessionEntry.step(
|
|
|
- history(),
|
|
|
+ const next = SessionEntryStepper.step(
|
|
|
+ memoryState(),
|
|
|
SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
|
|
|
)
|
|
|
expect(next.entries).toHaveLength(1)
|
|
|
@@ -677,5 +816,6 @@ describe("session-entry step", () => {
|
|
|
{ numRuns: 50 },
|
|
|
)
|
|
|
})
|
|
|
+ })
|
|
|
})
|
|
|
})
|