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 | /** |
| 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 | ||
| 7 | import 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. */ | |
| 17 | export 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. */ | |
| 27 | export 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. */ | |
| 35 | export interface PermissionRequestState { | |
| 36 | requestId: string; | |
| 37 | title: string; | |
| 38 | options: AcpPermissionOption[]; | |
| 39 | } | |
| 40 | ||
| 41 | export type ConnectionStatus = "connecting" | "online" | "offline"; | |
| 42 | ||
| 43 | /** The whole state the UI renders from. */ | |
| 44 | export interface ChatState { | |
| 45 | connection: ConnectionStatus; | |
| 46 | sessionId: string | null; | |
| 47 | turnActive: boolean; | |
| 48 | thread: ThreadItem[]; | |
| 49 | plan: AcpPlanEntry[]; | |
| 50 | permissions: PermissionRequestState[]; | |
| 51 | } | |
| 52 | ||
| 53 | export 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. */ | |
| 63 | export 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 | */ | |
| 75 | export 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 | ||
| 84 | type MessageHandler = (state: ChatState, msg: ServerMessage) => ChatState; | |
| 85 | ||
| 86 | /** One handler per server message type; unknown types fall through in reduce. */ | |
| 87 | const 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 | */ | |
| 122 | const 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 | ||
| 132 | function 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. */ | |
| 140 | function blockText( | |
| 141 | content: AcpContentBlock | AcpToolCallContent[] | undefined, | |
| 142 | ): string { | |
| 143 | if (!content || Array.isArray(content)) { | |
| 144 | return ""; | |
| 145 | } | |
| 146 | return content.text ?? ""; | |
| 147 | } | |
| 148 | ||
| 149 | function 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. */ | |
| 154 | function 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 | */ | |
| 174 | function 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 | ||
| 217 | function 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. */ | |
| 238 | function closeStreamItems(thread: ThreadItem[]): ThreadItem[] { | |
| 239 | return thread.map((item) => | |
| 240 | item.kind === "agent" || item.kind === "thought" | |
| 241 | ? { ...item, closed: true } | |
| 242 | : item, | |
| 243 | ); | |
| 244 | } |