| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681 |
- 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 { SessionEvent } from "../../src/v2/session-event"
- const time = (n: number) => DateTime.makeUnsafe(n)
- const word = FastCheck.string({ minLength: 1, maxLength: 8 })
- const text = FastCheck.string({ maxLength: 16 })
- const texts = FastCheck.array(text, { maxLength: 8 })
- const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
- const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
- const files = FastCheck.array(
- word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
- { maxLength: 2 },
- )
- function maybe<A>(arb: FastCheck.Arbitrary<A>) {
- return FastCheck.oneof(FastCheck.constant(undefined), arb)
- }
- function assistant() {
- return new SessionEntry.Assistant({
- id: SessionEvent.ID.create(),
- type: "assistant",
- time: { created: time(0) },
- content: [],
- })
- }
- function history() {
- const state: SessionEntry.History = {
- entries: [],
- pending: [],
- }
- return state
- }
- function active() {
- const state: SessionEntry.History = {
- 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 last(state: SessionEntry.History) {
- 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) {
- const entry = last(state)
- if (!entry) return []
- return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
- }
- function reasons(state: SessionEntry.History) {
- const entry = last(state)
- if (!entry) return []
- return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
- }
- function tools(state: SessionEntry.History) {
- 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) {
- return tools(state).find((x) => x.callID === callID)
- }
- describe("session-entry 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) }))
- expect(next.entries).toHaveLength(1)
- expect(next.entries[0]?.type).toBe("user")
- if (next.entries[0]?.type !== "user") return
- expect(next.entries[0].text).toBe(body)
- }),
- { numRuns: 50 },
- )
- })
- 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) }))
- expect(next.pending).toHaveLength(1)
- expect(next.pending[0]?.type).toBe("user")
- if (next.pending[0]?.type !== "user") return
- expect(next.pending[0].text).toBe(body)
- }),
- { numRuns: 50 },
- )
- })
- test("accumulates text deltas on the latest text part", () => {
- FastCheck.assert(
- 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) })),
- )
- expect(texts_of(next)).toEqual([
- {
- type: "text",
- text: parts.join(""),
- },
- ])
- }),
- { numRuns: 100 },
- )
- })
- test("routes later text deltas to the latest text segment", () => {
- FastCheck.assert(
- FastCheck.property(texts, texts, (a, b) => {
- const next = run(
- [
- SessionEvent.Text.Started.create({ timestamp: time(1) }),
- ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
- SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
- ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
- ],
- active(),
- )
- expect(texts_of(next)).toEqual([
- { type: "text", text: a.join("") },
- { type: "text", text: b.join("") },
- ])
- }),
- { numRuns: 50 },
- )
- })
- test("reasoning.ended replaces buffered reasoning text", () => {
- FastCheck.assert(
- FastCheck.property(texts, text, (parts, end) => {
- const next = run(
- [
- SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
- ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
- SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
- ],
- active(),
- )
- expect(reasons(next)).toEqual([
- {
- type: "reasoning",
- text: end,
- },
- ])
- }),
- { numRuns: 100 },
- )
- })
- test("tool.success completes the latest running tool", () => {
- FastCheck.assert(
- FastCheck.property(
- word,
- word,
- dict,
- maybe(text),
- maybe(dict),
- maybe(files),
- texts,
- (callID, title, input, output, metadata, attachments, parts) => {
- const next = run(
- [
- SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
- ...parts.map((x, i) =>
- SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
- ),
- SessionEvent.Tool.Called.create({
- callID,
- tool: "bash",
- input,
- provider: { executed: true },
- timestamp: time(parts.length + 2),
- }),
- SessionEvent.Tool.Success.create({
- callID,
- title,
- output,
- metadata,
- attachments,
- provider: { executed: true },
- timestamp: time(parts.length + 3),
- }),
- ],
- active(),
- )
- const match = tool(next, callID)
- expect(match?.state.status).toBe("completed")
- if (match?.state.status !== "completed") return
- expect(match.time.ran).toEqual(time(parts.length + 2))
- expect(match.state.input).toEqual(input)
- expect(match.state.output).toBe(output ?? "")
- expect(match.state.title).toBe(title)
- expect(match.state.metadata).toEqual(metadata ?? {})
- expect(match.state.attachments).toEqual(attachments ?? [])
- },
- ),
- { numRuns: 50 },
- )
- })
- test("tool.error completes the latest running tool with an error", () => {
- FastCheck.assert(
- FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
- const next = run(
- [
- SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
- SessionEvent.Tool.Called.create({
- callID,
- tool: "bash",
- input,
- provider: { executed: true },
- timestamp: time(2),
- }),
- SessionEvent.Tool.Error.create({
- callID,
- error,
- metadata,
- provider: { executed: true },
- timestamp: time(3),
- }),
- ],
- active(),
- )
- const match = tool(next, callID)
- expect(match?.state.status).toBe("error")
- if (match?.state.status !== "error") return
- expect(match.time.ran).toEqual(time(2))
- expect(match.state.input).toEqual(input)
- expect(match.state.error).toBe(error)
- expect(match.state.metadata).toEqual(metadata ?? {})
- }),
- { numRuns: 50 },
- )
- })
- test("tool.success is ignored before tool.called promotes the tool to running", () => {
- FastCheck.assert(
- FastCheck.property(word, word, (callID, title) => {
- const next = run(
- [
- SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
- SessionEvent.Tool.Success.create({
- callID,
- title,
- provider: { executed: true },
- timestamp: time(2),
- }),
- ],
- active(),
- )
- const match = tool(next, callID)
- expect(match?.state).toEqual({
- status: "pending",
- input: "",
- })
- }),
- { numRuns: 50 },
- )
- })
- test("step.ended copies completion fields onto the pending assistant", () => {
- FastCheck.assert(
- FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
- const event = SessionEvent.Step.Ended.create({
- reason: "stop",
- cost: 1,
- tokens: {
- input: 1,
- output: 2,
- reasoning: 3,
- cache: {
- read: 4,
- write: 5,
- },
- },
- timestamp: time(n),
- })
- const next = SessionEntry.step(active(), event)
- const entry = last(next)
- expect(entry).toBeDefined()
- if (!entry) return
- expect(entry.time.completed).toEqual(event.timestamp)
- expect(entry.cost).toBe(event.cost)
- expect(entry.tokens).toEqual(event.tokens)
- }),
- { numRuns: 50 },
- )
- })
- })
- 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) }))
- expect(old).not.toBe(next)
- expect(old.entries).toHaveLength(0)
- expect(next.entries).toHaveLength(1)
- }),
- { numRuns: 50 },
- )
- })
- test("prompt appends immutably when an assistant is pending", () => {
- FastCheck.assert(
- FastCheck.property(word, (body) => {
- const old = active()
- const next = SessionEntry.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)
- }),
- { numRuns: 50 },
- )
- })
- test("step.started creates an assistant consumed by follow-up events", () => {
- FastCheck.assert(
- FastCheck.property(texts, (parts) => {
- const next = run([
- SessionEvent.Step.Started.create({
- model: {
- id: "model",
- providerID: "provider",
- },
- timestamp: time(1),
- }),
- SessionEvent.Text.Started.create({ timestamp: time(2) }),
- ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
- SessionEvent.Step.Ended.create({
- reason: "stop",
- cost: 1,
- tokens: {
- input: 1,
- output: 2,
- reasoning: 3,
- cache: {
- read: 4,
- write: 5,
- },
- },
- timestamp: time(parts.length + 3),
- }),
- ])
- const entry = last(next)
- expect(entry).toBeDefined()
- if (!entry) return
- expect(entry.content).toEqual([
- {
- type: "text",
- text: parts.join(""),
- },
- ])
- expect(entry.time.completed).toEqual(time(parts.length + 3))
- }),
- { numRuns: 100 },
- )
- })
- test("replays prompt -> step -> text -> step.ended", () => {
- FastCheck.assert(
- FastCheck.property(word, texts, (body, parts) => {
- const next = run([
- SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
- SessionEvent.Step.Started.create({
- model: {
- id: "model",
- providerID: "provider",
- },
- timestamp: time(1),
- }),
- SessionEvent.Text.Started.create({ timestamp: time(2) }),
- ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
- SessionEvent.Step.Ended.create({
- reason: "stop",
- cost: 1,
- tokens: {
- input: 1,
- output: 2,
- reasoning: 3,
- cache: {
- read: 4,
- write: 5,
- },
- },
- timestamp: time(parts.length + 3),
- }),
- ])
- expect(next.entries).toHaveLength(2)
- expect(next.entries[0]?.type).toBe("user")
- expect(next.entries[1]?.type).toBe("assistant")
- if (next.entries[1]?.type !== "assistant") return
- expect(next.entries[1].content).toEqual([
- {
- type: "text",
- text: parts.join(""),
- },
- ])
- expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
- }),
- { numRuns: 50 },
- )
- })
- test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
- FastCheck.assert(
- FastCheck.property(
- word,
- texts,
- text,
- dict,
- word,
- maybe(text),
- maybe(dict),
- maybe(files),
- (body, reason, end, input, title, output, metadata, attachments) => {
- const callID = "call"
- const next = run([
- SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
- SessionEvent.Step.Started.create({
- model: {
- id: "model",
- providerID: "provider",
- },
- timestamp: time(1),
- }),
- SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
- ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
- SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
- SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
- SessionEvent.Tool.Called.create({
- callID,
- tool: "bash",
- input,
- provider: { executed: true },
- timestamp: time(reason.length + 5),
- }),
- SessionEvent.Tool.Success.create({
- callID,
- title,
- output,
- metadata,
- attachments,
- provider: { executed: true },
- timestamp: time(reason.length + 6),
- }),
- SessionEvent.Step.Ended.create({
- reason: "stop",
- cost: 1,
- tokens: {
- input: 1,
- output: 2,
- reasoning: 3,
- cache: {
- read: 4,
- write: 5,
- },
- },
- timestamp: time(reason.length + 7),
- }),
- ])
- expect(next.entries.at(-1)?.type).toBe("assistant")
- const entry = next.entries.at(-1)
- if (entry?.type !== "assistant") return
- expect(entry.content).toHaveLength(2)
- expect(entry.content[0]).toEqual({
- type: "reasoning",
- text: end,
- })
- expect(entry.content[1]?.type).toBe("tool")
- if (entry.content[1]?.type !== "tool") return
- expect(entry.content[1].state.status).toBe("completed")
- expect(entry.time.completed).toEqual(time(reason.length + 7))
- },
- ),
- { numRuns: 50 },
- )
- })
- test("starting a new step completes the old assistant and appends a new active assistant", () => {
- const next = run(
- [
- SessionEvent.Step.Started.create({
- model: {
- id: "model",
- providerID: "provider",
- },
- timestamp: time(1),
- }),
- ],
- active(),
- )
- expect(next.entries).toHaveLength(2)
- expect(next.entries[0]?.type).toBe("assistant")
- expect(next.entries[1]?.type).toBe("assistant")
- if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
- expect(next.entries[0].time.completed).toEqual(time(1))
- expect(next.entries[1].time.created).toEqual(time(1))
- expect(next.entries[1].time.completed).toBeUndefined()
- })
- test("handles sequential tools independently", () => {
- FastCheck.assert(
- FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
- const next = run(
- [
- SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
- SessionEvent.Tool.Called.create({
- callID: "a",
- tool: "bash",
- input: a,
- provider: { executed: true },
- timestamp: time(2),
- }),
- SessionEvent.Tool.Success.create({
- callID: "a",
- title,
- output: "done",
- provider: { executed: true },
- timestamp: time(3),
- }),
- SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
- SessionEvent.Tool.Called.create({
- callID: "b",
- tool: "bash",
- input: b,
- provider: { executed: true },
- timestamp: time(5),
- }),
- SessionEvent.Tool.Error.create({
- callID: "b",
- error,
- provider: { executed: true },
- timestamp: time(6),
- }),
- ],
- active(),
- )
- const first = tool(next, "a")
- const second = tool(next, "b")
- expect(first?.state.status).toBe("completed")
- if (first?.state.status !== "completed") return
- expect(first.state.input).toEqual(a)
- expect(first.state.output).toBe("done")
- expect(first.state.title).toBe(title)
- expect(second?.state.status).toBe("error")
- if (second?.state.status !== "error") return
- expect(second.state.input).toEqual(b)
- expect(second.state.error).toBe(error)
- }),
- { numRuns: 50 },
- )
- })
- test("routes tool events by callID when tool streams interleave", () => {
- FastCheck.assert(
- FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
- const next = run(
- [
- SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
- SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
- SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
- SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
- SessionEvent.Tool.Called.create({
- callID: "a",
- tool: "bash",
- input: a,
- provider: { executed: true },
- timestamp: time(5),
- }),
- SessionEvent.Tool.Called.create({
- callID: "b",
- tool: "grep",
- input: b,
- provider: { executed: true },
- timestamp: time(6),
- }),
- SessionEvent.Tool.Success.create({
- callID: "a",
- title: titleA,
- output: "done-a",
- provider: { executed: true },
- timestamp: time(7),
- }),
- SessionEvent.Tool.Success.create({
- callID: "b",
- title: titleB,
- output: "done-b",
- provider: { executed: true },
- timestamp: time(8),
- }),
- ],
- active(),
- )
- const first = tool(next, "a")
- const second = tool(next, "b")
- expect(first?.state.status).toBe("completed")
- expect(second?.state.status).toBe("completed")
- if (first?.state.status !== "completed" || second?.state.status !== "completed") return
- expect(first.state.input).toEqual(a)
- expect(second.state.input).toEqual(b)
- expect(first.state.title).toBe(titleA)
- expect(second.state.title).toBe(titleB)
- }),
- { numRuns: 50 },
- )
- })
- test("records synthetic events", () => {
- FastCheck.assert(
- FastCheck.property(word, (body) => {
- const next = SessionEntry.step(history(), 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
- expect(next.entries[0].text).toBe(body)
- }),
- { numRuns: 50 },
- )
- })
- test("records compaction events", () => {
- FastCheck.assert(
- FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
- const next = SessionEntry.step(
- history(),
- SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
- )
- expect(next.entries).toHaveLength(1)
- expect(next.entries[0]?.type).toBe("compaction")
- if (next.entries[0]?.type !== "compaction") return
- expect(next.entries[0].auto).toBe(auto)
- expect(next.entries[0].overflow).toBe(overflow)
- }),
- { numRuns: 50 },
- )
- })
- })
- })
|