import { describe, expect, it } from "vitest";
import type { Event } from "../src/core/events.js";
import {
apply,
budget,
capabilities,
conversation,
emptyBudget,
emptyCapabilities,
emptyConversation,
emptyPlan,
emptyProjections,
emptySessionMeta,
emptyStatus,
emptyWorkspace,
plan,
replay,
sessionMeta,
status,
workspace,
} from "../src/core/reducers.js";
const ts = "2026-04-29T12:00:00Z";
let nextId = 0;
const ev = <T extends Event>(e: Omit<T, "id"> & { id?: number }): T =>
({ ...e, id: e.id ?? ++nextId }) as T;
describe("conversation reducer", () => {
it("appends user message", () => {
const v = conversation(
emptyConversation(),
ev<Event>({ type: "user.message", ts, turn: 1, text: "hi" }),
);
expect(v.messages).toEqual([{ role: "user", content: "hi" }]);
});
it("appends assistant final with tool_calls and reasoning", () => {
const v = conversation(
emptyConversation(),
ev<Event>({
type: "model.final",
ts,
turn: 1,
content: "ok",
reasoningContent: "thinking",
toolCalls: [{ id: "c1", function: { name: "shell", arguments: "{}" } }],
usage: {},
costUsd: 0,
}),
);
expect(v.messages[0]).toEqual({
role: "assistant",
content: "ok",
reasoning_content: "thinking",
tool_calls: [{ id: "c1", function: { name: "shell", arguments: "{}" } }],
});
});
it("tool.intent → pending; tool.result → tool msg + pending cleared", () => {
let v = emptyConversation();
v = conversation(
v,
ev<Event>({ type: "tool.intent", ts, turn: 1, callId: "c1", name: "shell", args: "{}" }),
);
expect(v.pendingToolCalls).toEqual([{ callId: "c1", name: "shell" }]);
v = conversation(
v,
ev<Event>({
type: "tool.result",
ts,
turn: 1,
callId: "c1",
ok: true,
output: "done",
durationMs: 5,
}),
);
expect(v.pendingToolCalls).toEqual([]);
expect(v.messages).toEqual([{ role: "tool", content: "done", tool_call_id: "c1" }]);
});
it("tool.denied removes pending and writes denial message", () => {
let v = emptyConversation();
v = conversation(
v,
ev<Event>({ type: "tool.intent", ts, turn: 1, callId: "c1", name: "shell", args: "" }),
);
v = conversation(
v,
ev<Event>({ type: "tool.denied", ts, turn: 1, callId: "c1", reason: "permission" }),
);
expect(v.pendingToolCalls).toEqual([]);
expect(v.messages).toEqual([
{ role: "tool", content: "denied: permission", tool_call_id: "c1" },
]);
});
it("session.compacted REPLACES messages and clears pending", () => {
let v = emptyConversation();
v = conversation(v, ev<Event>({ type: "user.message", ts, turn: 1, text: "old" }));
v = conversation(
v,
ev<Event>({ type: "tool.intent", ts, turn: 1, callId: "c1", name: "shell", args: "" }),
);
v = conversation(
v,
ev<Event>({
type: "session.compacted",
ts,
turn: 2,
beforeMessages: 5,
afterMessages: 1,
reason: "user",
replacementMessages: [{ role: "system", content: "summary" }],
}),
);
expect(v.messages).toEqual([{ role: "system", content: "summary" }]);
expect(v.pendingToolCalls).toEqual([]);
});
});
describe("budget reducer", () => {
it("accumulates cost and token usage from model.final", () => {
let v = emptyBudget(10);
v = budget(
v,
ev<Event>({
type: "model.final",
ts,
turn: 1,
content: "",
toolCalls: [],
usage: { prompt_tokens: 100, completion_tokens: 50, prompt_cache_hit_tokens: 80 },
costUsd: 0.002,
}),
);
v = budget(
v,
ev<Event>({
type: "model.final",
ts,
turn: 2,
content: "",
toolCalls: [],
usage: { prompt_tokens: 200, prompt_cache_miss_tokens: 200 },
costUsd: 0.005,
}),
);
expect(v.spentUsd).toBeCloseTo(0.007);
expect(v.promptTokens).toBe(300);
expect(v.completionTokens).toBe(50);
expect(v.cacheHitTokens).toBe(80);
expect(v.cacheMissTokens).toBe(200);
expect(v.capUsd).toBe(10);
});
it("warned and blocked latch", () => {
let v = emptyBudget(1);
v = budget(
v,
ev<Event>({ type: "policy.budget.warning", ts, turn: 1, spentUsd: 0.8, capUsd: 1 }),
);
v = budget(v, ev<Event>({ type: "user.message", ts, turn: 2, text: "ignored" }));
expect(v.warned).toBe(true);
v = budget(
v,
ev<Event>({ type: "policy.budget.blocked", ts, turn: 2, spentUsd: 1.05, capUsd: 1 }),
);
expect(v.blocked).toBe(true);
});
});
describe("plan reducer", () => {
it("submitted populates steps as not-completed", () => {
const v = plan(
emptyPlan(),
ev<Event>({
type: "plan.submitted",
ts,
turn: 3,
steps: [
{ id: "a", title: "A", action: "do A", risk: "low" },
{ id: "b", title: "B", action: "do B" },
],
body: "## plan",
}),
);
expect(v.submittedTurn).toBe(3);
expect(v.steps).toEqual([
{ id: "a", title: "A", action: "do A", risk: "low", completed: false },
{ id: "b", title: "B", action: "do B", risk: undefined, completed: false },
]);
});
it("step.completed marks only the target", () => {
let v = plan(
emptyPlan(),
ev<Event>({
type: "plan.submitted",
ts,
turn: 1,
steps: [
{ id: "a", title: "A", action: "" },
{ id: "b", title: "B", action: "" },
],
body: "",
}),
);
v = plan(
v,
ev<Event>({
type: "plan.step.completed",
ts,
turn: 2,
stepId: "b",
notes: "ok",
completion: { kind: "step_completed", stepId: "b", result: "ok" },
}),
);
expect(v.steps.find((s) => s.id === "a")?.completed).toBe(false);
expect(v.steps.find((s) => s.id === "b")?.completed).toBe(true);
expect(v.steps.find((s) => s.id === "b")?.notes).toBe("ok");
});
it("step.completed with unknown id is a no-op", () => {
const before = plan(
emptyPlan(),
ev<Event>({
type: "plan.submitted",
ts,
turn: 1,
steps: [{ id: "a", title: "A", action: "" }],
body: "",
}),
);
const after = plan(
before,
ev<Event>({
type: "plan.step.completed",
ts,
turn: 2,
stepId: "ghost",
completion: { kind: "step_completed", stepId: "ghost", result: "" },
}),
);
expect(after).toBe(before);
});
});
describe("workspace reducer", () => {
it("file.touched upsert; same path replaces mode", () => {
let v = workspace(
emptyWorkspace(),
ev<Event>({
type: "effect.file.touched",
ts,
turn: 1,
path: "a.ts",
mode: "create",
bytes: 10,
}),
);
v = workspace(
v,
ev<Event>({
type: "effect.file.touched",
ts,
turn: 2,
path: "a.ts",
mode: "edit",
bytes: 12,
}),
);
v = workspace(
v,
ev<Event>({
type: "effect.file.touched",
ts,
turn: 2,
path: "b.ts",
mode: "create",
bytes: 5,
}),
);
expect(v.filesTouched.get("a.ts")).toBe("edit");
expect(v.filesTouched.get("b.ts")).toBe("create");
});
it("checkpoint.created sets lastCheckpointId", () => {
const v = workspace(
emptyWorkspace(),
ev<Event>({
type: "checkpoint.created",
ts,
turn: 1,
checkpointId: "cp-1",
name: "wip",
source: "manual",
fileCount: 2,
bytes: 100,
}),
);
expect(v.lastCheckpointId).toBe("cp-1");
});
});
describe("capabilities reducer", () => {
it("register / re-register replaces; remove drops", () => {
let v = capabilities(
emptyCapabilities(),
ev<Event>({ type: "capability.registered", ts, turn: 1, name: "shell", permission: "ask" }),
);
v = capabilities(
v,
ev<Event>({ type: "capability.registered", ts, turn: 1, name: "shell", permission: "allow" }),
);
v = capabilities(
v,
ev<Event>({ type: "capability.registered", ts, turn: 1, name: "fs", permission: "ask" }),
);
expect(v.tools).toEqual([
{ name: "shell", permission: "allow" },
{ name: "fs", permission: "ask" },
]);
v = capabilities(v, ev<Event>({ type: "capability.removed", ts, turn: 2, name: "shell" }));
expect(v.tools).toEqual([{ name: "fs", permission: "ask" }]);
});
});
describe("status reducer", () => {
it("status sets text; primary event clears it", () => {
let v = status(emptyStatus(), ev<Event>({ type: "status", ts, turn: 1, text: "harvesting" }));
expect(v.current).toBe("harvesting");
v = status(v, ev<Event>({ type: "model.delta", ts, turn: 1, channel: "content", text: "x" }));
expect(v.current).toBeNull();
});
it("non-primary event preserves status", () => {
let v = status(emptyStatus(), ev<Event>({ type: "status", ts, turn: 1, text: "thinking" }));
v = status(v, ev<Event>({ type: "user.message", ts, turn: 1, text: "later" }));
expect(v.current).toBe("thinking");
});
});
describe("sessionMeta reducer", () => {
it("session.opened sets name + openedAt; turn tracks max", () => {
let v = sessionMeta(
emptySessionMeta(),
ev<Event>({ type: "session.opened", ts, turn: 5, name: "wip", resumedFromTurn: 4 }),
);
expect(v.name).toBe("wip");
expect(v.openedAt).toBe(ts);
expect(v.currentTurn).toBe(5);
v = sessionMeta(v, ev<Event>({ type: "user.message", ts, turn: 7, text: "q" }));
expect(v.currentTurn).toBe(7);
v = sessionMeta(v, ev<Event>({ type: "user.message", ts, turn: 6, text: "stale" }));
expect(v.currentTurn).toBe(7);
});
it("error event records lastError", () => {
const v = sessionMeta(
emptySessionMeta(),
ev<Event>({ type: "error", ts, turn: 1, message: "boom", recoverable: true }),
);
expect(v.lastError).toBe("boom");
});
});
describe("replay determinism", () => {
it("same events twice → same projections", () => {
const events: Event[] = [
ev<Event>({ type: "session.opened", ts, turn: 1, name: "s", resumedFromTurn: 0 }),
ev<Event>({ type: "user.message", ts, turn: 1, text: "hi" }),
ev<Event>({
type: "model.final",
ts,
turn: 1,
content: "",
toolCalls: [{ id: "c1", function: { name: "shell", arguments: "{}" } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
costUsd: 0.001,
}),
ev<Event>({ type: "tool.intent", ts, turn: 1, callId: "c1", name: "shell", args: "{}" }),
ev<Event>({ type: "tool.dispatched", ts, turn: 1, callId: "c1" }),
ev<Event>({
type: "tool.result",
ts,
turn: 1,
callId: "c1",
ok: true,
output: "done",
durationMs: 3,
}),
];
const a = replay(events, 5);
const b = replay(events, 5);
expect(a).toEqual(b);
expect(a.conversation.messages).toHaveLength(3);
expect(a.conversation.pendingToolCalls).toHaveLength(0);
expect(a.budget.spentUsd).toBeCloseTo(0.001);
expect(a.budget.capUsd).toBe(5);
expect(a.session.currentTurn).toBe(1);
});
it("apply composes all reducers", () => {
const e: Event = ev<Event>({
type: "checkpoint.created",
ts,
turn: 1,
checkpointId: "cp-x",
name: "wip",
source: "manual",
fileCount: 0,
bytes: 0,
});
const next = apply(emptyProjections(), e);
expect(next.workspace.lastCheckpointId).toBe("cp-x");
expect(next.session.currentTurn).toBe(1);
});
});