- Introduced a new FormattedCodeBlock component for improved display of code snippets in approvals and tasks views. - Updated styles for formatted code blocks to enhance readability and compactness in detail panels. - Enhanced tool discovery to support compact signatures by default, allowing for clearer and more concise tool descriptions. - Added functionality to handle large output previews in task summaries, ensuring better user experience with runtime outputs. - Improved type hints and documentation for tool discovery functions to reflect recent changes in input handling.
Unified · Split
| @@ -173,6 +173,42 @@ · 172 unmodified lines | ||
| 173 | 173 | border-radius: 0.375rem; |
| 174 | 174 | } |
| 175 | 175 | |
| 176 | +/* Slim code block presentation for compact detail panels */ | |
| 177 | +.formatted-code-block [data-streamdown="code-block"] { | |
| 178 | + margin: 0; | |
| 179 | + border-radius: 0.375rem; | |
| 180 | + border: 1px solid var(--border); | |
| 181 | +} | |
| 182 | + | |
| 183 | +.formatted-code-block [data-streamdown="code-block-header"] { | |
| 184 | + display: none; | |
| 185 | +} | |
| 186 | + | |
| 187 | +.formatted-code-block [data-streamdown="code-block-body"] { | |
| 188 | + border: 0 !important; | |
| 189 | + border-top: 0 !important; | |
| 190 | + border-radius: 0.375rem; | |
| 191 | + padding: 0.75rem !important; | |
| 192 | + font-size: 0.75rem; | |
| 193 | + line-height: 1.55; | |
| 194 | +} | |
| 195 | + | |
| 196 | +.formatted-code-block [data-streamdown="code-block-body"] code > span::before { | |
| 197 | + display: none; | |
| 198 | +} | |
| 199 | + | |
| 200 | +.formatted-code-block--green [data-streamdown="code-block-body"] { | |
| 201 | + color: color-mix(in oklch, var(--terminal-green) 80%, var(--foreground)) !important; | |
| 202 | +} | |
| 203 | + | |
| 204 | +.formatted-code-block--amber [data-streamdown="code-block-body"] { | |
| 205 | + color: color-mix(in oklch, var(--terminal-amber) 80%, var(--foreground)) !important; | |
| 206 | +} | |
| 207 | + | |
| 208 | +.formatted-code-block--red [data-streamdown="code-block-body"] { | |
| 209 | + color: color-mix(in oklch, var(--terminal-red) 80%, var(--foreground)) !important; | |
| 210 | +} | |
| 211 | + | |
| 176 | 212 | /* Scrollbar styling */ |
| 177 | 213 | ::-webkit-scrollbar { |
| 178 | 214 | width: 6px; |
| @@ -40,6 +40,7 @@ import { useSession } from "@/lib/session-context"; · 39 unmodified lines | ||
| 40 | 40 | import { convexApi } from "@/lib/convex-api"; |
| 41 | 41 | import { workosEnabled } from "@/lib/auth-capabilities"; |
| 42 | 42 | import { ThemeSwitcher } from "@/components/theme-switcher"; |
| 43 | +import { ApprovalNotifier } from "@/components/approval-notifier"; | |
| 43 | 44 | import { cn } from "@/lib/utils"; |
| 44 | 45 | |
| 45 | 46 | const NAV_ITEMS = [ |
| @@ -710,6 +711,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { · 664 unmodified lines | ||
| 710 | 711 | |
| 711 | 712 | return ( |
| 712 | 713 | <div className="flex min-h-screen"> |
| 714 | + <ApprovalNotifier /> | |
| 713 | 715 | <Sidebar /> |
| 714 | 716 | <div className="flex-1 flex flex-col min-w-0"> |
| 715 | 717 | <MobileHeader /> |
| @@ -0,0 +1,156 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useEffect, useRef } from "react"; | |
| 4 | +import { useRouter } from "next/navigation"; | |
| 5 | +import { useQuery } from "convex/react"; | |
| 6 | +import { toast } from "sonner"; | |
| 7 | +import { useSession } from "@/lib/session-context"; | |
| 8 | +import { convexApi } from "@/lib/convex-api"; | |
| 9 | +import type { PendingApprovalRecord } from "@/lib/types"; | |
| 10 | + | |
| 11 | +const NOTIFICATION_PROMPT_KEY = "executor_approval_desktop_notifications_prompted_v1"; | |
| 12 | + | |
| 13 | +function supportsDesktopNotifications(): boolean { | |
| 14 | + return typeof window !== "undefined" && "Notification" in window; | |
| 15 | +} | |
| 16 | + | |
| 17 | +function notifyDesktop(approval: PendingApprovalRecord) { | |
| 18 | + if (!supportsDesktopNotifications()) { | |
| 19 | + return; | |
| 20 | + } | |
| 21 | + | |
| 22 | + if (Notification.permission !== "granted") { | |
| 23 | + return; | |
| 24 | + } | |
| 25 | + | |
| 26 | + if (document.visibilityState === "visible") { | |
| 27 | + return; | |
| 28 | + } | |
| 29 | + | |
| 30 | + const notification = new Notification("Approval required", { | |
| 31 | + body: `${approval.toolPath} is waiting for review`, | |
| 32 | + tag: `approval-${approval.id}`, | |
| 33 | + }); | |
| 34 | + | |
| 35 | + notification.onclick = () => { | |
| 36 | + window.focus(); | |
| 37 | + window.location.assign("/approvals"); | |
| 38 | + notification.close(); | |
| 39 | + }; | |
| 40 | + | |
| 41 | + window.setTimeout(() => { | |
| 42 | + notification.close(); | |
| 43 | + }, 12_000); | |
| 44 | +} | |
| 45 | + | |
| 46 | +export function ApprovalNotifier() { | |
| 47 | + const router = useRouter(); | |
| 48 | + const { context } = useSession(); | |
| 49 | + const seenApprovalIdsRef = useRef<Set<string>>(new Set()); | |
| 50 | + const activeWorkspaceRef = useRef<string | null>(null); | |
| 51 | + | |
| 52 | + const approvals = useQuery( | |
| 53 | + convexApi.workspace.listPendingApprovals, | |
| 54 | + context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | |
| 55 | + ); | |
| 56 | + | |
| 57 | + useEffect(() => { | |
| 58 | + if (!context || approvals === undefined) { | |
| 59 | + return; | |
| 60 | + } | |
| 61 | + | |
| 62 | + const approvalRecords = approvals as PendingApprovalRecord[]; | |
| 63 | + | |
| 64 | + const currentWorkspaceId = context.workspaceId; | |
| 65 | + const currentIds = new Set(approvalRecords.map((approval) => approval.id)); | |
| 66 | + | |
| 67 | + if (activeWorkspaceRef.current !== currentWorkspaceId) { | |
| 68 | + activeWorkspaceRef.current = currentWorkspaceId; | |
| 69 | + seenApprovalIdsRef.current = currentIds; | |
| 70 | + return; | |
| 71 | + } | |
| 72 | + | |
| 73 | + const newApprovals = approvalRecords.filter( | |
| 74 | + (approval) => !seenApprovalIdsRef.current.has(approval.id), | |
| 75 | + ); | |
| 76 | + | |
| 77 | + seenApprovalIdsRef.current = currentIds; | |
| 78 | + | |
| 79 | + if (newApprovals.length === 0) { | |
| 80 | + return; | |
| 81 | + } | |
| 82 | + | |
| 83 | + const newest = newApprovals[newApprovals.length - 1]; | |
| 84 | + | |
| 85 | + toast.info( | |
| 86 | + newApprovals.length === 1 | |
| 87 | + ? `Approval required: ${newest.toolPath}` | |
| 88 | + : `${newApprovals.length} new approvals pending`, | |
| 89 | + { | |
| 90 | + description: | |
| 91 | + newApprovals.length === 1 | |
| 92 | + ? `Task ${newest.taskId} is waiting for review.` | |
| 93 | + : "Open approvals to review pending tool calls.", | |
| 94 | + action: { | |
| 95 | + label: "Review", | |
| 96 | + onClick: () => router.push("/approvals"), | |
| 97 | + }, | |
| 98 | + }, | |
| 99 | + ); | |
| 100 | + | |
| 101 | + for (const approval of newApprovals) { | |
| 102 | + notifyDesktop(approval); | |
| 103 | + } | |
| 104 | + }, [approvals, context, router]); | |
| 105 | + | |
| 106 | + useEffect(() => { | |
| 107 | + if (!context) { | |
| 108 | + return; | |
| 109 | + } | |
| 110 | + | |
| 111 | + if (!supportsDesktopNotifications()) { | |
| 112 | + return; | |
| 113 | + } | |
| 114 | + | |
| 115 | + if (Notification.permission !== "default") { | |
| 116 | + return; | |
| 117 | + } | |
| 118 | + | |
| 119 | + if (localStorage.getItem(NOTIFICATION_PROMPT_KEY)) { | |
| 120 | + return; | |
| 121 | + } | |
| 122 | + | |
| 123 | + localStorage.setItem(NOTIFICATION_PROMPT_KEY, "1"); | |
| 124 | + | |
| 125 | + toast.message("Enable desktop approval alerts?", { | |
| 126 | + description: "Get browser notifications when new approvals arrive.", | |
| 127 | + duration: 14_000, | |
| 128 | + action: { | |
| 129 | + label: "Enable", | |
| 130 | + onClick: async () => { | |
| 131 | + if (!supportsDesktopNotifications()) { | |
| 132 | + return; | |
| 133 | + } | |
| 134 | + | |
| 135 | + try { | |
| 136 | + const permission = await Notification.requestPermission(); | |
| 137 | + | |
| 138 | + if (permission === "granted") { | |
| 139 | + toast.success("Desktop notifications enabled"); | |
| 140 | + } else if (permission === "denied") { | |
| 141 | + toast.error("Desktop notifications blocked in this browser"); | |
| 142 | + } | |
| 143 | + } catch { | |
| 144 | + toast.error("Failed to request notification permission"); | |
| 145 | + } | |
| 146 | + }, | |
| 147 | + }, | |
| 148 | + cancel: { | |
| 149 | + label: "Later", | |
| 150 | + onClick: () => {}, | |
| 151 | + }, | |
| 152 | + }); | |
| 153 | + }, [context]); | |
| 154 | + | |
| 155 | + return null; | |
| 156 | +} | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useState } from "react"; | |
| 3 | +import { useMemo, useState } from "react"; | |
| 4 | 4 | import { |
| 5 | 5 | ShieldCheck, |
| 6 | 6 | ShieldX, |
| @@ -8,7 +8,7 @@ import { · 1 unmodified line | ||
| 8 | 8 | Clock, |
| 9 | 9 | } from "lucide-react"; |
| 10 | 10 | import { Button } from "@/components/ui/button"; |
| 11 | −import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| 11 | +import { Card, CardContent } from "@/components/ui/card"; | |
| 12 | 12 | import { Skeleton } from "@/components/ui/skeleton"; |
| 13 | 13 | import { Separator } from "@/components/ui/separator"; |
| 14 | 14 | import { PageHeader } from "@/components/page-header"; |
| @@ -19,6 +19,63 @@ import { useMutation, useQuery } from "convex/react"; · 4 unmodified lines | ||
| 19 | 19 | import type { PendingApprovalRecord } from "@/lib/types"; |
| 20 | 20 | import { toast } from "sonner"; |
| 21 | 21 | import { formatTimeAgo } from "@/lib/format"; |
| 22 | +import { FormattedCodeBlock } from "@/components/formatted-code-block"; | |
| 23 | + | |
| 24 | +function formatApprovalInput( | |
| 25 | + input: unknown, | |
| 26 | +): { | |
| 27 | + content: string; | |
| 28 | + language: "json" | "text"; | |
| 29 | +} | null { | |
| 30 | + if (input === null || input === undefined) { | |
| 31 | + return null; | |
| 32 | + } | |
| 33 | + | |
| 34 | + if (typeof input === "string") { | |
| 35 | + const trimmed = input.trim(); | |
| 36 | + if (!trimmed) { | |
| 37 | + return null; | |
| 38 | + } | |
| 39 | + | |
| 40 | + try { | |
| 41 | + const parsed = JSON.parse(trimmed); | |
| 42 | + const serialized = JSON.stringify(parsed, null, 2); | |
| 43 | + if (serialized === "null" || serialized === "{}") { | |
| 44 | + return null; | |
| 45 | + } | |
| 46 | + return { | |
| 47 | + content: serialized, | |
| 48 | + language: "json", | |
| 49 | + }; | |
| 50 | + } catch { | |
| 51 | + return { | |
| 52 | + content: trimmed, | |
| 53 | + language: "text", | |
| 54 | + }; | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + try { | |
| 59 | + const serialized = JSON.stringify(input, null, 2); | |
| 60 | + if (serialized === "null" || serialized === "{}") { | |
| 61 | + return null; | |
| 62 | + } | |
| 63 | + | |
| 64 | + return { | |
| 65 | + content: serialized, | |
| 66 | + language: "json", | |
| 67 | + }; | |
| 68 | + } catch { | |
| 69 | + const fallback = String(input).trim(); | |
| 70 | + if (!fallback) { | |
| 71 | + return null; | |
| 72 | + } | |
| 73 | + return { | |
| 74 | + content: fallback, | |
| 75 | + language: "text", | |
| 76 | + }; | |
| 77 | + } | |
| 78 | +} | |
| 22 | 79 | |
| 23 | 80 | function ApprovalCard({ |
| 24 | 81 | approval, |
| @@ -53,7 +110,10 @@ function ApprovalCard({ · 28 unmodified lines | ||
| 53 | 110 | } |
| 54 | 111 | }; |
| 55 | 112 | |
| 56 | − const inputStr = JSON.stringify(approval.input, null, 2); | |
| 113 | + const inputDisplay = useMemo( | |
| 114 | + () => formatApprovalInput(approval.input), | |
| 115 | + [approval.input], | |
| 116 | + ); | |
| 57 | 117 | |
| 58 | 118 | return ( |
| 59 | 119 | <Card className="bg-card border-border border-l-2 border-l-terminal-amber glow-amber"> |
| @@ -83,14 +143,16 @@ function ApprovalCard({ · 23 unmodified lines | ||
| 83 | 143 | </div> |
| 84 | 144 | |
| 85 | 145 | {/* Input */} |
| 86 | − {inputStr !== "null" && inputStr !== "{}" && ( | |
| 146 | + {inputDisplay && ( | |
| 87 | 147 | <div> |
| 88 | 148 | <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1.5"> |
| 89 | 149 | Input |
| 90 | 150 | </span> |
| 91 | − <pre className="terminal-block max-h-36 overflow-y-auto text-xs"> | |
| 92 | − {inputStr} | |
| 93 | − </pre> | |
| 151 | + <FormattedCodeBlock | |
| 152 | + content={inputDisplay.content} | |
| 153 | + language={inputDisplay.language} | |
| 154 | + className="max-h-52 overflow-y-auto" | |
| 155 | + /> | |
| 94 | 156 | </div> |
| 95 | 157 | )} |
| 96 | 158 | |
| @@ -0,0 +1,45 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useMemo } from "react"; | |
| 4 | +import { Streamdown } from "streamdown"; | |
| 5 | +import { createCodePlugin } from "@streamdown/code"; | |
| 6 | +import { cn } from "@/lib/utils"; | |
| 7 | + | |
| 8 | +const codePlugin = createCodePlugin({ | |
| 9 | + themes: ["github-light", "github-dark"], | |
| 10 | +}); | |
| 11 | + | |
| 12 | +type CodeTone = "default" | "green" | "amber" | "red"; | |
| 13 | + | |
| 14 | +function toCodeFence(content: string, language: string) { | |
| 15 | + return `~~~~${language}\n${content}\n~~~~`; | |
| 16 | +} | |
| 17 | + | |
| 18 | +function toneClass(tone: CodeTone): string { | |
| 19 | + if (tone === "green") return "formatted-code-block--green"; | |
| 20 | + if (tone === "amber") return "formatted-code-block--amber"; | |
| 21 | + if (tone === "red") return "formatted-code-block--red"; | |
| 22 | + return ""; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export function FormattedCodeBlock({ | |
| 26 | + content, | |
| 27 | + language = "text", | |
| 28 | + tone = "default", | |
| 29 | + className, | |
| 30 | +}: { | |
| 31 | + content: string; | |
| 32 | + language?: string; | |
| 33 | + tone?: CodeTone; | |
| 34 | + className?: string; | |
| 35 | +}) { | |
| 36 | + const markdown = useMemo(() => toCodeFence(content, language), [content, language]); | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <div className={cn("formatted-code-block", toneClass(tone), className)}> | |
| 40 | + <Streamdown plugins={{ code: codePlugin }} controls={false}> | |
| 41 | + {markdown} | |
| 42 | + </Streamdown> | |
| 43 | + </div> | |
| 44 | + ); | |
| 45 | +} | |
| @@ -24,6 +24,7 @@ import { Skeleton } from "@/components/ui/skeleton"; · 23 unmodified lines | ||
| 24 | 24 | import { Separator } from "@/components/ui/separator"; |
| 25 | 25 | import { PageHeader } from "@/components/page-header"; |
| 26 | 26 | import { TaskStatusBadge } from "@/components/status-badge"; |
| 27 | +import { FormattedCodeBlock } from "@/components/formatted-code-block"; | |
| 27 | 28 | import { useSession } from "@/lib/session-context"; |
| 28 | 29 | import { useWorkspaceTools } from "@/hooks/use-workspace-tools"; |
| 29 | 30 | import { useMutation, useQuery } from "convex/react"; |
| @@ -298,9 +299,11 @@ function TaskDetail({ · 268 unmodified lines | ||
| 298 | 299 | <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2"> |
| 299 | 300 | Code |
| 300 | 301 | </span> |
| 301 | − <pre className="terminal-block max-h-48 overflow-y-auto"> | |
| 302 | − {liveTask.code} | |
| 303 | − </pre> | |
| 302 | + <FormattedCodeBlock | |
| 303 | + content={liveTask.code} | |
| 304 | + language="typescript" | |
| 305 | + className="max-h-48 overflow-y-auto" | |
| 306 | + /> | |
| 304 | 307 | </div> |
| 305 | 308 | |
| 306 | 309 | {/* Stdout */} |
| @@ -309,9 +312,12 @@ function TaskDetail({ · 2 unmodified lines | ||
| 309 | 312 | <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2"> |
| 310 | 313 | Stdout |
| 311 | 314 | </span> |
| 312 | − <pre className="terminal-block max-h-48 overflow-y-auto text-terminal-green/80"> | |
| 313 | − {liveStdout} | |
| 314 | − </pre> | |
| 315 | + <FormattedCodeBlock | |
| 316 | + content={liveStdout} | |
| 317 | + language="text" | |
| 318 | + tone="green" | |
| 319 | + className="max-h-48 overflow-y-auto" | |
| 320 | + /> | |
| 315 | 321 | </div> |
| 316 | 322 | )} |
| 317 | 323 | |
| @@ -321,9 +327,12 @@ function TaskDetail({ · 3 unmodified lines | ||
| 321 | 327 | <span className="text-[10px] uppercase tracking-widest text-terminal-amber block mb-2"> |
| 322 | 328 | Stderr |
| 323 | 329 | </span> |
| 324 | − <pre className="terminal-block max-h-48 overflow-y-auto text-terminal-amber/80"> | |
| 325 | − {liveStderr} | |
| 326 | − </pre> | |
| 330 | + <FormattedCodeBlock | |
| 331 | + content={liveStderr} | |
| 332 | + language="text" | |
| 333 | + tone="amber" | |
| 334 | + className="max-h-48 overflow-y-auto" | |
| 335 | + /> | |
| 327 | 336 | </div> |
| 328 | 337 | )} |
| 329 | 338 | |
| @@ -333,9 +342,12 @@ function TaskDetail({ · 3 unmodified lines | ||
| 333 | 342 | <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2"> |
| 334 | 343 | Error |
| 335 | 344 | </span> |
| 336 | − <pre className="terminal-block max-h-48 overflow-y-auto text-terminal-red/80"> | |
| 337 | − {liveTask.error} | |
| 338 | − </pre> | |
| 345 | + <FormattedCodeBlock | |
| 346 | + content={liveTask.error} | |
| 347 | + language="text" | |
| 348 | + tone="red" | |
| 349 | + className="max-h-48 overflow-y-auto" | |
| 350 | + /> | |
| 339 | 351 | </div> |
| 340 | 352 | )} |
| 341 | 353 | </CardContent> |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { v } from "convex/values"; |
| 2 | 2 | import type { Doc } from "./_generated/dataModel"; |
| 3 | +import { internal } from "./_generated/api"; | |
| 3 | 4 | import { mutation, |
| 4 | 5 | query, |
| 5 | 6 | type MutationCtx, type QueryCtx } from "./_generated/server"; |
| @@ -503,6 +504,13 @@ export const createApproval = internalMutation({ · 497 unmodified lines | ||
| 503 | 504 | createdAt: now, |
| 504 | 505 | }); |
| 505 | 506 | |
| 507 | + await ctx.scheduler.runAfter(0, internal.pushNotifications.sendApprovalRequested, { | |
| 508 | + workspaceId: task.workspaceId, | |
| 509 | + approvalId: args.id, | |
| 510 | + taskId: args.taskId, | |
| 511 | + toolPath: args.toolPath, | |
| 512 | + }); | |
| 513 | + | |
| 506 | 514 | const created = await getApprovalDoc(ctx, args.id); |
| 507 | 515 | if (!created) { |
| 508 | 516 | throw new Error(`Failed to fetch approval ${args.id}`); |
| @@ -57,7 +57,7 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 56 unmodified lines | ||
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { |
| 60 | − const signatureVersion = "v13"; | |
| 60 | + const signatureVersion = "v14"; | |
| 61 | 61 | const parts = sources |
| 62 | 62 | .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) |
| 63 | 63 | .sort(); |
| @@ -217,6 +217,21 @@ export default defineSchema({ · 216 unmodified lines | ||
| 217 | 217 | .index("by_workspace_created", ["workspaceId", "createdAt"]) |
| 218 | 218 | .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]), |
| 219 | 219 | |
| 220 | + pushSubscriptions: defineTable({ | |
| 221 | + workspaceId: v.string(), | |
| 222 | + accountId: v.string(), | |
| 223 | + endpoint: v.string(), | |
| 224 | + p256dh: v.string(), | |
| 225 | + auth: v.string(), | |
| 226 | + expirationTime: v.optional(v.number()), | |
| 227 | + userAgent: v.optional(v.string()), | |
| 228 | + createdAt: v.number(), | |
| 229 | + updatedAt: v.number(), | |
| 230 | + }) | |
| 231 | + .index("by_workspace_created", ["workspaceId", "createdAt"]) | |
| 232 | + .index("by_workspace_account", ["workspaceId", "accountId"]) | |
| 233 | + .index("by_endpoint", ["endpoint"]), | |
| 234 | + | |
| 220 | 235 | taskEvents: defineTable({ |
| 221 | 236 | sequence: v.number(), |
| 222 | 237 | taskId: v.string(), |
| @@ -43,7 +43,9 @@ class FakeMcpService { · 42 unmodified lines | ||
| 43 | 43 | completedAt: current.createdAt + 2, |
| 44 | 44 | updatedAt: current.createdAt + 2, |
| 45 | 45 | exitCode: 0, |
| 46 | − stdout: `ran:${input.code.slice(0, 20)}`, | |
| 46 | + stdout: input.metadata?.largeStdout === true | |
| 47 | + ? `header\n${"x".repeat(35_000)}` | |
| 48 | + : `ran:${input.code.slice(0, 20)}`, | |
| 47 | 49 | stderr: "", |
| 48 | 50 | }); |
| 49 | 51 | // Notify subscribers |
| @@ -164,6 +166,38 @@ test("run_code MCP tool returns terminal task result", async () => { · 114 unmodified lines | ||
| 164 | 166 | }); |
| 165 | 167 | }); |
| 166 | 168 | |
| 169 | +test("run_code MCP tool warns and previews large output", async () => { | |
| 170 | + const service = new FakeMcpService(); | |
| 171 | + | |
| 172 | + await withMcpClient(service, async (client) => { | |
| 173 | + const result = (await client.callTool({ | |
| 174 | + name: "run_code", | |
| 175 | + arguments: { | |
| 176 | + code: "console.log('large')", | |
| 177 | + metadata: { largeStdout: true }, | |
| 178 | + }, | |
| 179 | + })) as { | |
| 180 | + isError?: boolean; | |
| 181 | + content: Array<{ type: string; text?: string }>; | |
| 182 | + structuredContent?: Record<string, unknown>; | |
| 183 | + }; | |
| 184 | + | |
| 185 | + expect(result.isError).toBeUndefined(); | |
| 186 | + const text = result.content.find((part) => part.type === "text"); | |
| 187 | + expect(text?.type).toBe("text"); | |
| 188 | + if (text?.type === "text") { | |
| 189 | + expect(text.text).toContain("outputWarning: Large runtime output detected"); | |
| 190 | + expect(text.text).toContain("outputHint: Return compact summaries"); | |
| 191 | + expect(text.text).toContain("[stdout preview truncated"); | |
| 192 | + } | |
| 193 | + | |
| 194 | + const fullStdout = typeof result.structuredContent?.stdout === "string" | |
| 195 | + ? result.structuredContent.stdout | |
| 196 | + : ""; | |
| 197 | + expect(fullStdout.length).toBeGreaterThan(30_000); | |
| 198 | + }); | |
| 199 | +}); | |
| 200 | + | |
| 167 | 201 | test("run_code MCP tool bootstraps anonymous context when workspace is omitted", async () => { |
| 168 | 202 | const service = new FakeMcpService(); |
| 169 | 203 | |
| @@ -128,6 +128,7 @@ async function loadSourceDtsByUrl(dtsUrls: Record<string, string>): Promise<Reco · 127 unmodified lines | ||
| 128 | 128 | } |
| 129 | 129 | |
| 130 | 130 | function summarizeTask(task: TaskRecord): string { |
| 131 | + const maxStreamPreviewChars = 30_000; | |
| 131 | 132 | const lines = [ |
| 132 | 133 | `taskId: ${task.id}`, |
| 133 | 134 | `status: ${task.status}`, |
| @@ -142,12 +143,29 @@ function summarizeTask(task: TaskRecord): string { · 8 unmodified lines | ||
| 142 | 143 | lines.push(`error: ${task.error}`); |
| 143 | 144 | } |
| 144 | 145 | |
| 146 | + const stdoutLength = task.stdout?.length ?? 0; | |
| 147 | + const stderrLength = task.stderr?.length ?? 0; | |
| 148 | + if (stdoutLength > maxStreamPreviewChars || stderrLength > maxStreamPreviewChars) { | |
| 149 | + lines.push( | |
| 150 | + `outputWarning: Large runtime output detected (stdout=${stdoutLength} chars, stderr=${stderrLength} chars).`, | |
| 151 | + ); | |
| 152 | + lines.push( | |
| 153 | + "outputHint: Return compact summaries (counts, IDs, top-N samples) to avoid truncation in tool responses.", | |
| 154 | + ); | |
| 155 | + } | |
| 156 | + | |
| 145 | 157 | let text = lines.join("\n"); |
| 146 | 158 | if (task.stdout && task.stdout.trim()) { |
| 147 | − text += asCodeBlock("text", task.stdout); | |
| 159 | + const stdoutPreview = task.stdout.length > maxStreamPreviewChars | |
| 160 | + ? `${task.stdout.slice(0, maxStreamPreviewChars)}\n... [stdout preview truncated ${task.stdout.length - maxStreamPreviewChars} chars]` | |
| 161 | + : task.stdout; | |
| 162 | + text += asCodeBlock("text", stdoutPreview); | |
| 148 | 163 | } |
| 149 | 164 | if (task.stderr && task.stderr.trim()) { |
| 150 | − text += asCodeBlock("text", task.stderr); | |
| 165 | + const stderrPreview = task.stderr.length > maxStreamPreviewChars | |
| 166 | + ? `${task.stderr.slice(0, maxStreamPreviewChars)}\n... [stderr preview truncated ${task.stderr.length - maxStreamPreviewChars} chars]` | |
| 167 | + : task.stderr; | |
| 168 | + text += asCodeBlock("text", stderrPreview); | |
| 151 | 169 | } |
| 152 | 170 | return text; |
| 153 | 171 | } |
| @@ -283,12 +301,13 @@ function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 129 unmodified lines | ||
| 283 | 301 | ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}` |
| 284 | 302 | : ""; |
| 285 | 303 | const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql")); |
| 286 | − const discoverNote = "\n\nTip: use `tools.discover({ query, depth?, limit? })` first. It returns `{ results, total }`; call the exact `results[i].path` (or copy `results[i].exampleCall`). Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`)."; | |
| 304 | + const discoverNote = "\n\nTip: use `tools.discover({ query, depth?, limit?, compact? })` first. It returns `{ results, total }`; call the exact `results[i].path` (or copy `results[i].exampleCall`). Compact mode is on by default (set `compact: false` for full signatures). Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`)."; | |
| 305 | + const executionNote = "\n\nExecution tip: for migration/ETL-style tasks, discover once, then run in small batches and `return` compact summaries (counts, IDs, and top-N samples) instead of full objects."; | |
| 287 | 306 | const graphqlNote = hasGraphqlTools |
| 288 | 307 | ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`." |
| 289 | 308 | : ""; |
| 290 | 309 | |
| 291 | − return base + rootKeysNote + discoverNote + graphqlNote + generateToolInventory(toolList); | |
| 310 | + return base + rootKeysNote + discoverNote + executionNote + graphqlNote + generateToolInventory(toolList); | |
| 292 | 311 | } |
| 293 | 312 | |
| 294 | 313 | function formatApprovalInput(input: unknown, maxLength = 2000): string { |
| @@ -21,6 +21,7 @@ test("discover returns aliases and example calls", async () => { · 20 unmodified lines | ||
| 21 | 21 | { query: "addnumbers", depth: 2 }, |
| 22 | 22 | { taskId: "t", workspaceId: "w", isToolAllowed: () => true }, |
| 23 | 23 | ) as { |
| 24 | + bestPath: string | null; | |
| 24 | 25 | results: Array<{ |
| 25 | 26 | path: string; |
| 26 | 27 | aliases: string[]; |
| @@ -30,6 +31,7 @@ test("discover returns aliases and example calls", async () => { · 3 unmodified lines | ||
| 30 | 31 | total: number; |
| 31 | 32 | }; |
| 32 | 33 | |
| 34 | + expect(result.bestPath).toBe("calc.math.add_numbers"); | |
| 33 | 35 | expect(result.total).toBe(1); |
| 34 | 36 | expect(result.results[0]?.path).toBe("calc.math.add_numbers"); |
| 35 | 37 | expect(result.results[0]?.aliases).toContain("calc.math.addNumbers"); |
| @@ -57,10 +59,79 @@ test("discover example call handles input-shaped args", async () => { · 21 unmodified lines | ||
| 57 | 59 | { query: "issuecreate", depth: 2 }, |
| 58 | 60 | { taskId: "t", workspaceId: "w", isToolAllowed: () => true }, |
| 59 | 61 | ) as { |
| 62 | + bestPath: string | null; | |
| 60 | 63 | results: Array<{ exampleCall: string }>; |
| 61 | 64 | }; |
| 62 | 65 | |
| 66 | + expect(result.bestPath).toBe("linear.mutation.issuecreate"); | |
| 63 | 67 | expect(result.results[0]?.exampleCall).toBe( |
| 64 | 68 | "await tools.linear.mutation.issuecreate({ input: { /* ... */ } });", |
| 65 | 69 | ); |
| 66 | 70 | }); |
| 71 | + | |
| 72 | +test("discover uses compact signatures by default and allows full mode", async () => { | |
| 73 | + const tool = createDiscoverTool([ | |
| 74 | + { | |
| 75 | + path: "linear.query.teams", | |
| 76 | + description: "All teams whose issues can be accessed by the user. Compact output should trim this long explanation before it reaches the trailing marker text to keep discover results concise for models. TRAILING_MARKER_TEXT", | |
| 77 | + approval: "auto", | |
| 78 | + source: "graphql:linear", | |
| 79 | + metadata: { | |
| 80 | + argsType: "{ filter?: TeamFilter; before?: string; after?: string; first?: number; last?: number; includeArchived?: boolean; orderBy?: PaginationOrderBy }", | |
| 81 | + returnsType: "{ data: TeamConnection; errors: unknown[] }", | |
| 82 | + }, | |
| 83 | + run: async () => ({ data: {}, errors: [] }), | |
| 84 | + } satisfies ToolDefinition, | |
| 85 | + ]); | |
| 86 | + | |
| 87 | + const compactResult = await tool.run( | |
| 88 | + { query: "linear teams", depth: 2 }, | |
| 89 | + { taskId: "t", workspaceId: "w", isToolAllowed: () => true }, | |
| 90 | + ) as { | |
| 91 | + bestPath: string | null; | |
| 92 | + results: Array<{ description: string; signature: string }>; | |
| 93 | + }; | |
| 94 | + | |
| 95 | + const fullResult = await tool.run( | |
| 96 | + { query: "linear teams", depth: 2, compact: false }, | |
| 97 | + { taskId: "t", workspaceId: "w", isToolAllowed: () => true }, | |
| 98 | + ) as { | |
| 99 | + bestPath: string | null; | |
| 100 | + results: Array<{ description: string; signature: string }>; | |
| 101 | + }; | |
| 102 | + | |
| 103 | + expect(compactResult.bestPath).toBe("linear.query.teams"); | |
| 104 | + expect(fullResult.bestPath).toBe("linear.query.teams"); | |
| 105 | + expect(compactResult.results[0]?.signature).toContain("filter: ..."); | |
| 106 | + expect(compactResult.results[0]?.signature).toContain("Promise<{ data: ...; errors: unknown[] }>"); | |
| 107 | + expect(compactResult.results[0]?.description).not.toContain("TRAILING_MARKER_TEXT"); | |
| 108 | + | |
| 109 | + expect(fullResult.results[0]?.signature).toContain("TeamFilter"); | |
| 110 | + expect(fullResult.results[0]?.signature).toContain("TeamConnection"); | |
| 111 | + expect(fullResult.results[0]?.description).toContain("TRAILING_MARKER_TEXT"); | |
| 112 | +}); | |
| 113 | + | |
| 114 | +test("discover returns null bestPath when there are no matches", async () => { | |
| 115 | + const tool = createDiscoverTool([ | |
| 116 | + { | |
| 117 | + path: "calc.math.add_numbers", | |
| 118 | + description: "Add numbers", | |
| 119 | + approval: "auto", | |
| 120 | + source: "openapi:calc", | |
| 121 | + metadata: { | |
| 122 | + argsType: "{ a: number; b: number }", | |
| 123 | + returnsType: "{ sum: number }", | |
| 124 | + }, | |
| 125 | + run: async () => ({ sum: 0 }), | |
| 126 | + } satisfies ToolDefinition, | |
| 127 | + ]); | |
| 128 | + | |
| 129 | + const result = await tool.run( | |
| 130 | + { query: "totally_unrelated_keyword" }, | |
| 131 | + { taskId: "t", workspaceId: "w", isToolAllowed: () => true }, | |
| 132 | + ) as { bestPath: string | null; results: Array<unknown>; total: number }; | |
| 133 | + | |
| 134 | + expect(result.bestPath).toBeNull(); | |
| 135 | + expect(result.total).toBe(0); | |
| 136 | + expect(result.results).toHaveLength(0); | |
| 137 | +}); | |
| @@ -511,3 +511,125 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 510 unmodified lines | ||
| 511 | 511 | server.stop(true); |
| 512 | 512 | } |
| 513 | 513 | }); |
| 514 | + | |
| 515 | +test("openapi fallback type hints include index signature when truncated", async () => { | |
| 516 | + const manyProps = Object.fromEntries( | |
| 517 | + Array.from({ length: 20 }, (_, i) => [`field_${i}`, { type: "string" }]), | |
| 518 | + ); | |
| 519 | + | |
| 520 | + const { tools, warnings } = await loadExternalTools([ | |
| 521 | + { | |
| 522 | + type: "openapi", | |
| 523 | + name: "wide", | |
| 524 | + baseUrl: "https://example.com", | |
| 525 | + spec: { | |
| 526 | + openapi: "3.0.3", | |
| 527 | + info: { title: "Wide", version: "1.0.0" }, | |
| 528 | + paths: { | |
| 529 | + "/items": { | |
| 530 | + get: { | |
| 531 | + operationId: "getItems", | |
| 532 | + tags: ["items"], | |
| 533 | + responses: { | |
| 534 | + "200": { | |
| 535 | + description: "ok", | |
| 536 | + content: { | |
| 537 | + "application/json": { | |
| 538 | + schema: { | |
| 539 | + type: "object", | |
| 540 | + properties: manyProps, | |
| 541 | + }, | |
| 542 | + }, | |
| 543 | + }, | |
| 544 | + }, | |
| 545 | + }, | |
| 546 | + }, | |
| 547 | + }, | |
| 548 | + }, | |
| 549 | + }, | |
| 550 | + }, | |
| 551 | + ]); | |
| 552 | + | |
| 553 | + expect(warnings).toHaveLength(0); | |
| 554 | + const getItems = tools.find((tool) => tool.path === "wide.items.get_items"); | |
| 555 | + expect(getItems).toBeDefined(); | |
| 556 | + expect(getItems?.metadata?.returnsType).toContain("[key: string]: any"); | |
| 557 | +}); | |
| 558 | + | |
| 559 | +test("graphql helper tools inherit credential spec from source auth", async () => { | |
| 560 | + const server = Bun.serve({ | |
| 561 | + port: 0, | |
| 562 | + fetch: async (req) => { | |
| 563 | + const body = (await req.json()) as { query?: string }; | |
| 564 | + const query = String(body.query ?? ""); | |
| 565 | + | |
| 566 | + if (query.includes("__schema")) { | |
| 567 | + return Response.json({ | |
| 568 | + data: { | |
| 569 | + __schema: { | |
| 570 | + queryType: { name: "Query" }, | |
| 571 | + mutationType: null, | |
| 572 | + types: [ | |
| 573 | + { | |
| 574 | + kind: "OBJECT", | |
| 575 | + name: "Query", | |
| 576 | + fields: [ | |
| 577 | + { | |
| 578 | + name: "teams", | |
| 579 | + description: null, | |
| 580 | + args: [], | |
| 581 | + type: { kind: "SCALAR", name: "String", ofType: null }, | |
| 582 | + }, | |
| 583 | + ], | |
| 584 | + inputFields: null, | |
| 585 | + enumValues: null, | |
| 586 | + }, | |
| 587 | + { | |
| 588 | + kind: "SCALAR", | |
| 589 | + name: "String", | |
| 590 | + fields: null, | |
| 591 | + inputFields: null, | |
| 592 | + enumValues: null, | |
| 593 | + }, | |
| 594 | + ], | |
| 595 | + }, | |
| 596 | + }, | |
| 597 | + }); | |
| 598 | + } | |
| 599 | + | |
| 600 | + return Response.json({ data: { teams: "ok" } }); | |
| 601 | + }, | |
| 602 | + }); | |
| 603 | + | |
| 604 | + try { | |
| 605 | + const { tools, warnings } = await loadExternalTools([ | |
| 606 | + { | |
| 607 | + type: "graphql", | |
| 608 | + name: "linear", | |
| 609 | + endpoint: `http://127.0.0.1:${server.port}/graphql`, | |
| 610 | + auth: { | |
| 611 | + type: "bearer", | |
| 612 | + mode: "actor", | |
| 613 | + }, | |
| 614 | + }, | |
| 615 | + ]); | |
| 616 | + | |
| 617 | + expect(warnings).toHaveLength(0); | |
| 618 | + | |
| 619 | + const rawTool = tools.find((tool) => tool.path === "linear.graphql"); | |
| 620 | + const helperTool = tools.find((tool) => tool.path === "linear.query.teams"); | |
| 621 | + | |
| 622 | + expect(rawTool?.credential).toEqual({ | |
| 623 | + sourceKey: "graphql:linear", | |
| 624 | + mode: "actor", | |
| 625 | + authType: "bearer", | |
| 626 | + }); | |
| 627 | + expect(helperTool?.credential).toEqual({ | |
| 628 | + sourceKey: "graphql:linear", | |
| 629 | + mode: "actor", | |
| 630 | + authType: "bearer", | |
| 631 | + }); | |
| 632 | + } finally { | |
| 633 | + server.stop(true); | |
| 634 | + } | |
| 635 | +}); | |
| @@ -107,6 +107,40 @@ function buildExampleCall(entry: DiscoverIndexEntry): string { · 106 unmodified lines | ||
| 107 | 107 | return `await tools.${entry.path}({ /* ... */ });`; |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | +function truncateInline(value: string, maxLength: number): string { | |
| 111 | + const normalized = value.replace(/\s+/g, " ").trim(); | |
| 112 | + if (normalized.length <= maxLength) return normalized; | |
| 113 | + return `${normalized.slice(0, Math.max(16, maxLength - 3)).trim()}...`; | |
| 114 | +} | |
| 115 | + | |
| 116 | +function compactArgTypeHint(argsType: string): string { | |
| 117 | + if (argsType === "{}") return "{}"; | |
| 118 | + const keys = extractTopLevelArgKeys(argsType); | |
| 119 | + if (keys.length > 0) { | |
| 120 | + const maxKeys = 4; | |
| 121 | + const shown = keys.slice(0, maxKeys).map((key) => `${key}: ...`); | |
| 122 | + const suffix = keys.length > maxKeys ? "; ..." : ""; | |
| 123 | + return `{ ${shown.join("; ")}${suffix} }`; | |
| 124 | + } | |
| 125 | + return truncateInline(argsType, 120); | |
| 126 | +} | |
| 127 | + | |
| 128 | +function compactReturnTypeHint(returnsType: string): string { | |
| 129 | + const normalized = returnsType.replace(/\s+/g, " ").trim(); | |
| 130 | + if (normalized.startsWith("{ data:") && normalized.includes("errors:")) { | |
| 131 | + return "{ data: ...; errors: unknown[] }"; | |
| 132 | + } | |
| 133 | + if (normalized.endsWith("[]") && normalized.length > 90) { | |
| 134 | + return "Array<...>"; | |
| 135 | + } | |
| 136 | + return truncateInline(normalized, 130); | |
| 137 | +} | |
| 138 | + | |
| 139 | +function compactDescription(description: string): string { | |
| 140 | + const firstLine = description.split("\n")[0] ?? description; | |
| 141 | + return truncateInline(firstLine, 180); | |
| 142 | +} | |
| 143 | + | |
| 110 | 144 | function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { |
| 111 | 145 | return tools |
| 112 | 146 | .filter((tool) => tool.path !== "discover") |
| @@ -152,7 +186,21 @@ function scoreEntry(entry: DiscoverIndexEntry, terms: string[]): number { · 39 unmodified lines | ||
| 152 | 186 | return score + matched * 2; |
| 153 | 187 | } |
| 154 | 188 | |
| 155 | −function formatSignature(entry: DiscoverIndexEntry, depth: number): string { | |
| 189 | +function formatSignature(entry: DiscoverIndexEntry, depth: number, compact: boolean): string { | |
| 190 | + if (compact) { | |
| 191 | + if (depth <= 0) { | |
| 192 | + return "(input: ...): Promise<...>"; | |
| 193 | + } | |
| 194 | + | |
| 195 | + const args = compactArgTypeHint(entry.argsType); | |
| 196 | + const returns = compactReturnTypeHint(entry.returnsType); | |
| 197 | + | |
| 198 | + if (depth === 1) { | |
| 199 | + return `(input: ${args}): Promise<${returns}>`; | |
| 200 | + } | |
| 201 | + return `(input: ${args}): Promise<${returns}> [source=${entry.source}]`; | |
| 202 | + } | |
| 203 | + | |
| 156 | 204 | if (depth <= 0) { |
| 157 | 205 | return `(input: ${entry.argsType}): Promise<...>`; |
| 158 | 206 | } |
| @@ -170,17 +218,18 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 11 unmodified lines | ||
| 170 | 218 | source: "system", |
| 171 | 219 | approval: "auto", |
| 172 | 220 | description: |
| 173 | − "Search available tools by keyword. Returns canonical path, aliases, signature hints, and ready-to-copy call examples.", | |
| 221 | + "Search available tools by keyword. Returns canonical path, aliases, signature hints, and ready-to-copy call examples. Compact mode is enabled by default.", | |
| 174 | 222 | metadata: { |
| 175 | − argsType: "{ query: string; depth?: number; limit?: number }", | |
| 223 | + argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }", | |
| 176 | 224 | returnsType: |
| 177 | − "{ results: Array<{ path: string; aliases: string[]; source: string; approval: 'auto' | 'required'; description: string; signature: string; exampleCall: string }>; total: number }", | |
| 225 | + "{ bestPath: string | null; results: Array<{ path: string; aliases: string[]; source: string; approval: 'auto' | 'required'; description: string; signature: string; exampleCall: string }>; total: number }", | |
| 178 | 226 | }, |
| 179 | 227 | run: async (input: unknown, context) => { |
| 180 | 228 | const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; |
| 181 | 229 | const query = String(payload.query ?? "").trim().toLowerCase(); |
| 182 | 230 | const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1))); |
| 183 | 231 | const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8))); |
| 232 | + const compact = payload.compact === false ? false : true; | |
| 184 | 233 | const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : []; |
| 185 | 234 | |
| 186 | 235 | const ranked = index |
| @@ -194,12 +243,13 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 7 unmodified lines | ||
| 194 | 243 | aliases: entry.aliases, |
| 195 | 244 | source: entry.source, |
| 196 | 245 | approval: entry.approval, |
| 197 | − description: entry.description, | |
| 198 | − signature: formatSignature(entry, depth), | |
| 246 | + description: compact ? compactDescription(entry.description) : entry.description, | |
| 247 | + signature: formatSignature(entry, depth, compact), | |
| 199 | 248 | exampleCall: buildExampleCall(entry), |
| 200 | 249 | })); |
| 201 | 250 | |
| 202 | 251 | return { |
| 252 | + bestPath: ranked[0]?.path ?? null, | |
| 203 | 253 | results: ranked, |
| 204 | 254 | total: ranked.length, |
| 205 | 255 | }; |
| @@ -484,11 +484,14 @@ function jsonSchemaTypeHintFallback( · 483 unmodified lines | ||
| 484 | 484 | } |
| 485 | 485 | return "Record<string, unknown>"; |
| 486 | 486 | } |
| 487 | + const maxInlineProps = 12; | |
| 488 | + const isTruncated = propEntries.length > maxInlineProps; | |
| 487 | 489 | const inner = propEntries |
| 488 | − .slice(0, 12) | |
| 490 | + .slice(0, maxInlineProps) | |
| 489 | 491 | .map(([key, value]) => `${formatTsPropertyKey(key)}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1, componentSchemas, seenRefs)}`) |
| 490 | 492 | .join("; "); |
| 491 | − return `{ ${inner} }`; | |
| 493 | + const indexSignature = isTruncated ? `${inner ? "; " : ""}[key: string]: any` : ""; | |
| 494 | + return `{ ${inner}${indexSignature} }`; | |
| 492 | 495 | } |
| 493 | 496 | |
| 494 | 497 | return "unknown"; |
| @@ -1627,6 +1630,7 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 1132 unmodified lines | ||
| 1627 | 1630 | ? `${field.description}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })` |
| 1628 | 1631 | : `GraphQL ${operationType}: ${field.name}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`, |
| 1629 | 1632 | approval, |
| 1633 | + credential: credentialSpec, | |
| 1630 | 1634 | metadata: { |
| 1631 | 1635 | argsType: gqlFieldArgsTypeHint(field.args, typeMap), |
| 1632 | 1636 | returnsType: `{ data: ${gqlTypeToHint(field.type, typeMap)}; errors: unknown[] }`, |
| @@ -137,7 +137,7 @@ describe("generateToolInventory", () => { · 136 unmodified lines | ||
| 137 | 137 | expect(result).toContain("You have access to these tool namespaces:"); |
| 138 | 138 | expect(result).toContain("admin (1)"); |
| 139 | 139 | expect(result).toContain("math (1)"); |
| 140 | − expect(result).toContain("Use `tools.discover({ query, depth?, limit? })` first"); | |
| 140 | + expect(result).toContain("Use `tools.discover({ query, depth?, limit?, compact? })` first"); | |
| 141 | 141 | expect(result).toContain("Never shadow the global `tools` object"); |
| 142 | 142 | }); |
| 143 | 143 | |
| @@ -378,7 +378,7 @@ export interface operations { · 234 unmodified lines | ||
| 378 | 378 | description: "Discover tools", |
| 379 | 379 | approval: "auto", |
| 380 | 380 | source: "system", |
| 381 | − argsType: "{ query: string; depth?: number; limit?: number }", | |
| 381 | + argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }", | |
| 382 | 382 | returnsType: "unknown", |
| 383 | 383 | }); |
| 384 | 384 | |
| @@ -406,7 +406,7 @@ export interface operations { · 21 unmodified lines | ||
| 406 | 406 | path: "discover", |
| 407 | 407 | description: "Discover tools", |
| 408 | 408 | approval: "auto", |
| 409 | − argsType: "{ query: string; depth?: number; limit?: number }", | |
| 409 | + argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }", | |
| 410 | 410 | returnsType: "unknown", |
| 411 | 411 | }, |
| 412 | 412 | ]; |
| @@ -225,8 +225,9 @@ export function generateToolInventory(tools: ToolDescriptor[]): string { · 224 unmodified lines | ||
| 225 | 225 | "You have access to these tool namespaces:", |
| 226 | 226 | ` ${namespaces.join(", ")}`, |
| 227 | 227 | "", |
| 228 | − "Use `tools.discover({ query, depth?, limit? })` first. It returns `{ results, total }` (not an array).", | |
| 228 | + "Use `tools.discover({ query, depth?, limit?, compact? })` first. It returns `{ results, total }` (not an array).", | |
| 229 | 229 | "Use the exact `path` returned by discover, and prefer its `exampleCall` snippet for invocation shape.", |
| 230 | + "For migration/ETL tasks: discover once, then execute in small batches and return compact summaries (counts, IDs, top-N samples).", | |
| 230 | 231 | "Never shadow the global `tools` object (do NOT write `const tools = ...`).", |
| 231 | 232 | "Then call tools directly using the returned path.", |
| 232 | 233 | ...(hasGraphqlTools |