History

approval flow works

Rhys Sullivan committed 9 months ago

Commit 98faa991 parent 378b5704 files · +196 −249Browse files

Unified · Split

4 files
  1. apps/bot/src/commands/ask.tsx +10 −144
  2. apps/bot/src/views/task-message.tsx +115 −101
  3. apps/server/src/index.ts +53 −2
  4. apps/server/src/task-runner.ts +18 −2
apps/bot/src/commands/ask.tsx+10−144
@@ -1,24 +1,17 @@
11 /**
22 * /ask command handler
33 *
4− * 1. Defers the interaction immediately (Discord has a 3s window)
5− * 2. Creates a task on the server via Eden Treaty
6− * 3. Renders a live-updating Reacord message (uses editReply since deferred)
7− * 4. Polls the server for task events and updates the message
8− * 5. Handles approval button clicks by calling the server
4+ * Defers the interaction, creates a task on the server,
5+ * and mounts a self-updating <TaskMessage /> that subscribes
6+ * to the server's SSE stream via Eden Treaty.
97 */
108
11−import type { ChatInputCommandInteraction, ButtonInteraction, CommandInteraction } from "discord.js";
9+import type { ChatInputCommandInteraction, CommandInteraction } from "discord.js";
1210 import type { Client as ApiClient } from "@openassistant/server/client";
1311 import { unwrap } from "@openassistant/server/client";
1412 import type { ReacordInstance } from "@openassistant/reacord";
1513 import { Effect, Runtime } from "effect";
16−import {
17− TaskMessage,
18− initialTaskState,
19− reduceTaskEvent,
20− type TaskState,
21−} from "../views/task-message";
14+import { TaskMessage } from "../views/task-message";
2215
2316 // ---------------------------------------------------------------------------
2417 // Types
@@ -43,17 +36,13 @@ export async function handleAskCommand( · 18 unmodified lines
4336 const requesterId = interaction.user.id;
4437
4538 // Defer immediately — Discord interactions expire after 3 seconds.
46− // Reacord checks interaction.deferred and uses editReply instead of reply.
4739 await interaction.deferReply();
4840
49− // 1. Create task on the server
41+ // Create task on the server
5042 let taskId: string;
5143 try {
5244 const data = await unwrap(
53− deps.api.api.tasks.post({
54− prompt,
55− requesterId,
56− }),
45+ deps.api.api.tasks.post({ prompt, requesterId }),
5746 );
5847 taskId = data.taskId;
5948 } catch (error) {
@@ -63,134 +52,11 @@ export async function handleAskCommand( · 3 unmodified lines
6352 return;
6453 }
6554
66− // 2. Render initial message via Reacord
67− let state = initialTaskState();
68−
69− const instance = await Runtime.runPromise(Runtime.defaultRuntime)(
55+ // Mount the component — it handles its own SSE subscription and state
56+ await Runtime.runPromise(Runtime.defaultRuntime)(
7057 deps.reacord.reply(
7158 interaction,
72− <TaskMessage
73− prompt={prompt}
74− state={state}
75− onApprove={(callId) => handleApproval(callId, "approved", deps)}
76− onDeny={(callId) => handleApproval(callId, "denied", deps)}
77− />,
59+ <TaskMessage taskId={taskId} prompt={prompt} api={deps.api} />,
7860 ),
7961 );
80−
81− // 3. Poll for events and update the message
82− await pollAndUpdate(taskId, prompt, state, instance, deps);
83−}
84−
85−// ---------------------------------------------------------------------------
86−// Event polling loop
87−// ---------------------------------------------------------------------------
88−
89−async function pollAndUpdate(
90− taskId: string,
91− prompt: string,
92− initialState: TaskState,
93− instance: ReacordInstance,
94− deps: AskCommandDeps,
95−): Promise<void> {
96− let state = initialState;
97−
98− // Poll every 500ms for new events
99− const maxPolls = 600; // 5 minutes max
100− for (let i = 0; i < maxPolls; i++) {
101− await Bun.sleep(500);
102−
103− try {
104− const task = await unwrap(deps.api.api.tasks({ id: taskId }).get());
105−
106− // Check if there are pending approvals we haven't seen
107− for (const approval of task.pendingApprovals) {
108− if (!state.pendingApprovals.find((a) => a.id === approval.callId)) {
109− state = reduceTaskEvent(state, {
110− type: "approval_request",
111− id: approval.callId,
112− toolPath: approval.toolPath,
113− input: undefined,
114− preview: { title: `${approval.toolPath}` },
115− });
116− }
117− }
118−
119− // Remove approvals that the server no longer reports
120− const serverCallIds = new Set(task.pendingApprovals.map((a) => a.callId));
121− for (const approval of state.pendingApprovals) {
122− if (!serverCallIds.has(approval.id)) {
123− state = reduceTaskEvent(state, {
124− type: "approval_resolved",
125− id: approval.id,
126− decision: "approved",
127− });
128− }
129− }
130−
131− // Check for completion
132− if (task.status === "completed" && state.status !== "completed") {
133− if (task.resultText) {
134− state = reduceTaskEvent(state, {
135− type: "agent_message",
136− text: task.resultText,
137− });
138− }
139− state = reduceTaskEvent(state, {
140− type: "completed",
141− receipts: [],
142− });
143− } else if (task.status === "failed" && state.status !== "failed") {
144− state = reduceTaskEvent(state, {
145− type: "error",
146− error: task.errorMessage ?? "Task failed (unknown error)",
147− });
148− } else if (task.status === "cancelled" && state.status !== "cancelled") {
149− state = { ...state, status: "cancelled", statusMessage: "Cancelled" };
150− }
151−
152− // Re-render
153− instance.render(
154− <TaskMessage
155− prompt={prompt}
156− state={state}
157− onApprove={(callId) => handleApproval(callId, "approved", deps)}
158− onDeny={(callId) => handleApproval(callId, "denied", deps)}
159− />,
160− );
161−
162− // Stop polling if done
163− if (state.status !== "running") {
164− setTimeout(() => instance.deactivate(), 5000);
165− return;
166− }
167− } catch (error) {
168− console.error(`[poll ${taskId}] error:`, error);
169− }
170− }
171−
172− // Timeout
173− state = { ...state, status: "failed", error: "Task timed out after 5 minutes", statusMessage: "Timed out" };
174− instance.render(
175− <TaskMessage prompt={prompt} state={state} />,
176− );
177− instance.deactivate();
178−}
179−
180−// ---------------------------------------------------------------------------
181−// Approval handler
182−// ---------------------------------------------------------------------------
183−
184−async function handleApproval(
185− callId: string,
186− decision: "approved" | "denied",
187− deps: AskCommandDeps,
188−): Promise<void> {
189− try {
190− await unwrap(
191− deps.api.api.approvals({ callId }).post({ decision }),
192− );
193− } catch (error) {
194− console.error(`[approval ${callId}] error:`, error);
195− }
19662 }
apps/bot/src/views/task-message.tsx+115−101
@@ -1,84 +1,69 @@
11 /**
2− * TaskMessage — the live-updating Discord message for a running task.
2+ * TaskMessage — self-contained live-updating Discord message for a task.
33 *
4− * Shows:
5− * - Current status (thinking, generating code, running, etc.)
6− * - Code blocks when code is generated
7− * - Tool call results
8− * - Approval buttons when approval is required
9− * - Final agent response when complete
4+ * Subscribes to the server's SSE stream via Eden Treaty on mount,
5+ * reduces TaskEvents into local state, and re-renders reactively.
6+ *
7+ * The command handler just mounts this component and walks away:
8+ * <TaskMessage taskId={id} prompt={prompt} api={api} />
109 */
1110
11+import { useState, useEffect } from "react";
1212 import {
1313 Container,
1414 TextDisplay,
1515 Separator,
16− Section,
1716 ActionRow,
1817 Button,
1918 Loading,
2019 useInstance,
2120 } from "@openassistant/reacord";
22−import type { ButtonInteraction } from "discord.js";
21+import type { Client as ApiClient } from "@openassistant/server/client";
22+import { unwrap } from "@openassistant/server/client";
2323 import type { TaskEvent } from "@openassistant/core/events";
2424
2525 // ---------------------------------------------------------------------------
26−// Types
26+// State
2727 // ---------------------------------------------------------------------------
2828
29−export interface PendingApproval {
29+interface PendingApproval {
3030 readonly id: string;
3131 readonly toolPath: string;
3232 readonly preview: { title: string; details?: string };
3333 }
3434
35−export interface TaskState {
35+interface TaskState {
3636 readonly status: "running" | "completed" | "failed" | "cancelled";
3737 readonly statusMessage: string;
38− readonly codeBlocks: string[];
3938 readonly toolResults: string[];
4039 readonly pendingApprovals: PendingApproval[];
4140 readonly agentMessage: string | null;
4241 readonly error: string | null;
4342 }
4443
45−export function initialTaskState(): TaskState {
46− return {
47− status: "running",
48− statusMessage: "Thinking...",
49− codeBlocks: [],
50− toolResults: [],
51− pendingApprovals: [],
52− agentMessage: null,
53− error: null,
54− };
55−}
44+const INITIAL_STATE: TaskState = {
45+ status: "running",
46+ statusMessage: "Thinking...",
47+ toolResults: [],
48+ pendingApprovals: [],
49+ agentMessage: null,
50+ error: null,
51+};
5652
57−/**
58− * Reduce a TaskEvent into the current TaskState.
59− */
60−export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState {
53+function reduceEvent(state: TaskState, event: TaskEvent): TaskState {
6154 switch (event.type) {
6255 case "status":
6356 return { ...state, statusMessage: event.message };
6457
6558 case "code_generated":
66− return {
67− ...state,
68− codeBlocks: [...state.codeBlocks, event.code],
69− statusMessage: "Running code...",
70− };
59+ return { ...state, statusMessage: "Running code..." };
7160
7261 case "approval_request":
7362 return {
7463 ...state,
7564 pendingApprovals: [
7665 ...state.pendingApprovals,
77− {
78− id: event.id,
79− toolPath: event.toolPath,
80− preview: event.preview,
81− },
66+ { id: event.id, toolPath: event.toolPath, preview: event.preview },
8267 ],
8368 statusMessage: "Waiting for approval...",
8469 };
@@ -87,17 +72,14 @@ export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState { · 2 unmodified lines
8772 return {
8873 ...state,
8974 pendingApprovals: state.pendingApprovals.filter((a) => a.id !== event.id),
90− statusMessage: event.decision === "approved" ? "Approved, continuing..." : "Denied, continuing...",
75+ statusMessage: event.decision === "approved" ? "Approved, continuing..." : "Denied.",
9176 };
9277
9378 case "tool_result": {
9479 const r = event.receipt;
95− const status = r.status === "succeeded" ? "\u2705" : r.status === "denied" ? "\u26d4" : "\u274c";
96− const line = `${status} \`${r.toolPath}\`${r.outputPreview ? ` \u2192 ${r.outputPreview.slice(0, 100)}` : ""}`;
97− return {
98− ...state,
99− toolResults: [...state.toolResults, line],
100− };
80+ const icon = r.status === "succeeded" ? "\u2705" : r.status === "denied" ? "\u26d4" : "\u274c";
81+ const line = `${icon} \`${r.toolPath}\`${r.outputPreview ? ` \u2192 ${r.outputPreview.slice(0, 100)}` : ""}`;
82+ return { ...state, toolResults: [...state.toolResults, line] };
10183 }
10284
10385 case "agent_message":
@@ -111,63 +93,87 @@ export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState { · 7 unmodified lines
11193 }
11294 }
11395
114−// ---------------------------------------------------------------------------
115−// Status indicator
116−// ---------------------------------------------------------------------------
117−
118−function statusEmoji(status: TaskState["status"]): string {
119− switch (status) {
120− case "running": return "\u23f3";
121− case "completed": return "\u2705";
122− case "failed": return "\u274c";
123− case "cancelled": return "\u26d4";
124− }
125−}
126−
12796 // ---------------------------------------------------------------------------
12897 // Component
12998 // ---------------------------------------------------------------------------
13099
131100 export interface TaskMessageProps {
101+ readonly taskId: string;
132102 readonly prompt: string;
133− readonly state: TaskState;
134− readonly onApprove?: (callId: string, interaction: ButtonInteraction) => void;
135− readonly onDeny?: (callId: string, interaction: ButtonInteraction) => void;
103+ readonly api: ApiClient;
136104 }
137105
138−export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessageProps) {
106+export function TaskMessage({ taskId, prompt, api }: TaskMessageProps) {
139107 const instance = useInstance();
108+ const [state, setState] = useState<TaskState>(INITIAL_STATE);
109+
110+ // Subscribe to SSE stream on mount
111+ useEffect(() => {
112+ let cancelled = false;
113+
114+ const subscribe = async () => {
115+ try {
116+ const { data: stream, error } = await api.api.tasks({ id: taskId }).events.get();
117+
118+ if (error || !stream) {
119+ if (!cancelled) {
120+ setState((s) => ({ ...s, status: "failed", error: "Failed to connect to event stream", statusMessage: "Failed" }));
121+ }
122+ return;
123+ }
124+
125+ for await (const sse of stream as AsyncIterable<{ event: string; data: TaskEvent }>) {
126+ if (cancelled) break;
127+ setState((s) => reduceEvent(s, sse.data));
128+ }
129+ } catch (err) {
130+ if (!cancelled) {
131+ setState((s) => ({
132+ ...s,
133+ status: "failed",
134+ error: err instanceof Error ? err.message : String(err),
135+ statusMessage: "Failed",
136+ }));
137+ }
138+ }
139+ };
140+
141+ subscribe();
142+
143+ return () => {
144+ cancelled = true;
145+ };
146+ }, [taskId]);
147+
148+ // Deactivate buttons once done
149+ useEffect(() => {
150+ if (state.status !== "running") {
151+ const timer = setTimeout(() => instance.deactivate(), 5000);
152+ return () => clearTimeout(timer);
153+ }
154+ }, [state.status]);
140155
141− // Deactivate interactive components when task is done
142156 const isDone = state.status !== "running";
157+ const accentColor = isDone
158+ ? state.status === "completed" ? 0x57f287 : 0xed4245
159+ : 0x5865f2;
143160
144161 return (
145− <Container accentColor={isDone ? (state.status === "completed" ? 0x57f287 : 0xed4245) : 0x5865f2}>
146− {/* Header */}
162+ <Container accentColor={accentColor}>
147163 <TextDisplay>{`${statusEmoji(state.status)} **${state.statusMessage}**`}</TextDisplay>
148164 <TextDisplay>{`> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`}</TextDisplay>
149165
150− {/* Tool results */}
151166 {state.toolResults.length > 0 && (
152167 <>
153168 <Separator />
154− <TextDisplay>
155− {state.toolResults.slice(-10).join("\n")}
156− </TextDisplay>
169+ <TextDisplay>{state.toolResults.slice(-10).join("\n")}</TextDisplay>
157170 </>
158171 )}
159172
160− {/* Pending approvals */}
161173 {state.pendingApprovals.map((approval) => (
162− <ApprovalSection
163− key={approval.id}
164− approval={approval}
165− onApprove={onApprove}
166− onDeny={onDeny}
167− />
174+ <ApprovalButtons key={approval.id} approval={approval} api={api} />
168175 ))}
169176
170− {/* Error */}
171177 {state.error && (
172178 <>
173179 <Separator />
@@ -175,7 +181,6 @@ export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessagePro · 1 unmodified line
175181 </>
176182 )}
177183
178− {/* Final response */}
179184 {state.agentMessage && (
180185 <>
181186 <Separator />
@@ -187,8 +192,7 @@ export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessagePro · 5 unmodified lines
187192 </>
188193 )}
189194
190− {/* Loading indicator while running */}
191− {state.status === "running" && !state.pendingApprovals.length && (
195+ {state.status === "running" && state.pendingApprovals.length === 0 && (
192196 <Loading />
193197 )}
194198 </Container>
@@ -196,36 +200,46 @@ export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessagePro · 1 unmodified line
196200 }
197201
198202 // ---------------------------------------------------------------------------
199−// Approval sub-component
203+// Approval buttons
200204 // ---------------------------------------------------------------------------
201205
202−interface ApprovalSectionProps {
203− readonly approval: PendingApproval;
204− readonly onApprove?: (callId: string, interaction: ButtonInteraction) => void;
205− readonly onDeny?: (callId: string, interaction: ButtonInteraction) => void;
206−}
206+function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: ApiClient }) {
207+ const [resolved, setResolved] = useState(false);
208+
209+ const handle = async (decision: "approved" | "denied") => {
210+ setResolved(true);
211+ try {
212+ await unwrap(api.api.approvals({ callId: approval.id }).post({ decision }));
213+ } catch (err) {
214+ console.error(`[approval ${approval.id}]`, err);
215+ }
216+ };
207217
208−function ApprovalSection({ approval, onApprove, onDeny }: ApprovalSectionProps) {
209218 return (
210219 <>
211220 <Separator />
212221 <TextDisplay>
213222 {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}`}
214223 </TextDisplay>
215− <ActionRow>
216− <Button
217− label="Approve"
218− style="success"
219− emoji="\u2705"
220− onClick={(interaction) => onApprove?.(approval.id, interaction)}
221− />
222− <Button
223− label="Deny"
224− style="danger"
225− emoji="\u274c"
226− onClick={(interaction) => onDeny?.(approval.id, interaction)}
227− />
228− </ActionRow>
224+ {!resolved && (
225+ <ActionRow>
226+ <Button label="Approve" style="success" onClick={() => handle("approved")} />
227+ <Button label="Deny" style="danger" onClick={() => handle("denied")} />
228+ </ActionRow>
229+ )}
229230 </>
230231 );
231232 }
233+
234+// ---------------------------------------------------------------------------
235+// Helpers
236+// ---------------------------------------------------------------------------
237+
238+function statusEmoji(status: TaskState["status"]): string {
239+ switch (status) {
240+ case "running": return "\u23f3";
241+ case "completed": return "\u2705";
242+ case "failed": return "\u274c";
243+ case "cancelled": return "\u26d4";
244+ }
245+}
apps/server/src/index.ts+53−2
@@ -8,9 +8,10 @@ · 7 unmodified lines
88 import { readFileSync } from "node:fs";
99 import { createApp } from "./routes.js";
1010 import { createPiAiModel } from "@openassistant/core";
11−import { mergeToolTrees, type ToolTree } from "@openassistant/core/tools";
11+import { defineTool, mergeToolTrees, type ToolTree } from "@openassistant/core/tools";
1212 import { generateMcpTools } from "@openassistant/tool-gen/mcp";
1313 import type { McpToolSource } from "@openassistant/tool-gen/mcp";
14+import { z } from "zod";
1415
1516 // ---------------------------------------------------------------------------
1617 // API key resolution
@@ -53,12 +54,62 @@ const mcpSources: McpToolSource[] = [ · 36 unmodified lines
5354 },
5455 ];
5556
57+// ---------------------------------------------------------------------------
58+// Hand-written tools (approval testing)
59+// ---------------------------------------------------------------------------
60+
61+const builtinTools: ToolTree = {
62+ admin: {
63+ /** Requires approval — lets you test the approval UI flow. */
64+ send_announcement: defineTool({
65+ description: "Send an announcement message to a channel. Requires approval because it posts publicly.",
66+ approval: "required",
67+ args: z.object({
68+ channel: z.string().describe("Channel name to post in"),
69+ message: z.string().describe("The announcement text"),
70+ }),
71+ returns: z.object({ sent: z.boolean(), channel: z.string(), message: z.string() }),
72+ run: async (input) => ({ sent: true, channel: input.channel, message: input.message }),
73+ formatApproval: (input) => ({
74+ title: `Post to #${input.channel}`,
75+ details: `Message: "${input.message}"`,
76+ }),
77+ }),
78+
79+ /** Requires approval — destructive action. */
80+ delete_data: defineTool({
81+ description: "Delete stored data by key. Requires approval because it's destructive.",
82+ approval: "required",
83+ args: z.object({
84+ key: z.string().describe("The data key to delete"),
85+ }),
86+ returns: z.object({ deleted: z.boolean(), key: z.string() }),
87+ run: async (input) => ({ deleted: true, key: input.key }),
88+ formatApproval: (input) => ({
89+ title: `Delete "${input.key}"`,
90+ details: "This action cannot be undone.",
91+ }),
92+ }),
93+ },
94+
95+ utils: {
96+ /** Auto-approved — safe read-only tool. */
97+ get_time: defineTool({
98+ description: "Get the current date and time.",
99+ approval: "auto",
100+ args: z.object({}),
101+ returns: z.object({ iso: z.string(), unix: z.number() }),
102+ run: async () => ({ iso: new Date().toISOString(), unix: Date.now() }),
103+ }),
104+ },
105+};
106+
56107 // ---------------------------------------------------------------------------
57108 // Tool loading
58109 // ---------------------------------------------------------------------------
59110
60111 async function loadTools(): Promise<ToolTree> {
61− const trees: ToolTree[] = [];
112+ const trees: ToolTree[] = [builtinTools];
62113
63114 for (const source of mcpSources) {
64115 try {
apps/server/src/task-runner.ts+18−2
@@ -37,14 +37,30 @@ export async function runTask( · 36 unmodified lines
3737 tools: options.tools,
3838 model: options.model,
3939 requestApproval: (request: ApprovalRequest): Promise<ApprovalDecision> => {
40+ // Emit the approval_request event so SSE subscribers see it
41+ emitTaskEvent(taskId, {
42+ type: "approval_request",
43+ id: request.callId,
44+ toolPath: request.toolPath,
45+ input: request.input,
46+ preview: request.preview,
47+ });
48+
4049 return new Promise<ApprovalDecision>((resolve) => {
41− // Register the pending approval so REST API can resolve it
4250 registerApproval({
4351 callId: request.callId,
4452 taskId,
4553 toolPath: request.toolPath,
4654 input: request.input,
47− resolve,
55+ resolve: (decision: ApprovalDecision) => {
56+ // Emit resolved event before unblocking the runner
57+ emitTaskEvent(taskId, {
58+ type: "approval_resolved",
59+ id: request.callId,
60+ decision,
61+ });
62+ resolve(decision);
63+ },
4864 });
4965 });
5066 },