forked from bots-garden/ori
| ✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { initialState, reduce, type ChatState } from "./reducer"; | |
| 3 | import type { ServerMessage } from "./protocol"; | |
| 4 | ||
| 5 | function 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 | ||
| 15 | describe("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 | 278 | 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 | 301 | 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 | }); |