Unified · Split
| @@ -1,24 +1,17 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * /ask command handler |
| 3 | 3 | * |
| 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. | |
| 9 | 7 | */ |
| 10 | 8 | |
| 11 | −import type { ChatInputCommandInteraction, ButtonInteraction, CommandInteraction } from "discord.js"; | |
| 9 | +import type { ChatInputCommandInteraction, CommandInteraction } from "discord.js"; | |
| 12 | 10 | import type { Client as ApiClient } from "@openassistant/server/client"; |
| 13 | 11 | import { unwrap } from "@openassistant/server/client"; |
| 14 | 12 | import type { ReacordInstance } from "@openassistant/reacord"; |
| 15 | 13 | 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"; | |
| 22 | 15 | |
| 23 | 16 | // --------------------------------------------------------------------------- |
| 24 | 17 | // Types |
| @@ -43,17 +36,13 @@ export async function handleAskCommand( · 18 unmodified lines | ||
| 43 | 36 | const requesterId = interaction.user.id; |
| 44 | 37 | |
| 45 | 38 | // Defer immediately — Discord interactions expire after 3 seconds. |
| 46 | − // Reacord checks interaction.deferred and uses editReply instead of reply. | |
| 47 | 39 | await interaction.deferReply(); |
| 48 | 40 | |
| 49 | − // 1. Create task on the server | |
| 41 | + // Create task on the server | |
| 50 | 42 | let taskId: string; |
| 51 | 43 | try { |
| 52 | 44 | const data = await unwrap( |
| 53 | − deps.api.api.tasks.post({ | |
| 54 | − prompt, | |
| 55 | − requesterId, | |
| 56 | − }), | |
| 45 | + deps.api.api.tasks.post({ prompt, requesterId }), | |
| 57 | 46 | ); |
| 58 | 47 | taskId = data.taskId; |
| 59 | 48 | } catch (error) { |
| @@ -63,134 +52,11 @@ export async function handleAskCommand( · 3 unmodified lines | ||
| 63 | 52 | return; |
| 64 | 53 | } |
| 65 | 54 | |
| 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)( | |
| 70 | 57 | deps.reacord.reply( |
| 71 | 58 | 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} />, | |
| 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 | − | |
| 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 | − } | |
| 196 | 62 | } |
| @@ -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: | |
| 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} /> | |
| 10 | 9 | */ |
| 11 | 10 | |
| 11 | +import { useState, useEffect } from "react"; | |
| 12 | 12 | import { |
| 13 | 13 | Container, |
| 14 | 14 | TextDisplay, |
| 15 | 15 | Separator, |
| 16 | − Section, | |
| 17 | 16 | ActionRow, |
| 18 | 17 | Button, |
| 19 | 18 | Loading, |
| 20 | 19 | useInstance, |
| 21 | 20 | } 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"; | |
| 23 | 23 | import type { TaskEvent } from "@openassistant/core/events"; |
| 24 | 24 | |
| 25 | 25 | // --------------------------------------------------------------------------- |
| 26 | −// Types | |
| 26 | +// State | |
| 27 | 27 | // --------------------------------------------------------------------------- |
| 28 | 28 | |
| 29 | −export interface PendingApproval { | |
| 29 | +interface PendingApproval { | |
| 30 | 30 | readonly id: string; |
| 31 | 31 | readonly toolPath: string; |
| 32 | 32 | readonly preview: { title: string; details?: string }; |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | −export interface TaskState { | |
| 35 | +interface TaskState { | |
| 36 | 36 | readonly status: "running" | "completed" | "failed" | "cancelled"; |
| 37 | 37 | readonly statusMessage: string; |
| 38 | − readonly codeBlocks: string[]; | |
| 39 | 38 | readonly toolResults: string[]; |
| 40 | 39 | readonly pendingApprovals: PendingApproval[]; |
| 41 | 40 | readonly agentMessage: string | null; |
| 42 | 41 | readonly error: string | null; |
| 43 | 42 | } |
| 44 | 43 | |
| 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 | +}; | |
| 56 | 52 | |
| 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 { | |
| 61 | 54 | switch (event.type) { |
| 62 | 55 | case "status": |
| 63 | 56 | return { ...state, statusMessage: event.message }; |
| 64 | 57 | |
| 65 | 58 | case "code_generated": |
| 66 | − return { | |
| 67 | − ...state, | |
| 68 | − codeBlocks: [...state.codeBlocks, event.code], | |
| 69 | − statusMessage: "Running code...", | |
| 70 | − }; | |
| 59 | + return { ...state, statusMessage: "Running code..." }; | |
| 71 | 60 | |
| 72 | 61 | case "approval_request": |
| 73 | 62 | return { |
| 74 | 63 | ...state, |
| 75 | 64 | pendingApprovals: [ |
| 76 | 65 | ...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 }, | |
| 82 | 67 | ], |
| 83 | 68 | statusMessage: "Waiting for approval...", |
| 84 | 69 | }; |
| @@ -87,17 +72,14 @@ export function reduceTaskEvent(state: TaskState, event: TaskEvent): TaskState { · 2 unmodified lines | ||
| 87 | 72 | return { |
| 88 | 73 | ...state, |
| 89 | 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 | 78 | case "tool_result": { |
| 94 | 79 | 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] }; | |
| 101 | 83 | } |
| 102 | 84 | |
| 103 | 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 | − | |
| 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 | − | |
| 127 | 96 | // --------------------------------------------------------------------------- |
| 128 | 97 | // Component |
| 129 | 98 | // --------------------------------------------------------------------------- |
| 130 | 99 | |
| 131 | 100 | export interface TaskMessageProps { |
| 101 | + readonly taskId: string; | |
| 132 | 102 | 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; | |
| 136 | 104 | } |
| 137 | 105 | |
| 138 | −export function TaskMessage({ prompt, state, onApprove, onDeny }: TaskMessageProps) { | |
| 106 | +export function TaskMessage({ taskId, prompt, api }: TaskMessageProps) { | |
| 139 | 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 | 156 | const isDone = state.status !== "running"; |
| 157 | + const accentColor = isDone | |
| 158 | + ? state.status === "completed" ? 0x57f287 : 0xed4245 | |
| 159 | + : 0x5865f2; | |
| 143 | 160 | |
| 144 | 161 | return ( |
| 145 | − <Container accentColor={isDone ? (state.status === "completed" ? 0x57f287 : 0xed4245) : 0x5865f2}> | |
| 146 | − {/* Header */} | |
| 162 | + <Container accentColor={accentColor}> | |
| 147 | 163 | <TextDisplay>{`${statusEmoji(state.status)} **${state.statusMessage}**`}</TextDisplay> |
| 148 | 164 | <TextDisplay>{`> ${prompt.length > 200 ? prompt.slice(0, 200) + "..." : prompt}`}</TextDisplay> |
| 149 | 165 | |
| 150 | − {/* Tool results */} | |
| 151 | 166 | {state.toolResults.length > 0 && ( |
| 152 | 167 | <> |
| 153 | 168 | <Separator /> |
| 154 | − <TextDisplay> | |
| 155 | − {state.toolResults.slice(-10).join("\n")} | |
| 156 | − </TextDisplay> | |
| 169 | + <TextDisplay>{state.toolResults.slice(-10).join("\n")}</TextDisplay> | |
| 157 | 170 | </> |
| 158 | 171 | )} |
| 159 | 172 | |
| 160 | − {/* Pending approvals */} | |
| 161 | 173 | {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} /> | |
| 168 | 175 | ))} |
| 169 | 176 | |
| 170 | − {/* Error */} | |
| 171 | 177 | {state.error && ( |
| 172 | 178 | <> |
| 173 | 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 | 184 | {state.agentMessage && ( |
| 180 | 185 | <> |
| 181 | 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 */} | |
| 191 | − {state.status === "running" && !state.pendingApprovals.length && ( | |
| 195 | + {state.status === "running" && state.pendingApprovals.length === 0 && ( | |
| 192 | 196 | <Loading /> |
| 193 | 197 | )} |
| 194 | 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-component | |
| 203 | +// Approval buttons | |
| 200 | 204 | // --------------------------------------------------------------------------- |
| 201 | 205 | |
| 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 | + }; | |
| 207 | 217 | |
| 208 | −function ApprovalSection({ approval, onApprove, onDeny }: ApprovalSectionProps) { | |
| 209 | 218 | return ( |
| 210 | 219 | <> |
| 211 | 220 | <Separator /> |
| 212 | 221 | <TextDisplay> |
| 213 | 222 | {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}`} |
| 214 | 223 | </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 | + )} | |
| 229 | 230 | </> |
| 230 | 231 | ); |
| 231 | 232 | } |
| 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 | +} | |
| @@ -8,9 +8,10 @@ · 7 unmodified lines | ||
| 8 | 8 | import { readFileSync } from "node:fs"; |
| 9 | 9 | import { createApp } from "./routes.js"; |
| 10 | 10 | import { createPiAiModel } from "@openassistant/core"; |
| 11 | −import { mergeToolTrees, type ToolTree } from "@openassistant/core/tools"; | |
| 11 | +import { defineTool, mergeToolTrees, type ToolTree } from "@openassistant/core/tools"; | |
| 12 | 12 | import { generateMcpTools } from "@openassistant/tool-gen/mcp"; |
| 13 | 13 | import type { McpToolSource } from "@openassistant/tool-gen/mcp"; |
| 14 | +import { z } from "zod"; | |
| 14 | 15 | |
| 15 | 16 | // --------------------------------------------------------------------------- |
| 16 | 17 | // 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 | + | |
| 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 | + | |
| 56 | 107 | // --------------------------------------------------------------------------- |
| 57 | 108 | // Tool loading |
| 58 | 109 | // --------------------------------------------------------------------------- |
| 59 | 110 | |
| 60 | 111 | async function loadTools(): Promise<ToolTree> { |
| 61 | − const trees: ToolTree[] = []; | |
| 112 | + const trees: ToolTree[] = [builtinTools]; | |
| 62 | 113 | |
| 63 | 114 | for (const source of mcpSources) { |
| 64 | 115 | try { |
| @@ -37,14 +37,30 @@ export async function runTask( · 36 unmodified lines | ||
| 37 | 37 | tools: options.tools, |
| 38 | 38 | model: options.model, |
| 39 | 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 | 49 | return new Promise<ApprovalDecision>((resolve) => { |
| 41 | − // Register the pending approval so REST API can resolve it | |
| 42 | 50 | registerApproval({ |
| 43 | 51 | callId: request.callId, |
| 44 | 52 | taskId, |
| 45 | 53 | toolPath: request.toolPath, |
| 46 | 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 | }, |