nandi/oripublic Fork 0
55d4c9e2f7a9027b52846558166f42e50851523a
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
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
/**
 * Pure state logic of the chat: a reducer folding server messages and
 * connection changes into the UI state. Keeping it a plain function makes the
 * whole conversation flow testable without React or a WebSocket.
 */

import type {
	AcpAvailableCommand,
	AcpContentBlock,
	AcpPlanEntry,
	AcpPermissionOption,
	AcpToolCallContent,
	AcpToolCallLocation,
	ServerMessage,
} from "./protocol";

/** The UI's view of one tool call, folded from tool_call / tool_call_update. */
export interface ToolCall {
	toolCallId: string;
	title: string;
	toolKind: string;
	status: string;
	contents: AcpToolCallContent[];
	locations: AcpToolCallLocation[];
}

/** One entry of the conversation thread. */
export type ThreadItem =
	| { kind: "user"; text: string }
	| { kind: "agent"; text: string; closed: boolean }
	| { kind: "thought"; text: string; closed: boolean }
	| { kind: "tool"; call: ToolCall }
	| { kind: "error"; text: string };

/** A permission request awaiting the user's decision. */
export interface PermissionRequestState {
	requestId: string;
	title: string;
	options: AcpPermissionOption[];
}

export type ConnectionStatus = "connecting" | "online" | "offline";

/** The whole state the UI renders from. */
export interface ChatState {
	connection: ConnectionStatus;
	sessionId: string | null;
	turnActive: boolean;
	thread: ThreadItem[];
	plan: AcpPlanEntry[];
	permissions: PermissionRequestState[];
	/** Slash commands the agent announced (available_commands_update). */
	commands: AcpAvailableCommand[];
}

export const initialState: ChatState = {
	connection: "connecting",
	sessionId: null,
	turnActive: false,
	thread: [],
	plan: [],
	permissions: [],
	commands: [],
};

/** Everything that can change the state. */
export type ChatEvent =
	| { type: "server"; message: ServerMessage }
	| { type: "connection"; status: ConnectionStatus };

/**
 * reduce returns the next state for an event. It never mutates its input.
 *
 * @example
 * let state = initialState;
 * state = reduce(state, { type: "server", message: { type: "hello", sessionId: "s1" } });
 * state.sessionId; // "s1"
 */
export function reduce(state: ChatState, event: ChatEvent): ChatState {
	if (event.type === "connection") {
		return { ...state, connection: event.status };
	}
	// Unknown or not-yet-handled message types must never break the UI.
	const handler = serverHandlers[event.message.type];
	return handler ? handler(state, event.message) : state;
}

type MessageHandler = (state: ChatState, msg: ServerMessage) => ChatState;

/** One handler per server message type; unknown types fall through in reduce. */
const serverHandlers: Record<string, MessageHandler> = {
	// A hello opens every (re)connection, right before the full history
	// replay: resetting everything here makes reconnects idempotent.
	hello: (state, msg) => ({
		...state,
		connection: "online",
		sessionId: msg.sessionId ?? null,
		turnActive: msg.turnActive ?? false,
		thread: [],
		plan: [],
		permissions: [],
		commands: [],
	}),
	user_message: (state, msg) =>
		withItem(state, { kind: "user", text: msg.text ?? "" }),
	turn_started: (state) => ({ ...state, turnActive: true }),
	turn_ended: (state) => ({
		...state,
		turnActive: false,
		thread: closeStreamItems(state.thread),
	}),
	session_update: reduceUpdate,
	permission_request: addPermission,
	permission_resolved: (state, msg) => ({
		...state,
		permissions: state.permissions.filter((p) => p.requestId !== msg.requestId),
	}),
	error: (state, msg) =>
		withItem(state, { kind: "error", text: msg.message ?? "unknown error" }),
};

/**
 * One handler per ACP update kind; the rest (current_mode_update, ...) are
 * not rendered yet, and ignoring them keeps the UI forward-compatible.
 */
