nandi/oripublic Fork 0
ca7e020cbb6442f047e0002eb9831ba29f8a17af
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

reducer.test.ts · 322 lines · 8.4 KBTypeScript Blame HistoryRaw
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday1import { describe, expect, it } from "vitest";
2import { initialState, reduce, type ChatState } from "./reducer";
3import type { ServerMessage } from "./protocol";
4
5function afterServer(
6 state: ChatState,
7 ...messages: ServerMessage[]
8): ChatState {
9 return messages.reduce(
10 (s, message) => reduce(s, { type: "server", message }),
11 state,
12 );
13}
14
15describe("reduce", () => {
16 it("stores the session and goes online on hello", () => {
17 const state = afterServer(initialState, {
18 type: "hello",
19 sessionId: "s1",
20 turnActive: true,
21 });
22 expect(state.sessionId).toBe("s1");
23 expect(state.connection).toBe("online");
24 expect(state.turnActive).toBe(true);
25 });
26
27 it("resets the thread on hello so reconnect replays are idempotent", () => {
28 let state = afterServer(
29 initialState,
30 { type: "hello", sessionId: "s1" },
31 { type: "user_message", text: "hi" },
32 );
33 expect(state.thread).toHaveLength(1);
34 state = afterServer(
35 state,
36 { type: "hello", sessionId: "s1" },
37 { type: "user_message", text: "hi" },
38 );
39 expect(state.thread).toHaveLength(1);
40 });
41
42 it("appends user messages to the thread", () => {
43 const state = afterServer(initialState, {
44 type: "user_message",
45 text: "do the thing",
46 });
47 expect(state.thread).toEqual([{ kind: "user", text: "do the thing" }]);
48 });
49
50 it("accumulates agent chunks into one open item", () => {
51 const chunk = (text: string): ServerMessage => ({
52 type: "session_update",
53 update: {
54 sessionUpdate: "agent_message_chunk",
55 content: { type: "text", text },
56 },
57 });
58 const state = afterServer(initialState, chunk("Hello "), chunk("world"));
59 expect(state.thread).toEqual([
60 { kind: "agent", text: "Hello world", closed: false },
61 ]);
62 });
63
64 it("starts a fresh agent item after the turn ends", () => {
65 const chunk = (text: string): ServerMessage => ({
66 type: "session_update",
67 update: {
68 sessionUpdate: "agent_message_chunk",
69 content: { type: "text", text },
70 },
71 });
72 const state = afterServer(
73 initialState,
74 chunk("first answer"),
75 { type: "turn_ended", stopReason: "end_turn" },
76 chunk("second answer"),
77 );
78 expect(state.thread).toHaveLength(2);
79 expect(state.thread[0]).toMatchObject({
80 kind: "agent",
81 text: "first answer",
82 closed: true,
83 });
84 expect(state.thread[1]).toMatchObject({
85 kind: "agent",
86 text: "second answer",
87 closed: false,
88 });
89 });
90
91 it("tracks the turn flag through markers", () => {
92 let state = afterServer(initialState, { type: "turn_started" });
93 expect(state.turnActive).toBe(true);
94 state = afterServer(state, { type: "turn_ended", stopReason: "end_turn" });
95 expect(state.turnActive).toBe(false);
96 });
97
98 it("renders server errors as thread items", () => {
99 const state = afterServer(initialState, { type: "error", message: "boom" });
100 expect(state.thread).toEqual([{ kind: "error", text: "boom" }]);
101 });
102
103 it("ignores unknown update kinds without breaking", () => {
104 const state = afterServer(initialState, {
105 type: "session_update",
106 update: { sessionUpdate: "something_new" },
107 });
108 expect(state.thread).toEqual([]);
109 });
110
111 it("accumulates thought chunks separately from agent messages", () => {
112 const state = afterServer(
113 initialState,
114 {
115 type: "session_update",
116 update: {
117 sessionUpdate: "agent_thought_chunk",
118 content: { type: "text", text: "hmm " },
119 },
120 },
121 {
122 type: "session_update",
123 update: {
124 sessionUpdate: "agent_thought_chunk",
125 content: { type: "text", text: "ok" },
126 },
127 },
128 {
129 type: "session_update",
130 update: {
131 sessionUpdate: "agent_message_chunk",
132 content: { type: "text", text: "Answer" },
133 },
134 },
135 );
136 expect(state.thread).toHaveLength(2);
137 expect(state.thread[0]).toMatchObject({ kind: "thought", text: "hmm ok" });
138 expect(state.thread[1]).toMatchObject({ kind: "agent", text: "Answer" });
139 });
140
141 it("creates a tool call and merges its updates", () => {
142 let state = afterServer(initialState, {
143 type: "session_update",
144 update: {
145 sessionUpdate: "tool_call",
146 toolCallId: "call-1",
147 title: "Reading files",
148 kind: "read",
149 status: "pending",
150 locations: [{ path: "/p/main.go" }],
151 },
152 });
153 expect(state.thread).toEqual([
154 {
155 kind: "tool",
156 call: {
157 toolCallId: "call-1",
158 title: "Reading files",
159 toolKind: "read",
160 status: "pending",
161 contents: [],
162 locations: [{ path: "/p/main.go" }],
163 },
164 },
165 ]);
166
167 state = afterServer(state, {
168 type: "session_update",
169 update: {
170 sessionUpdate: "tool_call_update",
171 toolCallId: "call-1",
172 status: "completed",
173 content: [
174 { type: "content", content: { type: "text", text: "42 lines" } },
175 ],
176 },
177 });
178 expect(state.thread).toHaveLength(1);
179 const item = state.thread[0];
180 if (item.kind !== "tool") throw new Error("expected a tool item");
181 expect(item.call.status).toBe("completed");
182 expect(item.call.title).toBe("Reading files");
183 expect(item.call.contents).toHaveLength(1);
184 });
185
186 it("creates a tool item when an update arrives for an unknown id", () => {
187 const state = afterServer(initialState, {
188 type: "session_update",
189 update: {
190 sessionUpdate: "tool_call_update",
191 toolCallId: "ghost",
192 status: "in_progress",
193 },
194 });
195 expect(state.thread).toHaveLength(1);
196 expect(state.thread[0]).toMatchObject({
197 kind: "tool",
198 call: { toolCallId: "ghost", status: "in_progress" },
199 });
200 });
201
202 it("replaces the plan on each plan update", () => {
203 let state = afterServer(initialState, {
204 type: "session_update",
205 update: {
206 sessionUpdate: "plan",
207 entries: [{ content: "step 1", priority: "high", status: "pending" }],
208 },
209 });
210 expect(state.plan).toHaveLength(1);
211 state = afterServer(state, {
212 type: "session_update",
213 update: {
214 sessionUpdate: "plan",
215 entries: [
216 { content: "step 1", priority: "high", status: "completed" },
217 { content: "step 2", priority: "low", status: "pending" },
218 ],
219 },
220 });
221 expect(state.plan).toHaveLength(2);
222 expect(state.plan[0].status).toBe("completed");
223 });
224
225 it("tracks permission requests until they are resolved", () => {
226 let state = afterServer(initialState, {
227 type: "permission_request",
228 requestId: "perm-1",
229 request: {
230 toolCall: { title: "Run make test" },
231 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
232 },
233 });
234 expect(state.permissions).toEqual([
235 {
236 requestId: "perm-1",
237 title: "Run make test",
238 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
239 },
240 ]);
241
242 // replayed duplicates are ignored
243 state = afterServer(state, {
244 type: "permission_request",
245 requestId: "perm-1",
246 request: { options: [] },
247 });
248 expect(state.permissions).toHaveLength(1);
249
250 state = afterServer(state, {
251 type: "permission_resolved",
252 requestId: "perm-1",
253 });
254 expect(state.permissions).toEqual([]);
255 });
256
257 it("clears plan and permissions on hello", () => {
258 let state = afterServer(
259 initialState,
260 {
261 type: "permission_request",
262 requestId: "perm-1",
263 request: { options: [] },
264 },
265 {
266 type: "session_update",
267 update: {
268 sessionUpdate: "plan",
269 entries: [{ content: "x", priority: "low", status: "pending" }],
270 },
271 },
272 );
273 state = afterServer(state, { type: "hello", sessionId: "s1" });
274 expect(state.permissions).toEqual([]);
275 expect(state.plan).toEqual([]);
276 });
277
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday278 it("stores the agent's available commands and clears them on hello", () => {
279 let state = reduce(initialState, {
280 type: "server",
281 message: {
282 type: "session_update",
283 update: {
284 sessionUpdate: "available_commands_update",
285 availableCommands: [
286 { name: "review", description: "Review changes" },
287 { name: "compact", description: "Summarise", input: { hint: "x" } },
288 ],
289 },
290 },
291 });
292 expect(state.commands.map((c) => c.name)).toEqual(["review", "compact"]);
293
294 state = reduce(state, {
295 type: "server",
296 message: { type: "hello", sessionId: "s2" },
297 });
298 expect(state.commands).toEqual([]);
299 });
300
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday301 it("tracks connection status changes", () => {
302 const state = reduce(initialState, {
303 type: "connection",
304 status: "offline",
305 });
306 expect(state.connection).toBe("offline");
307 });
308
309 it("never mutates the previous state", () => {
310 const before = afterServer(initialState, {
311 type: "user_message",
312 text: "a",
313 });
314 const snapshot = JSON.parse(JSON.stringify(before));
315 afterServer(
316 before,
317 { type: "user_message", text: "b" },
318 { type: "turn_started" },
319 );
320 expect(before).toEqual(snapshot);
321 });
322});