nandi/oripublic Fork 0
5d476ff5f3dc3b7db3afaccaedc7d9661981449e
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.ts · 252 lines · 7.2 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 yesterday1/**
2 * Pure state logic of the chat: a reducer folding server messages and
3 * connection changes into the UI state. Keeping it a plain function makes the
4 * whole conversation flow testable without React or a WebSocket.
5 */
6
7import type {
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday8 AcpAvailableCommand,
✨ 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 yesterday9 AcpContentBlock,
10 AcpPlanEntry,
11 AcpPermissionOption,
12 AcpToolCallContent,
13 AcpToolCallLocation,
14 ServerMessage,
15} from "./protocol";
16
17/** The UI's view of one tool call, folded from tool_call / tool_call_update. */
18export interface ToolCall {
19 toolCallId: string;
20 title: string;
21 toolKind: string;
22 status: string;
23 contents: AcpToolCallContent[];
24 locations: AcpToolCallLocation[];
25}
26
27/** One entry of the conversation thread. */
28export type ThreadItem =
29 | { kind: "user"; text: string }
30 | { kind: "agent"; text: string; closed: boolean }
31 | { kind: "thought"; text: string; closed: boolean }
32 | { kind: "tool"; call: ToolCall }
33 | { kind: "error"; text: string };
34
35/** A permission request awaiting the user's decision. */
36export interface PermissionRequestState {
37 requestId: string;
38 title: string;
39 options: AcpPermissionOption[];
40}
41
42export type ConnectionStatus = "connecting" | "online" | "offline";
43
44/** The whole state the UI renders from. */
45export interface ChatState {
46 connection: ConnectionStatus;
47 sessionId: string | null;
48 turnActive: boolean;
49 thread: ThreadItem[];
50 plan: AcpPlanEntry[];
51 permissions: PermissionRequestState[];
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday52 /** Slash commands the agent announced (available_commands_update). */
53 commands: AcpAvailableCommand[];
✨ 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 yesterday54}
55
56export const initialState: ChatState = {
57 connection: "connecting",
58 sessionId: null,
59 turnActive: false,
60 thread: [],
61 plan: [],
62 permissions: [],
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday63 commands: [],
✨ 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 yesterday64};
65
66/** Everything that can change the state. */
67export type ChatEvent =
68 | { type: "server"; message: ServerMessage }
69 | { type: "connection"; status: ConnectionStatus };
70
71/**
72 * reduce returns the next state for an event. It never mutates its input.
73 *
74 * @example
75 * let state = initialState;
76 * state = reduce(state, { type: "server", message: { type: "hello", sessionId: "s1" } });
77 * state.sessionId; // "s1"
78 */
79export function reduce(state: ChatState, event: ChatEvent): ChatState {
80 if (event.type === "connection") {
81 return { ...state, connection: event.status };
82 }
83 // Unknown or not-yet-handled message types must never break the UI.
84 const handler = serverHandlers[event.message.type];
85 return handler ? handler(state, event.message) : state;
86}
87
88type MessageHandler = (state: ChatState, msg: ServerMessage) => ChatState;
89
90/** One handler per server message type; unknown types fall through in reduce. */
91const serverHandlers: Record<string, MessageHandler> = {
92 // A hello opens every (re)connection, right before the full history
93 // replay: resetting everything here makes reconnects idempotent.
94 hello: (state, msg) => ({
95 ...state,
96 connection: "online",
97 sessionId: msg.sessionId ?? null,
98 turnActive: msg.turnActive ?? false,
99 thread: [],
100 plan: [],
101 permissions: [],
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday102 commands: [],
✨ 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 yesterday103 }),
104 user_message: (state, msg) =>
105 withItem(state, { kind: "user", text: msg.text ?? "" }),
106 turn_started: (state) => ({ ...state, turnActive: true }),
107 turn_ended: (state) => ({
108 ...state,
109 turnActive: false,
110 thread: closeStreamItems(state.thread),
111 }),
112 session_update: reduceUpdate,
113 permission_request: addPermission,
114 permission_resolved: (state, msg) => ({
115 ...state,
116 permissions: state.permissions.filter((p) => p.requestId !== msg.requestId),
117 }),
118 error: (state, msg) =>
119 withItem(state, { kind: "error", text: msg.message ?? "unknown error" }),
120};
121
122/**
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday123 * One handler per ACP update kind; the rest (current_mode_update, ...) are
124 * not rendered yet, and ignoring them keeps the UI forward-compatible.
✨ 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 yesterday125 */
126const updateHandlers: Record<string, MessageHandler> = {
127 agent_message_chunk: (state, msg) =>
128 appendStreamText(state, "agent", blockText(msg.update?.content)),
129 agent_thought_chunk: (state, msg) =>
130 appendStreamText(state, "thought", blockText(msg.update?.content)),
131 tool_call: upsertToolCall,
132 tool_call_update: upsertToolCall,
133 plan: (state, msg) => ({ ...state, plan: msg.update?.entries ?? [] }),
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday134 available_commands_update: (state, msg) => ({
135 ...state,
136 commands: msg.update?.availableCommands ?? [],
137 }),
✨ 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 yesterday138};
139
140function reduceUpdate(state: ChatState, msg: ServerMessage): ChatState {
141 const handler = msg.update
142 ? updateHandlers[msg.update.sessionUpdate]
143 : undefined;
144 return handler ? handler(state, msg) : state;
145}
146
147/** blockText extracts the text of a single content block, if any. */
148function blockText(
149 content: AcpContentBlock | AcpToolCallContent[] | undefined,
150): string {
151 if (!content || Array.isArray(content)) {
152 return "";
153 }
154 return content.text ?? "";
155}
156
157function withItem(state: ChatState, item: ThreadItem): ChatState {
158 return { ...state, thread: [...state.thread, item] };
159}
160
161/** Streamed chunks accumulate into the last open item of the same kind. */
162function appendStreamText(
163 state: ChatState,
164 kind: "agent" | "thought",
165 text: string,
166): ChatState {
167 const thread = [...state.thread];
168 const last = thread[thread.length - 1];
169 if (last && last.kind === kind && !last.closed) {
170 thread[thread.length - 1] = { ...last, text: last.text + text };
171 return { ...state, thread };
172 }
173 thread.push({ kind, text, closed: false });
174 return { ...state, thread };
175}
176
177/**
178 * upsertToolCall folds a tool_call or tool_call_update into the thread:
179 * updates merge only the fields they carry, and an update for an unknown id
180 * creates the entry (the adapter may resume a call after a reconnect).
181 */
182function upsertToolCall(state: ChatState, msg: ServerMessage): ChatState {
183 const update = msg.update;
184 if (!update || !update.toolCallId) {
185 return state;
186 }
187
188 const contents = Array.isArray(update.content) ? update.content : undefined;
189 const thread = [...state.thread];
190 const index = thread.findIndex(
191 (item) =>
192 item.kind === "tool" && item.call.toolCallId === update.toolCallId,
193 );
194
195 if (index >= 0) {
196 const existing = thread[index] as Extract<ThreadItem, { kind: "tool" }>;
197 thread[index] = {
198 kind: "tool",
199 call: {
200 ...existing.call,
201 title: update.title ?? existing.call.title,
202 toolKind: update.kind ?? existing.call.toolKind,
203 status: update.status ?? existing.call.status,
204 contents: contents ?? existing.call.contents,
205 locations: update.locations ?? existing.call.locations,
206 },
207 };
208 return { ...state, thread };
209 }
210
211 thread.push({
212 kind: "tool",
213 call: {
214 toolCallId: update.toolCallId,
215 title: update.title ?? "Tool call",
216 toolKind: update.kind ?? "other",
217 status: update.status ?? "pending",
218 contents: contents ?? [],
219 locations: update.locations ?? [],
220 },
221 });
222 return { ...state, thread };
223}
224
225function addPermission(state: ChatState, msg: ServerMessage): ChatState {
226 if (
227 !msg.requestId ||
228 state.permissions.some((p) => p.requestId === msg.requestId)
229 ) {
230 return state;
231 }
232 return {
233 ...state,
234 permissions: [
235 ...state.permissions,
236 {
237 requestId: msg.requestId,
238 title: msg.request?.toolCall?.title ?? "The agent asks for permission",
239 options: msg.request?.options ?? [],
240 },
241 ],
242 };
243}
244
245/** turn_ended closes every streaming item so the next turn starts fresh. */
246function closeStreamItems(thread: ThreadItem[]): ThreadItem[] {
247 return thread.map((item) =>
248 item.kind === "agent" || item.kind === "thought"
249 ? { ...item, closed: true }
250 : item,
251 );
252}