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 @@
1/**1/**
2 * /ask command handler2 * /ask command handler
3 *3 *
4 * 1. Defers the interaction immediately (Discord has a 3s window)4 * Defers the interaction, creates a task on the server,
5 * 2. Creates a task on the server via Eden Treaty5 * and mounts a self-updating <TaskMessage /> that subscribes
6 * 3. Renders a live-updating Reacord message (uses editReply since deferred)6 * to the server's SSE stream via Eden Treaty.
7 * 4. Polls the server for task events and updates the message
8 * 5. Handles approval button clicks by calling the server
9 */7 */
10 8
11import type { ChatInputCommandInteraction, ButtonInteraction, CommandInteraction } from "discord.js";9import type { ChatInputCommandInteraction, CommandInteraction } from "discord.js";
12import type { Client as ApiClient } from "@openassistant/server/client";10import type { Client as ApiClient } from "@openassistant/server/client";
13import { unwrap } from "@openassistant/server/client";11import { unwrap } from "@openassistant/server/client";
14import type { ReacordInstance } from "@openassistant/reacord";12import type { ReacordInstance } from "@openassistant/reacord";
15import { Effect, Runtime } from "effect";13import { Effect, Runtime } from "effect";
16import {14import { TaskMessage } from "../views/task-message";
17 TaskMessage,
18 initialTaskState,
19 reduceTaskEvent,
20 type TaskState,
21} from "../views/task-message";
22 15
23// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
24// Types17// Types
@@ -43,17 +36,13 @@ export async function handleAskCommand( · 18 unmodified lines
43 const requesterId = interaction.user.id;36 const requesterId = interaction.user.id;
44 37
45 // Defer immediately — Discord interactions expire after 3 seconds.38 // Defer immediately — Discord interactions expire after 3 seconds.
46 // Reacord checks interaction.deferred and uses editReply instead of reply.
47 await interaction.deferReply();39 await interaction.deferReply();
48 40
49 // 1. Create task on the server41 // Create task on the server
50 let taskId: string;42 let taskId: string;
51 try {43 try {
52 const data = await unwrap(44 const data = await unwrap(
53 deps.api.api.tasks.post({45 deps.api.api.tasks.post({ prompt, requesterId }),
54 prompt,
55 requesterId,
56 }),
57 );46 );
58 taskId = data.taskId;47 taskId = data.taskId;
59 } catch (error) {48 } catch (error) {
@@ -63,134 +52,11 @@ export async function handleAskCommand( · 3 unmodified lines
63 return;52 return;
64 }53 }
65 54
66 // 2. Render initial message via Reacord55 // Mount the component — it handles its own SSE subscription and state
67 let state = initialTaskState();56 await Runtime.runPromise(Runtime.defaultRuntime)(
68
69 const instance = await Runtime.runPromise(Runtime.defaultRuntime)(
70 deps.reacord.reply(57 deps.reacord.reply(
71 interaction,58 interaction,
72 <TaskMessage59 <TaskMessage taskId={taskId} prompt={prompt} api={deps.api} />,
73 prompt={prompt}
74 state={state}
75 onApprove={(callId) => handleApproval(callId, "approved", deps)}
76 onDeny={(callId) => handleApproval(callId, "denied", deps)}
77 />,
78 ),60 ),
79 );61 );
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
89async 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
184async 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 }
196}62}
apps/bot/src/views/task-message.tsx+115−101
@@ -1,84 +1,69 @@
1/**1/**
2 * TaskMessage — the live-updating Discord message for a running task.2 * TaskMessage — self-contained live-updating Discord message for a task.
3 *3 *
4 * Shows:4 * Subscribes to the server's SSE stream via Eden Treaty on mount,
5 * - Current status (thinking, generating code, running, etc.)5 * reduces TaskEvents into local state, and re-renders reactively.
6 * - Code blocks when code is generated6 *
7 * - Tool call results7 * The command handler just mounts this component and walks away:
8 * - Approval buttons when approval is required8 * <TaskMessage taskId={id} prompt={prompt} api={api} />
9 * - Final agent response when complete
10 */9 */
11 10
11import { useState, useEffect } from "react";
12import {12import {
13 Container,13 Container,
14 TextDisplay,14 TextDisplay,
15 Separator,15 Separator,
16 Section,
17 ActionRow,16 ActionRow,
18 Button,17 Button,
19 Loading,18 Loading,
20 useInstance,19 useInstance,
21} from "@openassistant/reacord";20} from "@openassistant/reacord";
22import type { ButtonInteraction } from "discord.js";21import type { Client as ApiClient } from "@openassistant/server/client";
22import { unwrap } from "@openassistant/server/client";
23import type { TaskEvent } from "@openassistant/core/events";23import type { TaskEvent } from "@openassistant/core/events";
24 24
25// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
26// Types26// State
27// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
28 28
29export interface PendingApproval {29interface PendingApproval {
30 readonly id: string;30 readonly id: string;
31 readonly toolPath: string;31 readonly toolPath: string;
32 readonly preview: { title: string; details?: string };32 readonly preview: { title: string; details?: string };
33}33}
34 34
35export interface TaskState {35interface TaskState {
36 readonly status: "running" | "completed" | "failed" | "cancelled";36 readonly status: "running" | "completed" | "failed" | "cancelled";
37 readonly statusMessage: string;37 readonly statusMessage: string;
38 readonly codeBlocks: string[];
39 readonly toolResults: string[];38 readonly toolResults: string[];
40 readonly pendingApprovals: PendingApproval[];39 readonly pendingApprovals: PendingApproval[];
41 readonly agentMessage: string | null;40 readonly agentMessage: string | null;
42 readonly error: string | null;41 readonly error: string | null;
43}42}
44 43
45export function initialTaskState(): TaskState {44const INITIAL_STATE: TaskState = {
46 return {45 status: "running",
47 status: "running",46 statusMessage: "Thinking...",
48 statusMessage: "Thinking...",47 toolResults: [],
49 codeBlocks: [],48 pendingApprovals: [],
50 toolResults: [],49 agentMessage: null,
51 pendingApprovals: [],50 error: null,
52 agentMessage: null,51};
53 error: null,
54 };
55}
56 52
57/**53function reduceEvent(state: TaskState, event: TaskEvent): TaskState {
58 * Reduce a TaskEvent into the current TaskState.
59 */
60export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState {
61 switch (event.type) {54 switch (event.type) {
62 case "status":55 case "status":
63 return { ...state, statusMessage: event.message };56 return { ...state, statusMessage: event.message };
64 57
65 case "code_generated":58 case "code_generated":
66 return {59 return { ...state, statusMessage: "Running code..." };
67 ...state,
68 codeBlocks: [...state.codeBlocks, event.code],
69 statusMessage: "Running code...",
70 };
71 60
72 case "approval_request":61 case "approval_request":
73 return {62 return {
74 ...state,63 ...state,
75 pendingApprovals: [64 pendingApprovals: [
76 ...state.pendingApprovals,65 ...state.pendingApprovals,
77 {66 { id: event.id, toolPath: event.toolPath, preview: event.preview },
78 id: event.id,
79 toolPath: event.toolPath,
80 preview: event.preview,
81 },
82 ],67 ],
83 statusMessage: "Waiting for approval...",68 statusMessage: "Waiting for approval...",
84 };69 };
@@ -87,17 +72,14 @@ export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState { · 2 unmodified lines
87 return {72 return {
88 ...state,73 ...state,
89 pendingApprovals: state.pendingApprovals.filter((a) => a.id !== event.id),74 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.",
91 };76 };
92 77
93 case "tool_result": {78 case "tool_result": {
94 const r = event.receipt;79 const r = event.receipt;
95 const status = r.status === "succeeded" ? "\u2705" : r.status === "denied" ? "\u26d4" : "\u274c";80 const icon = r.status === "succeeded" ? "\u2705" : r.status === "denied" ? "\u26d4" : "\u274c";
96 const line = `${status} \`${r.toolPath}\`${r.outputPreview ? ` \u2192 ${r.outputPreview.slice(0, 100)}` : ""}`;81 const line = `${icon} \`${r.toolPath}\`${r.outputPreview ? ` \u2192 ${r.outputPreview.slice(0, 100)}` : ""}`;
97 return {82 return { ...state, toolResults: [...state.toolResults, line] };
98 ...state,
99 toolResults: [...state.toolResults, line],
100 };
101 }83 }
102 84
103 case "agent_message":85 case "agent_message":
@@ -111,63 +93,87 @@ export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState { · 7 unmodified lines
111 }93 }
112}94}
113 95
114// ---------------------------------------------------------------------------
115// Status indicator
116// ---------------------------------------------------------------------------
117
118function 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
127// ---------------------------------------------------------------------------96// ---------------------------------------------------------------------------
128// Component97// Component
129// ---------------------------------------------------------------------------98// ---------------------------------------------------------------------------
130 99
131export interface TaskMessageProps {100export interface TaskMessageProps {
101 readonly taskId: string;
132 readonly prompt: string;102 readonly prompt: string;
133 readonly state: TaskState;103 readonly api: ApiClient;
134 readonly onApprove?: (callId: string, interaction: ButtonInteraction) => void;
135 readonly onDeny?: (callId: string, interaction: ButtonInteraction) => void;
136}104}
137 105
138export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessageProps) {106export function TaskMessage({ taskId, prompt, api }: TaskMessageProps) {
139 const instance = useInstance();107 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]);
140 155
141 // Deactivate interactive components when task is done
142 const isDone = state.status !== "running";156 const isDone = state.status !== "running";
157 const accentColor = isDone
158 ? state.status === "completed" ? 0x57f287 : 0xed4245
159 : 0x5865f2;
143 160
144 return (161 return (
145 <Container accentColor={isDone ? (state.status === "completed" ? 0x57f287 : 0xed4245) : 0x5865f2}>162 <Container accentColor={accentColor}>
146 {/* Header */}
147 <TextDisplay>{`${statusEmoji(state.status)} **${state.statusMessage}**`}</TextDisplay>163 <TextDisplay>{`${statusEmoji(state.status)} **${state.statusMessage}**`}</TextDisplay>
148 <TextDisplay>{`> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`}</TextDisplay>164 <TextDisplay>{`> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`}</TextDisplay>
149 165
150 {/* Tool results */}
151 {state.toolResults.length > 0 && (166 {state.toolResults.length > 0 && (
152 <>167 <>
153 <Separator />168 <Separator />
154 <TextDisplay>169 <TextDisplay>{state.toolResults.slice(-10).join("\n")}</TextDisplay>
155 {state.toolResults.slice(-10).join("\n")}
156 </TextDisplay>
157 </>170 </>
158 )}171 )}
159 172
160 {/* Pending approvals */}
161 {state.pendingApprovals.map((approval) => (173 {state.pendingApprovals.map((approval) => (
162 <ApprovalSection174 <ApprovalButtons key={approval.id} approval={approval} api={api} />
163 key={approval.id}
164 approval={approval}
165 onApprove={onApprove}
166 onDeny={onDeny}
167 />
168 ))}175 ))}
169 176
170 {/* Error */}
171 {state.error && (177 {state.error && (
172 <>178 <>
173 <Separator />179 <Separator />
@@ -175,7 +181,6 @@ export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessagePro · 1 unmodified line
175 </>181 </>
176 )}182 )}
177 183
178 {/* Final response */}
179 {state.agentMessage && (184 {state.agentMessage && (
180 <>185 <>
181 <Separator />186 <Separator />
@@ -187,8 +192,7 @@ export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessagePro · 5 unmodified lines
187 </>192 </>
188 )}193 )}
189 194
190 {/* Loading indicator while running */}195 {state.status === "running" && state.pendingApprovals.length === 0 && (
191 {state.status === "running" && !state.pendingApprovals.length && (
192 <Loading />196 <Loading />
193 )}197 )}
194 </Container>198 </Container>
@@ -196,36 +200,46 @@ export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessagePro · 1 unmodified line
196}200}
197 201
198// ---------------------------------------------------------------------------202// ---------------------------------------------------------------------------
199// Approval sub-component203// Approval buttons
200// ---------------------------------------------------------------------------204// ---------------------------------------------------------------------------
201 205
202interface ApprovalSectionProps {206function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: ApiClient }) {
203 readonly approval: PendingApproval;207 const [resolved, setResolved] = useState(false);
204 readonly onApprove?: (callId: string, interaction: ButtonInteraction) => void;208
205 readonly onDeny?: (callId: string, interaction: ButtonInteraction) => void;209 const handle = async (decision: "approved" | "denied") => {
206}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 };
207 217
208function ApprovalSection({ approval, onApprove, onDeny }: ApprovalSectionProps) {
209 return (218 return (
210 <>219 <>
211 <Separator />220 <Separator />
212 <TextDisplay>221 <TextDisplay>
213 {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}`}222 {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}`}
214 </TextDisplay>223 </TextDisplay>
215 <ActionRow>224 {!resolved && (
216 <Button225 <ActionRow>
217 label="Approve"226 <Button label="Approve" style="success" onClick={() => handle("approved")} />
218 style="success"227 <Button label="Deny" style="danger" onClick={() => handle("denied")} />
219 emoji="\u2705"228 </ActionRow>
220 onClick={(interaction) => onApprove?.(approval.id, interaction)}229 )}
221 />
222 <Button
223 label="Deny"
224 style="danger"
225 emoji="\u274c"
226 onClick={(interaction) => onDeny?.(approval.id, interaction)}
227 />
228 </ActionRow>
229 </>230 </>
230 );231 );
231}232}
233
234// ---------------------------------------------------------------------------
235// Helpers
236// ---------------------------------------------------------------------------
237
238function 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
8import { readFileSync } from "node:fs";8import { readFileSync } from "node:fs";
9import { createApp } from "./routes.js";9import { createApp } from "./routes.js";
10import { createPiAiModel } from "@openassistant/core";10import { createPiAiModel } from "@openassistant/core";
11import { mergeToolTrees, type ToolTree } from "@openassistant/core/tools";11import { defineTool, mergeToolTrees, type ToolTree } from "@openassistant/core/tools";
12import { generateMcpTools } from "@openassistant/tool-gen/mcp";12import { generateMcpTools } from "@openassistant/tool-gen/mcp";
13import type { McpToolSource } from "@openassistant/tool-gen/mcp";13import type { McpToolSource } from "@openassistant/tool-gen/mcp";
14import { z } from "zod";
14 15
15// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
16// API key resolution17// API key resolution
@@ -53,12 +54,62 @@ const mcpSources: McpToolSource[] = [ · 36 unmodified lines
53 },54 },
54];55];
55 56
57// ---------------------------------------------------------------------------
58// Hand-written tools (approval testing)
59// ---------------------------------------------------------------------------
60
61const 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
56// ---------------------------------------------------------------------------107// ---------------------------------------------------------------------------
57// Tool loading108// Tool loading
58// ---------------------------------------------------------------------------109// ---------------------------------------------------------------------------
59 110
60async function loadTools(): Promise<ToolTree> {111async function loadTools(): Promise<ToolTree> {
61 const trees: ToolTree[] = [];112 const trees: ToolTree[] = [builtinTools];
62 113
63 for (const source of mcpSources) {114 for (const source of mcpSources) {
64 try {115 try {
apps/server/src/task-runner.ts+18−2
@@ -37,14 +37,30 @@ export async function runTask( · 36 unmodified lines
37 tools: options.tools,37 tools: options.tools,
38 model: options.model,38 model: options.model,
39 requestApproval: (request: ApprovalRequest): Promise<ApprovalDecision> => {39 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
40 return new Promise<ApprovalDecision>((resolve) => {49 return new Promise<ApprovalDecision>((resolve) => {
41 // Register the pending approval so REST API can resolve it
42 registerApproval({50 registerApproval({
43 callId: request.callId,51 callId: request.callId,
44 taskId,52 taskId,
45 toolPath: request.toolPath,53 toolPath: request.toolPath,
46 input: request.input,54 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 },
48 });64 });
49 });65 });
50 },66 },