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