const updateHandlers: Record<string, MessageHandler> = {
	agent_message_chunk: (state, msg) =>
		appendStreamText(state, "agent", blockText(msg.update?.content)),
	agent_thought_chunk: (state, msg) =>
		appendStreamText(state, "thought", blockText(msg.update?.content)),
	tool_call: upsertToolCall,
	tool_call_update: upsertToolCall,
	plan: (state, msg) => ({ ...state, plan: msg.update?.entries ?? [] }),
	available_commands_update: (state, msg) => ({
		...state,
		commands: msg.update?.availableCommands ?? [],
	}),
};

function reduceUpdate(state: ChatState, msg: ServerMessage): ChatState {
	const handler = msg.update
		? updateHandlers[msg.update.sessionUpdate]
		: undefined;
	return handler ? handler(state, msg) : state;
}

/** blockText extracts the text of a single content block, if any. */
function blockText(
	content: AcpContentBlock | AcpToolCallContent[] | undefined,
): string {
	if (!content || Array.isArray(content)) {
		return "";
	}
	return content.text ?? "";
}

function withItem(state: ChatState, item: ThreadItem): ChatState {
	return { ...state, thread: [...state.thread, item] };
}

/** Streamed chunks accumulate into the last open item of the same kind. */
function appendStreamText(
	state: ChatState,
	kind: "agent" | "thought",
	text: string,
): ChatState {
	const thread = [...state.thread];
	const last = thread[thread.length - 1];
	if (last && last.kind === kind && !last.closed) {
		thread[thread.length - 1] = { ...last, text: last.text + text };
		return { ...state, thread };
	}
	thread.push({ kind, text, closed: false });
	return { ...state, thread };
}

/**
 * upsertToolCall folds a tool_call or tool_call_update into the thread:
 * updates merge only the fields they carry, and an update for an unknown id
 * creates the entry (the adapter may resume a call after a reconnect).
 */
function upsertToolCall(state: ChatState, msg: ServerMessage): ChatState {
	const update = msg.update;
	if (!update || !update.toolCallId) {
		return state;
	}

	const contents = Array.isArray(update.content) ? update.content : undefined;
	const thread = [...state.thread];
	const index = thread.findIndex(
		(item) =>
			item.kind === "tool" && item.call.toolCallId === update.toolCallId,
	);

	if (index >= 0) {
		const existing = thread[index] as Extract<ThreadItem, { kind: "tool" }>;
		thread[index] = {
			kind: "tool",
			call: {
				...existing.call,
				title: update.title ?? existing.call.title,
				toolKind: update.kind ?? existing.call.toolKind,
				status: update.status ?? existing.call.status,
				contents: contents ?? existing.call.contents,
				locations: update.locations ?? existing.call.locations,
			},
		};
		return { ...state, thread };
	}

	thread.push({
		kind: "tool",
		call: {
			toolCallId: update.toolCallId,
			title: update.title ?? "Tool call",
			toolKind: update.kind ?? "other",
			status: update.status ?? "pending",
			contents: contents ?? [],
			locations: update.locations ?? [],
		},
	});
	return { ...state, thread };
}

function addPermission(state: ChatState, msg: ServerMessage): ChatState {
	if (
		!msg.requestId ||
		state.permissions.some((p) => p.requestId === msg.requestId)
	) {
		return state;
	}
	return {
		...state,
		permissions: [
			...state.permissions,
			{
				requestId: msg.requestId,
				title: msg.request?.toolCall?.title ?? "The agent asks for permission",
				options: msg.request?.options ?? [],
			},
		],
	};
}

/** turn_ended closes every streaming item so the next turn starts fresh. */
function closeStreamItems(thread: ThreadItem[]): ThreadItem[] {
	return thread.map((item) =>
		item.kind === "agent" || item.kind === "thought"
			? { ...item, closed: true }
			: item,
	);
}