)}
@@ -600,23 +516,14 @@ const WorkspaceApiKeysCardComponent = (
}
return filteredKeys.map((key) => {
- const rawKeyValue = key.key || key.displayKey || ''
- const isRevealed = Boolean(revealedKeys[key.id])
- const displayValue = rawKeyValue
- ? isRevealed
- ? rawKeyValue
- : getMaskedKeyValue(key)
- : key.displayKey || '—'
- const canRevealOrCopy = Boolean(rawKeyValue)
- const isCopied = copiedKeyId === key.id
const isEditing = canRenameKeys && editingKeyId === key.id
return (
-
+
{formatDate(key.createdAt)}
-
+
{canRenameKeys && editingKeyId === key.id ? (
@@ -646,58 +553,24 @@ const WorkspaceApiKeysCardComponent = (
{renameError}
)}
- ) : (
-
- )}
+ ) : (
+
+ )}
-
toggleRevealKey(key.id)}
- >
- {isRevealed ? (
-
- ) : (
-
- )}
-
- {isRevealed
- ? t('labels.hide', { scope: scopeLabel })
- : t('labels.reveal', { scope: scopeLabel })}
-
-
-
handleCopyKey(rawKeyValue, key.id)}
- >
- {isCopied ? (
-
- ) : (
-
- )}
- {t('labels.copy', { scope: scopeLabel })}
-
-
+
{formatDate(key.lastUsed)}
-
+
{isEditing ? (
<>
-
- {t('labels.unableToDetermineWorkspace')}
-
+ {t('labels.unableToDetermineWorkspace')}
)
}
@@ -921,7 +792,7 @@ const WorkspaceApiKeysCardComponent = (
if (createError) setCreateError(null)
}}
/>
- {createError && {createError}
}
+ {createError && {createError}
}
@@ -960,21 +831,22 @@ const WorkspaceApiKeysCardComponent = (
{t('dialogs.newKeyTitle', { scope: scopeLabel })}
-
- {t('dialogs.newKeyDescription')}
-
+ {t('dialogs.newKeyDescription')}
{newKey && (
- {newKey.key}
+ {newKey.key || '—'}
copyToClipboard(newKey.key)}
+ onClick={() => {
+ if (newKey.key) copyToClipboard(newKey.key)
+ }}
>
{copySuccess ? : }
@@ -987,16 +859,12 @@ const WorkspaceApiKeysCardComponent = (
{t('dialogs.deleteTitle', { scope: scopeLabel })}
-
- {t('dialogs.deleteDescription')}
-
+ {t('dialogs.deleteDescription')}
{deleteKey && (
-
- {t('dialogs.deletePrompt', { name: deleteKey.name })}
-
+
{t('dialogs.deletePrompt', { name: deleteKey.name })}
-
- {docCount} {docCount === 1 ? t('docsSingular') : t('docsPlural')}
-
-
•
{id?.slice(0, 8)}
- {/* Timestamps */}
- {(createdAt || updatedAt) && (
-
- {updatedAt && (
-
- {t('updated')} {formatRelativeTime(updatedAt, locale)}
-
- )}
- {updatedAt && createdAt && • }
- {createdAt && (
-
- {t('created')} {formatRelativeTime(createdAt, locale)}
-
- )}
-
- )}
-
{description}
@@ -240,13 +163,7 @@ export function BaseOverview({
{t('deleteTitle')}
-
- {t('deleteDescription', {
- title,
- count: docCount,
- plural: docCount === 1 ? '' : 's',
- })}
-
+ {t('deleteDescription', { title })}
{t('cancel')}
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx
index 9b0ce39e1..64a65f7e8 100644
--- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx
+++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@@ -22,7 +23,6 @@ import {
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
-import { useTranslations } from 'next-intl'
const logger = createLogger('CreateModal')
@@ -317,8 +317,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
const newKnowledgeBase = result.data
if (files.length > 0) {
- newKnowledgeBase.docCount = files.length
-
if (onKnowledgeBaseCreated) {
onKnowledgeBaseCreated(newKnowledgeBase)
}
@@ -524,13 +522,9 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
isDragging ? 'text-amber-700' : ''
}`}
>
- {isDragging
- ? t('dropFilesHere')
- : t('dropFilesHereOrClickToBrowse')}
-
-
- {t('supportedFormats')}
+ {isDragging ? t('dropFilesHere') : t('dropFilesHereOrClickToBrowse')}
+ {t('supportedFormats')}
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts
index e113d769f..1d135c238 100644
--- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts
+++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts
@@ -6,15 +6,10 @@ export const dropdownContentClass =
export const commandListClass = 'overflow-y-auto overflow-x-hidden'
-export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'docCount'
+export type SortOption = 'name'
export type SortOrder = 'asc' | 'desc'
export const SORT_OPTION_DEFINITIONS = [
- { value: 'updatedAt-desc', labelKey: 'sort.lastUpdated' },
- { value: 'createdAt-desc', labelKey: 'sort.newestFirst' },
- { value: 'createdAt-asc', labelKey: 'sort.oldestFirst' },
{ value: 'name-asc', labelKey: 'sort.nameAsc' },
{ value: 'name-desc', labelKey: 'sort.nameDesc' },
- { value: 'docCount-desc', labelKey: 'sort.mostDocuments' },
- { value: 'docCount-asc', labelKey: 'sort.leastDocuments' },
] as const
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx
index 633ea290b..90ce47ca9 100644
--- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx
+++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx
@@ -26,7 +26,6 @@ import {
dropdownContentClass,
filterButtonClass,
SORT_OPTION_DEFINITIONS,
- type SortOption,
type SortOrder,
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
import {
@@ -38,10 +37,6 @@ import { GlobalNavbarHeader } from '@/global-navbar'
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
-interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
- docCount?: number
-}
-
export function Knowledge() {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -54,10 +49,9 @@ export function Knowledge() {
const [searchQuery, setSearchQuery] = useState('')
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
- const [sortBy, setSortBy] = useState('updatedAt')
- const [sortOrder, setSortOrder] = useState('desc')
+ const [sortOrder, setSortOrder] = useState('asc')
- const currentSortValue = `${sortBy}-${sortOrder}`
+ const currentSortValue = `name-${sortOrder}`
const sortOptions = useMemo(
() =>
SORT_OPTION_DEFINITIONS.map((option) => ({
@@ -67,11 +61,10 @@ export function Knowledge() {
[t]
)
const currentSortLabel =
- sortOptions.find((opt) => opt.value === currentSortValue)?.label || t('sort.lastUpdated')
+ sortOptions.find((opt) => opt.value === currentSortValue)?.label || t('sort.nameAsc')
const handleSortChange = (value: string) => {
- const [field, order] = value.split('-') as [SortOption, SortOrder]
- setSortBy(field)
+ const [, order] = value.split('-') as ['name', SortOrder]
setSortOrder(order)
}
@@ -85,16 +78,13 @@ export function Knowledge() {
const filteredAndSortedKnowledgeBases = useMemo(() => {
const filtered = filterKnowledgeBases(knowledgeBases, searchQuery)
- return sortKnowledgeBases(filtered, sortBy, sortOrder)
- }, [knowledgeBases, searchQuery, sortBy, sortOrder])
+ return sortKnowledgeBases(filtered, sortOrder)
+ }, [knowledgeBases, searchQuery, sortOrder])
- const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseWithDocCount) => ({
+ const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseData) => ({
id: kb.id,
title: kb.name,
- docCount: kb.docCount || 0,
description: kb.description || t('defaults.noDescriptionProvided'),
- createdAt: kb.createdAt,
- updatedAt: kb.updatedAt,
})
const headerLeftContent = (
@@ -132,7 +122,7 @@ export function Knowledge() {
>
{sortOptions.map((option, index) => (
-
+
handleSortChange(option.value)}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
@@ -177,9 +167,7 @@ export function Knowledge() {
{/* Error State */}
{error && (
-
- {t('errors.load', { error })}
-
+
{t('errors.load', { error })}
setIsCreateModalOpen(true)
- : () => { }
+ : () => {}
}
icon={ }
/>
@@ -222,18 +210,13 @@ export function Knowledge() {
)
) : (
filteredAndSortedKnowledgeBases.map((kb) => {
- const displayData = formatKnowledgeBaseForDisplay(
- kb as KnowledgeBaseWithDocCount
- )
+ const displayData = formatKnowledgeBaseForDisplay(kb)
return (
)
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/utils/sort.ts b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/utils/sort.ts
index 660699fbc..f96b020da 100644
--- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/utils/sort.ts
+++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/utils/sort.ts
@@ -1,38 +1,15 @@
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
-import type { SortOption, SortOrder } from '../components/shared'
-
-interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
- docCount?: number
-}
+import type { SortOrder } from '../components/shared'
/**
* Sort knowledge bases by the specified field and order
*/
export function sortKnowledgeBases(
knowledgeBases: KnowledgeBaseData[],
- sortBy: SortOption,
sortOrder: SortOrder
): KnowledgeBaseData[] {
return [...knowledgeBases].sort((a, b) => {
- let comparison = 0
-
- switch (sortBy) {
- case 'name':
- comparison = a.name.localeCompare(b.name)
- break
- case 'createdAt':
- comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
- break
- case 'updatedAt':
- comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
- break
- case 'docCount':
- comparison =
- ((a as KnowledgeBaseWithDocCount).docCount || 0) -
- ((b as KnowledgeBaseWithDocCount).docCount || 0)
- break
- }
-
+ const comparison = a.name.localeCompare(b.name)
return sortOrder === 'asc' ? comparison : -comparison
})
}
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts
index 5968c8cbf..813b584a2 100644
--- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts
+++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts
@@ -23,6 +23,7 @@ import type {
import { parseMonitorSavedViewConfig } from '../view/view-config'
const FALLBACK_INDICATOR_COLOR = '#3972F6'
+export const MONITOR_DATA_CHANGED_EVENT = 'tradinggoose:monitor-data-changed'
type WorkflowTargetFallbackCopy = {
workflowName: string
@@ -154,8 +155,7 @@ export async function loadWorkflowTargetOptions(
const resolvedBlockId = toTrimmed(data?.id) || blockId
const workflowName = toTrimmed(workflowRow?.name) || fallbackCopy.workflowName
- const blockName =
- toTrimmed(data?.name) || fallbackCopy.triggerBlockNames[data.type]
+ const blockName = toTrimmed(data?.name) || fallbackCopy.triggerBlockNames[data.type]
const source = getMonitorProviderForTriggerId(data.type)
return {
source,
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/indicator-input-fields.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/indicator-input-fields.tsx
index fe3b20202..4de406c34 100644
--- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/indicator-input-fields.tsx
+++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/indicator-input-fields.tsx
@@ -4,7 +4,6 @@ import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy'
import {
Select,
SelectContent,
@@ -14,6 +13,7 @@ import {
} from '@/components/ui/select'
import { buildInputsMapFromMeta } from '@/lib/indicators/input-meta'
import type { InputMeta, InputMetaMap } from '@/lib/indicators/types'
+import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy'
type IndicatorInputFieldsProps = {
inputMeta: InputMetaMap | undefined
@@ -91,7 +91,7 @@ const patchSparseInput = (
const next = { ...sparseInputs }
const coerced = coerceDraftValue(meta, rawValue)
- const defaultValue = coerceDraftValue(meta, meta.value ?? meta.defval)
+ const defaultValue = coerceDraftValue(meta, meta.defval)
if (typeof coerced === 'undefined' || valuesEqual(coerced, defaultValue)) {
delete next[title]
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx
index 2a9f6962b..3b11c174b 100644
--- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx
+++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx
@@ -267,6 +267,7 @@ vi.mock('@/hooks/queries/oauth-provider-availability', () => ({
}))
vi.mock('@/app/workspace/[workspaceId]/monitor/components/data/api', () => ({
+ MONITOR_DATA_CHANGED_EVENT: 'tradinggoose:monitor-data-changed',
createMonitorView: vi.fn(),
createMonitorRecord: vi.fn(),
deleteMonitorRecord: vi.fn(),
diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx
index fbea62d2d..c3bcccc60 100644
--- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx
+++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx
@@ -23,6 +23,7 @@ import {
deleteMonitorRecord,
listMonitorViews,
loadMonitors,
+ MONITOR_DATA_CHANGED_EVENT,
removeMonitorView,
reorderMonitorViews,
setActiveMonitorView,
@@ -64,15 +65,11 @@ import {
} from '@/app/workspace/[workspaceId]/monitor/components/view/view-preferences'
import { MonitorConfigWorkspace } from '@/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace'
import { MonitorExecutionWorkspace } from '@/app/workspace/[workspaceId]/monitor/components/workspace/monitor-execution-workspace'
+import { getMonitorModeLabel, useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy'
import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/records/components/logs-toolbar'
import { GlobalNavbarHeader } from '@/global-navbar'
import { buildLogsRequestParams, useLogDetail } from '@/hooks/queries/logs'
-import { formatTemplate } from '@/i18n/utils'
import { usePathname } from '@/i18n/navigation'
-import {
- getMonitorModeLabel,
- useMonitorCopy,
-} from '@/app/workspace/[workspaceId]/monitor/copy'
type MonitorPageProps = {
workspaceId: string
@@ -579,6 +576,16 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) {
void loadMonitorData()
}, [loadMonitorData])
+ useEffect(() => {
+ const handleMonitorDataChanged = (event: Event) => {
+ const detail = (event as CustomEvent<{ workspaceId?: string }>).detail
+ if (detail?.workspaceId === workspaceId) void loadMonitorData()
+ }
+
+ window.addEventListener(MONITOR_DATA_CHANGED_EVENT, handleMonitorDataChanged)
+ return () => window.removeEventListener(MONITOR_DATA_CHANGED_EVENT, handleMonitorDataChanged)
+ }, [loadMonitorData, workspaceId])
+
const { executionItems, orderedVisibleLogIds, isSelectionResolved, isLoading, error, refresh } =
useMonitorWorkspaceLogs({
workspaceId,
@@ -682,7 +689,13 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) {
} finally {
setIsRefreshingAll(false)
}
- }, [copy.errors.persistBeforeRefresh, loadMonitorData, persistDirtyModes, refresh, reloadViewState])
+ }, [
+ copy.errors.persistBeforeRefresh,
+ loadMonitorData,
+ persistDirtyModes,
+ refresh,
+ reloadViewState,
+ ])
const handleExportExecutionLogs = useCallback(() => {
const filters = buildMonitorExecutionLogFilters(executionViewConfig)
@@ -802,18 +815,21 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) {
[copy.errors.updateMonitorState, upsertMonitor, workspaceId]
)
- const handleDeleteMonitor = useCallback(async (monitorId: string) => {
- setMonitorsError(null)
+ const handleDeleteMonitor = useCallback(
+ async (monitorId: string) => {
+ setMonitorsError(null)
- try {
- await deleteMonitorRecord(monitorId)
- setMonitors((current) => current.filter((monitor) => monitor.monitorId !== monitorId))
- } catch (error) {
- const message = error instanceof Error ? error.message : copy.errors.deleteMonitor
- setMonitorsError(message)
- throw error instanceof Error ? error : new Error(message)
- }
- }, [copy.errors.deleteMonitor])
+ try {
+ await deleteMonitorRecord(monitorId)
+ setMonitors((current) => current.filter((monitor) => monitor.monitorId !== monitorId))
+ } catch (error) {
+ const message = error instanceof Error ? error.message : copy.errors.deleteMonitor
+ setMonitorsError(message)
+ throw error instanceof Error ? error : new Error(message)
+ }
+ },
+ [copy.errors.deleteMonitor]
+ )
const handleReorderColumnCards = useCallback(
(columnId: string, nextExecutionIds: string[]) => {
@@ -1051,14 +1067,14 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) {
nameDialogValue,
persistDirtyModes,
setActiveModeViewId,
- updateWorkingState,
- viewNameDialog,
- workspaceId,
- copy.errors.createView,
- copy.errors.dialogStale,
- copy.errors.nameEmpty,
- copy.errors.renameView,
- ])
+ updateWorkingState,
+ viewNameDialog,
+ workspaceId,
+ copy.errors.createView,
+ copy.errors.dialogStale,
+ copy.errors.nameEmpty,
+ copy.errors.renameView,
+ ])
const handleReorderViews = useCallback(
async (nextLayouts: LayoutTab[]) => {
@@ -1173,7 +1189,9 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) {
if (nextMode === activeMode) return true
if (!renderableModes.includes(nextMode)) {
setViewsError(
- nextMode === 'config' ? copy.errors.configViewsUnavailable : copy.errors.executionViewsUnavailable
+ nextMode === 'config'
+ ? copy.errors.configViewsUnavailable
+ : copy.errors.executionViewsUnavailable
)
return false
}
@@ -1185,11 +1203,7 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) {
try {
await persistDirtyModes()
} catch (error) {
- setViewsError(
- error instanceof Error
- ? error.message
- : copy.errors.persistBeforeSwitching
- )
+ setViewsError(error instanceof Error ? error.message : copy.errors.persistBeforeSwitching)
return false
}
@@ -1218,9 +1232,14 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) {
const configHeaderCards = useMemo(
() =>
- buildConfigMonitorCards(monitors, referenceData, {}, {
- unknownListingLabel: copy.execution.unknownListing,
- }),
+ buildConfigMonitorCards(
+ monitors,
+ referenceData,
+ {},
+ {
+ unknownListingLabel: copy.execution.unknownListing,
+ }
+ ),
[copy.execution.unknownListing, monitors, referenceData]
)
const viewControlsBusy =
diff --git a/apps/tradinggoose/components/ui/tool-call.tsx b/apps/tradinggoose/components/ui/tool-call.tsx
index d90fe2169..19c048e42 100644
--- a/apps/tradinggoose/components/ui/tool-call.tsx
+++ b/apps/tradinggoose/components/ui/tool-call.tsx
@@ -24,6 +24,14 @@ interface ToolCallIndicatorProps {
toolNames?: string[]
}
+const REDACTED_VALUE = '[redacted]'
+
+function redactUrlQuery(value: unknown): string {
+ const url = String(value || '')
+ const queryStart = url.indexOf('?')
+ return queryStart === -1 ? url : `${url.slice(0, queryStart)}?${REDACTED_VALUE}`
+}
+
// Detection State Component
export function ToolCallDetection({ content }: { content: string }) {
return (
@@ -48,13 +56,13 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
>
-
+
{toolCall.displayName || toolCall.name}
{toolCall.progress && (
{toolCall.progress}
@@ -69,15 +77,14 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
-
+
Executing...
{toolCall.parameters &&
Object.keys(toolCall.parameters).length > 0 &&
(toolCall.name === 'make_api_request' ||
- toolCall.name === 'set_environment_variables' ||
- toolCall.name === 'set_workflow_variables') && (
+ toolCall.name === 'set_environment_variables') && (
{toolCall.name === 'make_api_request' ? (
@@ -99,9 +106,9 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
- {String((toolCall.parameters as any).url || '') || 'URL not provided'}
+ {redactUrlQuery((toolCall.parameters as any).url) || 'URL not provided'}
@@ -112,10 +119,11 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
? (() => {
const variables =
(toolCall.parameters as any).variables &&
- typeof (toolCall.parameters as any).variables === 'object'
+ typeof (toolCall.parameters as any).variables === 'object' &&
+ !Array.isArray((toolCall.parameters as any).variables)
? (toolCall.parameters as any).variables
: {}
- const entries = Object.entries(variables)
+ const names = Object.keys(variables)
return (
@@ -126,82 +134,25 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
Value
- {entries.length === 0 ? (
+ {names.length === 0 ? (
No variables provided
) : (
- {entries.map(([k, v]) => (
+ {names.map((name) => (
-
- {k}
-
-
-
- {String(v)}
-
+
+ {name}
-
- ))}
-
- )}
-
- )
- })()
- : null}
-
- {toolCall.name === 'set_workflow_variables'
- ? (() => {
- const ops = Array.isArray((toolCall.parameters as any).operations)
- ? ((toolCall.parameters as any).operations as any[])
- : []
- return (
-
-
-
- Name
-
-
- Type
-
-
- Value
-
-
- {ops.length === 0 ? (
-
- No operations provided
-
- ) : (
-
- {ops.map((op, idx) => (
-
-
- {String(op.name || '')}
-
-
-
-
- {String(op.type || '')}
+
+ {REDACTED_VALUE}
-
- {op.value !== undefined ? (
-
- {String(op.value)}
-
- ) : (
- —
- )}
-
))}
@@ -307,38 +258,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
- {toolCall.parameters &&
- Object.keys(toolCall.parameters).length > 0 &&
- (toolCall.name === 'make_api_request' ||
- toolCall.name === 'set_environment_variables') && (
-
-
- Parameters:
-
-
- {JSON.stringify(toolCall.parameters, null, 2)}
-
-
- )}
-
{toolCall.error && (
diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx
index 28cddac7b..ed580cdca 100644
--- a/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx
+++ b/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx
@@ -129,12 +129,14 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
useEffect(() => {
if (images.length > 0 && scrollContainerRef.current) {
const scrollContainer = scrollContainerRef.current
- setTimeout(() => {
+ const timer = setTimeout(() => {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
})
}, SCROLL_DELAY_MS)
+
+ return () => clearTimeout(timer)
}
}, [images.length])
diff --git a/apps/tradinggoose/hooks/queries/api-keys.ts b/apps/tradinggoose/hooks/queries/api-keys.ts
index 2daf9fb1d..165676985 100644
--- a/apps/tradinggoose/hooks/queries/api-keys.ts
+++ b/apps/tradinggoose/hooks/queries/api-keys.ts
@@ -17,7 +17,7 @@ export const apiKeysKeys = {
export interface ApiKey {
id: string
name: string
- key: string
+ key?: string
displayKey?: string
lastUsed?: string
createdAt: string
diff --git a/apps/tradinggoose/hooks/queries/custom-tools.ts b/apps/tradinggoose/hooks/queries/custom-tools.ts
index dbc729eab..e053cf5be 100644
--- a/apps/tradinggoose/hooks/queries/custom-tools.ts
+++ b/apps/tradinggoose/hooks/queries/custom-tools.ts
@@ -49,12 +49,7 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo
code: typeof tool.code === 'string' ? tool.code : '',
workspaceId: tool.workspaceId ?? workspaceId,
userId: tool.userId ?? null,
- createdAt:
- typeof tool.createdAt === 'string'
- ? tool.createdAt
- : tool.updatedAt && typeof tool.updatedAt === 'string'
- ? tool.updatedAt
- : new Date().toISOString(),
+ createdAt: typeof tool.createdAt === 'string' ? tool.createdAt : undefined,
updatedAt: typeof tool.updatedAt === 'string' ? tool.updatedAt : undefined,
schema: {
type: tool.schema.type ?? 'function',
@@ -320,36 +315,6 @@ export function useUpdateCustomTool() {
logger.info(`Updated custom tool: ${toolId}`)
return data.data
},
- onMutate: async ({ workspaceId, toolId, updates }) => {
- await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
-
- const previousTools = queryClient.getQueryData
(
- customToolsKeys.list(workspaceId)
- )
-
- if (previousTools) {
- queryClient.setQueryData(
- customToolsKeys.list(workspaceId),
- previousTools.map((tool) =>
- tool.id === toolId
- ? {
- ...tool,
- title: updates.title ?? tool.title,
- schema: updates.schema ?? tool.schema,
- code: updates.code ?? tool.code,
- }
- : tool
- )
- )
- }
-
- return { previousTools }
- },
- onError: (_err, variables, context) => {
- if (context?.previousTools) {
- queryClient.setQueryData(customToolsKeys.list(variables.workspaceId), context.previousTools)
- }
- },
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
},
@@ -386,28 +351,7 @@ export function useDeleteCustomTool() {
logger.info(`Deleted custom tool: ${toolId}`)
return data
},
- onMutate: async ({ workspaceId, toolId }) => {
- await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
-
- const previousTools = queryClient.getQueryData(
- customToolsKeys.list(workspaceId)
- )
-
- if (previousTools) {
- queryClient.setQueryData(
- customToolsKeys.list(workspaceId),
- previousTools.filter((tool) => tool.id !== toolId)
- )
- }
-
- return { previousTools, workspaceId }
- },
- onError: (_err, _variables, context) => {
- if (context?.previousTools && context?.workspaceId) {
- queryClient.setQueryData(customToolsKeys.list(context.workspaceId), context.previousTools)
- }
- },
- onSettled: (_data, _error, variables) => {
+ onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
},
})
diff --git a/apps/tradinggoose/hooks/queries/indicators.ts b/apps/tradinggoose/hooks/queries/indicators.ts
index 6120786c1..d2cc4400e 100644
--- a/apps/tradinggoose/hooks/queries/indicators.ts
+++ b/apps/tradinggoose/hooks/queries/indicators.ts
@@ -36,12 +36,7 @@ function normalizeIndicator(indicator: ApiIndicator, workspaceId: string): Indic
indicator.inputMeta && typeof indicator.inputMeta === 'object'
? (indicator.inputMeta as InputMetaMap)
: undefined,
- createdAt:
- typeof indicator.createdAt === 'string'
- ? indicator.createdAt
- : indicator.updatedAt && typeof indicator.updatedAt === 'string'
- ? indicator.updatedAt
- : new Date().toISOString(),
+ createdAt: typeof indicator.createdAt === 'string' ? indicator.createdAt : undefined,
updatedAt: typeof indicator.updatedAt === 'string' ? indicator.updatedAt : undefined,
}
}
@@ -142,7 +137,7 @@ export function useIndicators(workspaceId: string) {
interface CreateIndicatorParams {
workspaceId: string
- indicator: Pick
+ indicator: Pick
}
export function useCreateIndicator() {
@@ -183,9 +178,7 @@ export function useCreateIndicator() {
interface UpdateIndicatorParams {
workspaceId: string
indicatorId: string
- updates: Partial<
- Omit
- >
+ updates: Partial>
}
interface ImportIndicatorsParams {
@@ -209,10 +202,6 @@ export function useUpdateIndicator() {
throw new Error('Indicator not found')
}
- const resolvedInputMeta = Object.hasOwn(updates, 'inputMeta')
- ? updates.inputMeta
- : currentIndicator.inputMeta
-
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -222,7 +211,6 @@ export function useUpdateIndicator() {
id: indicatorId,
name: updates.name ?? currentIndicator.name,
pineCode: updates.pineCode ?? currentIndicator.pineCode,
- inputMeta: resolvedInputMeta,
},
],
workspaceId,
@@ -242,37 +230,6 @@ export function useUpdateIndicator() {
logger.info(`Updated indicator: ${indicatorId}`)
return data.data
},
- onMutate: async ({ workspaceId, indicatorId, updates }) => {
- await queryClient.cancelQueries({ queryKey: indicatorKeys.list(workspaceId) })
-
- const previousIndicators = queryClient.getQueryData(
- indicatorKeys.list(workspaceId)
- )
-
- if (previousIndicators) {
- queryClient.setQueryData(
- indicatorKeys.list(workspaceId),
- previousIndicators.map((indicator) =>
- indicator.id === indicatorId
- ? {
- ...indicator,
- ...updates,
- }
- : indicator
- )
- )
- }
-
- return { previousIndicators }
- },
- onError: (_err, variables, context) => {
- if (context?.previousIndicators) {
- queryClient.setQueryData(
- indicatorKeys.list(variables.workspaceId),
- context.previousIndicators
- )
- }
- },
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: indicatorKeys.list(variables.workspaceId) })
},
diff --git a/apps/tradinggoose/hooks/queries/skills.ts b/apps/tradinggoose/hooks/queries/skills.ts
index 551394f0c..797e23bfc 100644
--- a/apps/tradinggoose/hooks/queries/skills.ts
+++ b/apps/tradinggoose/hooks/queries/skills.ts
@@ -41,12 +41,7 @@ function normalizeSkill(
name: rawSkill.name,
description: rawSkill.description,
content: rawSkill.content,
- createdAt:
- typeof rawSkill.createdAt === 'string'
- ? rawSkill.createdAt
- : rawSkill.updatedAt && typeof rawSkill.updatedAt === 'string'
- ? rawSkill.updatedAt
- : new Date().toISOString(),
+ createdAt: typeof rawSkill.createdAt === 'string' ? rawSkill.createdAt : undefined,
updatedAt: typeof rawSkill.updatedAt === 'string' ? rawSkill.updatedAt : undefined,
}
}
@@ -274,36 +269,6 @@ export function useUpdateSkill() {
return data.data
},
- onMutate: async ({ workspaceId, skillId, updates }) => {
- await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) })
-
- const previousSkills = queryClient.getQueryData(
- skillsKeys.list(workspaceId)
- )
-
- if (previousSkills) {
- queryClient.setQueryData(
- skillsKeys.list(workspaceId),
- previousSkills.map((skill) =>
- skill.id === skillId
- ? {
- ...skill,
- name: updates.name ?? skill.name,
- description: updates.description ?? skill.description,
- content: updates.content ?? skill.content,
- }
- : skill
- )
- )
- }
-
- return { previousSkills }
- },
- onError: (_err, variables, context) => {
- if (context?.previousSkills) {
- queryClient.setQueryData(skillsKeys.list(variables.workspaceId), context.previousSkills)
- }
- },
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
},
@@ -335,28 +300,7 @@ export function useDeleteSkill() {
return data
},
- onMutate: async ({ workspaceId, skillId }) => {
- await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) })
-
- const previousSkills = queryClient.getQueryData(
- skillsKeys.list(workspaceId)
- )
-
- if (previousSkills) {
- queryClient.setQueryData(
- skillsKeys.list(workspaceId),
- previousSkills.filter((skill) => skill.id !== skillId)
- )
- }
-
- return { previousSkills, workspaceId }
- },
- onError: (_err, _variables, context) => {
- if (context?.previousSkills && context?.workspaceId) {
- queryClient.setQueryData(skillsKeys.list(context.workspaceId), context.previousSkills)
- }
- },
- onSettled: (_data, _error, variables) => {
+ onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
},
})
diff --git a/apps/tradinggoose/hooks/queries/workflows.ts b/apps/tradinggoose/hooks/queries/workflows.ts
index 3783d61c5..1e423b36e 100644
--- a/apps/tradinggoose/hooks/queries/workflows.ts
+++ b/apps/tradinggoose/hooks/queries/workflows.ts
@@ -1,7 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createLogger } from '@/lib/logs/console/logger'
import { generateCreativeWorkflowName } from '@/lib/naming'
-import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowQueries')
@@ -52,21 +51,6 @@ export function useCreateWorkflow() {
logger.info(`Successfully created workflow ${workflowId}`)
- const { workflowState } = buildDefaultWorkflowArtifacts()
-
- const stateResponse = await fetch(`/api/workflows/${workflowId}/state`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(workflowState),
- })
-
- if (!stateResponse.ok) {
- const text = await stateResponse.text()
- logger.error('Failed to persist default Start block:', text)
- } else {
- logger.info('Successfully persisted default Start block')
- }
-
return {
id: workflowId,
name: createdWorkflow.name,
diff --git a/apps/tradinggoose/hooks/use-mcp-tools.ts b/apps/tradinggoose/hooks/use-mcp-tools.ts
index d06db9ec0..95b10d27d 100644
--- a/apps/tradinggoose/hooks/use-mcp-tools.ts
+++ b/apps/tradinggoose/hooks/use-mcp-tools.ts
@@ -6,14 +6,15 @@
*/
import type React from 'react'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import { WrenchIcon } from 'lucide-react'
import { createLogger } from '@/lib/logs/console/logger'
import type { McpTool } from '@/lib/mcp/types'
import { createMcpToolId } from '@/lib/mcp/utils'
-import { useMcpServersStore } from '@/stores/mcp-servers/store'
+import { MCP_TOOLS_CHANGED_EVENT, useMcpServersStore } from '@/stores/mcp-servers/store'
const logger = createLogger('useMcpTools')
+const DISCOVERY_CACHE_MS = 5 * 60 * 1000
export interface McpToolForUI {
id: string
@@ -31,10 +32,62 @@ export interface UseMcpToolsResult {
mcpTools: McpToolForUI[]
isLoading: boolean
error: string | null
- refreshTools: (forceRefresh?: boolean) => Promise
+ refreshTools: () => Promise
getToolsByServer: (serverId: string) => McpToolForUI[]
}
+const discoveryCache = new Map()
+const discoveryRequests = new Map>()
+
+async function discoverMcpTools(
+ workspaceId: string,
+ serversFingerprint: string,
+ force: boolean
+) {
+ const cacheKey = `${workspaceId}:${serversFingerprint}`
+ const pending = discoveryRequests.get(cacheKey)
+ if (pending) return pending
+
+ const cached = discoveryCache.get(cacheKey)
+ if (!force && cached && cached.expiresAt > Date.now()) {
+ return cached.tools
+ }
+
+ const request = fetch(`/api/mcp/tools/discover?workspaceId=${encodeURIComponent(workspaceId)}`)
+ .then(async (response) => {
+ if (!response.ok) {
+ throw new Error(`Failed to discover MCP tools: ${response.status} ${response.statusText}`)
+ }
+
+ const data = await response.json()
+ if (!data.success) {
+ throw new Error(data.error || 'Failed to discover MCP tools')
+ }
+
+ const tools = (data.data.tools || []).map((tool: McpTool) => ({
+ id: createMcpToolId(tool.serverId, tool.name),
+ name: tool.name,
+ description: tool.description,
+ serverId: tool.serverId,
+ serverName: tool.serverName,
+ type: 'mcp' as const,
+ inputSchema: tool.inputSchema,
+ bgColor: '#6366F1',
+ icon: WrenchIcon,
+ }))
+
+ discoveryCache.set(cacheKey, { expiresAt: Date.now() + DISCOVERY_CACHE_MS, tools })
+ logger.info(`Discovered ${tools.length} MCP tools`)
+ return tools
+ })
+ .finally(() => {
+ discoveryRequests.delete(cacheKey)
+ })
+
+ discoveryRequests.set(cacheKey, request)
+ return request
+}
+
export function useMcpTools(workspaceId: string): UseMcpToolsResult {
const [mcpTools, setMcpTools] = useState([])
const [isLoading, setIsLoading] = useState(false)
@@ -43,21 +96,23 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
const servers = useMcpServersStore((state) => state.servers)
- // Track the last fingerprint
- const lastProcessedFingerprintRef = useRef('')
-
// Create a stable server fingerprint
const serversFingerprint = useMemo(() => {
return servers
- .filter((s) => s.enabled && !s.deletedAt)
- .map((s) => `${s.id}-${s.enabled}-${s.updatedAt}`)
+ .filter((s) => !s.deletedAt)
+ .map((s) => `${s.id}:${s.enabled !== false ? '1' : '0'}:${s.updatedAt ?? ''}`)
.sort()
.join('|')
}, [servers])
- const refreshTools = useCallback(
- async (forceRefresh = false) => {
- if (!normalizedWorkspaceId) {
+ const hasEnabledServers = useMemo(
+ () => servers.some((server) => !server.deletedAt && server.enabled !== false),
+ [servers]
+ )
+
+ const loadTools = useCallback(
+ async (force = false) => {
+ if (!normalizedWorkspaceId || !hasEnabledServers) {
setMcpTools([])
setError(null)
setIsLoading(false)
@@ -68,42 +123,8 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
setError(null)
try {
- logger.info('Discovering MCP tools', { forceRefresh, workspaceId: normalizedWorkspaceId })
-
- const response = await fetch(
- `/api/mcp/tools/discover?workspaceId=${encodeURIComponent(
- normalizedWorkspaceId
- )}&refresh=${forceRefresh}`
- )
-
- if (!response.ok) {
- throw new Error(`Failed to discover MCP tools: ${response.status} ${response.statusText}`)
- }
-
- const data = await response.json()
-
- if (!data.success) {
- throw new Error(data.error || 'Failed to discover MCP tools')
- }
-
- const tools = data.data.tools || []
- const transformedTools = tools.map((tool: McpTool) => ({
- id: createMcpToolId(tool.serverId, tool.name),
- name: tool.name,
- description: tool.description,
- serverId: tool.serverId,
- serverName: tool.serverName,
- type: 'mcp' as const,
- inputSchema: tool.inputSchema,
- bgColor: '#6366F1',
- icon: WrenchIcon,
- }))
-
- setMcpTools(transformedTools)
-
- logger.info(
- `Discovered ${transformedTools.length} MCP tools from ${data.data.byServer ? Object.keys(data.data.byServer).length : 0} servers`
- )
+ logger.info('Discovering MCP tools', { workspaceId: normalizedWorkspaceId })
+ setMcpTools(await discoverMcpTools(normalizedWorkspaceId, serversFingerprint, force))
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to discover MCP tools'
logger.error('Error discovering MCP tools:', err)
@@ -113,9 +134,11 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
setIsLoading(false)
}
},
- [normalizedWorkspaceId, workspaceId]
+ [hasEnabledServers, normalizedWorkspaceId, serversFingerprint]
)
+ const refreshTools = useCallback(() => loadTools(true), [loadTools])
+
const getToolsByServer = useCallback(
(serverId: string): McpToolForUI[] => {
return mcpTools.filter((tool) => tool.serverId === serverId)
@@ -131,41 +154,36 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
return
}
- refreshTools()
- }, [normalizedWorkspaceId, refreshTools])
+ void loadTools()
+ }, [loadTools, normalizedWorkspaceId])
- // Refresh tools when servers change
useEffect(() => {
- if (
- !normalizedWorkspaceId ||
- !serversFingerprint ||
- serversFingerprint === lastProcessedFingerprintRef.current
- ) {
- return
- }
+ if (!normalizedWorkspaceId) return
- logger.info('Active servers changed, refreshing MCP tools', {
- serverCount: servers.filter((s) => s.enabled && !s.deletedAt).length,
- fingerprint: serversFingerprint,
- })
+ const handleToolsChanged = (event: Event) => {
+ const workspaceId = (event as CustomEvent<{ workspaceId?: string }>).detail?.workspaceId
+ if (!workspaceId || workspaceId === normalizedWorkspaceId) {
+ void refreshTools()
+ }
+ }
- lastProcessedFingerprintRef.current = serversFingerprint
- refreshTools()
- }, [normalizedWorkspaceId, serversFingerprint, refreshTools, servers])
+ window.addEventListener(MCP_TOOLS_CHANGED_EVENT, handleToolsChanged)
+ return () => window.removeEventListener(MCP_TOOLS_CHANGED_EVENT, handleToolsChanged)
+ }, [normalizedWorkspaceId, refreshTools])
// Auto-refresh every 5 minutes
useEffect(() => {
const interval = setInterval(
() => {
if (!isLoading && normalizedWorkspaceId) {
- refreshTools()
+ void loadTools()
}
},
5 * 60 * 1000
)
return () => clearInterval(interval)
- }, [isLoading, normalizedWorkspaceId, refreshTools])
+ }, [isLoading, loadTools, normalizedWorkspaceId])
return {
mcpTools,
diff --git a/apps/tradinggoose/hooks/workflow/use-current-workflow.test.tsx b/apps/tradinggoose/hooks/workflow/use-current-workflow.test.tsx
index cfbec3819..884607585 100644
--- a/apps/tradinggoose/hooks/workflow/use-current-workflow.test.tsx
+++ b/apps/tradinggoose/hooks/workflow/use-current-workflow.test.tsx
@@ -68,8 +68,6 @@ describe('useCurrentWorkflow', () => {
edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }],
loops: {},
parallels: {},
- isDeployed: true,
- deployedAt: '2026-04-06T00:00:00.000Z',
lastSaved: '2026-04-06T01:00:00.000Z',
})
@@ -106,8 +104,6 @@ describe('useCurrentWorkflow', () => {
expect(currentWorkflow.getEdgeCount()).toBe(1)
expect(currentWorkflow.hasBlocks()).toBe(true)
expect(currentWorkflow.hasEdges()).toBe(true)
- expect(currentWorkflow.isDeployed).toBe(true)
- expect(currentWorkflow.deployedAt?.toISOString()).toBe('2026-04-06T00:00:00.000Z')
expect(currentWorkflow.lastSaved).toBe(new Date('2026-04-06T01:00:00.000Z').getTime())
})
})
diff --git a/apps/tradinggoose/hooks/workflow/use-current-workflow.ts b/apps/tradinggoose/hooks/workflow/use-current-workflow.ts
index 34a2d1fa9..eb30d0fc8 100644
--- a/apps/tradinggoose/hooks/workflow/use-current-workflow.ts
+++ b/apps/tradinggoose/hooks/workflow/use-current-workflow.ts
@@ -1,8 +1,8 @@
import { useCallback, useMemo } from 'react'
-import { useLatestRef } from '@/hooks/use-latest-ref'
import type { Edge } from '@xyflow/react'
import { resolveStoredDateValue } from '@/lib/time-format'
import { useWorkflowDoc } from '@/lib/yjs/use-workflow-doc'
+import { useLatestRef } from '@/hooks/use-latest-ref'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
/**
@@ -15,8 +15,6 @@ export interface CurrentWorkflow {
loops: Record
parallels: Record
lastSaved?: number
- isDeployed?: boolean
- deployedAt?: Date
// Helper methods
getBlockById: (blockId: string) => BlockState | undefined
getBlockCount: () => number
@@ -31,48 +29,24 @@ export interface CurrentWorkflow {
* Now reads directly from the Yjs document via use-workflow-doc hooks.
*/
export function useCurrentWorkflow(): CurrentWorkflow {
- const {
- blocks,
- edges,
- loops,
- parallels,
- isDeployed,
- deployedAt: rawDeployedAt,
- lastSaved: rawLastSaved,
- } = useWorkflowDoc()
+ const { blocks, edges, loops, parallels, lastSaved: rawLastSaved } = useWorkflowDoc()
// Keep refs in sync so stable callbacks always read current data
const blocksRef = useLatestRef(blocks)
const edgesRef = useLatestRef(edges)
// Stable helper callbacks that read from refs — their identity never changes
- const getBlockById = useCallback(
- (blockId: string) => blocksRef.current?.[blockId],
- []
- )
- const getBlockCount = useCallback(
- () => Object.keys(blocksRef.current || {}).length,
- []
- )
- const getEdgeCount = useCallback(
- () => (edgesRef.current || []).length,
- []
- )
- const hasBlocks = useCallback(
- () => Object.keys(blocksRef.current || {}).length > 0,
- []
- )
- const hasEdges = useCallback(
- () => (edgesRef.current || []).length > 0,
- []
- )
+ const getBlockById = useCallback((blockId: string) => blocksRef.current?.[blockId], [])
+ const getBlockCount = useCallback(() => Object.keys(blocksRef.current || {}).length, [])
+ const getEdgeCount = useCallback(() => (edgesRef.current || []).length, [])
+ const hasBlocks = useCallback(() => Object.keys(blocksRef.current || {}).length > 0, [])
+ const hasEdges = useCallback(() => (edgesRef.current || []).length > 0, [])
// Create the abstracted interface - optimized to prevent unnecessary re-renders
// Note: stable callbacks (getBlockById, etc.) are intentionally omitted from deps
// since their identity never changes (empty dep arrays on useCallback).
const currentWorkflow = useMemo((): CurrentWorkflow => {
const lastSaved = resolveStoredDateValue(rawLastSaved)?.getTime()
- const deployedAt = resolveStoredDateValue(rawDeployedAt)
const resolvedBlocks = blocks || {}
const resolvedEdges = edges || []
@@ -86,8 +60,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
loops: resolvedLoops,
parallels: resolvedParallels,
lastSaved,
- isDeployed,
- deployedAt,
// Helper methods — stable references from useCallback above
getBlockById,
getBlockCount,
@@ -95,8 +67,8 @@ export function useCurrentWorkflow(): CurrentWorkflow {
hasBlocks,
hasEdges,
}
- // eslint-disable-next-line react-hooks/exhaustive-deps -- stable callbacks (getBlockById, etc.) never change
- }, [blocks, edges, loops, parallels, rawLastSaved, rawDeployedAt, isDeployed])
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- stable callbacks (getBlockById, etc.) never change
+ }, [blocks, edges, loops, parallels, rawLastSaved])
return currentWorkflow
}
diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json
index 2feb70333..41ef3b9e8 100644
--- a/apps/tradinggoose/i18n/messages/en.json
+++ b/apps/tradinggoose/i18n/messages/en.json
@@ -356,6 +356,32 @@
"ssoDisabled": "SSO authentication is disabled. Please use another sign-in method.",
"failed": "SSO sign-in failed. Please try again."
}
+ },
+ "mcp": {
+ "eyebrow": "MCP authorization",
+ "confirm": {
+ "title": "Approve personal API key",
+ "description": "A local TradingGoose MCP setup command is requesting a personal API key for this account.",
+ "approve": "Approve key",
+ "cancel": "Cancel",
+ "terminalHint": "Only approve this if you started the setup or login command in your own terminal."
+ },
+ "invalid": {
+ "title": "Invalid MCP login",
+ "description": "The local setup command did not provide a valid login code."
+ },
+ "expired": {
+ "title": "MCP login expired",
+ "description": "Return to your terminal and run the TradingGoose MCP login command again."
+ },
+ "approved": {
+ "title": "Personal API key approved",
+ "description": "Return to your terminal to finish configuring your local agent."
+ },
+ "cancelled": {
+ "title": "MCP login cancelled",
+ "description": "No API key was created. You can close this page."
+ }
}
},
"localeNames": {
@@ -1015,13 +1041,8 @@
"title": "Knowledge",
"searchPlaceholder": "Search knowledge bases...",
"sort": {
- "lastUpdated": "Last Updated",
- "newestFirst": "Newest First",
- "oldestFirst": "Oldest First",
"nameAsc": "Name (A-Z)",
- "nameDesc": "Name (Z-A)",
- "mostDocuments": "Most Documents",
- "leastDocuments": "Least Documents"
+ "nameDesc": "Name (Z-A)"
},
"actions": {
"create": "Create",
@@ -1057,14 +1078,10 @@
"failedToCopy": "Failed to copy knowledge base"
},
"baseOverview": {
- "docsSingular": "doc",
- "docsPlural": "docs",
- "updated": "Updated",
- "created": "Created",
"copyId": "Copy knowledge base ID",
"deleteButtonLabel": "Delete knowledge base",
"deleteTitle": "Delete Knowledge Base",
- "deleteDescription": "Are you sure you want to delete \"{title}\"? This will remove the knowledge base and its {count} document{plural} permanently.",
+ "deleteDescription": "Are you sure you want to delete \"{title}\"? This will permanently remove the knowledge base.",
"cancel": "Cancel",
"deleteConfirm": "Delete",
"deleting": "Deleting..."
diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json
index 77a91d41d..dfc5b4696 100644
--- a/apps/tradinggoose/i18n/messages/es.json
+++ b/apps/tradinggoose/i18n/messages/es.json
@@ -356,6 +356,32 @@
"ssoDisabled": "La autenticación SSO está deshabilitada. Usa otro método de inicio de sesión.",
"failed": "El inicio de sesión SSO falló. Inténtalo de nuevo."
}
+ },
+ "mcp": {
+ "eyebrow": "Autorización MCP",
+ "confirm": {
+ "title": "Aprobar clave API personal",
+ "description": "Un comando local de configuración de TradingGoose MCP solicita una clave API personal para esta cuenta.",
+ "approve": "Aprobar clave",
+ "cancel": "Cancelar",
+ "terminalHint": "Aprueba esto solo si iniciaste el comando de configuración o inicio de sesión en tu propia terminal."
+ },
+ "invalid": {
+ "title": "Inicio de sesión MCP no válido",
+ "description": "El comando de configuración local no proporcionó un código de inicio de sesión válido."
+ },
+ "expired": {
+ "title": "El inicio de sesión MCP expiró",
+ "description": "Vuelve a la terminal y ejecuta de nuevo el comando de inicio de sesión de TradingGoose MCP."
+ },
+ "approved": {
+ "title": "Clave API personal aprobada",
+ "description": "Vuelve a la terminal para terminar de configurar tu agente local."
+ },
+ "cancelled": {
+ "title": "Inicio de sesión MCP cancelado",
+ "description": "No se creó ninguna clave API. Puedes cerrar esta página."
+ }
}
},
"localeNames": {
@@ -1015,13 +1041,8 @@
"title": "Conocimiento",
"searchPlaceholder": "Buscar bases de conocimiento...",
"sort": {
- "lastUpdated": "Última actualización",
- "newestFirst": "Más recientes primero",
- "oldestFirst": "Más antiguos primero",
"nameAsc": "Nombre (A-Z)",
- "nameDesc": "Nombre (Z-A)",
- "mostDocuments": "Más documentos",
- "leastDocuments": "Menos documentos"
+ "nameDesc": "Nombre (Z-A)"
},
"actions": {
"create": "Crear",
@@ -1057,14 +1078,10 @@
"failedToCopy": "Error al copiar la base de conocimiento"
},
"baseOverview": {
- "docsSingular": "doc",
- "docsPlural": "docs",
- "updated": "Actualizado",
- "created": "Creado",
"copyId": "Copiar ID de la base de conocimiento",
"deleteButtonLabel": "Eliminar base de conocimiento",
"deleteTitle": "Eliminar base de conocimiento",
- "deleteDescription": "¿Estás seguro de que quieres eliminar \"{title}\"? Esto eliminará la base de conocimiento y sus {count} documento{plural} de forma permanente.",
+ "deleteDescription": "¿Estás seguro de que quieres eliminar \"{title}\"? Esto eliminará la base de conocimiento de forma permanente.",
"cancel": "Cancelar",
"deleteConfirm": "Eliminar",
"deleting": "Eliminando..."
diff --git a/apps/tradinggoose/i18n/messages/zh.json b/apps/tradinggoose/i18n/messages/zh.json
index 36ea2520b..f434ac41e 100644
--- a/apps/tradinggoose/i18n/messages/zh.json
+++ b/apps/tradinggoose/i18n/messages/zh.json
@@ -356,6 +356,32 @@
"ssoDisabled": "SSO身份验证已禁用。请使用其他登录方式。",
"failed": "SSO登录失败。请重试。"
}
+ },
+ "mcp": {
+ "eyebrow": "MCP 授权",
+ "confirm": {
+ "title": "批准个人 API 密钥",
+ "description": "本地 TradingGoose MCP 设置命令正在请求为此账户创建个人 API 密钥。",
+ "approve": "批准密钥",
+ "cancel": "取消",
+ "terminalHint": "仅在你自己终端中启动了设置或登录命令时才批准。"
+ },
+ "invalid": {
+ "title": "MCP 登录无效",
+ "description": "本地设置命令未提供有效的登录代码。"
+ },
+ "expired": {
+ "title": "MCP 登录已过期",
+ "description": "返回终端并重新运行 TradingGoose MCP 登录命令。"
+ },
+ "approved": {
+ "title": "个人 API 密钥已批准",
+ "description": "返回终端完成本地代理配置。"
+ },
+ "cancelled": {
+ "title": "MCP 登录已取消",
+ "description": "未创建 API 密钥。你可以关闭此页面。"
+ }
}
},
"localeNames": {
@@ -1002,13 +1028,8 @@
"title": "知识库",
"searchPlaceholder": "搜索知识库...",
"sort": {
- "lastUpdated": "最近更新",
- "newestFirst": "最新在前",
- "oldestFirst": "最早在前",
"nameAsc": "名称(A-Z)",
- "nameDesc": "名称(Z-A)",
- "mostDocuments": "文档最多",
- "leastDocuments": "文档最少"
+ "nameDesc": "名称(Z-A)"
},
"actions": {
"create": "创建",
@@ -1044,14 +1065,10 @@
"failedToCopy": "复制知识库失败"
},
"baseOverview": {
- "docsSingular": "个文档",
- "docsPlural": "个文档",
- "updated": "已更新",
- "created": "已创建",
"copyId": "复制知识库 ID",
"deleteButtonLabel": "删除知识库",
"deleteTitle": "删除知识库",
- "deleteDescription": "确定要删除“{title}”吗?此操作将永久删除该知识库及其 {count} 个文档{plural}。",
+ "deleteDescription": "确定要删除“{title}”吗?此操作将永久删除该知识库。",
"cancel": "取消",
"deleteConfirm": "删除",
"deleting": "删除中..."
diff --git a/apps/tradinggoose/i18n/public-copy.test.ts b/apps/tradinggoose/i18n/public-copy.test.ts
index 01e064805..f22baf2bc 100644
--- a/apps/tradinggoose/i18n/public-copy.test.ts
+++ b/apps/tradinggoose/i18n/public-copy.test.ts
@@ -153,6 +153,9 @@ describe('public copy', () => {
getPublicCopy('en').auth.common.verifyEmail
)
expect(getPublicCopy('en').auth.common.loading).toBe('Loading...')
+ expect(getPublicCopy('en').auth.mcp.approved.title).toBe('Personal API key approved')
+ expect(getPublicCopy('es').auth.mcp.approved.title).toBe('Clave API personal aprobada')
+ expect(getPublicCopy('zh').auth.mcp.approved.title).toBe('个人 API 密钥已批准')
})
it('includes localized verification screen copy', () => {
diff --git a/apps/tradinggoose/lib/api-key/auth.ts b/apps/tradinggoose/lib/api-key/auth.ts
deleted file mode 100644
index 0036f1ec3..000000000
--- a/apps/tradinggoose/lib/api-key/auth.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-import {
- decryptApiKey,
- encryptApiKey,
- generateApiKey,
- generateEncryptedApiKey,
- isEncryptedApiKeyFormat,
- isLegacyApiKeyFormat,
-} from '@/lib/api-key/service'
-import { env } from '@/lib/env'
-import { createLogger } from '@/lib/logs/console/logger'
-
-const logger = createLogger('ApiKeyAuth')
-
-/**
- * API key authentication utilities supporting both legacy plain text keys
- * and modern encrypted keys for gradual migration without breaking existing keys
- */
-
-/**
- * Checks if a stored key is in the new encrypted format
- * @param storedKey - The key stored in the database
- * @returns true if the key is encrypted, false if it's plain text
- */
-export function isEncryptedKey(storedKey: string): boolean {
- // Check if it follows the encrypted format: iv:encrypted:authTag
- return storedKey.includes(':') && storedKey.split(':').length === 3
-}
-
-/**
- * Authenticates an API key against a stored key, supporting both legacy and new encrypted formats
- * @param inputKey - The API key provided by the client
- * @param storedKey - The key stored in the database (may be plain text or encrypted)
- * @returns Promise - true if the key is valid
- */
-export async function authenticateApiKey(inputKey: string, storedKey: string): Promise {
- try {
- // If input key has new encrypted prefix (sk-tradinggoose-), only check against encrypted storage
- if (isEncryptedApiKeyFormat(inputKey)) {
- if (isEncryptedKey(storedKey)) {
- try {
- const { decrypted } = await decryptApiKey(storedKey)
- return inputKey === decrypted
- } catch (decryptError) {
- logger.error('Failed to decrypt stored API key:', { error: decryptError })
- return false
- }
- }
- // New format keys should never match against plain text storage
- return false
- }
-
- // If input key has the plain-text prefix (tradinggoose_), check both encrypted and plain text
- if (isLegacyApiKeyFormat(inputKey)) {
- if (isEncryptedKey(storedKey)) {
- try {
- const { decrypted } = await decryptApiKey(storedKey)
- return inputKey === decrypted
- } catch (decryptError) {
- logger.error('Failed to decrypt stored API key:', { error: decryptError })
- // Fall through to plain text comparison if decryption fails
- }
- }
- // Legacy format can match against plain text storage
- return inputKey === storedKey
- }
-
- // If no recognized prefix, fall back to original behavior
- if (isEncryptedKey(storedKey)) {
- try {
- const { decrypted } = await decryptApiKey(storedKey)
- return inputKey === decrypted
- } catch (decryptError) {
- logger.error('Failed to decrypt stored API key:', { error: decryptError })
- }
- }
-
- return inputKey === storedKey
- } catch (error) {
- logger.error('API key authentication error:', { error })
- return false
- }
-}
-
-/**
- * Encrypts an API key for secure storage
- * @param apiKey - The plain text API key to encrypt
- * @returns Promise - The encrypted key
- */
-export async function encryptApiKeyForStorage(apiKey: string): Promise {
- try {
- const { encrypted } = await encryptApiKey(apiKey)
- return encrypted
- } catch (error) {
- logger.error('API key encryption error:', { error })
- throw new Error('Failed to encrypt API key')
- }
-}
-
-/**
- * Creates a new API key
- * @param useStorage - Whether to encrypt the key before storage (default: true)
- * @returns Promise<{key: string, encryptedKey?: string}> - The plain key and optionally encrypted version
- */
-export async function createApiKey(useStorage = true): Promise<{
- key: string
- encryptedKey?: string
-}> {
- try {
- const hasEncryptionKey = env.API_ENCRYPTION_KEY !== undefined
-
- const plainKey = hasEncryptionKey ? generateEncryptedApiKey() : generateApiKey()
-
- if (useStorage) {
- const encryptedKey = await encryptApiKeyForStorage(plainKey)
- return { key: plainKey, encryptedKey }
- }
-
- return { key: plainKey }
- } catch (error) {
- logger.error('API key creation error:', { error })
- throw new Error('Failed to create API key')
- }
-}
-
-/**
- * Decrypts an API key from storage for display purposes
- * @param encryptedKey - The encrypted API key from the database
- * @returns Promise - The decrypted API key
- */
-export async function decryptApiKeyFromStorage(encryptedKey: string): Promise {
- try {
- const { decrypted } = await decryptApiKey(encryptedKey)
- return decrypted
- } catch (error) {
- logger.error('API key decryption error:', { error })
- throw new Error('Failed to decrypt API key')
- }
-}
-
-/**
- * Gets the last 4 characters of an API key for display purposes
- * @param apiKey - The API key (plain text)
- * @returns string - The last 4 characters
- */
-export function getApiKeyLast4(apiKey: string): string {
- return apiKey.slice(-4)
-}
-
-/**
- * Gets the display format for an API key showing prefix and last 4 characters
- * @param encryptedKey - The encrypted API key from the database
- * @returns Promise - The display format like "sk-tradinggoose-...r6AA"
- */
-export async function getApiKeyDisplayFormat(encryptedKey: string): Promise {
- try {
- if (isEncryptedKey(encryptedKey)) {
- const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
- return formatApiKeyForDisplay(decryptedKey)
- }
- // For plain text keys (legacy), format directly
- return formatApiKeyForDisplay(encryptedKey)
- } catch (error) {
- logger.error('Failed to format API key for display:', { error })
- return '****'
- }
-}
-
-/**
- * Formats an API key for display showing prefix and last 4 characters
- * @param apiKey - The API key (plain text)
- * @returns string - The display format like "sk-tradinggoose-...r6AA" or "tradinggoose_...r6AA"
- */
-export function formatApiKeyForDisplay(apiKey: string): string {
- if (isEncryptedApiKeyFormat(apiKey)) {
- // For sk-tradinggoose- format: "sk-tradinggoose-...r6AA"
- const last4 = getApiKeyLast4(apiKey)
- return `sk-tradinggoose-...${last4}`
- }
- if (isLegacyApiKeyFormat(apiKey)) {
- // For tradinggoose_ format: "tradinggoose_...r6AA"
- const last4 = getApiKeyLast4(apiKey)
- return `tradinggoose_...${last4}`
- }
- // Unknown format, just show last 4
- const last4 = getApiKeyLast4(apiKey)
- return `...${last4}`
-}
-
-/**
- * Gets the last 4 characters of an encrypted API key by decrypting it first
- * @param encryptedKey - The encrypted API key from the database
- * @returns Promise - The last 4 characters
- */
-export async function getEncryptedApiKeyLast4(encryptedKey: string): Promise {
- try {
- if (isEncryptedKey(encryptedKey)) {
- const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
- return getApiKeyLast4(decryptedKey)
- }
- // For plain text keys (legacy), return last 4 directly
- return getApiKeyLast4(encryptedKey)
- } catch (error) {
- logger.error('Failed to get last 4 characters of API key:', { error })
- return '****'
- }
-}
-
-/**
- * Validates API key format (basic validation)
- * @param apiKey - The API key to validate
- * @returns boolean - true if the format appears valid
- */
-export function isValidApiKeyFormat(apiKey: string): boolean {
- return typeof apiKey === 'string' && apiKey.length > 10 && apiKey.length < 200
-}
diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts
new file mode 100644
index 000000000..f84b5815e
--- /dev/null
+++ b/apps/tradinggoose/lib/api-key/service.test.ts
@@ -0,0 +1,140 @@
+/**
+ * @vitest-environment node
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockDbSelect, mockEnv } = vi.hoisted(() => ({
+ mockDbSelect: vi.fn(),
+ mockEnv: { API_ENCRYPTION_KEY: 'a'.repeat(64) } as { API_ENCRYPTION_KEY?: string },
+}))
+
+vi.mock('@tradinggoose/db', () => ({
+ db: {
+ select: (...args: unknown[]) => mockDbSelect(...args),
+ update: vi.fn(),
+ },
+}))
+
+vi.mock('@tradinggoose/db/schema', () => ({
+ apiKey: {
+ id: 'apiKey.id',
+ userId: 'apiKey.userId',
+ workspaceId: 'apiKey.workspaceId',
+ type: 'apiKey.type',
+ key: 'apiKey.key',
+ expiresAt: 'apiKey.expiresAt',
+ lastUsed: 'apiKey.lastUsed',
+ },
+}))
+
+vi.mock('drizzle-orm', () => ({
+ and: vi.fn((...conditions) => ({ conditions })),
+ eq: vi.fn((field, value) => ({ field, value })),
+ inArray: vi.fn((field, values) => ({ field, values })),
+ like: vi.fn((field, value) => ({ field, value })),
+}))
+
+vi.mock('@/lib/env', () => ({ env: mockEnv }))
+vi.mock('@/lib/logs/console/logger', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ }),
+}))
+
+function mockApiKeyRows(rows: unknown[]) {
+ mockDbSelect.mockReturnValue({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ limit: vi.fn().mockResolvedValue(rows),
+ })),
+ })),
+ })
+}
+
+describe('API key service', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.clearAllMocks()
+ mockEnv.API_ENCRYPTION_KEY = 'a'.repeat(64)
+ mockApiKeyRows([])
+ })
+
+ it('rejects malformed API keys before reading key records', async () => {
+ const { authenticateApiKeyFromHeader } = await import('./service')
+
+ await expect(authenticateApiKeyFromHeader('not-a-generated-key')).resolves.toMatchObject({
+ success: false,
+ error: 'Invalid API key',
+ })
+ expect(mockDbSelect).not.toHaveBeenCalled()
+ })
+
+ it('disables API-key authentication when encrypted storage is not configured', async () => {
+ mockEnv.API_ENCRYPTION_KEY = undefined
+ const { authenticateApiKeyFromHeader, storedApiKeyMatches } = await import('./service')
+
+ await expect(
+ authenticateApiKeyFromHeader(`sk-tradinggoose-${'a'.repeat(32)}`)
+ ).resolves.toMatchObject({
+ success: false,
+ error: 'API key access is not configured',
+ })
+ await expect(
+ storedApiKeyMatches(
+ `sk-tradinggoose-${'a'.repeat(32)}`,
+ 'sk-tradinggoose-...aaaa:'.concat('b'.repeat(64), ':iv:encrypted:tag')
+ )
+ ).resolves.toBe(false)
+ expect(mockDbSelect).not.toHaveBeenCalled()
+ })
+
+ it('rejects retired plaintext API-key prefixes before reading key records', async () => {
+ const { authenticateApiKeyFromHeader } = await import('./service')
+
+ await expect(
+ authenticateApiKeyFromHeader(`tradinggoose_${'a'.repeat(32)}`)
+ ).resolves.toMatchObject({
+ success: false,
+ error: 'Invalid API key',
+ })
+ expect(mockDbSelect).not.toHaveBeenCalled()
+ })
+
+ it('looks up the stored key by stable encrypted-storage prefix and scopes by key type', async () => {
+ const { authenticateApiKeyFromHeader, getStoredApiKey } = await import('./service')
+ const { eq, inArray, like } = await import('drizzle-orm')
+
+ const apiKey = `sk-tradinggoose-${'a'.repeat(32)}`
+ await authenticateApiKeyFromHeader(apiKey)
+ const [displayKey, lookupDigest] = getStoredApiKey(apiKey).split(':')
+ expect(like).toHaveBeenCalledWith('apiKey.key', `${displayKey}:${lookupDigest}:%`)
+ expect(inArray).toHaveBeenCalledWith('apiKey.type', ['personal', 'workspace'])
+
+ await authenticateApiKeyFromHeader(apiKey, { keyTypes: ['personal'] })
+ expect(eq).toHaveBeenCalledWith('apiKey.type', 'personal')
+ })
+
+ it('stores encrypted API keys with stable lookup prefixes', async () => {
+ const { getStoredApiKey, storedApiKeyMatches } = await import('./service')
+ const apiKey = `sk-tradinggoose-${'b'.repeat(32)}`
+ const firstStoredKey = getStoredApiKey(apiKey)
+ const secondStoredKey = getStoredApiKey(apiKey)
+
+ expect(firstStoredKey).not.toBe(secondStoredKey)
+ expect(firstStoredKey.split(':').slice(0, 2)).toEqual(secondStoredKey.split(':').slice(0, 2))
+ await expect(storedApiKeyMatches(apiKey, firstStoredKey)).resolves.toBe(true)
+ })
+
+ it('rejects retired stored API-key formats without fallback decryption', async () => {
+ const { getApiKeyDisplayFormat, storedApiKeyMatches } = await import('./service')
+
+ await expect(
+ storedApiKeyMatches(`sk-tradinggoose-${'b'.repeat(32)}`, 'iv:ciphertext:authTag')
+ ).resolves.toBe(false)
+ expect(getApiKeyDisplayFormat('iv:ciphertext:authTag')).toBeNull()
+ })
+})
diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts
index 8e3a9bd58..8f0f7c511 100644
--- a/apps/tradinggoose/lib/api-key/service.ts
+++ b/apps/tradinggoose/lib/api-key/service.ts
@@ -1,29 +1,56 @@
-import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
+import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'crypto'
import { db } from '@tradinggoose/db'
-import { apiKey as apiKeyTable, workspace } from '@tradinggoose/db/schema'
-import { and, eq } from 'drizzle-orm'
+import { apiKey as apiKeyTable } from '@tradinggoose/db/schema'
+import { and, eq, inArray, like, type SQL } from 'drizzle-orm'
import { nanoid } from 'nanoid'
-import { authenticateApiKey } from '@/lib/api-key/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ApiKeyService')
+const API_KEY_SECRET_PATTERN = /^[A-Za-z0-9_-]{32}$/
+const API_ENCRYPTION_KEY_PATTERN = /^[a-fA-F0-9]{64}$/
+const API_KEY_PREFIX = 'sk-tradinggoose-'
+const STORED_API_KEY_SEPARATOR = ':'
+const API_KEY_ACCESS_NOT_CONFIGURED = 'API key access is not configured'
+const DEFAULT_API_KEY_AUTH_TYPES: ApiKeyType[] = ['personal', 'workspace']
+// Current API-key contract: only sk-tradinggoose-* rows stored as
+// display:lookupDigest:iv:ciphertext:authTag are authenticated or listed.
+
+export type ApiKeyType = 'personal' | 'workspace'
export interface ApiKeyAuthOptions {
userId?: string
workspaceId?: string
- keyTypes?: ('personal' | 'workspace')[]
+ keyTypes?: ApiKeyType[]
}
export interface ApiKeyAuthResult {
success: boolean
userId?: string
keyId?: string
- keyType?: 'personal' | 'workspace'
+ keyType?: ApiKeyType
workspaceId?: string
error?: string
}
+export async function createApiKey(useStorage = true): Promise<{
+ key: string
+ storedKey?: string
+}> {
+ try {
+ const plainKey = generateApiKey()
+
+ if (useStorage) {
+ return { key: plainKey, storedKey: getStoredApiKey(plainKey) }
+ }
+
+ return { key: plainKey }
+ } catch (error) {
+ logger.error('API key creation error:', { error })
+ throw new Error('Failed to create API key')
+ }
+}
+
/**
* Authenticate an API key from header with flexible filtering options
*/
@@ -31,30 +58,19 @@ export async function authenticateApiKeyFromHeader(
apiKeyHeader: string,
options: ApiKeyAuthOptions = {}
): Promise {
- if (!apiKeyHeader) {
+ const apiKey = apiKeyHeader.trim()
+ if (!apiKey) {
return { success: false, error: 'API key required' }
}
+ if (!isApiKeyStorageAvailable()) {
+ return { success: false, error: API_KEY_ACCESS_NOT_CONFIGURED }
+ }
+ if (!isApiKeyFormat(apiKey)) {
+ return { success: false, error: 'Invalid API key' }
+ }
try {
- // Build query based on options
- let query = db
- .select({
- id: apiKeyTable.id,
- userId: apiKeyTable.userId,
- workspaceId: apiKeyTable.workspaceId,
- type: apiKeyTable.type,
- key: apiKeyTable.key,
- expiresAt: apiKeyTable.expiresAt,
- })
- .from(apiKeyTable)
-
- // Add workspace join if needed for workspace keys
- if (options.workspaceId || options.keyTypes?.includes('workspace')) {
- query = query.leftJoin(workspace, eq(apiKeyTable.workspaceId, workspace.id)) as any
- }
-
- // Apply filters
- const conditions = []
+ const conditions: SQL[] = [like(apiKeyTable.key, `${getStoredApiKeyLookupPrefix(apiKey)}%`)]
if (options.userId) {
conditions.push(eq(apiKeyTable.userId, options.userId))
@@ -64,50 +80,40 @@ export async function authenticateApiKeyFromHeader(
conditions.push(eq(apiKeyTable.workspaceId, options.workspaceId))
}
- if (options.keyTypes?.length) {
- if (options.keyTypes.length === 1) {
- conditions.push(eq(apiKeyTable.type, options.keyTypes[0]))
- } else {
- // For multiple types, we'll filter in memory since drizzle's inArray is complex here
- }
+ const keyTypes = options.keyTypes?.length ? options.keyTypes : DEFAULT_API_KEY_AUTH_TYPES
+ if (keyTypes.length === 1) {
+ conditions.push(eq(apiKeyTable.type, keyTypes[0]))
+ } else {
+ conditions.push(inArray(apiKeyTable.type, keyTypes))
}
- if (conditions.length > 0) {
- query = query.where(and(...conditions)) as any
+ const query = db
+ .select({
+ id: apiKeyTable.id,
+ userId: apiKeyTable.userId,
+ workspaceId: apiKeyTable.workspaceId,
+ type: apiKeyTable.type,
+ key: apiKeyTable.key,
+ expiresAt: apiKeyTable.expiresAt,
+ })
+ .from(apiKeyTable)
+
+ const [storedKey] = await query.where(and(...conditions)).limit(1)
+ if (!storedKey || (storedKey.expiresAt && storedKey.expiresAt < new Date())) {
+ return { success: false, error: 'Invalid API key' }
}
- const keyRecords = await query
-
- // Filter by keyTypes in memory if multiple types specified
- const filteredRecords =
- options.keyTypes?.length && options.keyTypes.length > 1
- ? keyRecords.filter((record) => options.keyTypes!.includes(record.type as any))
- : keyRecords
-
- // Authenticate each key
- for (const storedKey of filteredRecords) {
- // Skip expired keys
- if (storedKey.expiresAt && storedKey.expiresAt < new Date()) {
- continue
- }
-
- try {
- const isValid = await authenticateApiKey(apiKeyHeader, storedKey.key)
- if (isValid) {
- return {
- success: true,
- userId: storedKey.userId,
- keyId: storedKey.id,
- keyType: storedKey.type as 'personal' | 'workspace',
- workspaceId: storedKey.workspaceId || undefined,
- }
- }
- } catch (error) {
- logger.error('Error authenticating API key:', error)
- }
+ if (!(await storedApiKeyMatches(apiKey, storedKey.key))) {
+ return { success: false, error: 'Invalid API key' }
}
- return { success: false, error: 'Invalid API key' }
+ return {
+ success: true,
+ userId: storedKey.userId,
+ keyId: storedKey.id,
+ keyType: storedKey.type as ApiKeyType,
+ workspaceId: storedKey.workspaceId || undefined,
+ }
} catch (error) {
logger.error('API key authentication error:', error)
return { success: false, error: 'Authentication failed' }
@@ -146,128 +152,124 @@ export async function getApiKeyOwnerUserId(
}
}
-/**
- * Get the API encryption key from the environment
- * @returns The API encryption key
- */
-function getApiEncryptionKey(): Buffer | null {
- const key = env.API_ENCRYPTION_KEY
- if (!key) {
- logger.warn(
- 'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.'
- )
- return null
- }
- if (key.length !== 64) {
- throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
- }
- return Buffer.from(key, 'hex')
+export function generateApiKey(): string {
+ return `${API_KEY_PREFIX}${nanoid(32)}`
}
-/**
- * Encrypts an API key using the dedicated API encryption key
- * @param apiKey - The API key to encrypt
- * @returns A promise that resolves to an object containing the encrypted API key and IV
- */
-export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
- const key = getApiEncryptionKey()
-
- // If no API encryption key is set, return the key as-is for backward compatibility
- if (!key) {
- return { encrypted: apiKey, iv: '' }
+export function isApiKeyFormat(apiKey: string): boolean {
+ if (isCurrentApiKeyFormat(apiKey)) {
+ return API_KEY_SECRET_PATTERN.test(apiKey.slice(API_KEY_PREFIX.length))
}
+ return false
+}
- const iv = randomBytes(16)
- const cipher = createCipheriv('aes-256-gcm', key, iv)
- let encrypted = cipher.update(apiKey, 'utf8', 'hex')
- encrypted += cipher.final('hex')
-
- const authTag = cipher.getAuthTag()
-
- // Format: iv:encrypted:authTag
- return {
- encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`,
- iv: iv.toString('hex'),
- }
+export function isCurrentApiKeyFormat(apiKey: string): boolean {
+ return apiKey.startsWith(API_KEY_PREFIX)
}
-/**
- * Decrypts an API key using the dedicated API encryption key
- * @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text
- * @returns A promise that resolves to an object containing the decrypted API key
- */
-export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
- // Check if this is actually encrypted (contains colons)
- if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
- // This is a plain text key, return as-is
- return { decrypted: encryptedValue }
+export function formatApiKeyForDisplay(apiKey: string): string {
+ const last4 = apiKey.slice(-4)
+ if (isCurrentApiKeyFormat(apiKey)) {
+ return `${API_KEY_PREFIX}...${last4}`
}
+ return `...${last4}`
+}
- const key = getApiEncryptionKey()
+function getApiKeyLookupDigest(apiKey: string): string {
+ return createHmac('sha256', getApiEncryptionKey()).update(apiKey).digest('hex')
+}
- // If no API encryption key is set, assume it's plain text
+function getApiEncryptionKey(): Buffer {
+ const key = env.API_ENCRYPTION_KEY
if (!key) {
- return { decrypted: encryptedValue }
+ throw new Error(API_KEY_ACCESS_NOT_CONFIGURED)
}
+ if (!API_ENCRYPTION_KEY_PATTERN.test(key)) {
+ throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
+ }
+ return Buffer.from(key, 'hex')
+}
+
+export function isApiKeyStorageAvailable(): boolean {
+ return Boolean(env.API_ENCRYPTION_KEY && API_ENCRYPTION_KEY_PATTERN.test(env.API_ENCRYPTION_KEY))
+}
- const parts = encryptedValue.split(':')
- const ivHex = parts[0]
- const authTagHex = parts[parts.length - 1]
- const encrypted = parts.slice(1, -1).join(':')
+function encryptApiKeyForStorage(apiKey: string): string {
+ const iv = randomBytes(12)
+ const cipher = createCipheriv('aes-256-gcm', getApiEncryptionKey(), iv)
+ let encrypted = cipher.update(apiKey, 'utf8', 'hex')
+ encrypted += cipher.final('hex')
+ return [
+ formatApiKeyForDisplay(apiKey),
+ getApiKeyLookupDigest(apiKey),
+ iv.toString('hex'),
+ encrypted,
+ cipher.getAuthTag().toString('hex'),
+ ].join(STORED_API_KEY_SEPARATOR)
+}
+function decryptStoredApiKey(storedApiKey: string): string {
+ const [, , ivHex, encrypted, authTagHex] = storedApiKey.split(STORED_API_KEY_SEPARATOR)
if (!ivHex || !encrypted || !authTagHex) {
- throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"')
+ throw new Error('Invalid stored API key format')
}
- const iv = Buffer.from(ivHex, 'hex')
- const authTag = Buffer.from(authTagHex, 'hex')
+ const decipher = createDecipheriv('aes-256-gcm', getApiEncryptionKey(), Buffer.from(ivHex, 'hex'))
+ decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8')
+ decrypted += decipher.final('utf8')
+ return decrypted
+}
- try {
- const decipher = createDecipheriv('aes-256-gcm', key, iv)
- decipher.setAuthTag(authTag)
-
- let decrypted = decipher.update(encrypted, 'hex', 'utf8')
- decrypted += decipher.final('utf8')
-
- return { decrypted }
- } catch (error: unknown) {
- logger.error('API key decryption error:', {
- error: error instanceof Error ? error.message : 'Unknown error',
- })
- throw error
- }
+function getStoredApiKeyLookupPrefix(apiKey: string): string {
+ return [formatApiKeyForDisplay(apiKey), getApiKeyLookupDigest(apiKey), ''].join(
+ STORED_API_KEY_SEPARATOR
+ )
}
-/**
- * Generates a standardized API key with the 'tradinggoose_' prefix (plain-text format)
- * @returns A new API key string
- */
-export function generateApiKey(): string {
- return `tradinggoose_${nanoid(32)}`
+export function getStoredApiKey(apiKey: string): string {
+ return encryptApiKeyForStorage(apiKey)
}
-/**
- * Generates a new encrypted API key with the 'sk-tradinggoose-' prefix
- * @returns A new encrypted API key string
- */
-export function generateEncryptedApiKey(): string {
- return `sk-tradinggoose-${nanoid(32)}`
+function isCurrentStoredApiKeyFormat(storedApiKey: string): boolean {
+ const [displayKey, lookupDigest, iv, encrypted, authTag, extra] =
+ storedApiKey.split(STORED_API_KEY_SEPARATOR)
+ return Boolean(
+ displayKey?.startsWith(API_KEY_PREFIX) &&
+ lookupDigest?.length === 64 &&
+ iv &&
+ encrypted &&
+ authTag &&
+ !extra
+ )
}
-/**
- * Determines if an API key uses the new encrypted format based on prefix
- * @param apiKey - The API key to check
- * @returns true if the key uses the new encrypted format (sk-tradinggoose- prefix)
- */
-export function isEncryptedApiKeyFormat(apiKey: string): boolean {
- return apiKey.startsWith('sk-tradinggoose-')
+function constantTimeEqual(left: string, right: string): boolean {
+ return left.length === right.length && timingSafeEqual(Buffer.from(left), Buffer.from(right))
}
-/**
- * Determines if an API key uses the plain-text format based on prefix
- * @param apiKey - The API key to check
- * @returns true if the key uses the plain-text format (tradinggoose_ prefix)
- */
-export function isLegacyApiKeyFormat(apiKey: string): boolean {
- return apiKey.startsWith('tradinggoose_') && !apiKey.startsWith('sk-tradinggoose-')
+export async function storedApiKeyMatches(apiKey: string, storedApiKey: string): Promise {
+ if (
+ !isApiKeyStorageAvailable() ||
+ !isApiKeyFormat(apiKey) ||
+ !isCurrentStoredApiKeyFormat(storedApiKey)
+ ) {
+ return false
+ }
+ const [, lookupDigest] = storedApiKey.split(STORED_API_KEY_SEPARATOR)
+ if (!lookupDigest || !constantTimeEqual(getApiKeyLookupDigest(apiKey), lookupDigest)) {
+ return false
+ }
+ try {
+ return constantTimeEqual(apiKey, decryptStoredApiKey(storedApiKey))
+ } catch (error) {
+ logger.error('Failed to decrypt stored API key:', { error })
+ return false
+ }
+}
+
+export function getApiKeyDisplayFormat(storedApiKey: string): string | null {
+ return isCurrentStoredApiKeyFormat(storedApiKey)
+ ? storedApiKey.split(STORED_API_KEY_SEPARATOR)[0]
+ : null
}
diff --git a/apps/tradinggoose/lib/api/rate-limit.ts b/apps/tradinggoose/lib/api/rate-limit.ts
new file mode 100644
index 000000000..3e84549c8
--- /dev/null
+++ b/apps/tradinggoose/lib/api/rate-limit.ts
@@ -0,0 +1,169 @@
+import { createHash } from 'node:crypto'
+import { getPersonalEffectiveSubscription } from '@/lib/billing/core/subscription'
+import { isBillingEnabledForRuntime } from '@/lib/billing/settings'
+import type { BillingTierRecord } from '@/lib/billing/tiers'
+import { createLogger } from '@/lib/logs/console/logger'
+import { ExecutionLimiter } from '@/services/queue/ExecutionLimiter'
+
+const logger = createLogger('ApiRateLimit')
+const rateLimiter = new ExecutionLimiter()
+
+export interface RateLimitResult {
+ allowed: boolean
+ remaining: number
+ resetAt: Date
+ limit: number
+ userId?: string
+ error?: string
+ failureKind?: 'auth' | 'dependency'
+}
+
+export type ApiRateLimitEndpoint =
+ | 'api-endpoint'
+ | 'copilot-mcp'
+ | 'copilot-mcp-public'
+ | 'logs'
+ | 'logs-detail'
+ | 'mcp-auth-start'
+ | 'mcp-auth-poll'
+
+const PUBLIC_API_ENDPOINT_LIMITS: Partial> = {
+ 'copilot-mcp-public': 300,
+ 'mcp-auth-start': 20,
+ 'mcp-auth-poll': 120,
+}
+
+function getApiEndpointRateLimitScope(userId: string, endpoint: ApiRateLimitEndpoint) {
+ return endpoint === 'api-endpoint'
+ ? undefined
+ : {
+ scopeType: 'user' as const,
+ scopeId: `${userId}:${endpoint}`,
+ organizationId: null,
+ userId,
+ }
+}
+
+export async function createApiAuthFailureRateLimitResult(error: string): Promise {
+ const limit = await isBillingEnabledForRuntime()
+ .then((enabled) => (enabled ? 0 : Number.MAX_SAFE_INTEGER))
+ .catch(() => 0)
+ return {
+ allowed: false,
+ remaining: 0,
+ limit,
+ resetAt: new Date(),
+ error,
+ failureKind: 'auth',
+ }
+}
+
+export async function checkApiEndpointRateLimit(
+ userId: string,
+ endpoint: ApiRateLimitEndpoint = 'api-endpoint'
+): Promise {
+ try {
+ const billingEnabled = await isBillingEnabledForRuntime()
+ if (!billingEnabled) {
+ return {
+ allowed: true,
+ remaining: Number.MAX_SAFE_INTEGER,
+ limit: Number.MAX_SAFE_INTEGER,
+ resetAt: new Date(Date.now() + 60000),
+ userId,
+ }
+ }
+
+ const subscription = await getPersonalEffectiveSubscription(userId)
+ const billingScope = getApiEndpointRateLimitScope(userId, endpoint)
+
+ const result = await rateLimiter.checkRateLimitWithSubscription(
+ userId,
+ subscription,
+ 'api-endpoint',
+ false,
+ billingScope
+ )
+
+ if (!result.allowed) {
+ logger.warn(`Rate limit exceeded for user ${userId}`, {
+ endpoint,
+ remaining: result.remaining,
+ resetAt: result.resetAt,
+ })
+ }
+
+ const rateLimitStatus = await rateLimiter.getRateLimitStatusWithSubscription(
+ userId,
+ subscription,
+ 'api-endpoint',
+ false,
+ billingScope
+ )
+
+ return {
+ ...result,
+ limit: rateLimitStatus.limit,
+ userId,
+ }
+ } catch (error) {
+ logger.error('Rate limit check error; failing closed', { error, endpoint, userId })
+ return {
+ allowed: false,
+ remaining: 0,
+ limit: 0,
+ resetAt: new Date(Date.now() + 60000),
+ error: 'Rate limit service unavailable',
+ failureKind: 'dependency',
+ userId,
+ }
+ }
+}
+
+function getRequesterKey(request: Request): string {
+ const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
+ const requester =
+ request.headers.get('cf-connecting-ip')?.trim() ||
+ request.headers.get('x-real-ip')?.trim() ||
+ forwardedFor ||
+ 'unknown'
+ const host = request.headers.get('host')?.trim() || new URL(request.url).host
+ return createHash('sha256').update(`${host}\n${requester}`).digest('hex')
+}
+
+export async function checkPublicApiEndpointRateLimit(
+ request: Request,
+ endpoint: Extract
+): Promise {
+ const limit = PUBLIC_API_ENDPOINT_LIMITS[endpoint] ?? 0
+ const scopeId = `public:${endpoint}:${getRequesterKey(request)}`
+
+ const result = await rateLimiter.checkRateLimitWithSubscription(
+ scopeId,
+ {
+ referenceType: 'user',
+ referenceId: scopeId,
+ tier: {
+ displayName: endpoint,
+ syncRateLimitPerMinute: 0,
+ asyncRateLimitPerMinute: 0,
+ apiEndpointRateLimitPerMinute: limit,
+ } as BillingTierRecord,
+ },
+ 'api-endpoint',
+ false,
+ {
+ scopeType: 'user',
+ scopeId,
+ organizationId: null,
+ userId: null,
+ },
+ { enforceWithoutBilling: true, failClosedOnError: true }
+ )
+
+ return {
+ ...result,
+ limit,
+ userId: scopeId,
+ }
+}
diff --git a/apps/tradinggoose/lib/auth.ts b/apps/tradinggoose/lib/auth.ts
index d3ebc5404..dbc5182a8 100644
--- a/apps/tradinggoose/lib/auth.ts
+++ b/apps/tradinggoose/lib/auth.ts
@@ -82,6 +82,7 @@ import {
} from '@/lib/system-services/stripe-runtime'
import { getResolvedSystemSettings } from '@/lib/system-settings/service'
import { getBaseUrl } from '@/lib/urls/utils'
+import { createDefaultWorkspaceForUser } from '@/lib/workspaces/service'
import { localizeUrl } from '@/i18n/utils'
import { resolveAlpacaTradingBaseUrl } from '@/providers/trading/alpaca/config'
import { resolveTradierBaseUrl } from '@/providers/trading/tradier/client'
@@ -458,6 +459,8 @@ export const auth = betterAuth({
userId: user.id,
})
+ await createDefaultWorkspaceForUser(user.id, user.name)
+
try {
await markWaitlistEntrySignedUp(user.email, user.id)
} catch (error) {
diff --git a/apps/tradinggoose/lib/auth/hybrid.ts b/apps/tradinggoose/lib/auth/hybrid.ts
index 2e22fc286..49063f932 100644
--- a/apps/tradinggoose/lib/auth/hybrid.ts
+++ b/apps/tradinggoose/lib/auth/hybrid.ts
@@ -1,5 +1,9 @@
import type { NextRequest } from 'next/server'
-import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
+import {
+ type ApiKeyType,
+ authenticateApiKeyFromHeader,
+ updateApiKeyLastUsed,
+} from '@/lib/api-key/service'
import { getSession } from '@/lib/auth'
import {
type InternalTokenVerificationResult,
@@ -28,7 +32,7 @@ export interface AuthResult {
userName?: string | null
userEmail?: string | null
authType?: (typeof AuthType)[keyof typeof AuthType]
- apiKeyType?: 'personal' | 'workspace'
+ apiKeyType?: ApiKeyType
internalWorkflowExecution?: InternalWorkflowExecutionContext
error?: string
}
diff --git a/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts b/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts
index f8a15392c..3ed0edc3b 100644
--- a/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts
+++ b/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts
@@ -35,7 +35,7 @@ describe('chat replay safety', () => {
expect(
isAcceptedLiveMutationToolCall({
id: 'tool-3b',
- name: 'set_workflow_variables',
+ name: 'edit_workflow_variable',
state: 'success',
})
).toBe(true)
diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts
index d93412ddb..1f02ddd37 100644
--- a/apps/tradinggoose/lib/copilot/entity-documents.ts
+++ b/apps/tradinggoose/lib/copilot/entity-documents.ts
@@ -1,14 +1,20 @@
import { z } from 'zod'
+import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema'
+import { inferInputMetaFromPineCode } from '@/lib/indicators/input-meta'
+import { validateMcpServerUrl } from '@/lib/mcp/url-validator'
export const SKILL_DOCUMENT_FORMAT = 'tg-skill-document-v1' as const
export const CUSTOM_TOOL_DOCUMENT_FORMAT = 'tg-custom-tool-document-v1' as const
export const INDICATOR_DOCUMENT_FORMAT = 'tg-indicator-document-v1' as const
export const MCP_SERVER_DOCUMENT_FORMAT = 'tg-mcp-server-document-v1' as const
+export const KNOWLEDGE_BASE_DOCUMENT_FORMAT = 'tg-knowledge-base-document-v1' as const
+export const WORKFLOW_VARIABLE_DOCUMENT_FORMAT = 'tg-workflow-variable-document-v1' as const
export const ENTITY_DOCUMENT_FORMATS = {
skill: SKILL_DOCUMENT_FORMAT,
custom_tool: CUSTOM_TOOL_DOCUMENT_FORMAT,
indicator: INDICATOR_DOCUMENT_FORMAT,
mcp_server: MCP_SERVER_DOCUMENT_FORMAT,
+ knowledge_base: KNOWLEDGE_BASE_DOCUMENT_FORMAT,
} as const
export type EntityDocumentKind = keyof typeof ENTITY_DOCUMENT_FORMATS
@@ -35,8 +41,8 @@ const CustomToolDocumentSchema = z.object({
const IndicatorDocumentSchema = z.object({
name: z.string(),
+ color: z.string(),
pineCode: z.string(),
- inputMeta: z.record(z.unknown()).nullable(),
})
const McpServerDocumentSchema = z.object({
@@ -44,27 +50,89 @@ const McpServerDocumentSchema = z.object({
description: z.string(),
transport: z.enum(['http', 'sse', 'streamable-http']),
url: z.string(),
- headers: z.record(z.string()),
+ headers: z
+ .record(z.string())
+ .describe(
+ 'MCP server headers. Secret values are returned as [redacted]; keep [redacted] to preserve an existing value, send a concrete value to replace it, or omit a key to delete it.'
+ ),
command: z.string(),
args: z.array(z.string()),
- env: z.record(z.string()),
+ env: z
+ .record(z.string())
+ .describe(
+ 'MCP server environment variables. Secret values are returned as [redacted]; keep [redacted] to preserve an existing value, send a concrete value to replace it, or omit a key to delete it.'
+ ),
timeout: z.number(),
retries: z.number(),
enabled: z.boolean(),
})
+const KnowledgeBaseDocumentSchema = z.object({
+ name: z.string().trim().min(1),
+ description: z.string(),
+ chunkingConfig: z
+ .object({
+ maxSize: z.number().min(100).max(4000),
+ minSize: z.number().min(1).max(2000),
+ overlap: z.number().min(0).max(500),
+ })
+ .refine((data) => data.minSize < data.maxSize, {
+ message: 'minSize must be less than maxSize',
+ }),
+})
+
export const EntityDocumentSchemas = {
skill: SkillDocumentSchema,
custom_tool: CustomToolDocumentSchema,
indicator: IndicatorDocumentSchema,
mcp_server: McpServerDocumentSchema,
+ knowledge_base: KnowledgeBaseDocumentSchema,
} as const
export type EntityDocumentFields = z.infer<
(typeof EntityDocumentSchemas)[K]
>
-function normalizeEntityFields(
+export const ENTITY_SECRET_PLACEHOLDER = '[redacted]'
+const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
+
+function redactStringRecordValues(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return {}
+ }
+
+ return Object.fromEntries(
+ Object.keys(value as Record).map((key) => [key, ENTITY_SECRET_PLACEHOLDER])
+ )
+}
+
+export function normalizeStringRecord(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return {}
+ }
+
+ return Object.fromEntries(
+ Object.entries(value as Record).map(([key, item]) => [
+ key,
+ typeof item === 'string' ? item : String(item ?? ''),
+ ])
+ )
+}
+
+function normalizeHttpHeaderRecord(value: unknown): Record {
+ const entries = Object.entries(normalizeStringRecord(value))
+ .map(([key, item]) => [key.trim(), item.trim()] as const)
+ .filter(([key, item]) => key.length > 0 && item.length > 0)
+
+ const invalidKey = entries.find(([key]) => !HTTP_HEADER_NAME_PATTERN.test(key))?.[0]
+ if (invalidKey) {
+ throw new Error(`Invalid MCP server header "${invalidKey}"`)
+ }
+
+ return Object.fromEntries(entries)
+}
+
+export function normalizeEntityFields(
kind: EntityDocumentKind,
fields: Record | null | undefined
): Record {
@@ -73,63 +141,68 @@ function normalizeEntityFields(
switch (kind) {
case 'skill':
return {
- name: typeof source.name === 'string' ? source.name : '',
- description: typeof source.description === 'string' ? source.description : '',
- content: typeof source.content === 'string' ? source.content : '',
+ name: typeof source.name === 'string' ? source.name.trim() : '',
+ description: typeof source.description === 'string' ? source.description.trim() : '',
+ content: typeof source.content === 'string' ? source.content.trim() : '',
}
- case 'custom_tool':
+ case 'custom_tool': {
+ const schemaText = typeof source.schemaText === 'string' ? source.schemaText : ''
return {
- title: typeof source.title === 'string' ? source.title : '',
- schemaText: typeof source.schemaText === 'string' ? source.schemaText : '',
+ title: typeof source.title === 'string' ? source.title.trim().replace(/\s+/g, ' ') : '',
+ schemaText: JSON.stringify(parseCustomToolSchemaText(schemaText), null, 2),
codeText: typeof source.codeText === 'string' ? source.codeText : '',
}
- case 'indicator':
+ }
+ case 'indicator': {
+ const pineCode = typeof source.pineCode === 'string' ? source.pineCode : ''
return {
- name: typeof source.name === 'string' ? source.name : '',
- pineCode: typeof source.pineCode === 'string' ? source.pineCode : '',
- inputMeta:
- source.inputMeta &&
- typeof source.inputMeta === 'object' &&
- !Array.isArray(source.inputMeta)
- ? (source.inputMeta as Record)
- : null,
+ name: typeof source.name === 'string' ? source.name.trim() : '',
+ color: typeof source.color === 'string' ? source.color.trim() : '',
+ pineCode,
+ inputMeta: inferInputMetaFromPineCode(pineCode) ?? null,
}
- case 'mcp_server':
+ }
+ case 'mcp_server': {
+ if (
+ source.transport !== 'http' &&
+ source.transport !== 'sse' &&
+ source.transport !== 'streamable-http'
+ ) {
+ throw new Error(`Invalid MCP server transport "${String(source.transport ?? '')}"`)
+ }
+
+ const enabled = typeof source.enabled === 'boolean' ? source.enabled : true
+ const rawUrl = typeof source.url === 'string' ? source.url.trim() : ''
+ const validation = rawUrl ? validateMcpServerUrl(rawUrl) : null
+ if (!rawUrl && enabled) {
+ throw new Error('Invalid MCP server URL: URL is required and must be a string')
+ }
+ if (validation && !validation.isValid) {
+ throw new Error(`Invalid MCP server URL: ${validation.error}`)
+ }
+
return {
- name: typeof source.name === 'string' ? source.name : '',
- description: typeof source.description === 'string' ? source.description : '',
- transport:
- source.transport === 'http' ||
- source.transport === 'sse' ||
- source.transport === 'streamable-http'
- ? source.transport
- : 'http',
- url: typeof source.url === 'string' ? source.url : '',
- headers:
- source.headers && typeof source.headers === 'object' && !Array.isArray(source.headers)
- ? Object.fromEntries(
- Object.entries(source.headers as Record).map(([key, value]) => [
- key,
- typeof value === 'string' ? value : String(value ?? ''),
- ])
- )
- : {},
- command: typeof source.command === 'string' ? source.command : '',
+ name: typeof source.name === 'string' ? source.name.trim() : '',
+ description: typeof source.description === 'string' ? source.description.trim() : '',
+ transport: source.transport,
+ url: validation?.normalizedUrl ?? rawUrl,
+ headers: normalizeHttpHeaderRecord(source.headers),
+ command: typeof source.command === 'string' ? source.command.trim() : '',
args: Array.isArray(source.args)
? source.args.map((value) => (typeof value === 'string' ? value : String(value ?? '')))
: [],
- env:
- source.env && typeof source.env === 'object' && !Array.isArray(source.env)
- ? Object.fromEntries(
- Object.entries(source.env as Record).map(([key, value]) => [
- key,
- typeof value === 'string' ? value : String(value ?? ''),
- ])
- )
- : {},
+ env: normalizeStringRecord(source.env),
timeout: typeof source.timeout === 'number' ? source.timeout : 30000,
retries: typeof source.retries === 'number' ? source.retries : 3,
- enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
+ enabled,
+ }
+ }
+ case 'knowledge_base':
+ return {
+ name: typeof source.name === 'string' ? source.name.trim() : source.name,
+ description:
+ typeof source.description === 'string' ? source.description.trim() : source.description,
+ chunkingConfig: source.chunkingConfig,
}
}
}
@@ -151,13 +224,28 @@ export function parseEntityDocument(
return EntityDocumentSchemas[kind].parse(normalized) as EntityDocumentFields
}
+function redactEntityDocumentSecretFields(
+ kind: K,
+ fields: Record | null | undefined
+): EntityDocumentFields {
+ const normalized = normalizeEntityFields(kind, fields)
+ const redacted =
+ kind === 'mcp_server'
+ ? {
+ ...normalized,
+ headers: redactStringRecordValues(normalized.headers),
+ env: redactStringRecordValues(normalized.env),
+ }
+ : normalized
+
+ return EntityDocumentSchemas[kind].parse(redacted) as EntityDocumentFields
+}
+
export function serializeEntityDocument(
kind: K,
fields: Record | null | undefined
): string {
- const normalized = normalizeEntityFields(kind, fields)
- const parsed = EntityDocumentSchemas[kind].parse(normalized)
- return JSON.stringify(parsed, null, 2)
+ return JSON.stringify(redactEntityDocumentSecretFields(kind, fields), null, 2)
}
export function getEntityDocumentName(
@@ -175,5 +263,7 @@ export function getEntityDocumentName(
return String(normalized.name ?? '')
case 'mcp_server':
return String(normalized.name ?? '')
+ case 'knowledge_base':
+ return String(normalized.name ?? '')
}
}
diff --git a/apps/tradinggoose/lib/copilot/inline-tool-call.tsx b/apps/tradinggoose/lib/copilot/inline-tool-call.tsx
index 4edbe2715..0c16e832a 100644
--- a/apps/tradinggoose/lib/copilot/inline-tool-call.tsx
+++ b/apps/tradinggoose/lib/copilot/inline-tool-call.tsx
@@ -116,6 +116,14 @@ const ACTION_VERBS = [
'Resumed',
] as const
+const REDACTED_VALUE = '[redacted]'
+
+function redactUrlQuery(value: unknown): string {
+ const url = String(value || '')
+ const queryStart = url.indexOf('?')
+ return queryStart === -1 ? url : `${url.slice(0, queryStart)}?${REDACTED_VALUE}`
+}
+
function splitActionVerb(text: string): [string | null, string] {
for (const verb of ACTION_VERBS) {
if (text.startsWith(`${verb} `)) {
@@ -214,7 +222,9 @@ function isEntityReviewKind(entityKind: unknown): entityKind is string {
entityKind === 'skill' ||
entityKind === 'custom_tool' ||
entityKind === 'indicator' ||
- entityKind === 'mcp_server'
+ entityKind === 'mcp_server' ||
+ entityKind === 'knowledge_base' ||
+ entityKind === 'workflow'
)
}
@@ -239,15 +249,19 @@ function readEntityReviewPayload(toolCall: CopilotToolCall): EntityReviewPayload
}
const entityLabel =
- result?.entityKind === 'custom_tool'
- ? 'Custom Tool'
- : result?.entityKind === 'mcp_server'
- ? 'MCP Server'
- : result?.entityKind === 'indicator'
- ? 'Indicator'
- : result?.entityKind === 'skill'
- ? 'Skill'
- : 'Entity'
+ result?.entityKind === 'workflow' && toolCall.name === 'edit_workflow_variable'
+ ? 'Workflow Variable'
+ : result?.entityKind === 'custom_tool'
+ ? 'Custom Tool'
+ : result?.entityKind === 'mcp_server'
+ ? 'MCP Server'
+ : result?.entityKind === 'knowledge_base'
+ ? 'Knowledge Base'
+ : result?.entityKind === 'indicator'
+ ? 'Indicator'
+ : result?.entityKind === 'skill'
+ ? 'Skill'
+ : 'Entity'
return {
title:
toolCall.state === ClientToolCallState.success
@@ -394,15 +408,11 @@ export function InlineToolCall({
const isExpandablePending =
toolState === 'pending' &&
- (toolName === 'make_api_request' ||
- toolName === 'set_environment_variables' ||
- toolName === 'set_workflow_variables')
+ (toolName === 'make_api_request' || toolName === 'set_environment_variables')
const [expanded, setExpanded] = useState(isExpandablePending)
const isExpandableTool =
- toolName === 'make_api_request' ||
- toolName === 'set_environment_variables' ||
- toolName === 'set_workflow_variables'
+ toolName === 'make_api_request' || toolName === 'set_environment_variables'
const accessLevel = useCopilotStore((s) => s.accessLevel)
@@ -425,7 +435,7 @@ export function InlineToolCall({
const renderPendingDetails = () => {
if (toolCall.name === 'make_api_request') {
- const url = params.url || ''
+ const url = redactUrlQuery(params.url)
const method = (params.method || '').toUpperCase()
return (
@@ -458,19 +468,10 @@ export function InlineToolCall({
if (toolCall.name === 'set_environment_variables') {
const variables =
- params.variables && typeof params.variables === 'object' ? params.variables : {}
-
- // Normalize variables - handle both direct key-value and nested {name, value} format
- const normalizedEntries: Array<[string, string]> = []
- Object.entries(variables).forEach(([key, value]) => {
- if (typeof value === 'object' && value !== null && 'name' in value && 'value' in value) {
- // Handle {name: "key", value: "val"} format
- normalizedEntries.push([String((value as any).name), String((value as any).value)])
- } else {
- // Handle direct key-value format
- normalizedEntries.push([key, String(value)])
- }
- })
+ params.variables && typeof params.variables === 'object' && !Array.isArray(params.variables)
+ ? params.variables
+ : {}
+ const variableNames = Object.keys(variables)
return (
@@ -482,11 +483,11 @@ export function InlineToolCall({
Value
- {normalizedEntries.length === 0 ? (
+ {variableNames.length === 0 ? (
No variables provided
) : (
- {normalizedEntries.map(([name, value]) => (
+ {variableNames.map((name) => (
- {value}
+ {REDACTED_VALUE}
@@ -507,54 +508,6 @@ export function InlineToolCall({
)
}
- if (toolCall.name === 'set_workflow_variables') {
- const ops = Array.isArray(params.operations) ? (params.operations as any[]) : []
- return (
-
-
-
- Name
-
-
- Type
-
-
- Value
-
-
- {ops.length === 0 ? (
-
No operations provided
- ) : (
-
- {ops.map((op, idx) => (
-
-
-
- {String(op.name || '')}
-
-
-
-
- {String(op.type || '')}
-
-
-
- {op.value !== undefined ? (
-
- {String(op.value)}
-
- ) : (
- —
- )}
-
-
- ))}
-
- )}
-
- )
- }
-
return null
}
diff --git a/apps/tradinggoose/lib/copilot/process-contents.test.ts b/apps/tradinggoose/lib/copilot/process-contents.test.ts
index 1954a2527..cf483bcec 100644
--- a/apps/tradinggoose/lib/copilot/process-contents.test.ts
+++ b/apps/tradinggoose/lib/copilot/process-contents.test.ts
@@ -8,8 +8,8 @@ const mockGetBlocksMetadataExecute = vi.fn()
const mockVerifyWorkflowAccess = vi.fn()
const mockVerifyReviewTargetAccess = vi.fn()
const mockReadBootstrappedReviewTargetSnapshot = vi.fn()
+const mockReadBootstrappedSavedEntityFields = vi.fn()
const mockReadWorkflowSnapshot = vi.fn()
-const mockGetEntityFields = vi.fn()
const mockSanitizeForCopilot = vi.fn((value) => value)
const mockAnd = vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' }))
const mockEq = vi.fn((field: unknown, value: unknown) => ({ field, type: 'eq', value }))
@@ -94,16 +94,13 @@ vi.mock('@/lib/copilot/tools/server/blocks/get-blocks-metadata', () => ({
vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({
readBootstrappedReviewTargetSnapshot: mockReadBootstrappedReviewTargetSnapshot,
+ readBootstrappedSavedEntityFields: mockReadBootstrappedSavedEntityFields,
}))
vi.mock('@/lib/yjs/workflow-session', () => ({
readWorkflowSnapshot: mockReadWorkflowSnapshot,
}))
-vi.mock('@/lib/yjs/entity-session', () => ({
- getEntityFields: mockGetEntityFields,
-}))
-
vi.mock('@/lib/workflows/json-sanitizer', () => ({
sanitizeForCopilot: mockSanitizeForCopilot,
}))
@@ -115,8 +112,8 @@ describe('processContextsServer', () => {
mockVerifyWorkflowAccess.mockReset()
mockVerifyReviewTargetAccess.mockReset()
mockReadBootstrappedReviewTargetSnapshot.mockReset()
+ mockReadBootstrappedSavedEntityFields.mockReset()
mockReadWorkflowSnapshot.mockReset()
- mockGetEntityFields.mockReset()
mockSanitizeForCopilot.mockClear()
mockAnd.mockClear()
mockEq.mockClear()
@@ -183,15 +180,7 @@ describe('processContextsServer', () => {
})
it('hydrates current entity contexts from Yjs', async () => {
- const doc = new Y.Doc()
- const snapshotBase64 = Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64')
- doc.destroy()
- mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue({
- snapshotBase64,
- descriptor: {},
- runtime: { docState: 'active', replaySafe: false, reseededFromCanonical: false },
- })
- mockGetEntityFields.mockReturnValue({
+ mockReadBootstrappedSavedEntityFields.mockResolvedValue({
name: 'Canonical Skill',
description: 'Canonical description',
content: 'Canonical content',
@@ -222,15 +211,11 @@ describe('processContextsServer', () => {
},
'read'
)
- expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith({
- workspaceId: 'workspace-1',
- entityKind: 'skill',
- entityId: 'skill-1',
- draftSessionId: null,
- reviewSessionId: null,
- yjsSessionId: 'skill-1',
- })
- expect(mockGetEntityFields).toHaveBeenCalledWith(expect.any(Y.Doc), 'skill')
+ expect(mockReadBootstrappedSavedEntityFields).toHaveBeenCalledWith(
+ 'skill',
+ 'skill-1',
+ 'workspace-1'
+ )
expect(result).toEqual([
{
type: 'current_skill',
@@ -272,7 +257,7 @@ describe('processContextsServer', () => {
)
expect(mockVerifyReviewTargetAccess).toHaveBeenCalled()
- expect(mockReadBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled()
+ expect(mockReadBootstrappedSavedEntityFields).not.toHaveBeenCalled()
expect(result).toEqual([])
})
diff --git a/apps/tradinggoose/lib/copilot/process-contents.ts b/apps/tradinggoose/lib/copilot/process-contents.ts
index ccb1b8a4e..4c8aa0d01 100644
--- a/apps/tradinggoose/lib/copilot/process-contents.ts
+++ b/apps/tradinggoose/lib/copilot/process-contents.ts
@@ -3,7 +3,6 @@ import {
copilotReviewItems,
copilotReviewSessions,
document,
- knowledgeBase,
permissions,
templates,
workflow,
@@ -12,18 +11,22 @@ import {
} from '@tradinggoose/db/schema'
import { and, asc, eq, isNull } from 'drizzle-orm'
import * as Y from 'yjs'
+import { buildSavedEntityDescriptor } from '@/lib/copilot/review-sessions/identity'
import {
verifyReviewTargetAccess,
verifyWorkflowAccess,
} from '@/lib/copilot/review-sessions/permissions'
import { REVIEW_ITEM_KINDS } from '@/lib/copilot/review-sessions/thread-history'
+import { ENTITY_KIND_KNOWLEDGE_BASE } from '@/lib/copilot/review-sessions/types'
import { createLogger } from '@/lib/logs/console/logger'
import { buildWorkspaceAccessScope } from '@/lib/permissions/utils'
import { escapeRegExp } from '@/lib/utils'
import { sanitizeForCopilot } from '@/lib/workflows/json-sanitizer'
+import {
+ readBootstrappedReviewTargetSnapshot,
+ readBootstrappedSavedEntityFields,
+} from '@/lib/yjs/server/bootstrap-review-target'
import { readWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session'
-import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target'
-import { getEntityFields } from '@/lib/yjs/entity-session'
import type { ChatContext } from '@/stores/copilot/types'
import { readCopilotWorkspaceEntityContext } from '@/widgets/widgets/copilot/workspace-entities'
@@ -98,6 +101,8 @@ export async function processContextsServer(
if (ctx.kind === 'knowledge' && ctx.knowledgeId) {
return await processKnowledgeContext(
ctx.knowledgeId,
+ userId,
+ ctx.workspaceId ?? workspaceId ?? null,
ctx.label ? `@${ctx.label}` : '@'
)
}
@@ -105,10 +110,7 @@ export async function processContextsServer(
return await processBlocksMetadata(ctx.blockTypes ?? [], ctx.label ? `@${ctx.label}` : '@')
}
if (ctx.kind === 'templates' && ctx.templateId) {
- return await processTemplateContext(
- ctx.templateId,
- ctx.label ? `@${ctx.label}` : '@'
- )
+ return await processTemplateContext(ctx.templateId, ctx.label ? `@${ctx.label}` : '@')
}
if (ctx.kind === 'logs' && ctx.executionId) {
return await processExecutionLogContext(
@@ -172,14 +174,7 @@ async function processEntityContext(params: {
try {
const access = await verifyReviewTargetAccess(
params.userId,
- {
- entityKind: params.entityKind,
- entityId: params.entityId,
- draftSessionId: null,
- reviewSessionId: null,
- workspaceId: params.workspaceId,
- yjsSessionId: params.entityId,
- },
+ buildSavedEntityDescriptor(params.entityKind, params.entityId, params.workspaceId),
'read'
)
if (!access.hasAccess || !access.workspaceId) {
@@ -192,7 +187,7 @@ async function processEntityContext(params: {
return null
}
- const fields = await readCopilotEntityFieldsFromYjs(
+ const fields = await readBootstrappedSavedEntityFields(
params.entityKind,
params.entityId,
access.workspaceId
@@ -222,29 +217,6 @@ async function processEntityContext(params: {
}
}
-async function readCopilotEntityFieldsFromYjs(
- entityKind: 'skill' | 'indicator' | 'custom_tool' | 'mcp_server',
- entityId: string,
- workspaceId: string
-): Promise
> {
- const fields = await readBootstrappedCopilotYjsDoc(
- {
- workspaceId,
- entityKind,
- entityId,
- draftSessionId: null,
- reviewSessionId: null,
- yjsSessionId: entityId,
- },
- (doc) => getEntityFields(doc, entityKind)
- )
- if (!fields) {
- throw new Error('Saved entity Yjs snapshot is empty')
- }
-
- return fields
-}
-
async function readBootstrappedCopilotYjsDoc(
descriptor: Parameters[0],
read: (doc: Y.Doc) => T
@@ -295,7 +267,6 @@ function serializeEntityContext(
name: row.name ?? null,
color: row.color ?? null,
pineCode: row.pineCode ?? null,
- inputMeta: row.inputMeta ?? null,
}
case 'custom_tool':
return {
@@ -488,23 +459,31 @@ async function processWorkflowContext({
async function processKnowledgeContext(
knowledgeBaseId: string,
+ userId: string,
+ workspaceId: string | null,
tag: string
): Promise {
try {
- // Load KB metadata
- const kbRows = await db
- .select({
- id: knowledgeBase.id,
- name: knowledgeBase.name,
- updatedAt: knowledgeBase.updatedAt,
+ const access = await verifyReviewTargetAccess(
+ userId,
+ buildSavedEntityDescriptor(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, workspaceId),
+ 'read'
+ )
+ if (!access.hasAccess || !access.workspaceId) {
+ logger.warn('Skipping unauthorized knowledge context', {
+ knowledgeBaseId,
+ workspaceId,
+ userId,
})
- .from(knowledgeBase)
- .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
- .limit(1)
- const kb = kbRows?.[0]
- if (!kb) return null
+ return null
+ }
+
+ const fields = await readBootstrappedSavedEntityFields(
+ ENTITY_KIND_KNOWLEDGE_BASE,
+ knowledgeBaseId,
+ access.workspaceId
+ )
- // Load up to 20 recent doc filenames
const docRows = await db
.select({ filename: document.filename })
.from(document)
@@ -513,12 +492,15 @@ async function processKnowledgeContext(
const sampleDocuments = docRows.map((d: any) => d.filename).filter(Boolean)
const summary = {
- id: kb.id,
- name: kb.name,
+ id: knowledgeBaseId,
+ workspaceId: access.workspaceId,
+ name: fields.name ?? null,
+ description: fields.description ?? null,
+ chunkingConfig: fields.chunkingConfig ?? null,
docCount: sampleDocuments.length,
sampleDocuments,
}
- const content = JSON.stringify(summary)
+ const content = JSON.stringify(summary, null, 2)
return { type: 'knowledge', tag, content }
} catch (error) {
logger.error('Error processing knowledge context', { knowledgeBaseId, error })
diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts
index 2ce13c9b7..05ec807b5 100644
--- a/apps/tradinggoose/lib/copilot/registry.ts
+++ b/apps/tradinggoose/lib/copilot/registry.ts
@@ -2,15 +2,16 @@ import { z } from 'zod'
import {
CUSTOM_TOOL_DOCUMENT_FORMAT,
INDICATOR_DOCUMENT_FORMAT,
+ KNOWLEDGE_BASE_DOCUMENT_FORMAT,
MCP_SERVER_DOCUMENT_FORMAT,
SKILL_DOCUMENT_FORMAT,
+ WORKFLOW_VARIABLE_DOCUMENT_FORMAT,
} from '@/lib/copilot/entity-documents'
import { MONITOR_DOCUMENT_FORMAT } from '@/lib/copilot/monitor/monitor-documents'
import {
TG_MERMAID_DOCUMENT_FORMAT,
WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT,
} from '@/lib/workflows/document-format'
-import { WORKFLOW_VARIABLE_TYPES, type WorkflowVariableType } from '@/lib/workflows/value-types'
import {
GetAgentAccessoryCatalogInput,
GetAgentAccessoryCatalogResult,
@@ -22,18 +23,12 @@ import {
GetIndicatorCatalogResult,
GetIndicatorMetadataInput,
GetIndicatorMetadataResult,
- KnowledgeBaseArgsSchema,
- KnowledgeBaseResultSchema,
ReadBlockOutputsInput,
ReadBlockOutputsResult,
ReadBlockUpstreamReferencesInput,
ReadBlockUpstreamReferencesResult,
} from './tools/shared/schemas'
-const WorkflowVariableTypeSchema = z.enum(
- WORKFLOW_VARIABLE_TYPES as [WorkflowVariableType, ...WorkflowVariableType[]]
-)
-
// Tool IDs supported by the Copilot runtime
export const COPILOT_TOOL_IDS = [
'plan',
@@ -59,12 +54,16 @@ export const COPILOT_TOOL_IDS = [
'read_oauth_credentials',
'read_credentials',
'list_workflows',
- 'read_workflow_variables',
- 'set_workflow_variables',
+ 'edit_workflow_variable',
'oauth_request_access',
'deploy_workflow',
'check_deployment_status',
- 'knowledge_base',
+ 'list_knowledge_bases',
+ 'read_knowledge_base',
+ 'create_knowledge_base',
+ 'edit_knowledge_base',
+ 'rename_knowledge_base',
+ 'query_knowledge_base',
'list_custom_tools',
'read_custom_tool',
'create_custom_tool',
@@ -124,6 +123,31 @@ const OptionalEntityTargetArgs = z.object({
const EntityTargetArgs = z.object({
entityId: RequiredId,
})
+const WorkspaceTargetArgs = z.object({
+ workspaceId: RequiredId,
+})
+const PersonalOrWorkspaceReadArgs = z.discriminatedUnion('scope', [
+ z
+ .object({
+ scope: z.literal('personal'),
+ })
+ .strict(),
+ WorkspaceTargetArgs.extend({
+ scope: z.literal('workspace'),
+ }).strict(),
+])
+const SetEnvironmentVariablesArgs = z.discriminatedUnion('scope', [
+ z
+ .object({
+ scope: z.literal('personal'),
+ variables: z.record(z.string()),
+ })
+ .strict(),
+ WorkspaceTargetArgs.extend({
+ scope: z.literal('workspace'),
+ variables: z.record(z.string()),
+ }).strict(),
+])
function buildEntityDocumentMutationArgs(
documentFormat: TDocumentFormat
@@ -139,12 +163,10 @@ function buildEntityDocumentMutationArgs(
function buildEntityDocumentCreateArgs(
documentFormat: TDocumentFormat
) {
- return z
- .object({
- entityDocument: z.string().min(1),
- documentFormat: z.literal(documentFormat).optional(),
- })
- .strict()
+ return WorkspaceTargetArgs.extend({
+ entityDocument: z.string().min(1),
+ documentFormat: z.literal(documentFormat).optional(),
+ }).strict()
}
const CreateWorkflowArgs = z
@@ -152,7 +174,7 @@ const CreateWorkflowArgs = z
name: z.string().trim().min(1).optional(),
description: z.string().optional(),
folderId: z.string().nullable().optional(),
- workspaceId: RequiredId.optional(),
+ workspaceId: RequiredId,
})
.strict()
@@ -221,8 +243,7 @@ const CustomToolDocumentMutationShape = {
const EditCustomToolArgs = EntityTargetArgs.extend(CustomToolDocumentMutationShape)
.strict()
.describe('Update a saved custom tool by replacing the full custom-tool document.')
-const CreateCustomToolArgs = z
- .object(CustomToolDocumentMutationShape)
+const CreateCustomToolArgs = WorkspaceTargetArgs.extend(CustomToolDocumentMutationShape)
.strict()
.describe('Create a custom tool from the full custom-tool document.')
const GetIndicatorArgs = z
@@ -250,6 +271,44 @@ const EditSkillArgs = buildEntityDocumentMutationArgs(SKILL_DOCUMENT_FORMAT)
const CreateSkillArgs = buildEntityDocumentCreateArgs(SKILL_DOCUMENT_FORMAT)
const EditMcpServerArgs = buildEntityDocumentMutationArgs(MCP_SERVER_DOCUMENT_FORMAT)
const CreateMcpServerArgs = buildEntityDocumentCreateArgs(MCP_SERVER_DOCUMENT_FORMAT)
+const EditWorkflowVariableArgs = EntityTargetArgs.extend({
+ entityDocument: z
+ .string()
+ .min(1)
+ .describe(
+ 'Full `tg-workflow-variable-document-v1` JSON document for workflow variables. Preserve existing `variableId` values from `read_workflow`; choose a new unique `variableId` only for a new variable: {"variables":[{"variableId":"var-risk-limit","name":"riskLimit","type":"number","value":100}]}.'
+ ),
+ removedVariableIds: z
+ .array(z.string().trim().min(1))
+ .optional()
+ .describe('Existing variable ids intentionally removed from the workflow.'),
+ documentFormat: z.literal(WORKFLOW_VARIABLE_DOCUMENT_FORMAT).optional(),
+}).strict()
+const KnowledgeBaseDocumentMutationShape = {
+ entityDocument: z
+ .string()
+ .min(1)
+ .describe(
+ 'Full `tg-knowledge-base-document-v1` JSON document with exactly `name`, `description`, and `chunkingConfig`: {"name":"Research","description":"","chunkingConfig":{"maxSize":1024,"minSize":1,"overlap":200}}.'
+ ),
+ documentFormat: z.literal(KNOWLEDGE_BASE_DOCUMENT_FORMAT).optional(),
+}
+const CreateKnowledgeBaseArgs = WorkspaceTargetArgs.extend(KnowledgeBaseDocumentMutationShape)
+ .strict()
+ .describe('Create a knowledge base in a workspace from the full knowledge-base document.')
+const EditKnowledgeBaseArgs = EntityTargetArgs.extend(KnowledgeBaseDocumentMutationShape)
+ .strict()
+ .describe('Update a knowledge base by replacing the full knowledge-base document.')
+const RenameKnowledgeBaseArgs = EditKnowledgeBaseArgs.describe(
+ 'Rename a knowledge base by replacing the full knowledge-base document with an updated `name`.'
+)
+const QueryKnowledgeBaseArgs = z
+ .object({
+ entityId: RequiredId,
+ query: z.string().trim().min(1),
+ topK: z.number().min(1).max(50).optional(),
+ })
+ .strict()
// Tool argument schemas for the Studio runtime tool surface
export const ToolArgSchemas = {
@@ -279,21 +338,8 @@ export const ToolArgSchemas = {
})
.strict(),
create_workflow: CreateWorkflowArgs,
- [CopilotTool.list_workflows]: z.object({}),
- [CopilotTool.read_workflow_variables]: z.object({
- entityId: RequiredId,
- }),
- [CopilotTool.set_workflow_variables]: z.object({
- entityId: RequiredId,
- operations: z.array(
- z.object({
- operation: z.enum(['add', 'delete', 'edit']),
- name: z.string(),
- type: WorkflowVariableTypeSchema.optional(),
- value: z.string().optional(),
- })
- ),
- }),
+ [CopilotTool.list_workflows]: WorkspaceTargetArgs.strict(),
+ [CopilotTool.edit_workflow_variable]: EditWorkflowVariableArgs,
oauth_request_access: z.object({
providerName: z.string().optional(),
}),
@@ -357,44 +403,46 @@ export const ToolArgSchemas = {
body: z.union([z.record(z.any()), z.string()]).optional(),
}),
- [CopilotTool.read_environment_variables]: OptionalEntityTargetArgs,
+ [CopilotTool.read_environment_variables]: PersonalOrWorkspaceReadArgs,
- set_environment_variables: OptionalEntityTargetArgs.extend({
- variables: z.record(z.string()),
- }),
+ set_environment_variables: SetEnvironmentVariablesArgs,
- [CopilotTool.read_oauth_credentials]: OptionalEntityTargetArgs,
+ [CopilotTool.read_oauth_credentials]: PersonalOrWorkspaceReadArgs,
- [CopilotTool.read_credentials]: OptionalEntityTargetArgs,
+ [CopilotTool.read_credentials]: PersonalOrWorkspaceReadArgs,
gdrive_request_access: z.object({}),
- list_gdrive_files: OptionalEntityTargetArgs.extend({
+ list_gdrive_files: WorkspaceTargetArgs.extend({
credentialId: z.string(),
search_query: z.string().optional(),
num_results: z.number().optional().default(50),
- }),
+ }).strict(),
- read_gdrive_file: z.object({
+ read_gdrive_file: WorkspaceTargetArgs.extend({
credentialId: z.string(),
fileId: z.string(),
type: z.enum(['doc', 'sheet']),
range: z.string().optional(),
- entityId: z.string().optional(),
- }),
+ }).strict(),
- knowledge_base: KnowledgeBaseArgsSchema,
+ list_knowledge_bases: WorkspaceTargetArgs.strict(),
+ read_knowledge_base: EntityTargetArgs,
+ create_knowledge_base: CreateKnowledgeBaseArgs,
+ edit_knowledge_base: EditKnowledgeBaseArgs,
+ rename_knowledge_base: RenameKnowledgeBaseArgs,
+ query_knowledge_base: QueryKnowledgeBaseArgs,
- list_custom_tools: z.object({}),
+ list_custom_tools: WorkspaceTargetArgs.strict(),
[CopilotTool.read_custom_tool]: EntityTargetArgs,
create_custom_tool: CreateCustomToolArgs,
edit_custom_tool: EditCustomToolArgs,
rename_custom_tool: EditCustomToolArgs,
- list_monitors: z.object({
+ list_monitors: WorkspaceTargetArgs.extend({
entityId: z.string().optional(),
blockId: z.string().optional(),
- }),
+ }).strict(),
[CopilotTool.read_monitor]: z.object({
monitorId: RequiredId,
}),
@@ -404,19 +452,19 @@ export const ToolArgSchemas = {
documentFormat: z.literal(MONITOR_DOCUMENT_FORMAT).optional(),
}),
- [CopilotTool.list_indicators]: z.object({}),
+ [CopilotTool.list_indicators]: WorkspaceTargetArgs.strict(),
[CopilotTool.read_indicator]: GetIndicatorArgs,
create_indicator: CreateIndicatorArgs,
edit_indicator: EditIndicatorArgs,
rename_indicator: EditIndicatorArgs,
- list_skills: z.object({}),
+ list_skills: WorkspaceTargetArgs.strict(),
[CopilotTool.read_skill]: EntityTargetArgs,
create_skill: CreateSkillArgs,
edit_skill: EditSkillArgs,
rename_skill: EditSkillArgs,
- list_mcp_servers: z.object({}),
+ list_mcp_servers: WorkspaceTargetArgs.strict(),
[CopilotTool.read_mcp_server]: EntityTargetArgs,
create_mcp_server: CreateMcpServerArgs,
edit_mcp_server: EditMcpServerArgs,
@@ -439,12 +487,8 @@ export const ToolArgSchemas = {
}),
} as const
-const CurrentWorkflowStateArg = { currentWorkflowState: z.string().min(1) }
-
export const ServerToolArgSchemas = {
...ToolArgSchemas,
- edit_workflow: EditWorkflowArgs.extend(CurrentWorkflowStateArg),
- edit_workflow_block: EditWorkflowBlockArgs.extend(CurrentWorkflowStateArg),
} satisfies Record
// Tool-specific SSE schemas (tool_call with typed arguments)
@@ -476,13 +520,9 @@ export const ToolSSESchemas = {
CopilotTool.list_workflows,
ToolArgSchemas.list_workflows
),
- [CopilotTool.read_workflow_variables]: toolCallSSEFor(
- CopilotTool.read_workflow_variables,
- ToolArgSchemas.read_workflow_variables
- ),
- [CopilotTool.set_workflow_variables]: toolCallSSEFor(
- CopilotTool.set_workflow_variables,
- ToolArgSchemas.set_workflow_variables
+ [CopilotTool.edit_workflow_variable]: toolCallSSEFor(
+ CopilotTool.edit_workflow_variable,
+ ToolArgSchemas.edit_workflow_variable
),
edit_workflow: toolCallSSEFor('edit_workflow', ToolArgSchemas.edit_workflow),
edit_workflow_block: toolCallSSEFor('edit_workflow_block', ToolArgSchemas.edit_workflow_block),
@@ -543,7 +583,18 @@ export const ToolSSESchemas = {
'check_deployment_status',
ToolArgSchemas.check_deployment_status
),
- knowledge_base: toolCallSSEFor('knowledge_base', ToolArgSchemas.knowledge_base),
+ list_knowledge_bases: toolCallSSEFor('list_knowledge_bases', ToolArgSchemas.list_knowledge_bases),
+ read_knowledge_base: toolCallSSEFor('read_knowledge_base', ToolArgSchemas.read_knowledge_base),
+ create_knowledge_base: toolCallSSEFor(
+ 'create_knowledge_base',
+ ToolArgSchemas.create_knowledge_base
+ ),
+ edit_knowledge_base: toolCallSSEFor('edit_knowledge_base', ToolArgSchemas.edit_knowledge_base),
+ rename_knowledge_base: toolCallSSEFor(
+ 'rename_knowledge_base',
+ ToolArgSchemas.rename_knowledge_base
+ ),
+ query_knowledge_base: toolCallSSEFor('query_knowledge_base', ToolArgSchemas.query_knowledge_base),
list_custom_tools: toolCallSSEFor('list_custom_tools', ToolArgSchemas.list_custom_tools),
[CopilotTool.read_custom_tool]: toolCallSSEFor(
CopilotTool.read_custom_tool,
@@ -646,20 +697,26 @@ const WorkflowSummaryResult = z.object({
),
})
+const WorkflowVariableReadEnvelope = z.object({
+ workflowVariableDocumentFormat: z.literal(WORKFLOW_VARIABLE_DOCUMENT_FORMAT),
+ workflowVariableDocument: z.string(),
+})
+
const WorkflowReadDocumentEnvelope = WorkflowDocumentEnvelope.extend({
workflowSummary: WorkflowSummaryResult,
+}).merge(WorkflowVariableReadEnvelope)
+
+const WorkflowVariableDocumentEnvelope = WorkflowTargetEnvelope.extend({
+ documentFormat: z.literal(WORKFLOW_VARIABLE_DOCUMENT_FORMAT),
+ entityDocument: z.string(),
+ variables: z.record(z.any()),
})
+// A list is a discovery surface: id, canonical name, and basic usability state.
const GenericEntityListEntry = z.object({
entityId: z.string(),
entityName: z.string().optional(),
- workspaceId: z.string().optional(),
- entityDescription: z.string().optional(),
- entityTitle: z.string().optional(),
- entityTransport: z.string().optional(),
- entityUrl: z.string().optional(),
- entityEnabled: z.boolean().optional(),
- entityConnectionStatus: z.string().optional(),
+ enabled: z.boolean().optional(),
})
const GenericEntityListResult = z.object({
@@ -668,6 +725,38 @@ const GenericEntityListResult = z.object({
count: z.number(),
})
+const KnowledgeBaseDocumentEnvelope = z.object({
+ entityKind: z.literal('knowledge_base'),
+ entityId: z.string(),
+ entityName: z.string().optional(),
+ workspaceId: z.string().optional(),
+ documentFormat: z.literal(KNOWLEDGE_BASE_DOCUMENT_FORMAT),
+ entityDocument: z.string(),
+ docCount: z.number().optional(),
+ tokenCount: z.number().optional(),
+ embeddingModel: z.string().optional(),
+ embeddingDimension: z.number().optional(),
+ createdAt: z.string().optional(),
+ updatedAt: z.string().optional(),
+})
+
+const QueryKnowledgeBaseResult = z.object({
+ entityKind: z.literal('knowledge_base'),
+ entityId: z.string(),
+ entityName: z.string().optional(),
+ query: z.string(),
+ topK: z.number(),
+ totalResults: z.number(),
+ results: z.array(
+ z.object({
+ documentId: z.string(),
+ content: z.string(),
+ chunkIndex: z.number(),
+ similarity: z.number(),
+ })
+ ),
+})
+
const IndicatorListEntry = z.object({
name: z.string(),
source: z.enum(['default', 'custom']),
@@ -705,9 +794,13 @@ const MonitorListEntry = z.object({
monitorDescription: z.string().optional(),
workflowId: z.string(),
blockId: z.string(),
+ source: z.string().optional(),
providerId: z.string(),
- indicatorId: z.string(),
- interval: z.string(),
+ indicatorId: z.string().optional(),
+ interval: z.string().optional(),
+ serviceId: z.string().optional(),
+ credentialId: z.string().optional(),
+ accountId: z.string().optional(),
isActive: z.boolean(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
@@ -735,8 +828,9 @@ const McpServerDocumentEnvelope = EntityDocumentEnvelopeBase.extend({
documentFormat: z.literal(MCP_SERVER_DOCUMENT_FORMAT),
})
-const EditEntityDocumentResultBase = z.object({
- success: z.boolean(),
+const DocumentDiffReviewMetadata = z.object({
+ requiresReview: z.literal(true).optional(),
+ reviewBaseStateHash: z.string().optional(),
preview: z
.object({
documentDiff: z.object({
@@ -747,9 +841,16 @@ const EditEntityDocumentResultBase = z.object({
.optional(),
})
-const WorkflowMutationResult = WorkflowTargetEnvelope.extend({
+const EditEntityDocumentResultBase = DocumentDiffReviewMetadata.extend({
+ success: z.boolean(),
+})
+
+const WorkflowMutationResult = WorkflowTargetEnvelope.merge(DocumentDiffReviewMetadata).extend({
success: z.boolean(),
})
+const WorkflowCreateMutationResult = WorkflowMutationResult.extend({
+ entityId: z.string().optional(),
+})
const CustomToolDocumentMutationResult = EditEntityDocumentResultBase.merge(
CustomToolDocumentEnvelope.extend({
@@ -769,6 +870,12 @@ const SkillDocumentMutationResult = EditEntityDocumentResultBase.merge(
})
)
+const KnowledgeBaseDocumentMutationResult = EditEntityDocumentResultBase.merge(
+ KnowledgeBaseDocumentEnvelope.extend({
+ entityId: z.string().optional(),
+ })
+)
+
const McpServerDocumentMutationResult = EditEntityDocumentResultBase.merge(
McpServerDocumentEnvelope.extend({
entityKind: z.literal('mcp_server'),
@@ -783,7 +890,9 @@ const WorkflowPreviewEdge = z.object({
})
const WorkflowMutationResultShape = {
+ requiresReview: z.literal(true).optional(),
workflowState: z.unknown().optional(),
+ reviewBaseStateHash: z.string().optional(),
preview: z
.object({
blockDiff: z.object({
@@ -808,6 +917,32 @@ const WorkflowMutationResultShape = {
const EditWorkflowResult = WorkflowGraphDocumentEnvelope.extend(WorkflowMutationResultShape)
const EditWorkflowBlockResult = WorkflowDocumentEnvelope.extend(WorkflowMutationResultShape)
+const EditWorkflowVariableResult = WorkflowVariableDocumentEnvelope.extend({
+ requiresReview: z.literal(true).optional(),
+ reviewBaseStateHash: z.string().optional(),
+ success: z.boolean().optional(),
+ preview: z
+ .object({
+ documentDiff: z.object({
+ before: z.string(),
+ after: z.string(),
+ }),
+ })
+ .optional(),
+})
+
+const EnvironmentVariablesMutationResult = DocumentDiffReviewMetadata.extend({
+ success: z.boolean(),
+ scope: z.enum(['personal', 'workspace']),
+ workspaceId: z.string().optional(),
+ message: z.any().optional(),
+ data: z.any().optional(),
+ variableCount: z.number().optional(),
+ variableNames: z.array(z.string()).optional(),
+ totalVariableCount: z.number().optional(),
+ addedVariables: z.array(z.string()).optional(),
+ updatedVariables: z.array(z.string()).optional(),
+})
const ExecutionEntry = z.object({
id: z.string(),
@@ -843,16 +978,11 @@ export const ToolResultSchemas = {
id: z.string(),
}),
[CopilotTool.read_workflow]: WorkflowReadDocumentEnvelope,
- create_workflow: WorkflowMutationResult,
+ create_workflow: WorkflowCreateMutationResult,
[CopilotTool.list_workflows]: GenericEntityListResult.extend({
entityKind: z.literal('workflow'),
}),
- [CopilotTool.read_workflow_variables]: z
- .object({ variables: z.record(z.any()) })
- .or(z.array(z.object({ name: z.string(), value: z.any() }))),
- [CopilotTool.set_workflow_variables]: z
- .object({ variables: z.record(z.any()) })
- .or(z.object({ message: z.any().optional(), data: z.any().optional() })),
+ [CopilotTool.edit_workflow_variable]: EditWorkflowVariableResult,
oauth_request_access: z.object({
granted: z.boolean().optional(),
message: z.string().optional(),
@@ -887,13 +1017,14 @@ export const ToolResultSchemas = {
data: z.any().optional(),
body: z.any().optional(),
}),
- [CopilotTool.read_environment_variables]: z.union([
- z.object({ variableNames: z.array(z.string()), count: z.number() }),
- z.object({ variables: z.record(z.string()) }),
- ]),
- set_environment_variables: z
- .object({ variables: z.record(z.string()) })
- .or(z.object({ message: z.any().optional(), data: z.any().optional() })),
+ [CopilotTool.read_environment_variables]: z.object({
+ variableNames: z.array(z.string()),
+ personalVariableNames: z.array(z.string()),
+ workspaceVariableNames: z.array(z.string()),
+ conflicts: z.array(z.string()),
+ count: z.number(),
+ }),
+ set_environment_variables: EnvironmentVariablesMutationResult,
[CopilotTool.read_oauth_credentials]: z.object({
credentials: z.array(
z.object({ id: z.string(), provider: z.string(), isDefault: z.boolean().optional() })
@@ -986,7 +1117,14 @@ export const ToolResultSchemas = {
chatDeployed: z.boolean(),
deployedAt: z.string().nullable(),
}),
- knowledge_base: KnowledgeBaseResultSchema,
+ list_knowledge_bases: GenericEntityListResult.extend({
+ entityKind: z.literal('knowledge_base'),
+ }),
+ read_knowledge_base: KnowledgeBaseDocumentEnvelope,
+ create_knowledge_base: KnowledgeBaseDocumentMutationResult,
+ edit_knowledge_base: KnowledgeBaseDocumentMutationResult,
+ rename_knowledge_base: KnowledgeBaseDocumentMutationResult,
+ query_knowledge_base: QueryKnowledgeBaseResult,
list_custom_tools: GenericEntityListResult.extend({
entityKind: z.literal('custom_tool'),
}),
@@ -1002,6 +1140,7 @@ export const ToolResultSchemas = {
.object({
success: z.boolean(),
})
+ .merge(DocumentDiffReviewMetadata)
.merge(MonitorDocumentEnvelope),
[CopilotTool.list_indicators]: IndicatorListResult,
[CopilotTool.read_indicator]: IndicatorDocumentEnvelope.extend({
diff --git a/apps/tradinggoose/lib/copilot/review-sessions/identity.test.ts b/apps/tradinggoose/lib/copilot/review-sessions/identity.test.ts
index 797659175..d3950d1f6 100644
--- a/apps/tradinggoose/lib/copilot/review-sessions/identity.test.ts
+++ b/apps/tradinggoose/lib/copilot/review-sessions/identity.test.ts
@@ -3,8 +3,11 @@
*/
import { describe, expect, it } from 'vitest'
import {
+ buildEntityListDescriptor,
buildReviewTargetDescriptorFromEnvelope,
buildYjsTransportEnvelope,
+ parseYjsTransportEnvelope,
+ serializeYjsTransportEnvelope,
} from '@/lib/copilot/review-sessions/identity'
describe('review target identity helpers', () => {
@@ -22,4 +25,43 @@ describe('review target identity helpers', () => {
descriptor
)
})
+
+ it('treats workflow as an entity transport target', () => {
+ const descriptor = {
+ workspaceId: 'ws-1',
+ entityKind: 'workflow' as const,
+ entityId: 'workflow-1',
+ draftSessionId: null,
+ reviewSessionId: null,
+ yjsSessionId: 'workflow-1',
+ }
+
+ const envelope = buildYjsTransportEnvelope(descriptor)
+ expect(envelope).toEqual({
+ targetKind: 'entity',
+ sessionId: 'workflow-1',
+ reviewSessionId: null,
+ workspaceId: 'ws-1',
+ entityKind: 'workflow',
+ entityId: 'workflow-1',
+ draftSessionId: null,
+ })
+ expect(buildReviewTargetDescriptorFromEnvelope(envelope)).toEqual(descriptor)
+ })
+
+ it('round-trips canonical entity-list envelopes and rejects entity targets', () => {
+ const descriptor = buildEntityListDescriptor('skill', 'ws-1')
+ const envelope = buildYjsTransportEnvelope(descriptor)
+ expect(envelope.targetKind).toBe('entity_list')
+ expect(envelope.entityId).toBeNull()
+ expect(envelope.sessionId).toBe('list:skill:ws-1')
+ expect(buildReviewTargetDescriptorFromEnvelope(envelope)).toEqual(descriptor)
+ const wire = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor))
+ expect(buildReviewTargetDescriptorFromEnvelope(parseYjsTransportEnvelope(wire))).toEqual(
+ descriptor
+ )
+ expect(() =>
+ buildReviewTargetDescriptorFromEnvelope({ ...envelope, entityId: 'skill-1' })
+ ).toThrow(/cannot carry/)
+ })
})
diff --git a/apps/tradinggoose/lib/copilot/review-sessions/identity.ts b/apps/tradinggoose/lib/copilot/review-sessions/identity.ts
index 3caf61c82..ff571c637 100644
--- a/apps/tradinggoose/lib/copilot/review-sessions/identity.ts
+++ b/apps/tradinggoose/lib/copilot/review-sessions/identity.ts
@@ -1,12 +1,13 @@
+import { normalizeOptionalString } from '@/lib/utils'
+import type { SavedEntityKind } from '@/lib/yjs/entity-state'
import {
REVIEW_ENTITY_KINDS,
- YJS_TARGET_KINDS,
type ReviewEntityKind,
type ReviewTargetDescriptor,
+ YJS_TARGET_KINDS,
type YjsTargetKind,
type YjsTransportEnvelope,
} from './types'
-import { normalizeOptionalString } from '@/lib/utils'
const REVIEW_ENTITY_KIND_SET = new Set(REVIEW_ENTITY_KINDS)
const YJS_TARGET_KIND_SET = new Set(YJS_TARGET_KINDS)
@@ -31,23 +32,67 @@ const requireYjsTargetKind = (value: string | undefined): YjsTargetKind => {
return normalized as YjsTargetKind
}
+/**
+ * Canonical descriptor for a saved entity's own Yjs document (no draft/review
+ * session; the Yjs session id is the entity id). The single source of the
+ * "saved entity Yjs target" contract, reused by the editor session hook, the
+ * server-side field reader, the access check, and the apply route, so every
+ * read/write addresses the entity identically.
+ */
+export function buildSavedEntityDescriptor(
+ entityKind: SavedEntityKind,
+ entityId: string,
+ workspaceId: string | null
+): ReviewTargetDescriptor {
+ return {
+ workspaceId,
+ entityKind,
+ entityId,
+ draftSessionId: null,
+ reviewSessionId: null,
+ yjsSessionId: entityId,
+ }
+}
+
+const ENTITY_LIST_SESSION_PREFIX = 'list:'
+
+function buildEntityListSessionId(entityKind: SavedEntityKind, workspaceId: string): string {
+ return `${ENTITY_LIST_SESSION_PREFIX}${entityKind}:${workspaceId}`
+}
+
+export function isEntityListSessionId(sessionId: string): boolean {
+ return sessionId.startsWith(ENTITY_LIST_SESSION_PREFIX)
+}
+
+export function buildEntityListDescriptor(
+ entityKind: SavedEntityKind,
+ workspaceId: string
+): ReviewTargetDescriptor {
+ return {
+ workspaceId,
+ entityKind,
+ entityId: null,
+ draftSessionId: null,
+ reviewSessionId: null,
+ yjsSessionId: buildEntityListSessionId(entityKind, workspaceId),
+ }
+}
+
/**
* Builds a YjsTransportEnvelope from a ReviewTargetDescriptor.
*/
export function buildYjsTransportEnvelope(
descriptor: ReviewTargetDescriptor
): YjsTransportEnvelope {
- const targetKind: YjsTargetKind =
- descriptor.entityKind === 'workflow'
- ? 'workflow'
- : descriptor.entityId
- ? 'entity'
- : 'review_session'
+ const targetKind: YjsTargetKind = isEntityListSessionId(descriptor.yjsSessionId)
+ ? 'entity_list'
+ : descriptor.entityId
+ ? 'entity'
+ : 'review_session'
return {
targetKind,
sessionId: descriptor.yjsSessionId,
- workflowId: descriptor.entityKind === 'workflow' ? descriptor.entityId : null,
reviewSessionId: targetKind === 'review_session' ? descriptor.reviewSessionId : null,
workspaceId: descriptor.workspaceId,
entityKind: descriptor.entityKind,
@@ -62,36 +107,33 @@ export function buildYjsTransportEnvelope(
export function buildReviewTargetDescriptorFromEnvelope(
envelope: YjsTransportEnvelope
): ReviewTargetDescriptor {
- if (envelope.targetKind === 'workflow') {
- if (envelope.entityKind !== 'workflow') {
- throw new Error('Workflow Yjs envelope must use entityKind="workflow"')
- }
-
- const workflowId = envelope.workflowId ?? envelope.entityId ?? envelope.sessionId
- if (!workflowId) {
- throw new Error('Workflow Yjs envelope requires a workflowId')
- }
-
- if (envelope.sessionId !== workflowId) {
- throw new Error('Workflow Yjs envelope sessionId must equal workflowId')
+ if (envelope.targetKind === 'entity_list') {
+ if (envelope.entityKind === 'workflow') {
+ throw new Error('Entity-list Yjs envelope cannot use entityKind="workflow"')
}
- if (envelope.entityId && envelope.entityId !== workflowId) {
- throw new Error('Workflow Yjs envelope entityId must equal workflowId')
+ if (!envelope.workspaceId) {
+ throw new Error('Entity-list Yjs envelope requires workspaceId')
}
- if (envelope.draftSessionId) {
- throw new Error('Workflow Yjs envelope cannot carry draftSessionId')
+ if (envelope.entityId || envelope.reviewSessionId || envelope.draftSessionId) {
+ throw new Error(
+ 'Entity-list Yjs envelope cannot carry entityId, reviewSessionId, or draftSessionId'
+ )
}
- if (envelope.reviewSessionId) {
- throw new Error('Workflow Yjs envelope cannot carry reviewSessionId')
+ if (
+ envelope.sessionId !== buildEntityListSessionId(envelope.entityKind, envelope.workspaceId)
+ ) {
+ throw new Error(
+ 'Entity-list Yjs envelope sessionId must equal list:{entityKind}:{workspaceId}'
+ )
}
return {
- workspaceId: envelope.workspaceId ?? null,
- entityKind: 'workflow',
- entityId: workflowId,
+ workspaceId: envelope.workspaceId,
+ entityKind: envelope.entityKind,
+ entityId: null,
draftSessionId: null,
reviewSessionId: null,
yjsSessionId: envelope.sessionId,
@@ -99,11 +141,7 @@ export function buildReviewTargetDescriptorFromEnvelope(
}
if (envelope.targetKind === 'entity') {
- if (envelope.entityKind === 'workflow') {
- throw new Error('Entity Yjs envelope cannot use entityKind="workflow"')
- }
-
- if (!envelope.workspaceId) {
+ if (envelope.entityKind !== 'workflow' && !envelope.workspaceId) {
throw new Error('Entity Yjs envelope requires workspaceId')
}
@@ -115,14 +153,12 @@ export function buildReviewTargetDescriptorFromEnvelope(
throw new Error('Entity Yjs envelope sessionId must equal entityId')
}
- if (envelope.workflowId || envelope.reviewSessionId || envelope.draftSessionId) {
- throw new Error(
- 'Entity Yjs envelope cannot carry workflowId, reviewSessionId, or draftSessionId'
- )
+ if (envelope.reviewSessionId || envelope.draftSessionId) {
+ throw new Error('Entity Yjs envelope cannot carry reviewSessionId or draftSessionId')
}
return {
- workspaceId: envelope.workspaceId,
+ workspaceId: envelope.workspaceId ?? null,
entityKind: envelope.entityKind,
entityId: envelope.entityId,
draftSessionId: null,
@@ -144,10 +180,6 @@ export function buildReviewTargetDescriptorFromEnvelope(
throw new Error('Review-session Yjs envelope sessionId must equal reviewSessionId')
}
- if (envelope.workflowId) {
- throw new Error('Review-session Yjs envelope cannot carry workflowId')
- }
-
if (!envelope.workspaceId) {
throw new Error('Review-session Yjs envelope requires workspaceId')
}
@@ -183,7 +215,6 @@ export function serializeYjsTransportEnvelope(
entityKind: envelope.entityKind,
}
- if (envelope.workflowId != null) result.workflowId = envelope.workflowId
if (envelope.reviewSessionId != null) result.reviewSessionId = envelope.reviewSessionId
if (envelope.workspaceId != null) result.workspaceId = envelope.workspaceId
if (envelope.entityId != null) result.entityId = envelope.entityId
@@ -198,6 +229,10 @@ export function serializeYjsTransportEnvelope(
export function parseYjsTransportEnvelope(
payload: Record
): YjsTransportEnvelope {
+ if (normalizeNullableString(payload.workflowId)) {
+ throw new Error('Yjs transport envelope cannot carry workflowId; use entityId')
+ }
+
const envelope: YjsTransportEnvelope = {
targetKind: requireYjsTargetKind(payload.targetKind),
sessionId:
@@ -205,7 +240,6 @@ export function parseYjsTransportEnvelope(
(() => {
throw new Error('Missing required transport envelope field: sessionId')
})(),
- workflowId: normalizeNullableString(payload.workflowId),
reviewSessionId: normalizeNullableString(payload.reviewSessionId),
workspaceId: normalizeNullableString(payload.workspaceId),
entityKind: requireReviewEntityKind(payload.entityKind),
diff --git a/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts b/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts
index c7f7c36dd..b38eee5b5 100644
--- a/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts
+++ b/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts
@@ -146,6 +146,33 @@ describe('review session permissions', () => {
})
})
+ it('rejects workflow targets when the supplied workspace does not match the workflow', async () => {
+ mockReadWorkflowAccessContext.mockResolvedValueOnce({
+ workflow: {
+ id: 'workflow-1',
+ userId: 'member-1',
+ workspaceId: 'workspace-actual',
+ } as NonNullable>>['workflow'],
+ workspaceOwnerId: 'owner-1',
+ workspacePermission: 'write',
+ isOwner: false,
+ isWorkspaceOwner: false,
+ })
+
+ const result = await verifyReviewTargetAccess(
+ 'collaborator-1',
+ { entityKind: 'workflow', entityId: 'workflow-1', workspaceId: 'workspace-supplied' },
+ 'read'
+ )
+
+ expect(result).toEqual({
+ hasAccess: false,
+ userPermission: null,
+ workspaceId: null,
+ isOwner: false,
+ })
+ })
+
it('rejects review-session targets that carry entity ids', async () => {
const reviewSessionRow = [
{
diff --git a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts
index 5c47c2070..c3c173b9d 100644
--- a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts
+++ b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts
@@ -1,6 +1,7 @@
import { db } from '@tradinggoose/db'
import { copilotReviewSessions, permissions, workspace } from '@tradinggoose/db/schema'
import { and, eq } from 'drizzle-orm'
+import { isEntityListSessionId } from '@/lib/copilot/review-sessions/identity'
import type {
ReviewAccessMode,
ReviewEntityKind,
@@ -265,15 +266,29 @@ export async function verifyReviewTargetAccess(
accessMode: ReviewAccessMode
): Promise {
if (reviewTarget.entityKind === 'workflow') {
- const workflowId =
- reviewTarget.entityId ?? ('yjsSessionId' in reviewTarget ? reviewTarget.yjsSessionId : null)
-
- if (!workflowId) {
+ if (!reviewTarget.entityId) {
logger.warn('Workflow review target missing workflow id', { userId, reviewTarget })
return { hasAccess: false, userPermission: null, workspaceId: null, isOwner: false }
}
- return verifyWorkflowAccess(userId, workflowId, accessMode)
+ const access = await verifyWorkflowAccess(userId, reviewTarget.entityId, accessMode)
+ if (reviewTarget.workspaceId && reviewTarget.workspaceId !== access.workspaceId) {
+ logger.warn('Workflow workspace mismatch', {
+ userId,
+ workflowId: reviewTarget.entityId,
+ })
+ return { hasAccess: false, userPermission: null, workspaceId: null, isOwner: false }
+ }
+
+ return access
+ }
+
+ if (reviewTarget.yjsSessionId && isEntityListSessionId(reviewTarget.yjsSessionId)) {
+ if (!reviewTarget.workspaceId) {
+ logger.warn('Entity-list review target missing workspaceId', { userId, reviewTarget })
+ return { hasAccess: false, userPermission: null, workspaceId: null, isOwner: false }
+ }
+ return verifyWorkspaceAccess(userId, reviewTarget.workspaceId, accessMode)
}
if (!reviewTarget.reviewSessionId) {
@@ -306,7 +321,7 @@ function hasAccessToReviewSession(
/**
* Loads a review session when the caller can access it.
* Review-session rows are chat/draft history and remain creator-owned.
- * Saved entities use canonical Yjs entity targets keyed by entityId.
+ * Saved entities use Yjs editing targets keyed by entityId.
*/
export async function loadReviewSessionForUser(
reviewSessionId: string,
diff --git a/apps/tradinggoose/lib/copilot/review-sessions/runtime.ts b/apps/tradinggoose/lib/copilot/review-sessions/runtime.ts
index d2c51bd7d..b3c74a918 100644
--- a/apps/tradinggoose/lib/copilot/review-sessions/runtime.ts
+++ b/apps/tradinggoose/lib/copilot/review-sessions/runtime.ts
@@ -1,5 +1,8 @@
-import * as Y from 'yjs'
-import type { ReviewTargetDocState, ReviewTargetRuntimeState } from '@/lib/copilot/review-sessions/types'
+import type * as Y from 'yjs'
+import type {
+ ReviewTargetDocState,
+ ReviewTargetRuntimeState,
+} from '@/lib/copilot/review-sessions/types'
function isReviewTargetDocState(value: unknown): value is ReviewTargetDocState {
return value === 'active' || value === 'expired'
diff --git a/apps/tradinggoose/lib/copilot/review-sessions/types.ts b/apps/tradinggoose/lib/copilot/review-sessions/types.ts
index c16c23dd5..38692376a 100644
--- a/apps/tradinggoose/lib/copilot/review-sessions/types.ts
+++ b/apps/tradinggoose/lib/copilot/review-sessions/types.ts
@@ -3,6 +3,7 @@ export const ENTITY_KIND_MCP_SERVER = 'mcp_server' as const
export const ENTITY_KIND_SKILL = 'skill' as const
export const ENTITY_KIND_CUSTOM_TOOL = 'custom_tool' as const
export const ENTITY_KIND_INDICATOR = 'indicator' as const
+export const ENTITY_KIND_KNOWLEDGE_BASE = 'knowledge_base' as const
export const REVIEW_ENTITY_KINDS = [
ENTITY_KIND_WORKFLOW,
@@ -10,6 +11,7 @@ export const REVIEW_ENTITY_KINDS = [
ENTITY_KIND_SKILL,
ENTITY_KIND_CUSTOM_TOOL,
ENTITY_KIND_INDICATOR,
+ ENTITY_KIND_KNOWLEDGE_BASE,
] as const
export type ReviewEntityKind = (typeof REVIEW_ENTITY_KINDS)[number]
@@ -34,17 +36,16 @@ export interface ReviewTargetRuntimeState {
export interface ResolvedReviewTarget {
descriptor: ReviewTargetDescriptor
- runtime: ReviewTargetRuntimeState | null
+ runtime: ReviewTargetRuntimeState
}
-export const YJS_TARGET_KINDS = ['workflow', 'entity', 'review_session'] as const
+export const YJS_TARGET_KINDS = ['entity', 'review_session', 'entity_list'] as const
export type YjsTargetKind = (typeof YJS_TARGET_KINDS)[number]
export interface YjsTransportEnvelope {
targetKind: YjsTargetKind
sessionId: string
- workflowId: string | null
reviewSessionId: string | null
workspaceId: string | null
entityKind: ReviewEntityKind
diff --git a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts
index 4f3eac525..da6b80da5 100644
--- a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts
+++ b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts
@@ -60,9 +60,9 @@ describe('copilot runtime tool manifest', () => {
entityKind: 'environment',
}),
expect.objectContaining({
- name: 'read_workflow_variables',
- description: expect.stringContaining(''),
- kind: 'read',
+ name: 'edit_workflow_variable',
+ description: expect.stringContaining('workflow-variable document'),
+ kind: 'edit',
entityKind: 'workflow',
}),
expect.objectContaining({
@@ -290,8 +290,8 @@ describe('copilot runtime tool manifest', () => {
?.semanticValidators?.find((validator) => validator.kind === 'string_json_schema')?.args
?.schema as { properties?: Record; required?: string[] } | undefined
expect(createWorkflowProperties).not.toHaveProperty('color')
- expect(createIndicatorSchema?.properties ?? {}).not.toHaveProperty('color')
- expect(createIndicatorSchema?.required ?? []).not.toContain('color')
+ expect(createIndicatorSchema?.properties ?? {}).toHaveProperty('color')
+ expect(createIndicatorSchema?.required ?? []).toContain('color')
expect(editWorkflowProperties).toHaveProperty('entityId')
expect(editWorkflowProperties).toHaveProperty('entityDocument')
expect(editWorkflowProperties).toHaveProperty('removedBlockIds')
diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts
index f0928a5b6..739e776d4 100644
--- a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts
+++ b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts
@@ -97,17 +97,24 @@ describe('copilot server tool errors', () => {
it('falls back to a generic 500 payload for unknown tool failures', () => {
const response = buildCopilotServerToolErrorResponse(
'make_api_request',
- new Error('socket hang up')
+ new Error('socket hang up at db.internal:5432')
+ )
+ const variableResponse = buildCopilotServerToolErrorResponse(
+ 'edit_workflow_variable',
+ new Error('Invalid edited workflow variables: Missing removedVariableIds.')
)
expect(response).toEqual({
status: 500,
body: {
code: 'server_tool_execution_failed',
- error: 'socket hang up',
+ error: 'Server tool execution failed',
retryable: false,
},
})
+ expect(response.body.error).not.toContain('db.internal')
+ expect(variableResponse.status).toBe(422)
+ expect(variableResponse.body.error).toContain('removedVariableIds')
})
it('returns a structured 422 payload for tool argument schema failures', () => {
diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.ts
index 2fdf1ee90..b07c7434d 100644
--- a/apps/tradinggoose/lib/copilot/server-tool-errors.ts
+++ b/apps/tradinggoose/lib/copilot/server-tool-errors.ts
@@ -16,6 +16,8 @@ export interface CopilotServerToolErrorResponse {
body: CopilotServerToolErrorPayload
}
+const GENERIC_SERVER_TOOL_ERROR = 'Server tool execution failed'
+
export class StructuredServerToolError extends Error {
public readonly status: number
public readonly code: string
@@ -129,13 +131,13 @@ function buildEditWorkflowError(message: string): CopilotServerToolErrorResponse
? 'Use only the canonical sub-block ids from `get_blocks_metadata` for that block type. Keep the existing canonical ids and remove invented keys.'
: details.includes('removedBlockIds')
? 'Keep every existing block id in the Mermaid graph unless the user explicitly asked to remove it; list intentional removals in `removedBlockIds`.'
- : details.includes('immutable identities')
- ? 'Keep the existing block id/type pair unchanged. `edit_workflow` rewrites topology only; it cannot replace an existing block or change its type.'
- : details.includes('unknown block type')
- ? 'Use block types exactly as returned by `get_available_blocks` or `get_blocks_metadata`.'
- : details.includes('Edge references non-existent')
- ? 'Every edge source and target must match a block id in the same document.'
- : 'Return a complete workflow graph that validates as workflow state. Preserve block ids and valid edge references.'
+ : details.includes('immutable identities')
+ ? 'Keep the existing block id/type pair unchanged. `edit_workflow` rewrites topology only; it cannot replace an existing block or change its type.'
+ : details.includes('unknown block type')
+ ? 'Use block types exactly as returned by `get_available_blocks` or `get_blocks_metadata`.'
+ : details.includes('Edge references non-existent')
+ ? 'Every edge source and target must match a block id in the same document.'
+ : 'Return a complete workflow graph that validates as workflow state. Preserve block ids and valid edge references.'
return {
status: 422,
@@ -174,19 +176,28 @@ export function buildCopilotServerToolErrorResponse(
}
const message = error instanceof Error ? error.message : 'Failed to execute server tool'
-
if (toolName === 'edit_workflow') {
const structuredError = buildEditWorkflowError(message)
if (structuredError) {
return structuredError
}
}
+ if (toolName === 'edit_workflow_variable' && /^(Invalid edited workflow variables:|Duplicate workflow variable|Unsupported workflow variable|Unsupported documentFormat ")/.test(message)) {
+ return {
+ status: 422,
+ body: {
+ code: 'invalid_workflow_variable_document',
+ error: message,
+ retryable: true,
+ },
+ }
+ }
return {
status: 500,
body: {
code: 'server_tool_execution_failed',
- error: message,
+ error: GENERIC_SERVER_TOOL_ERROR,
retryable: false,
},
}
diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts
index efad9e3bd..7c002a8c7 100644
--- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts
+++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts
@@ -9,6 +9,8 @@ export interface ToolPromptMetadata {
const CUSTOM_TOOL_DOCUMENT_GUIDANCE =
'Use full `tg-custom-tool-document-v1` JSON with exactly `title`, `schemaText`, and `codeText`. `title` is the canonical custom-tool name. `schemaText` is a JSON-encoded string, not an object, containing {"type":"function","function":{"description":"What the tool does","parameters":{"type":"object","properties":{},"required":[]}}}. Do not include a `name` property inside `function`. `codeText` is raw async JavaScript function body only; use for inputs and {{ENV_VAR_NAME}} for environment variables.'
+const KNOWLEDGE_BASE_DOCUMENT_GUIDANCE =
+ 'Use full `tg-knowledge-base-document-v1` JSON with exactly `name`, `description`, and `chunkingConfig` fields. `chunkingConfig` must include numeric `maxSize`, `minSize`, and `overlap`.'
export const TOOL_PROMPT_METADATA: Record = {
plan: {
@@ -28,7 +30,7 @@ export const TOOL_PROMPT_METADATA: Record = {
},
[CopilotTool.read_workflow]: {
description:
- 'Read a workflow by exact `entityId` and return full `tg-mermaid-v1` inspection Mermaid in `entityDocument`, plus `workflowSummary.blocks[].connections` counts and exact raw `workflowSummary.edges` with external/internal scope. Do not submit this full document to `edit_workflow`; that tool accepts minimal graph-only Mermaid. For topology, use only these edges/counts; do not infer graph connections from subBlock text references like `<...>`. `connectionIssues` only reports malformed existing edges.',
+ 'Read a workflow by exact `entityId` and return full `tg-mermaid-v1` inspection Mermaid in `entityDocument`, workflow variables in `workflowVariableDocument`, plus `workflowSummary.blocks[].connections` counts and exact raw `workflowSummary.edges` with external/internal scope. Do not submit the full workflow Mermaid to `edit_workflow`; that tool accepts minimal graph-only Mermaid. Use `workflowVariableDocument` with `edit_workflow_variable` for variable changes. For topology, use only these edges/counts; do not infer graph connections from subBlock text references like `<...>`. `connectionIssues` only reports malformed existing edges.',
kind: 'read',
entityKind: 'workflow',
},
@@ -81,7 +83,7 @@ export const TOOL_PROMPT_METADATA: Record = {
},
[CopilotTool.get_agent_accessory_catalog]: {
description:
- 'Get available Agent block accessories for the current workflow workspace. Returns `tools` options for Agent `subBlocks.tools` and `skills` options for Agent `subBlocks.skills`; write selected option `value` objects with `edit_workflow_block`.',
+ 'Get available Agent block accessories for the selected workspace. Returns `tools` options for Agent `subBlocks.tools` and `skills` options for Agent `subBlocks.skills`; write selected option `value` objects with `edit_workflow_block`.',
kind: 'inspect',
entityKind: 'workflow',
},
@@ -114,22 +116,23 @@ export const TOOL_PROMPT_METADATA: Record = {
},
[CopilotTool.read_environment_variables]: {
description:
- 'Read environment variables for the current workspace or workflow context. Use returned names with the exact `{{ENV_VAR_NAME}}` syntax in block inputs.',
+ 'Read environment variable names through an explicit personal or workspace scope. Use returned names with the exact `{{ENV_VAR_NAME}}` syntax in block inputs.',
kind: 'read',
entityKind: 'environment',
},
set_environment_variables: {
- description: 'Set environment variables.',
+ description: 'Set personal or workspace environment variables using an explicit scope.',
kind: 'edit',
entityKind: 'environment',
},
[CopilotTool.read_oauth_credentials]: {
- description: 'Read OAuth credentials.',
+ description: 'Read OAuth credentials through an explicit personal or workspace scope.',
kind: 'read',
entityKind: 'credential',
},
[CopilotTool.read_credentials]: {
- description: 'Read OAuth credentials and related environment variable names.',
+ description:
+ 'Read OAuth credentials and related environment variable names through an explicit personal or workspace scope.',
kind: 'read',
entityKind: 'credential',
},
@@ -139,14 +142,9 @@ export const TOOL_PROMPT_METADATA: Record = {
kind: 'list',
entityKind: 'workflow',
},
- [CopilotTool.read_workflow_variables]: {
+ [CopilotTool.edit_workflow_variable]: {
description:
- 'Read workflow variables. Use returned names with the exact `` syntax in block inputs.',
- kind: 'read',
- entityKind: 'workflow',
- },
- [CopilotTool.set_workflow_variables]: {
- description: 'Add, edit, or delete global workflow variables.',
+ 'Edit global workflow variables by replacing the full workflow-variable document returned by `read_workflow`. Use returned names with the exact `` syntax in block inputs.',
kind: 'edit',
entityKind: 'workflow',
},
@@ -165,9 +163,36 @@ export const TOOL_PROMPT_METADATA: Record = {
kind: 'read',
entityKind: 'workflow',
},
- knowledge_base: {
- description: 'Create, list, get, or query knowledge bases.',
- kind: 'knowledge',
+ list_knowledge_bases: {
+ description:
+ 'List knowledge bases in the current workspace. If the user identifies one by name, use this list to select the exact `entityId`.',
+ kind: 'list',
+ entityKind: 'knowledge_base',
+ },
+ read_knowledge_base: {
+ description: `Read one knowledge base by exact \`entityId\` as an editable document payload with \`entityDocument\` and \`documentFormat\`. ${KNOWLEDGE_BASE_DOCUMENT_GUIDANCE}`,
+ kind: 'read',
+ entityKind: 'knowledge_base',
+ },
+ create_knowledge_base: {
+ description: `Create a knowledge base in the current workspace from a full knowledge-base document and return the created document. ${KNOWLEDGE_BASE_DOCUMENT_GUIDANCE}`,
+ kind: 'create',
+ entityKind: 'knowledge_base',
+ },
+ edit_knowledge_base: {
+ description: `Update the target knowledge base from a full knowledge-base document and return the resulting document. ${KNOWLEDGE_BASE_DOCUMENT_GUIDANCE}`,
+ kind: 'edit',
+ entityKind: 'knowledge_base',
+ },
+ rename_knowledge_base: {
+ description: `Rename the target knowledge base by sending a full knowledge-base document with the updated \`name\`, then return the resulting document. ${KNOWLEDGE_BASE_DOCUMENT_GUIDANCE}`,
+ kind: 'rename',
+ entityKind: 'knowledge_base',
+ },
+ query_knowledge_base: {
+ description:
+ 'Search one knowledge base by exact `entityId` and query text. Use `read_knowledge_base` or `list_knowledge_bases` first when resolving a named knowledge base.',
+ kind: 'search',
entityKind: 'knowledge_base',
},
list_custom_tools: {
@@ -279,7 +304,7 @@ export const TOOL_PROMPT_METADATA: Record = {
},
[CopilotTool.read_mcp_server]: {
description:
- 'Return one MCP server by `entityId` as an editable document payload with `entityDocument` and `documentFormat`.',
+ 'Return one MCP server by `entityId` as an editable document payload. Secret header/env values are redacted as `[redacted]`.',
kind: 'read',
entityKind: 'mcp_server',
},
@@ -291,13 +316,13 @@ export const TOOL_PROMPT_METADATA: Record = {
},
edit_mcp_server: {
description:
- 'Update the target MCP server from a full server document and return the resulting document.',
+ 'Update the target MCP server from a full server document. Keep `[redacted]` header/env values to preserve existing secrets, send concrete values to replace them, or omit keys to delete them.',
kind: 'edit',
entityKind: 'mcp_server',
},
rename_mcp_server: {
description:
- 'Rename the target MCP server by sending a full server document with the updated `name`, then return the resulting document.',
+ 'Rename the target MCP server by sending a full server document with the updated `name`. Keep `[redacted]` header/env values to preserve existing secrets.',
kind: 'rename',
entityKind: 'mcp_server',
},
@@ -324,13 +349,13 @@ export const TOOL_PROMPT_METADATA: Record = {
},
list_gdrive_files: {
description:
- 'List Google Drive files using the credentialId returned by gdrive_request_access.',
+ 'List Google Drive files in the selected workspace using the credentialId returned by gdrive_request_access.',
kind: 'list',
entityKind: 'google_drive',
},
read_gdrive_file: {
description:
- 'Read a Google doc or sheet using the credentialId returned by gdrive_request_access.',
+ 'Read a Google doc or sheet in the selected workspace using the credentialId returned by gdrive_request_access.',
kind: 'read',
entityKind: 'google_drive',
},
diff --git a/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts b/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts
index f902a6ecf..45429232e 100644
--- a/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts
+++ b/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts
@@ -376,33 +376,3 @@ export class BaseClientTool {
return this.state
}
}
-
-export abstract class StagedReviewClientTool<
- TReviewResult = Record,
-> extends BaseClientTool {
- private stagedReviewResult?: TReviewResult
-
- protected getStagedReviewResult(): TReviewResult | undefined {
- return this.stagedReviewResult ?? this.resolvePersistedResult()
- }
-
- protected stageReviewResult(result: TReviewResult): void {
- this.stagedReviewResult = result
- this.setState(ClientToolCallState.review, { result })
- }
-
- protected abstract hasStagedReviewResult(result: TReviewResult | undefined): boolean
-
- getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
- return this.getState() === ClientToolCallState.review ? this.metadata.interrupt : undefined
- }
-
- protected async prepareReviewAccept(args?: Record): Promise {
- if (this.hasStagedReviewResult(this.getStagedReviewResult())) {
- return true
- }
-
- await this.execute(args)
- return this.resolveUserActionState() === ClientToolCallState.review
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tool-utils.ts b/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tool-utils.ts
deleted file mode 100644
index 462a28cfb..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tool-utils.ts
+++ /dev/null
@@ -1,482 +0,0 @@
-import { type EntityDocumentKind, getEntityDocumentName } from '@/lib/copilot/entity-documents'
-import type { ClientToolExecutionContext } from '@/lib/copilot/tools/client/base-tool'
-import { resolveOptionalCopilotEntityId } from '@/lib/copilot/tools/entity-target'
-import { CustomToolOpenAiSchema, parseCustomToolSchemaText } from '@/lib/custom-tools/schema'
-import { getDefaultIndicator } from '@/lib/indicators/default'
-import { getEntityFields, replaceEntityTextField, setEntityField } from '@/lib/yjs/entity-session'
-import { buildSavedEntityYjsDescriptor } from '@/lib/yjs/entity-state'
-import {
- bootstrapYjsProvider,
- waitForYjsWriteSync,
- type YjsProviderBootstrapResult,
-} from '@/lib/yjs/provider'
-import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins'
-
-type EntityListEntry = {
- entityId: string
- entityName: string
- entityDescription?: string
- entityTitle?: string
- entityTransport?: string
- entityUrl?: string
- entityEnabled?: boolean
- entityConnectionStatus?: string
-}
-
-export type CopilotIndicatorListEntry = {
- name: string
- source: 'default' | 'custom'
- editable: boolean
- callableInFunctionBlock: boolean
- inputTitles?: string[]
- entityId?: string
- runtimeId?: string
-}
-
-export type EntityReadTarget = {
- entityId?: string
- runtimeId?: string
-}
-
-type EntityApiConfig = {
- listEndpoint: string
- extractList: (data: any) => any[]
- toFields: (item: any) => Record
- toListEntry: (item: any) => EntityListEntry
-}
-
-type CopilotEntityYjsSessionLease = {
- session: CopilotEntityYjsSession
- release: () => void
-}
-
-type CopilotEntityYjsSession = {
- descriptor: YjsProviderBootstrapResult['descriptor']
- doc: YjsProviderBootstrapResult['doc']
- provider: YjsProviderBootstrapResult['provider']
- runtime: YjsProviderBootstrapResult['runtime']
- isSynced: boolean
- canUndo: boolean
- canRedo: boolean
-}
-
-const COPILOT_ENTITY_YJS_RELEASE_MS = 2_500
-
-const ENTITY_API_CONFIG: Record = {
- skill: {
- listEndpoint: '/api/skills',
- extractList: (data) => (Array.isArray(data?.data) ? data.data : []),
- toFields: (item) => ({
- name: item?.name ?? '',
- description: item?.description ?? '',
- content: item?.content ?? '',
- }),
- toListEntry: (item) => ({
- entityId: String(item?.id ?? ''),
- entityName: String(item?.name ?? ''),
- entityDescription: typeof item?.description === 'string' ? item.description : '',
- }),
- },
- custom_tool: {
- listEndpoint: '/api/tools/custom',
- extractList: (data) => (Array.isArray(data?.data) ? data.data : []),
- toFields: (item) => ({
- title: item?.title ?? '',
- schemaText:
- item?.schema && typeof item.schema === 'object'
- ? JSON.stringify(CustomToolOpenAiSchema.parse(item.schema), null, 2)
- : typeof item?.schemaText === 'string'
- ? item.schemaText
- : '',
- codeText: item?.code ?? item?.codeText ?? '',
- }),
- toListEntry: (item) => ({
- entityId: String(item?.id ?? ''),
- entityName: String(item?.title ?? ''),
- entityTitle: typeof item?.title === 'string' ? item.title : '',
- entityDescription:
- typeof item?.schema?.function?.description === 'string'
- ? item.schema.function.description
- : undefined,
- }),
- },
- indicator: {
- listEndpoint: '/api/indicators/custom',
- extractList: (data) => (Array.isArray(data?.data) ? data.data : []),
- toFields: (item) => ({
- name: item?.name ?? '',
- pineCode: item?.pineCode ?? '',
- inputMeta:
- item?.inputMeta && typeof item.inputMeta === 'object' && !Array.isArray(item.inputMeta)
- ? item.inputMeta
- : null,
- }),
- toListEntry: (item) => ({
- entityId: String(item?.id ?? ''),
- entityName: String(item?.name ?? ''),
- }),
- },
- mcp_server: {
- listEndpoint: '/api/mcp/servers',
- extractList: (data) => (Array.isArray(data?.data?.servers) ? data.data.servers : []),
- toFields: (item) => ({
- name: item?.name ?? '',
- description: item?.description ?? '',
- transport: item?.transport ?? 'http',
- url: item?.url ?? '',
- headers:
- item?.headers && typeof item.headers === 'object' && !Array.isArray(item.headers)
- ? item.headers
- : {},
- command: item?.command ?? '',
- args: Array.isArray(item?.args) ? item.args : [],
- env: item?.env && typeof item.env === 'object' && !Array.isArray(item.env) ? item.env : {},
- timeout: typeof item?.timeout === 'number' ? item.timeout : 30000,
- retries: typeof item?.retries === 'number' ? item.retries : 3,
- enabled: typeof item?.enabled === 'boolean' ? item.enabled : true,
- }),
- toListEntry: (item) => ({
- entityId: String(item?.id ?? ''),
- entityName: String(item?.name ?? ''),
- entityTransport: typeof item?.transport === 'string' ? item.transport : undefined,
- entityUrl: typeof item?.url === 'string' ? item.url : undefined,
- entityEnabled: typeof item?.enabled === 'boolean' ? item.enabled : undefined,
- entityConnectionStatus:
- typeof item?.connectionStatus === 'string' ? item.connectionStatus : undefined,
- }),
- },
-}
-
-function buildEntityCreateRequest(
- kind: EntityDocumentKind,
- workspaceId: string,
- fields: Record
-): { endpoint: string; body: Record } {
- switch (kind) {
- case 'skill':
- return {
- endpoint: '/api/skills',
- body: {
- workspaceId,
- skills: [
- {
- name: fields.name,
- description: fields.description,
- content: fields.content,
- },
- ],
- },
- }
- case 'custom_tool':
- return {
- endpoint: '/api/tools/custom',
- body: {
- workspaceId,
- tools: [
- {
- title: fields.title,
- schema: parseCustomToolSchemaText(fields.schemaText),
- code: fields.codeText,
- },
- ],
- },
- }
- case 'indicator':
- return {
- endpoint: '/api/indicators/custom',
- body: {
- workspaceId,
- indicators: [
- {
- name: fields.name,
- pineCode: fields.pineCode,
- inputMeta: fields.inputMeta ?? undefined,
- },
- ],
- },
- }
- case 'mcp_server':
- return {
- endpoint: '/api/mcp/servers',
- body: {
- workspaceId,
- name: fields.name,
- ...(typeof fields.description === 'string' && fields.description.trim()
- ? { description: fields.description.trim() }
- : {}),
- transport: fields.transport,
- ...(typeof fields.url === 'string' && fields.url.trim()
- ? { url: fields.url.trim() }
- : {}),
- headers: fields.headers,
- ...(typeof fields.command === 'string' && fields.command.trim()
- ? { command: fields.command.trim() }
- : {}),
- args: fields.args,
- env: fields.env,
- timeout: fields.timeout,
- retries: fields.retries,
- enabled: fields.enabled,
- },
- }
- }
-}
-
-function readCreatedEntityId(kind: EntityDocumentKind, payload: any): string {
- if (kind === 'mcp_server') {
- const serverId = payload?.data?.serverId
- if (typeof serverId === 'string' && serverId.trim()) {
- return serverId
- }
- throw new Error('Created MCP server is missing serverId')
- }
-
- const created = Array.isArray(payload?.data) ? payload.data[0] : null
- const entityId = created?.id
- if (typeof entityId === 'string' && entityId.trim()) {
- return entityId
- }
-
- throw new Error(`Created ${kind} is missing id`)
-}
-
-export async function createCanonicalEntityFromFields(
- kind: EntityDocumentKind,
- workspaceId: string,
- fields: Record
-): Promise<{
- entityId: string
- entityName: string
- fields: Record
-}> {
- const request = buildEntityCreateRequest(kind, workspaceId, fields)
- const response = await fetch(request.endpoint, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(request.body),
- })
- const payload = await response.json().catch(() => ({}))
-
- if (!response.ok) {
- throw new Error(payload?.error || `Failed to create ${kind}: ${response.status}`)
- }
-
- const entityId = readCreatedEntityId(kind, payload)
- const createdRecord = kind === 'mcp_server' ? null : payload.data[0]
- const createdFields = createdRecord ? ENTITY_API_CONFIG[kind].toFields(createdRecord) : fields
-
- return {
- entityId,
- entityName: getEntityDocumentName(kind, createdFields),
- fields: createdFields,
- }
-}
-
-export function resolveWorkspaceIdFromExecutionContext(
- executionContext: ClientToolExecutionContext
-): string {
- if (executionContext.workspaceId) {
- return executionContext.workspaceId
- }
-
- throw new Error(
- 'No active workspace found in execution context. Ensure workspaceId is included in tool provenance.'
- )
-}
-
-function createBootstrappedEntitySessionLease(
- result: YjsProviderBootstrapResult
-): CopilotEntityYjsSessionLease {
- const session: CopilotEntityYjsSession = {
- descriptor: result.descriptor,
- doc: result.doc,
- provider: result.provider,
- runtime: result.runtime,
- isSynced: result.provider.synced,
- canUndo: false,
- canRedo: false,
- }
-
- return {
- session,
- release: () => {
- setTimeout(() => {
- result.provider.disconnect()
- result.provider.destroy()
- result.doc.destroy()
- }, COPILOT_ENTITY_YJS_RELEASE_MS)
- },
- }
-}
-
-export async function resolveCopilotEntityYjsSessionLease(
- executionContext: ClientToolExecutionContext,
- kind: EntityDocumentKind,
- entityId?: string
-): Promise {
- const requestedEntityId = resolveOptionalCopilotEntityId({ entityId })
-
- if (!requestedEntityId) {
- throw new Error(`entityId is required to update a saved ${kind}`)
- }
-
- const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext)
- const resolved = buildSavedEntityYjsDescriptor(kind, requestedEntityId, workspaceId)
- const result = await bootstrapYjsProvider(resolved)
- await waitForYjsWriteSync(result.provider)
- return createBootstrappedEntitySessionLease(result)
-}
-
-async function fetchEntityList(kind: EntityDocumentKind, workspaceId: string): Promise {
- const config = ENTITY_API_CONFIG[kind]
- const response = await fetch(
- `${config.listEndpoint}?workspaceId=${encodeURIComponent(workspaceId)}`
- )
- const data = await response.json().catch(() => ({}))
-
- if (!response.ok) {
- throw new Error(data?.error || `Failed to fetch ${kind} entries: ${response.status}`)
- }
-
- return config.extractList(data)
-}
-
-export async function listCanonicalEntityEntries(
- kind: EntityDocumentKind,
- workspaceId: string
-): Promise {
- const config = ENTITY_API_CONFIG[kind]
- const items = await fetchEntityList(kind, workspaceId)
- return items.map((item) => config.toListEntry(item))
-}
-
-export async function listCopilotIndicators(
- workspaceId: string
-): Promise {
- const response = await fetch(
- `/api/indicators/options?workspaceId=${encodeURIComponent(workspaceId)}&surface=copilot`
- )
- const data = await response.json().catch(() => ({}))
-
- if (!response.ok) {
- throw new Error(data?.error || `Failed to fetch indicators: ${response.status}`)
- }
-
- const items = Array.isArray(data?.data) ? data.data : []
-
- return items.flatMap((item: any) => {
- const name = typeof item?.name === 'string' ? item.name : ''
- const source =
- item?.source === 'custom' ? 'custom' : item?.source === 'default' ? 'default' : null
- if (!name || !source) return []
-
- const entry: CopilotIndicatorListEntry = {
- name,
- source,
- editable: item?.editable === true,
- callableInFunctionBlock: item?.callableInFunctionBlock === true,
- ...(Array.isArray(item?.inputTitles)
- ? {
- inputTitles: item.inputTitles.filter(
- (value: unknown): value is string => typeof value === 'string'
- ),
- }
- : {}),
- ...(typeof item?.entityId === 'string' && item.entityId ? { entityId: item.entityId } : {}),
- ...(typeof item?.runtimeId === 'string' && item.runtimeId
- ? { runtimeId: item.runtimeId }
- : {}),
- }
-
- return [entry]
- })
-}
-
-export async function readEntityFieldsFromContext(
- executionContext: ClientToolExecutionContext,
- kind: EntityDocumentKind,
- target?: EntityReadTarget
-): Promise<{
- entityId?: string
- entityName: string
- fields: Record
-}> {
- let resolvedEntityId = resolveOptionalCopilotEntityId(target)
- const resolvedRuntimeId =
- kind === 'indicator' ? target?.runtimeId?.trim() || undefined : undefined
-
- if (resolvedRuntimeId) {
- if (resolvedEntityId) {
- throw new Error('Use either runtimeId or entityId, not both')
- }
-
- const indicator = getDefaultIndicator(resolvedRuntimeId)
- if (indicator) {
- return {
- entityName: indicator.name,
- fields: {
- name: indicator.name,
- pineCode: indicator.pineCode,
- inputMeta: indicator.inputMeta ?? null,
- },
- }
- }
-
- resolvedEntityId = resolvedRuntimeId
- }
-
- if (!resolvedEntityId) {
- throw new Error('entityId is required')
- }
-
- const lease = await resolveCopilotEntityYjsSessionLease(executionContext, kind, resolvedEntityId)
- try {
- const fields = getEntityFields(lease.session.doc, kind)
- return {
- entityId: lease.session.descriptor.entityId ?? resolvedEntityId,
- entityName: getEntityDocumentName(kind, fields),
- fields,
- }
- } finally {
- lease.release()
- }
-}
-
-export function applyEntityFieldsToSession(
- session: CopilotEntityYjsSession,
- kind: EntityDocumentKind,
- fields: Record
-): void {
- session.doc.transact(() => {
- switch (kind) {
- case 'skill':
- setEntityField(session.doc, 'name', fields.name ?? '')
- setEntityField(session.doc, 'description', fields.description ?? '')
- setEntityField(session.doc, 'content', fields.content ?? '')
- break
- case 'custom_tool':
- setEntityField(session.doc, 'title', fields.title ?? '')
- replaceEntityTextField(session.doc, 'schemaText', String(fields.schemaText ?? ''))
- replaceEntityTextField(session.doc, 'codeText', String(fields.codeText ?? ''))
- break
- case 'indicator':
- setEntityField(session.doc, 'name', fields.name ?? '')
- replaceEntityTextField(session.doc, 'pineCode', String(fields.pineCode ?? ''))
- setEntityField(session.doc, 'inputMeta', fields.inputMeta ?? null)
- break
- case 'mcp_server':
- setEntityField(session.doc, 'name', fields.name ?? '')
- setEntityField(session.doc, 'description', fields.description ?? '')
- setEntityField(session.doc, 'transport', fields.transport ?? 'http')
- setEntityField(session.doc, 'url', fields.url ?? '')
- setEntityField(session.doc, 'headers', fields.headers ?? {})
- setEntityField(session.doc, 'command', fields.command ?? '')
- setEntityField(session.doc, 'args', fields.args ?? [])
- setEntityField(session.doc, 'env', fields.env ?? {})
- setEntityField(session.doc, 'timeout', fields.timeout ?? 30000)
- setEntityField(session.doc, 'retries', fields.retries ?? 3)
- setEntityField(session.doc, 'enabled', fields.enabled ?? true)
- break
- }
- }, YJS_ORIGINS.COPILOT_TOOL)
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tools.ts b/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tools.ts
deleted file mode 100644
index 7a80d9c45..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tools.ts
+++ /dev/null
@@ -1,585 +0,0 @@
-import type { LucideIcon } from 'lucide-react'
-import {
- BarChart3,
- BookOpen,
- Check,
- Code2,
- FileJson,
- Loader2,
- Server,
- X,
- XCircle,
-} from 'lucide-react'
-import {
- type EntityDocumentKind,
- getEntityDocumentFormat,
- getEntityDocumentName,
- parseEntityDocument,
- serializeEntityDocument,
-} from '@/lib/copilot/entity-documents'
-import { CopilotTool } from '@/lib/copilot/registry'
-import {
- ENTITY_KIND_CUSTOM_TOOL,
- ENTITY_KIND_INDICATOR,
- ENTITY_KIND_MCP_SERVER,
- ENTITY_KIND_SKILL,
-} from '@/lib/copilot/review-sessions/types'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
- StagedReviewClientTool,
-} from '@/lib/copilot/tools/client/base-tool'
-import {
- applyEntityFieldsToSession,
- createCanonicalEntityFromFields,
- type EntityReadTarget,
- listCanonicalEntityEntries,
- listCopilotIndicators,
- readEntityFieldsFromContext,
- resolveCopilotEntityYjsSessionLease,
- resolveWorkspaceIdFromExecutionContext,
-} from '@/lib/copilot/tools/client/entities/entity-document-tool-utils'
-import { getEntityFields } from '@/lib/yjs/entity-session'
-import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access'
-
-type EntityToolConfig = {
- kind: EntityDocumentKind
- singularLabel: string
- pluralLabel: string
- icon: LucideIcon
-}
-
-type ReadEntityDocumentArgs = EntityReadTarget
-
-type EditEntityDocumentArgs = ReadEntityDocumentArgs & {
- entityDocument: string
- documentFormat?: string
-}
-
-type EntityMutationAction = 'create' | 'edit' | 'rename'
-
-function readStoredToolArgs(toolCallId: string): TArgs | undefined {
- try {
- const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState()
- return toolCallsById[toolCallId]?.params as TArgs | undefined
- } catch {
- return undefined
- }
-}
-
-function buildEntityDocumentDiff(
- kind: EntityDocumentKind,
- currentFields: Record,
- nextFields: Record
-): { before: string; after: string } {
- return {
- before: serializeEntityDocument(kind, currentFields),
- after: serializeEntityDocument(kind, nextFields),
- }
-}
-
-function createListMetadata(config: EntityToolConfig): BaseClientToolMetadata {
- return {
- displayNames: {
- [ClientToolCallState.generating]: {
- text: `Listing ${config.pluralLabel}`,
- icon: Loader2,
- },
- [ClientToolCallState.pending]: {
- text: `List ${config.pluralLabel}`,
- icon: config.icon,
- },
- [ClientToolCallState.executing]: {
- text: `Listing ${config.pluralLabel}`,
- icon: Loader2,
- },
- [ClientToolCallState.success]: {
- text: `Listed ${config.pluralLabel}`,
- icon: config.icon,
- },
- [ClientToolCallState.error]: {
- text: `Failed to list ${config.pluralLabel}`,
- icon: X,
- },
- [ClientToolCallState.aborted]: {
- text: `Aborted listing ${config.pluralLabel}`,
- icon: XCircle,
- },
- [ClientToolCallState.rejected]: {
- text: `Skipped listing ${config.pluralLabel}`,
- icon: XCircle,
- },
- },
- }
-}
-
-function createReadMetadata(config: EntityToolConfig): BaseClientToolMetadata {
- return {
- displayNames: {
- [ClientToolCallState.generating]: {
- text: `Reading ${config.singularLabel} document`,
- icon: Loader2,
- },
- [ClientToolCallState.pending]: {
- text: `Read ${config.singularLabel} document`,
- icon: FileJson,
- },
- [ClientToolCallState.executing]: {
- text: `Reading ${config.singularLabel} document`,
- icon: Loader2,
- },
- [ClientToolCallState.success]: {
- text: `Read ${config.singularLabel} document`,
- icon: FileJson,
- },
- [ClientToolCallState.error]: {
- text: `Failed to read ${config.singularLabel} document`,
- icon: X,
- },
- [ClientToolCallState.aborted]: {
- text: `Aborted reading ${config.singularLabel} document`,
- icon: XCircle,
- },
- [ClientToolCallState.rejected]: {
- text: `Skipped reading ${config.singularLabel} document`,
- icon: XCircle,
- },
- },
- }
-}
-
-function createMutationMetadata(
- config: EntityToolConfig,
- action: EntityMutationAction
-): BaseClientToolMetadata {
- const actionLabels =
- action === 'create'
- ? {
- gerund: 'Creating',
- past: 'Created',
- error: 'create',
- aborted: 'creating',
- }
- : action === 'rename'
- ? {
- gerund: 'Renaming',
- past: 'Renamed',
- error: 'rename',
- aborted: 'renaming',
- }
- : {
- gerund: 'Editing',
- past: 'Edited',
- error: 'edit',
- aborted: 'editing',
- }
-
- return {
- displayNames: {
- [ClientToolCallState.generating]: {
- text: `${actionLabels.gerund} ${config.singularLabel} document`,
- icon: Loader2,
- },
- [ClientToolCallState.pending]: {
- text: `${actionLabels.gerund} ${config.singularLabel} document`,
- icon: Loader2,
- },
- [ClientToolCallState.executing]: {
- text: `${actionLabels.gerund} ${config.singularLabel} document`,
- icon: Loader2,
- },
- [ClientToolCallState.review]: {
- text: `Review your ${config.singularLabel} changes`,
- icon: config.icon,
- },
- [ClientToolCallState.success]: {
- text: `${actionLabels.past} ${config.singularLabel} document`,
- icon: Check,
- },
- [ClientToolCallState.error]: {
- text: `Failed to ${actionLabels.error} ${config.singularLabel} document`,
- icon: X,
- },
- [ClientToolCallState.aborted]: {
- text: `Aborted ${actionLabels.aborted} ${config.singularLabel} document`,
- icon: XCircle,
- },
- [ClientToolCallState.rejected]: {
- text: `Skipped ${actionLabels.aborted} ${config.singularLabel} document`,
- icon: XCircle,
- },
- },
- interrupt: {
- accept: { text: 'Accept changes', icon: Check },
- reject: { text: 'Reject changes', icon: XCircle },
- },
- }
-}
-
-function createListEntityTool(toolId: string, config: EntityToolConfig) {
- return class ListEntityClientTool extends BaseClientTool {
- static readonly id = toolId
- static readonly metadata = createListMetadata(config)
-
- constructor(toolCallId: string) {
- super(toolCallId, toolId, ListEntityClientTool.metadata)
- }
-
- async execute(): Promise {
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
- const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext)
- const entities = await listCanonicalEntityEntries(config.kind, workspaceId)
-
- await this.markToolComplete(200, `Listed ${config.pluralLabel}`, {
- entityKind: config.kind,
- entities,
- count: entities.length,
- })
- this.setState(ClientToolCallState.success)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message)
- this.setState(ClientToolCallState.error)
- }
- }
- }
-}
-
-function createReadEntityDocumentTool(toolId: string, config: EntityToolConfig) {
- return class ReadEntityDocumentClientTool extends BaseClientTool {
- static readonly id = toolId
- static readonly metadata = createReadMetadata(config)
-
- constructor(toolCallId: string) {
- super(toolCallId, toolId, ReadEntityDocumentClientTool.metadata)
- }
-
- async execute(args?: ReadEntityDocumentArgs): Promise {
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
-
- const { entityId, entityName, fields } = await readEntityFieldsFromContext(
- executionContext,
- config.kind,
- args
- )
-
- await this.markToolComplete(200, `${config.singularLabel} document ready`, {
- entityKind: config.kind,
- entityId,
- entityName,
- documentFormat: getEntityDocumentFormat(config.kind),
- entityDocument: serializeEntityDocument(config.kind, fields),
- })
- this.setState(ClientToolCallState.success)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message)
- this.setState(ClientToolCallState.error)
- }
- }
- }
-}
-
-function createEntityDocumentMutationTool(
- toolId: string,
- config: EntityToolConfig,
- action: EntityMutationAction
-) {
- return class EditEntityDocumentClientTool extends StagedReviewClientTool> {
- static readonly id = toolId
- static readonly metadata = createMutationMetadata(config, action)
- private currentArgs?: EditEntityDocumentArgs
-
- constructor(toolCallId: string) {
- super(toolCallId, toolId, EditEntityDocumentClientTool.metadata)
- }
-
- async execute(args?: EditEntityDocumentArgs): Promise {
- try {
- this.currentArgs = args
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
- const resolvedArgs = args || readStoredToolArgs(this.toolCallId)
-
- if (!resolvedArgs?.entityDocument?.trim()) {
- throw new Error('entityDocument is required')
- }
-
- if (
- resolvedArgs.documentFormat &&
- resolvedArgs.documentFormat !== getEntityDocumentFormat(config.kind)
- ) {
- throw new Error(
- `Unsupported documentFormat "${resolvedArgs.documentFormat}". Expected ${getEntityDocumentFormat(config.kind)}`
- )
- }
-
- const entityId = resolvedArgs.entityId?.trim()
- if (action === 'create' && entityId) {
- throw new Error(`${toolId} does not accept entityId`)
- }
- const nextFields = parseEntityDocument(config.kind, resolvedArgs.entityDocument)
- let currentFields: Record = {}
- let resolvedEntityId: string | null | undefined = entityId
-
- if (action !== 'create') {
- const lease = await resolveCopilotEntityYjsSessionLease(
- executionContext,
- config.kind,
- entityId
- )
- try {
- currentFields = getEntityFields(lease.session.doc, config.kind)
- resolvedEntityId = lease.session.descriptor.entityId ?? entityId
- } finally {
- lease.release()
- }
- }
-
- const stagedResult = {
- success: false,
- entityKind: config.kind,
- ...(resolvedEntityId ? { entityId: resolvedEntityId } : {}),
- entityName: getEntityDocumentName(config.kind, nextFields),
- documentFormat: getEntityDocumentFormat(config.kind),
- entityDocument: serializeEntityDocument(config.kind, nextFields),
- preview: {
- documentDiff: buildEntityDocumentDiff(config.kind, currentFields, nextFields),
- },
- }
- this.stageReviewResult(stagedResult)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message)
- this.setState(ClientToolCallState.error)
- }
- }
-
- protected hasStagedReviewResult(result: Record | undefined): boolean {
- return !!result?.entityDocument
- }
-
- async handleAccept(args?: EditEntityDocumentArgs): Promise {
- try {
- this.setState(ClientToolCallState.executing)
-
- let stagedResult = this.getStagedReviewResult()
- if (!stagedResult?.entityDocument) {
- await this.execute(args)
- stagedResult = this.getStagedReviewResult()
- }
-
- if (!stagedResult?.entityDocument?.trim()) {
- throw new Error('entityDocument is required')
- }
-
- const executionContext = this.requireExecutionContext()
- const entityId =
- (typeof stagedResult.entityId === 'string' ? stagedResult.entityId.trim() : '') ||
- args?.entityId?.trim() ||
- this.currentArgs?.entityId?.trim()
- const nextFields = parseEntityDocument(config.kind, stagedResult.entityDocument)
-
- if (action === 'create') {
- if (entityId) {
- throw new Error(`${toolId} does not accept entityId`)
- }
-
- const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext)
- const created = await createCanonicalEntityFromFields(
- config.kind,
- workspaceId,
- nextFields
- )
-
- await this.markToolComplete(200, `${config.singularLabel} document created`, {
- success: true,
- entityKind: config.kind,
- entityId: created.entityId,
- entityName: created.entityName,
- documentFormat: getEntityDocumentFormat(config.kind),
- entityDocument: serializeEntityDocument(config.kind, created.fields),
- preview: stagedResult.preview,
- })
- this.setState(ClientToolCallState.success)
- return
- }
-
- const lease = await resolveCopilotEntityYjsSessionLease(
- executionContext,
- config.kind,
- entityId
- )
- try {
- applyEntityFieldsToSession(lease.session, config.kind, nextFields)
- const persistedFields = getEntityFields(lease.session.doc, config.kind)
- const savedEntityId = lease.session.descriptor.entityId ?? entityId
-
- await this.markToolComplete(200, `${config.singularLabel} document updated`, {
- success: true,
- entityKind: config.kind,
- ...(savedEntityId ? { entityId: savedEntityId } : {}),
- entityName: getEntityDocumentName(config.kind, persistedFields),
- documentFormat: getEntityDocumentFormat(config.kind),
- entityDocument: serializeEntityDocument(config.kind, persistedFields),
- preview: stagedResult.preview,
- })
- } finally {
- lease.release()
- }
- this.setState(ClientToolCallState.success)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message)
- this.setState(ClientToolCallState.error)
- }
- }
- }
-}
-
-const skillToolConfig: EntityToolConfig = {
- kind: ENTITY_KIND_SKILL,
- singularLabel: 'skill',
- pluralLabel: 'skills',
- icon: BookOpen,
-}
-
-const customToolConfig: EntityToolConfig = {
- kind: ENTITY_KIND_CUSTOM_TOOL,
- singularLabel: 'custom tool',
- pluralLabel: 'custom tools',
- icon: Code2,
-}
-
-const indicatorToolConfig: EntityToolConfig = {
- kind: ENTITY_KIND_INDICATOR,
- singularLabel: 'indicator',
- pluralLabel: 'indicators',
- icon: BarChart3,
-}
-
-const mcpServerToolConfig: EntityToolConfig = {
- kind: ENTITY_KIND_MCP_SERVER,
- singularLabel: 'MCP server',
- pluralLabel: 'MCP servers',
- icon: Server,
-}
-
-export const ListSkillsClientTool = createListEntityTool('list_skills', skillToolConfig)
-export const ReadSkillClientTool = createReadEntityDocumentTool(
- CopilotTool.read_skill,
- skillToolConfig
-)
-export const CreateSkillClientTool = createEntityDocumentMutationTool(
- 'create_skill',
- skillToolConfig,
- 'create'
-)
-export const EditSkillClientTool = createEntityDocumentMutationTool(
- 'edit_skill',
- skillToolConfig,
- 'edit'
-)
-export const RenameSkillClientTool = createEntityDocumentMutationTool(
- 'rename_skill',
- skillToolConfig,
- 'rename'
-)
-
-export const ListCustomToolsClientTool = createListEntityTool('list_custom_tools', customToolConfig)
-export const ReadCustomToolClientTool = createReadEntityDocumentTool(
- CopilotTool.read_custom_tool,
- customToolConfig
-)
-export const CreateCustomToolClientTool = createEntityDocumentMutationTool(
- 'create_custom_tool',
- customToolConfig,
- 'create'
-)
-export const EditCustomToolClientTool = createEntityDocumentMutationTool(
- 'edit_custom_tool',
- customToolConfig,
- 'edit'
-)
-export const RenameCustomToolClientTool = createEntityDocumentMutationTool(
- 'rename_custom_tool',
- customToolConfig,
- 'rename'
-)
-
-export class ListIndicatorsClientTool extends BaseClientTool {
- static readonly id = CopilotTool.list_indicators
- static readonly metadata = createListMetadata(indicatorToolConfig)
-
- constructor(toolCallId: string) {
- super(toolCallId, ListIndicatorsClientTool.id, ListIndicatorsClientTool.metadata)
- }
-
- async execute(): Promise {
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
- const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext)
- const indicators = await listCopilotIndicators(workspaceId)
-
- await this.markToolComplete(200, 'Listed indicators', {
- entityKind: 'indicator',
- indicators,
- count: indicators.length,
- })
- this.setState(ClientToolCallState.success)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message)
- this.setState(ClientToolCallState.error)
- }
- }
-}
-export const ReadIndicatorClientTool = createReadEntityDocumentTool(
- CopilotTool.read_indicator,
- indicatorToolConfig
-)
-export const CreateIndicatorClientTool = createEntityDocumentMutationTool(
- 'create_indicator',
- indicatorToolConfig,
- 'create'
-)
-export const EditIndicatorClientTool = createEntityDocumentMutationTool(
- 'edit_indicator',
- indicatorToolConfig,
- 'edit'
-)
-export const RenameIndicatorClientTool = createEntityDocumentMutationTool(
- 'rename_indicator',
- indicatorToolConfig,
- 'rename'
-)
-
-export const ListMcpServersClientTool = createListEntityTool(
- 'list_mcp_servers',
- mcpServerToolConfig
-)
-export const ReadMcpServerClientTool = createReadEntityDocumentTool(
- CopilotTool.read_mcp_server,
- mcpServerToolConfig
-)
-export const CreateMcpServerClientTool = createEntityDocumentMutationTool(
- 'create_mcp_server',
- mcpServerToolConfig,
- 'create'
-)
-export const EditMcpServerClientTool = createEntityDocumentMutationTool(
- 'edit_mcp_server',
- mcpServerToolConfig,
- 'edit'
-)
-export const RenameMcpServerClientTool = createEntityDocumentMutationTool(
- 'rename_mcp_server',
- mcpServerToolConfig,
- 'rename'
-)
diff --git a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts b/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts
deleted file mode 100644
index b4700a485..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts
+++ /dev/null
@@ -1,745 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { ToolArgSchemas, ToolResultSchemas } from '@/lib/copilot/registry'
-import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
-import { resolveCopilotEntityYjsSessionLease } from '@/lib/copilot/tools/client/entities/entity-document-tool-utils'
-import {
- CreateSkillClientTool,
- EditSkillClientTool,
- ListIndicatorsClientTool,
- ListSkillsClientTool,
- ReadCustomToolClientTool,
- ReadIndicatorClientTool,
-} from '@/lib/copilot/tools/client/entities/entity-document-tools'
-
-const mockRegistryState = {
- workflows: {} as Record,
-}
-
-const mockCopilotState = {
- toolCallsById: {} as Record }>,
-}
-
-const mockEntityFieldState = {
- values: {} as Record,
-}
-const mockBootstrapYjsProvider = vi.fn()
-const mockWaitForYjsWriteSync = vi.fn()
-
-const originalFetch = globalThis.fetch
-
-vi.mock('@/stores/workflows/registry/store', () => ({
- useWorkflowRegistry: {
- getState: () => mockRegistryState,
- },
-}))
-
-vi.mock('@/stores/copilot/store-access', () => ({
- getCopilotStoreForToolCall: () => ({
- getState: () => mockCopilotState,
- }),
-}))
-
-vi.mock('@/lib/yjs/provider', () => ({
- bootstrapYjsProvider: (...args: any[]) => mockBootstrapYjsProvider(...args),
- waitForYjsWriteSync: (...args: any[]) => mockWaitForYjsWriteSync(...args),
-}))
-
-vi.mock('@/lib/yjs/entity-session', () => ({
- getEntityFields: () => ({ ...mockEntityFieldState.values }),
- setEntityField: (_doc: unknown, key: string, value: unknown) => {
- mockEntityFieldState.values[key] = value
- },
- replaceEntityTextField: (_doc: unknown, key: string, value: string) => {
- mockEntityFieldState.values[key] = value
- },
-}))
-
-describe('entity document tools', () => {
- beforeEach(() => {
- vi.restoreAllMocks()
- vi.unstubAllGlobals?.()
- globalThis.fetch = originalFetch
- mockRegistryState.workflows = {
- 'wf-context': { workspaceId: 'ws-1' },
- }
- mockCopilotState.toolCallsById = {}
- mockEntityFieldState.values = {}
- mockBootstrapYjsProvider.mockReset()
- mockWaitForYjsWriteSync.mockReset()
- mockWaitForYjsWriteSync.mockResolvedValue(undefined)
- })
-
- function mockSavedEntitySession(entityKind: string, entityId: string, workspaceId = 'ws-1') {
- const provider = {
- synced: true,
- on: vi.fn(),
- off: vi.fn(),
- disconnect: vi.fn(),
- destroy: vi.fn(),
- }
- const doc = {
- transact: (cb: () => void) => cb(),
- destroy: vi.fn(),
- }
- const descriptor = {
- workspaceId,
- entityKind,
- entityId,
- reviewSessionId: null,
- draftSessionId: null,
- yjsSessionId: entityId,
- }
- mockBootstrapYjsProvider.mockResolvedValue({
- descriptor,
- doc,
- provider,
- runtime: null,
- })
- return descriptor
- }
-
- it('list_skills returns generic entity list results', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/skills?workspaceId=ws-1' && method === 'GET') {
- return {
- ok: true,
- status: 200,
- json: async () => ({
- data: [
- {
- id: 'skill-1',
- name: 'market-research',
- description: 'Research a market before trading.',
- },
- ],
- }),
- }
- }
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const toolCallId = 'list-skills'
- const tool = new ListSkillsClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'list_skills',
- channelId: 'pair-yellow',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute()
-
- expect(fetchMock).toHaveBeenCalledWith('/api/skills?workspaceId=ws-1')
- expect(tool.getState()).toBe(ClientToolCallState.success)
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body))
- expect(markCompleteBody.data).toMatchObject({
- entityKind: 'skill',
- count: 1,
- })
- expect(markCompleteBody.data.entities).toEqual([
- {
- entityId: 'skill-1',
- entityName: 'market-research',
- entityDescription: 'Research a market before trading.',
- },
- ])
- })
-
- it('read_custom_tool reads the explicit target entity and returns an entity document', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
- mockEntityFieldState.values = {
- title: 'market-tool',
- schemaText: JSON.stringify(
- {
- type: 'function',
- function: {
- description: 'Fetch market data',
- parameters: { type: 'object', properties: {} },
- },
- },
- null,
- 2
- ),
- codeText: 'return 1',
- }
- const descriptor = mockSavedEntitySession('custom_tool', 'tool-1')
-
- const toolCallId = 'get-custom-tool'
- const tool = new ReadCustomToolClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'read_custom_tool',
- channelId: 'pair-orange',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute({ entityId: 'tool-1' })
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
- expect(mockBootstrapYjsProvider).toHaveBeenCalledWith(descriptor)
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body))
-
- expect(markCompleteBody.data).toMatchObject({
- entityKind: 'custom_tool',
- entityId: 'tool-1',
- entityName: 'market-tool',
- documentFormat: 'tg-custom-tool-document-v1',
- })
- expect(markCompleteBody.data.entityDocument).toContain('"title": "market-tool"')
- expect(markCompleteBody.data.entityDocument).toContain('"codeText": "return 1"')
- expect(fetchMock).not.toHaveBeenCalledWith('/api/tools/custom?workspaceId=ws-1')
- })
-
- it('create_skill inserts through the canonical skills API after approval', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/skills' && method === 'POST') {
- expect(JSON.parse(String(init?.body))).toEqual({
- workspaceId: 'ws-1',
- skills: [
- {
- name: 'new-skill',
- description: 'New skill description',
- content: 'Do useful work.',
- },
- ],
- })
-
- return {
- ok: true,
- status: 200,
- json: async () => ({
- success: true,
- data: [
- {
- id: 'skill-new',
- name: 'new-skill',
- description: 'New skill description',
- content: 'Do useful work.',
- },
- ],
- }),
- }
- }
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const toolCallId = 'create-skill'
- const tool = new CreateSkillClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'create_skill',
- channelId: 'pair-yellow',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute({
- entityDocument: JSON.stringify({
- name: 'new-skill',
- description: 'New skill description',
- content: 'Do useful work.',
- }),
- documentFormat: 'tg-skill-document-v1',
- })
-
- expect(tool.getState()).toBe(ClientToolCallState.review)
- await tool.handleAccept()
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
- expect(mockBootstrapYjsProvider).not.toHaveBeenCalled()
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body))
- expect(markCompleteBody.name).toBe('create_skill')
- expect(markCompleteBody.data).toMatchObject({
- success: true,
- entityKind: 'skill',
- entityId: 'skill-new',
- entityName: 'new-skill',
- documentFormat: 'tg-skill-document-v1',
- })
- expect(markCompleteBody.data.entityDocument).toContain('"name": "new-skill"')
- expect(markCompleteBody.data).not.toHaveProperty('reviewSessionId')
- expect(markCompleteBody.data).not.toHaveProperty('draftSessionId')
- })
-
- it('list_indicators returns built-in and custom indicators with capability flags', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/indicators/options?workspaceId=ws-1&surface=copilot' && method === 'GET') {
- return {
- ok: true,
- status: 200,
- json: async () => ({
- data: [
- {
- id: 'RSI',
- name: 'Relative Strength Index',
- source: 'default',
- editable: false,
- callableInFunctionBlock: true,
- inputTitles: ['Length'],
- runtimeId: 'RSI',
- },
- {
- id: 'indicator-1',
- name: 'My Custom Indicator',
- source: 'custom',
- editable: true,
- callableInFunctionBlock: true,
- inputTitles: ['Fast Length'],
- entityId: 'indicator-1',
- runtimeId: 'indicator-1',
- },
- ],
- }),
- }
- }
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const toolCallId = 'list-indicators'
- const tool = new ListIndicatorsClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'list_indicators',
- channelId: 'pair-cyan',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute()
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body))
- expect(markCompleteBody.data).toMatchObject({
- entityKind: 'indicator',
- count: 2,
- })
- expect(markCompleteBody.data.indicators).toEqual([
- {
- name: 'Relative Strength Index',
- source: 'default',
- editable: false,
- callableInFunctionBlock: true,
- inputTitles: ['Length'],
- runtimeId: 'RSI',
- },
- {
- name: 'My Custom Indicator',
- source: 'custom',
- editable: true,
- callableInFunctionBlock: true,
- inputTitles: ['Fast Length'],
- entityId: 'indicator-1',
- runtimeId: 'indicator-1',
- },
- ])
- })
-
- it('read_indicator reads a built-in default indicator by runtimeId', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const toolCallId = 'get-indicator-default'
- const tool = new ReadIndicatorClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'read_indicator',
- channelId: 'pair-yellow',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute({ runtimeId: 'RSI' })
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body))
-
- expect(markCompleteBody.data).toMatchObject({
- entityKind: 'indicator',
- entityName: 'Relative Strength Index',
- documentFormat: 'tg-indicator-document-v1',
- })
- expect(markCompleteBody.data.entityDocument).toContain('"name": "Relative Strength Index"')
- expect(markCompleteBody.data.entityDocument).toContain('"pineCode"')
- expect(markCompleteBody.data.entityDocument).toContain('"Length"')
- })
-
- it('read_indicator reads a custom indicator by runtimeId', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
- mockEntityFieldState.values = {
- name: 'My Custom Indicator',
- pineCode: 'indicator("My Custom Indicator")',
- inputMeta: { Length: { defaultValue: 14 } },
- }
- const descriptor = mockSavedEntitySession('indicator', 'indicator-1')
-
- const toolCallId = 'get-indicator-custom-runtime'
- const tool = new ReadIndicatorClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'read_indicator',
- channelId: 'pair-yellow',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute({ runtimeId: 'indicator-1' })
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
- expect(mockBootstrapYjsProvider).toHaveBeenCalledWith(descriptor)
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body))
-
- expect(markCompleteBody.data).toMatchObject({
- entityKind: 'indicator',
- entityId: 'indicator-1',
- entityName: 'My Custom Indicator',
- documentFormat: 'tg-indicator-document-v1',
- })
- expect(markCompleteBody.data.entityDocument).toContain('"name": "My Custom Indicator"')
- expect(markCompleteBody.data.entityDocument).toContain('"pineCode"')
- })
-
- it('edit_skill bootstraps the canonical saved-entity Yjs session', async () => {
- vi.useFakeTimers()
- try {
- const provider = {
- on: vi.fn(),
- off: vi.fn(),
- disconnect: vi.fn(),
- destroy: vi.fn(),
- }
- const doc = {
- transact: (cb: () => void) => cb(),
- destroy: vi.fn(),
- }
- const descriptor = {
- workspaceId: 'ws-1',
- entityKind: 'skill',
- entityId: 'skill-1',
- reviewSessionId: null,
- draftSessionId: null,
- yjsSessionId: 'skill-1',
- }
-
- mockBootstrapYjsProvider.mockResolvedValue({
- descriptor,
- doc,
- provider,
- runtime: null,
- accessMode: 'write',
- })
-
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const toolCallId = 'edit-skill-bootstrap'
- const tool = new EditSkillClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'edit_skill',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute({
- entityId: 'skill-1',
- entityDocument: JSON.stringify({
- name: 'bootstrapped-skill',
- description: 'Updated through tool lease',
- content: 'Updated content',
- }),
- documentFormat: 'tg-skill-document-v1',
- })
- await tool.handleAccept()
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
- expect(mockBootstrapYjsProvider).toHaveBeenCalledWith(descriptor)
- expect(mockEntityFieldState.values).toMatchObject({
- name: 'bootstrapped-skill',
- description: 'Updated through tool lease',
- content: 'Updated content',
- })
-
- await vi.runOnlyPendingTimersAsync()
- expect(provider.disconnect).toHaveBeenCalled()
- expect(provider.destroy).toHaveBeenCalled()
- expect(doc.destroy).toHaveBeenCalled()
- } finally {
- vi.useRealTimers()
- }
- })
-
- it('requires write-authorized bootstrap for saved entity sessions', async () => {
- mockBootstrapYjsProvider.mockRejectedValue(new Error('Snapshot fetch failed: 403'))
-
- await expect(
- resolveCopilotEntityYjsSessionLease(
- { toolCallId: 'edit-skill', toolName: 'edit_skill', workspaceId: 'ws-1' },
- 'skill',
- 'skill-1'
- )
- ).rejects.toThrow('Snapshot fetch failed: 403')
- expect(mockBootstrapYjsProvider).toHaveBeenCalledWith({
- workspaceId: 'ws-1',
- entityKind: 'skill',
- entityId: 'skill-1',
- draftSessionId: null,
- reviewSessionId: null,
- yjsSessionId: 'skill-1',
- })
- })
-
- it('edit_skill rejects edits without an explicit entityId', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const toolCallId = 'edit-skill-without-entity-id'
- const tool = new EditSkillClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'edit_skill',
- channelId: 'pair-purple',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute({
- entityDocument: JSON.stringify({
- name: 'new-skill',
- description: '',
- content: '',
- }),
- documentFormat: 'tg-skill-document-v1',
- } as any)
- await tool.handleAccept()
-
- expect(tool.getState()).toBe(ClientToolCallState.error)
- expect(mockEntityFieldState.values).toEqual({})
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body))
- expect(markCompleteBody.status).toBe(500)
- expect(markCompleteBody.message).toContain('entityId is required to update a saved skill')
- })
-
- it('registry schemas accept optional explicit entity ids for entity document tools', () => {
- expect(ToolArgSchemas.list_skills.parse({})).toMatchObject({})
- expect(ToolArgSchemas.read_skill.parse({ entityId: 'skill-1' })).toMatchObject({
- entityId: 'skill-1',
- })
- expect(() => ToolArgSchemas.read_skill.parse({})).toThrow()
- expect(ToolArgSchemas.read_indicator.parse({ runtimeId: 'RSI' })).toMatchObject({
- runtimeId: 'RSI',
- })
- expect(() => ToolArgSchemas.read_indicator.parse({})).toThrow()
- expect(
- ToolArgSchemas.create_skill.parse({
- entityDocument: '{"name":"skill","description":"","content":""}',
- })
- ).toMatchObject({
- entityDocument: '{"name":"skill","description":"","content":""}',
- })
- expect(() =>
- ToolArgSchemas.create_skill.parse({
- entityId: 'skill-1',
- entityDocument: '{"name":"skill","description":"","content":""}',
- })
- ).toThrow()
- expect(
- ToolArgSchemas.edit_skill.parse({
- entityId: 'skill-1',
- entityDocument: '{"name":"skill","description":"","content":""}',
- })
- ).toMatchObject({
- entityId: 'skill-1',
- entityDocument: '{"name":"skill","description":"","content":""}',
- })
- expect(() =>
- ToolArgSchemas.edit_skill.parse({
- entityDocument: '{"name":"skill","description":"","content":""}',
- })
- ).toThrow()
- expect(
- ToolResultSchemas.read_custom_tool.parse({
- entityKind: 'custom_tool',
- entityId: 'tool-1',
- entityName: 'market-tool',
- documentFormat: 'tg-custom-tool-document-v1',
- entityDocument: '{}',
- })
- ).toBeDefined()
- expect(
- ToolResultSchemas.list_skills.parse({
- entityKind: 'skill',
- entities: [],
- count: 0,
- })
- ).toBeDefined()
- expect(
- ToolResultSchemas.list_indicators.parse({
- entityKind: 'indicator',
- indicators: [
- {
- name: 'Relative Strength Index',
- source: 'default',
- editable: false,
- callableInFunctionBlock: true,
- runtimeId: 'RSI',
- inputTitles: ['Length'],
- },
- ],
- count: 1,
- })
- ).toBeDefined()
- expect(
- ToolResultSchemas.rename_skill.parse({
- success: true,
- entityKind: 'skill',
- entityId: 'skill-1',
- entityName: 'renamed-skill',
- documentFormat: 'tg-skill-document-v1',
- entityDocument: '{"name":"renamed-skill","description":"","content":""}',
- })
- ).toBeDefined()
- })
-})
diff --git a/apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts b/apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts
deleted file mode 100644
index bd4afeb64..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { Database, Loader2, MinusCircle, PlusCircle, XCircle } from 'lucide-react'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import {
- executeCopilotServerTool,
- getCopilotServerToolErrorStatus,
-} from '@/lib/copilot/tools/client/server-tool-response'
-import type { KnowledgeBaseArgs } from '@/lib/copilot/tools/shared/schemas'
-import { createLogger } from '@/lib/logs/console/logger'
-import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access'
-
-/**
- * Client tool for knowledge base operations
- */
-export class KnowledgeBaseClientTool extends BaseClientTool {
- static readonly id = 'knowledge_base'
-
- constructor(toolCallId: string) {
- super(toolCallId, KnowledgeBaseClientTool.id, KnowledgeBaseClientTool.metadata)
- }
-
- getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
- const toolCallsById = getCopilotStoreForToolCall(this.toolCallId).getState().toolCallsById
- const toolCall = toolCallsById[this.toolCallId]
- const params = toolCall?.params as KnowledgeBaseArgs | undefined
-
- if (params?.operation === 'create') {
- const name = params?.args?.name || 'new knowledge base'
- return {
- accept: { text: `Create "${name}"`, icon: PlusCircle },
- reject: { text: 'Skip', icon: XCircle },
- }
- }
-
- return undefined
- }
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Accessing knowledge base', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Accessing knowledge base', icon: Loader2 },
- [ClientToolCallState.executing]: { text: 'Accessing knowledge base', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Accessed knowledge base', icon: Database },
- [ClientToolCallState.error]: { text: 'Failed to access knowledge base', icon: XCircle },
- [ClientToolCallState.aborted]: { text: 'Aborted knowledge base access', icon: MinusCircle },
- [ClientToolCallState.rejected]: { text: 'Skipped knowledge base access', icon: MinusCircle },
- },
- getDynamicText: (params: Record, state: ClientToolCallState) => {
- const operation = params?.operation as string | undefined
- const name = params?.args?.name as string | undefined
-
- const opVerbs: Record = {
- create: {
- active: 'Creating knowledge base',
- past: 'Created knowledge base',
- pending: name ? `Create knowledge base "${name}"?` : 'Create knowledge base?',
- },
- list: { active: 'Listing knowledge bases', past: 'Listed knowledge bases' },
- get: { active: 'Getting knowledge base', past: 'Retrieved knowledge base' },
- query: { active: 'Querying knowledge base', past: 'Queried knowledge base' },
- }
- const defaultVerb: { active: string; past: string; pending?: string } = {
- active: 'Accessing knowledge base',
- past: 'Accessed knowledge base',
- }
- const verb = operation ? opVerbs[operation] || defaultVerb : defaultVerb
-
- if (state === ClientToolCallState.success) {
- return verb.past
- }
- if (state === ClientToolCallState.pending && verb.pending) {
- return verb.pending
- }
- if (
- state === ClientToolCallState.generating ||
- state === ClientToolCallState.pending ||
- state === ClientToolCallState.executing
- ) {
- return verb.active
- }
- return undefined
- },
- }
-
- async handleReject(): Promise {
- await super.handleReject()
- this.setState(ClientToolCallState.rejected)
- }
-
- async handleAccept(args?: KnowledgeBaseArgs): Promise {
- await this.execute(args)
- }
-
- async execute(args?: KnowledgeBaseArgs): Promise {
- const logger = createLogger('KnowledgeBaseClientTool')
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.getExecutionContext()
- const payload: KnowledgeBaseArgs = { ...(args || { operation: 'list' }) }
- if (
- executionContext?.workspaceId &&
- (payload.operation === 'create' || payload.operation === 'list') &&
- !payload.args?.workspaceId
- ) {
- payload.args = { ...(payload.args ?? {}), workspaceId: executionContext.workspaceId }
- }
- const result = await executeCopilotServerTool({
- toolName: 'knowledge_base',
- payload,
- context: executionContext?.workspaceId
- ? { workspaceId: executionContext.workspaceId }
- : undefined,
- signal: this.getAbortSignal(),
- })
- await this.markToolComplete(200, 'Knowledge base operation completed', result)
- this.setState(ClientToolCallState.success)
- } catch (e: any) {
- logger.error('execute failed', { message: e?.message })
- this.setState(ClientToolCallState.error)
- await this.markToolComplete(
- getCopilotServerToolErrorStatus(e) ?? 500,
- e?.message || 'Failed to access knowledge base'
- )
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts
deleted file mode 100644
index 52cad8440..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { Activity, Check, Loader2, X, XCircle } from 'lucide-react'
-import {
- MONITOR_DOCUMENT_FORMAT,
- parseMonitorDocument,
- readMonitorDocumentName,
- serializeMonitorDocument,
-} from '@/lib/copilot/monitor/monitor-documents'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import { resolveWorkspaceIdFromExecutionContext } from '@/lib/copilot/tools/client/entities/entity-document-tool-utils'
-import {
- type EditMonitorArgs,
- type MonitorRecord,
- readStoredToolArgs,
- toMonitorDocumentFields,
-} from '@/lib/copilot/tools/client/monitor/monitor-tool-utils'
-
-export class EditMonitorClientTool extends BaseClientTool {
- static readonly id = 'edit_monitor'
- private currentArgs?: EditMonitorArgs
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Editing monitor document', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Edit monitor document?', icon: Activity },
- [ClientToolCallState.executing]: { text: 'Editing monitor document', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Edited monitor document', icon: Check },
- [ClientToolCallState.error]: { text: 'Failed to edit monitor document', icon: X },
- [ClientToolCallState.aborted]: { text: 'Aborted editing monitor document', icon: XCircle },
- [ClientToolCallState.rejected]: { text: 'Skipped editing monitor document', icon: XCircle },
- },
- interrupt: {
- accept: { text: 'Allow', icon: Check },
- reject: { text: 'Skip', icon: XCircle },
- },
- }
-
- constructor(toolCallId: string) {
- super(toolCallId, EditMonitorClientTool.id, EditMonitorClientTool.metadata)
- }
-
- getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
- const args = this.currentArgs || readStoredToolArgs(this.toolCallId)
- return args?.monitorDocument ? this.metadata.interrupt : undefined
- }
-
- async execute(args?: EditMonitorArgs): Promise {
- this.currentArgs = args
- }
-
- async handleAccept(args?: EditMonitorArgs): Promise {
- try {
- this.setState(ClientToolCallState.executing)
-
- const resolvedArgs =
- args || this.currentArgs || readStoredToolArgs(this.toolCallId)
-
- if (!resolvedArgs?.monitorId?.trim()) {
- throw new Error('monitorId is required')
- }
- if (!resolvedArgs.monitorDocument?.trim()) {
- throw new Error('monitorDocument is required')
- }
- if (resolvedArgs.documentFormat && resolvedArgs.documentFormat !== MONITOR_DOCUMENT_FORMAT) {
- throw new Error(
- `Unsupported documentFormat "${resolvedArgs.documentFormat}". Expected ${MONITOR_DOCUMENT_FORMAT}`
- )
- }
-
- const executionContext = this.requireExecutionContext()
- const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext)
- const nextFields = parseMonitorDocument(resolvedArgs.monitorDocument)
-
- const response = await fetch(`/api/monitors/${encodeURIComponent(resolvedArgs.monitorId)}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- source: nextFields.source,
- workspaceId,
- workflowId: nextFields.workflowId,
- blockId: nextFields.blockId,
- providerId: nextFields.providerId,
- ...(nextFields.source === 'portfolio'
- ? {
- serviceId: nextFields.serviceId,
- credentialId: nextFields.credentialId,
- accountId: nextFields.accountId,
- condition: nextFields.condition,
- fireMode: nextFields.fireMode,
- cooldownSeconds: nextFields.cooldownSeconds,
- pollIntervalSeconds: nextFields.pollIntervalSeconds,
- }
- : {
- interval: nextFields.interval,
- indicatorId: nextFields.indicatorId,
- listing: nextFields.listing,
- ...(nextFields.providerParams ? { providerParams: nextFields.providerParams } : {}),
- ...(nextFields.auth ? { auth: nextFields.auth } : {}),
- }),
- isActive: nextFields.isActive,
- }),
- })
- const payload = await response.json().catch(() => ({}))
-
- if (!response.ok) {
- throw new Error(payload?.error || `Failed to update monitor: ${response.status}`)
- }
-
- const updatedMonitor =
- payload?.data && typeof payload.data === 'object' ? (payload.data as MonitorRecord) : null
-
- if (!updatedMonitor) {
- throw new Error('Invalid updated monitor response')
- }
-
- const persistedFields = toMonitorDocumentFields(updatedMonitor)
- await this.markToolComplete(200, 'Monitor updated', {
- success: true,
- surfaceKind: 'monitor',
- monitorId: updatedMonitor.monitorId,
- monitorName: readMonitorDocumentName(persistedFields),
- documentFormat: MONITOR_DOCUMENT_FORMAT,
- monitorDocument: serializeMonitorDocument(persistedFields),
- })
- this.setState(ClientToolCallState.success)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message)
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts
deleted file mode 100644
index 1552854cf..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Activity, Loader2, X, XCircle } from 'lucide-react'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import { resolveWorkspaceIdFromExecutionContext } from '@/lib/copilot/tools/client/entities/entity-document-tool-utils'
-import {
- buildMonitorName,
- type ListMonitorArgs,
- type MonitorRecord,
-} from '@/lib/copilot/tools/client/monitor/monitor-tool-utils'
-import { resolveOptionalCopilotEntityId } from '@/lib/copilot/tools/entity-target'
-
-export class ListMonitorsClientTool extends BaseClientTool {
- static readonly id = 'list_monitors'
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Listing monitors', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'List monitors', icon: Activity },
- [ClientToolCallState.executing]: { text: 'Listing monitors', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Listed monitors', icon: Activity },
- [ClientToolCallState.error]: { text: 'Failed to list monitors', icon: X },
- [ClientToolCallState.aborted]: { text: 'Aborted listing monitors', icon: XCircle },
- [ClientToolCallState.rejected]: { text: 'Skipped listing monitors', icon: XCircle },
- },
- }
-
- constructor(toolCallId: string) {
- super(toolCallId, ListMonitorsClientTool.id, ListMonitorsClientTool.metadata)
- }
-
- async execute(args?: ListMonitorArgs): Promise {
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
- const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext)
- const searchParams = new URLSearchParams({ workspaceId })
-
- const entityId = resolveOptionalCopilotEntityId(args)
- if (entityId) {
- searchParams.set('workflowId', entityId)
- }
- if (args?.blockId) {
- searchParams.set('blockId', args.blockId)
- }
-
- const response = await fetch(`/api/monitors?${searchParams.toString()}`)
- const payload = await response.json().catch(() => ({}))
-
- if (!response.ok) {
- throw new Error(payload?.error || `Failed to fetch monitors: ${response.status}`)
- }
-
- const monitors = Array.isArray(payload?.data) ? (payload.data as MonitorRecord[]) : []
- const monitorsList = monitors.map((monitor) => ({
- monitorId: monitor.monitorId,
- monitorName: buildMonitorName(monitor),
- monitorDescription: `Workflow ${monitor.workflowId}, block ${monitor.blockId}`,
- workflowId: monitor.workflowId,
- blockId: monitor.blockId,
- source: monitor.source,
- providerId: monitor.providerConfig.monitor.providerId,
- indicatorId: monitor.providerConfig.monitor.indicatorId,
- interval: monitor.providerConfig.monitor.interval,
- isActive: monitor.isActive,
- createdAt: monitor.createdAt,
- updatedAt: monitor.updatedAt,
- }))
-
- await this.markToolComplete(200, 'Listed monitors', {
- surfaceKind: 'monitor',
- monitors: monitorsList,
- count: monitorsList.length,
- })
- this.setState(ClientToolCallState.success)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message)
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tools.test.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tools.test.ts
deleted file mode 100644
index 1c84dfcff..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tools.test.ts
+++ /dev/null
@@ -1,361 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { ToolArgSchemas, ToolResultSchemas } from '@/lib/copilot/registry'
-import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
-import { EditMonitorClientTool } from '@/lib/copilot/tools/client/monitor/edit-monitor'
-import { ListMonitorsClientTool } from '@/lib/copilot/tools/client/monitor/list-monitors'
-import { ReadMonitorClientTool } from '@/lib/copilot/tools/client/monitor/read-monitor'
-
-const mockRegistryState = {
- workflows: {} as Record,
-}
-
-const mockCopilotState = {
- toolCallsById: {} as Record }>,
-}
-
-vi.mock('@/stores/workflows/registry/store', () => ({
- useWorkflowRegistry: {
- getState: () => mockRegistryState,
- },
-}))
-
-vi.mock('@/stores/copilot/store-access', () => ({
- getCopilotStoreForToolCall: () => ({
- getState: () => mockCopilotState,
- }),
-}))
-
-describe('monitor tools', () => {
- beforeEach(() => {
- vi.restoreAllMocks()
- vi.unstubAllGlobals?.()
- mockRegistryState.workflows = {}
- mockCopilotState.toolCallsById = {}
- })
-
- it('list_monitors returns workspace monitor entries', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/monitors?workspaceId=ws-1' && method === 'GET') {
- return {
- ok: true,
- status: 200,
- json: async () => ({
- data: [
- {
- monitorId: 'monitor-1',
- source: 'indicator',
- workflowId: 'wf-1',
- blockId: 'trigger-1',
- isActive: true,
- providerConfig: {
- triggerId: 'indicator_trigger',
- version: 1,
- monitor: {
- providerId: 'alpaca',
- interval: '1m',
- indicatorId: 'rsi',
- listing: {
- listing_type: 'default',
- listing_id: 'AAPL',
- base_id: '',
- quote_id: '',
- name: 'Apple Inc.',
- },
- },
- },
- createdAt: '2026-04-11T00:00:00.000Z',
- updatedAt: '2026-04-11T01:00:00.000Z',
- },
- ],
- }),
- }
- }
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const tool = new ListMonitorsClientTool('list-monitors')
- tool.setExecutionContext({
- toolCallId: 'list-monitors',
- toolName: 'list_monitors',
- channelId: 'pair-blue',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute()
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
- expect(fetchMock).toHaveBeenCalledWith('/api/monitors?workspaceId=ws-1')
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const body = JSON.parse(String(markCompleteCall?.[1]?.body))
- expect(body.data).toMatchObject({
- surfaceKind: 'monitor',
- count: 1,
- })
- expect(body.data.monitors[0]).toMatchObject({
- monitorId: 'monitor-1',
- monitorName: 'rsi on Apple Inc. (1m)',
- workflowId: 'wf-1',
- blockId: 'trigger-1',
- providerId: 'alpaca',
- indicatorId: 'rsi',
- interval: '1m',
- isActive: true,
- })
- })
-
- it('read_monitor returns a monitor document', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/monitors/monitor-1' && method === 'GET') {
- return {
- ok: true,
- status: 200,
- json: async () => ({
- data: {
- monitorId: 'monitor-1',
- source: 'indicator',
- workflowId: 'wf-1',
- blockId: 'trigger-1',
- isActive: true,
- providerConfig: {
- triggerId: 'indicator_trigger',
- version: 1,
- monitor: {
- providerId: 'alpaca',
- interval: '5m',
- indicatorId: 'rsi',
- listing: {
- listing_type: 'default',
- listing_id: 'AAPL',
- base_id: '',
- quote_id: '',
- },
- auth: {
- hasEncryptedSecrets: true,
- encryptedSecretFieldIds: ['apiKey'],
- },
- providerParams: {
- exchange: 'NASDAQ',
- },
- },
- },
- createdAt: '2026-04-11T00:00:00.000Z',
- updatedAt: '2026-04-11T01:00:00.000Z',
- },
- }),
- }
- }
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const tool = new ReadMonitorClientTool('read-monitor')
- tool.setExecutionContext({
- toolCallId: 'read-monitor',
- toolName: 'read_monitor',
- channelId: 'pair-green',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- await tool.execute({ monitorId: 'monitor-1' })
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const body = JSON.parse(String(markCompleteCall?.[1]?.body))
- expect(body.data).toMatchObject({
- surfaceKind: 'monitor',
- monitorId: 'monitor-1',
- documentFormat: 'tg-monitor-document-v1',
- })
- expect(body.data.monitorDocument).toContain('"providerId": "alpaca"')
- expect(body.data.monitorDocument).toContain('"interval": "5m"')
- expect(body.data.monitorDocument).not.toContain('"secrets"')
- })
-
- it('edit_monitor patches the monitor after accept', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/monitors/monitor-1' && method === 'PATCH') {
- const payload = JSON.parse(String(init?.body))
- expect(payload).toMatchObject({
- source: 'indicator',
- workspaceId: 'ws-1',
- workflowId: 'wf-1',
- blockId: 'trigger-1',
- providerId: 'alpaca',
- interval: '15m',
- indicatorId: 'rsi',
- isActive: false,
- })
-
- return {
- ok: true,
- status: 200,
- json: async () => ({
- data: {
- monitorId: 'monitor-1',
- source: 'indicator',
- workflowId: 'wf-1',
- blockId: 'trigger-1',
- isActive: false,
- providerConfig: {
- triggerId: 'indicator_trigger',
- version: 1,
- monitor: {
- providerId: 'alpaca',
- interval: '15m',
- indicatorId: 'rsi',
- listing: {
- listing_type: 'default',
- listing_id: 'AAPL',
- base_id: '',
- quote_id: '',
- },
- providerParams: {
- exchange: 'NASDAQ',
- },
- },
- },
- createdAt: '2026-04-11T00:00:00.000Z',
- updatedAt: '2026-04-11T02:00:00.000Z',
- },
- }),
- }
- }
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const tool = new EditMonitorClientTool('edit-monitor')
- tool.setExecutionContext({
- toolCallId: 'edit-monitor',
- toolName: 'edit_monitor',
- channelId: 'pair-red',
- workspaceId: 'ws-1',
- log: vi.fn(),
- })
-
- const monitorDocument = JSON.stringify(
- {
- source: 'indicator',
- workflowId: 'wf-1',
- blockId: 'trigger-1',
- providerId: 'alpaca',
- interval: '15m',
- indicatorId: 'rsi',
- listing: {
- listing_type: 'default',
- listing_id: 'AAPL',
- base_id: '',
- quote_id: '',
- },
- isActive: false,
- providerParams: {
- exchange: 'NASDAQ',
- },
- },
- null,
- 2
- )
-
- await tool.execute({
- monitorId: 'monitor-1',
- monitorDocument,
- documentFormat: 'tg-monitor-document-v1',
- })
- await tool.handleAccept()
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const body = JSON.parse(String(markCompleteCall?.[1]?.body))
- expect(body.data).toMatchObject({
- success: true,
- surfaceKind: 'monitor',
- monitorId: 'monitor-1',
- documentFormat: 'tg-monitor-document-v1',
- })
- expect(body.data.monitorDocument).toContain('"interval": "15m"')
- expect(body.data.monitorDocument).toContain('"isActive": false')
- })
-
- it('exposes monitor tool schemas', () => {
- expect(
- ToolArgSchemas.list_monitors.parse({
- entityId: 'wf-1',
- })
- ).toMatchObject({ entityId: 'wf-1' })
-
- expect(
- ToolArgSchemas.edit_monitor.parse({
- monitorId: 'monitor-1',
- monitorDocument:
- '{"source":"indicator","workflowId":"wf-1","blockId":"trigger-1","providerId":"alpaca","interval":"1m","indicatorId":"rsi","listing":{"listing_type":"default","listing_id":"AAPL","base_id":"","quote_id":""},"isActive":true}',
- })
- ).toMatchObject({
- monitorId: 'monitor-1',
- })
-
- expect(
- ToolResultSchemas.read_monitor.parse({
- surfaceKind: 'monitor',
- monitorId: 'monitor-1',
- monitorName: 'rsi on AAPL (1m)',
- documentFormat: 'tg-monitor-document-v1',
- monitorDocument:
- '{"source":"indicator","workflowId":"wf-1","blockId":"trigger-1","providerId":"alpaca","interval":"1m","indicatorId":"rsi","listing":{"listing_type":"default","listing_id":"AAPL","base_id":"","quote_id":""},"isActive":true}',
- })
- ).toMatchObject({
- surfaceKind: 'monitor',
- monitorId: 'monitor-1',
- })
- })
-})
diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/read-monitor.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/read-monitor.ts
deleted file mode 100644
index 6345b12dc..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/monitor/read-monitor.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { FileJson, Loader2, X, XCircle } from 'lucide-react'
-import {
- MONITOR_DOCUMENT_FORMAT,
- readMonitorDocumentName,
- serializeMonitorDocument,
-} from '@/lib/copilot/monitor/monitor-documents'
-import { CopilotTool } from '@/lib/copilot/registry'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import {
- fetchMonitorById,
- type ReadMonitorArgs,
- toMonitorDocumentFields,
-} from '@/lib/copilot/tools/client/monitor/monitor-tool-utils'
-
-export class ReadMonitorClientTool extends BaseClientTool {
- static readonly id = CopilotTool.read_monitor
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Reading monitor document', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Read monitor document', icon: FileJson },
- [ClientToolCallState.executing]: { text: 'Reading monitor document', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Read monitor document', icon: FileJson },
- [ClientToolCallState.error]: { text: 'Failed to read monitor document', icon: X },
- [ClientToolCallState.aborted]: { text: 'Aborted reading monitor document', icon: XCircle },
- [ClientToolCallState.rejected]: { text: 'Skipped reading monitor document', icon: XCircle },
- },
- }
-
- constructor(toolCallId: string) {
- super(toolCallId, ReadMonitorClientTool.id, ReadMonitorClientTool.metadata)
- }
-
- async execute(args?: ReadMonitorArgs): Promise {
- try {
- this.setState(ClientToolCallState.executing)
-
- if (!args?.monitorId?.trim()) {
- throw new Error('monitorId is required')
- }
-
- const monitor = await fetchMonitorById(args.monitorId)
- const fields = toMonitorDocumentFields(monitor)
-
- await this.markToolComplete(200, 'Monitor document ready', {
- surfaceKind: 'monitor',
- monitorId: monitor.monitorId,
- monitorName: readMonitorDocumentName(fields),
- documentFormat: MONITOR_DOCUMENT_FORMAT,
- monitorDocument: serializeMonitorDocument(fields),
- })
- this.setState(ClientToolCallState.success)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message)
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts b/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts
index 9c2b763f1..6c4b785d6 100644
--- a/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts
+++ b/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts
@@ -1,20 +1,34 @@
+import type { LucideIcon } from 'lucide-react'
import {
+ Activity,
+ BarChart3,
Blocks,
BookOpen,
BookOpenText,
Bot,
+ Check,
+ Code2,
+ Database,
+ FileJson,
FileSearch,
FileText,
FolderOpen,
+ GitBranch,
Globe,
Globe2,
+ Grid2x2,
Key,
KeyRound,
ListFilter,
+ ListChecks,
Loader2,
MinusCircle,
+ Rocket,
+ Server,
Settings2,
+ Tag,
TerminalSquare,
+ Workflow,
X,
XCircle,
} from 'lucide-react'
@@ -24,6 +38,72 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
+function createEntityListMetadata(pluralLabel: string, icon: LucideIcon): BaseClientToolMetadata {
+ return {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: `Listing ${pluralLabel}`, icon: Loader2 },
+ [ClientToolCallState.pending]: { text: `Listing ${pluralLabel}`, icon: Loader2 },
+ [ClientToolCallState.executing]: { text: `Listing ${pluralLabel}`, icon: Loader2 },
+ [ClientToolCallState.success]: { text: `Listed ${pluralLabel}`, icon },
+ [ClientToolCallState.error]: { text: `Failed to list ${pluralLabel}`, icon: XCircle },
+ [ClientToolCallState.aborted]: { text: `Aborted listing ${pluralLabel}`, icon: XCircle },
+ [ClientToolCallState.rejected]: { text: `Skipped listing ${pluralLabel}`, icon: MinusCircle },
+ },
+ }
+}
+
+function createEntityReadMetadata(label: string): BaseClientToolMetadata {
+ return {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: `Reading ${label} document`, icon: Loader2 },
+ [ClientToolCallState.pending]: { text: `Reading ${label} document`, icon: Loader2 },
+ [ClientToolCallState.executing]: { text: `Reading ${label} document`, icon: Loader2 },
+ [ClientToolCallState.success]: { text: `Read ${label} document`, icon: FileJson },
+ [ClientToolCallState.error]: { text: `Failed to read ${label} document`, icon: XCircle },
+ [ClientToolCallState.aborted]: { text: `Aborted reading ${label} document`, icon: XCircle },
+ [ClientToolCallState.rejected]: {
+ text: `Skipped reading ${label} document`,
+ icon: MinusCircle,
+ },
+ },
+ }
+}
+
+function createEntityMutationMetadata(
+ label: string,
+ action: 'create' | 'edit' | 'rename',
+ icon: LucideIcon
+): BaseClientToolMetadata {
+ const gerund = action === 'create' ? 'Creating' : action === 'rename' ? 'Renaming' : 'Editing'
+ const gerundLower = gerund.toLowerCase()
+ const past = action === 'create' ? 'Created' : action === 'rename' ? 'Renamed' : 'Edited'
+
+ return {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: `${gerund} ${label} document`, icon: Loader2 },
+ [ClientToolCallState.pending]: { text: `${gerund} ${label} document`, icon: Loader2 },
+ [ClientToolCallState.executing]: { text: `${gerund} ${label} document`, icon: Loader2 },
+ [ClientToolCallState.success]: { text: `${past} ${label} document`, icon },
+ [ClientToolCallState.error]: {
+ text: `Failed to ${action} ${label} document`,
+ icon: XCircle,
+ },
+ [ClientToolCallState.aborted]: {
+ text: `Aborted ${gerundLower} ${label} document`,
+ icon: XCircle,
+ },
+ [ClientToolCallState.rejected]: {
+ text: `Skipped ${gerundLower} ${label} document`,
+ icon: MinusCircle,
+ },
+ },
+ interrupt: {
+ accept: { text: 'Apply changes', icon: Check },
+ reject: { text: 'Skip', icon: MinusCircle },
+ },
+ }
+}
+
export const SERVER_TOOL_METADATA = {
[CopilotTool.read_workflow_logs]: {
displayNames: {
@@ -210,6 +290,252 @@ export const SERVER_TOOL_METADATA = {
},
},
},
+ list_knowledge_bases: createEntityListMetadata('knowledge bases', Database),
+ read_knowledge_base: createEntityReadMetadata('knowledge base'),
+ create_knowledge_base: createEntityMutationMetadata('knowledge base', 'create', Database),
+ edit_knowledge_base: createEntityMutationMetadata('knowledge base', 'edit', Database),
+ rename_knowledge_base: createEntityMutationMetadata('knowledge base', 'rename', Database),
+ query_knowledge_base: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Querying knowledge base', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Querying knowledge base', icon: Loader2 },
+ [ClientToolCallState.executing]: { text: 'Querying knowledge base', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Queried knowledge base', icon: Database },
+ [ClientToolCallState.error]: { text: 'Failed to query knowledge base', icon: XCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted querying knowledge base', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped querying knowledge base', icon: MinusCircle },
+ },
+ },
+ [CopilotTool.list_workflows]: createEntityListMetadata('workflows', ListChecks),
+ [CopilotTool.read_workflow]: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Analyzing your workflow', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Analyzing your workflow', icon: Workflow },
+ [ClientToolCallState.executing]: { text: 'Analyzing your workflow', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Analyzed your workflow', icon: Workflow },
+ [ClientToolCallState.error]: { text: 'Failed to analyze your workflow', icon: XCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted analyzing your workflow', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped analyzing your workflow', icon: MinusCircle },
+ },
+ },
+ [CopilotTool.edit_workflow_variable]: {
+ displayNames: {
+ [ClientToolCallState.generating]: {
+ text: 'Preparing workflow variable changes',
+ icon: Loader2,
+ },
+ [ClientToolCallState.pending]: { text: 'Set workflow variables?', icon: Settings2 },
+ [ClientToolCallState.executing]: { text: 'Editing workflow variables', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Workflow variables updated', icon: Settings2 },
+ [ClientToolCallState.error]: { text: 'Failed to edit workflow variables', icon: XCircle },
+ [ClientToolCallState.review]: { text: 'Review workflow variable changes', icon: Settings2 },
+ [ClientToolCallState.aborted]: { text: 'Aborted editing workflow variables', icon: XCircle },
+ [ClientToolCallState.rejected]: {
+ text: 'Rejected workflow variable changes',
+ icon: MinusCircle,
+ },
+ },
+ interrupt: {
+ accept: { text: 'Accept changes', icon: Check },
+ reject: { text: 'Reject changes', icon: MinusCircle },
+ },
+ },
+ create_workflow: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Creating workflow', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Create workflow?', icon: Grid2x2 },
+ [ClientToolCallState.executing]: { text: 'Creating workflow', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Created workflow', icon: Check },
+ [ClientToolCallState.error]: { text: 'Failed to create workflow', icon: XCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted creating workflow', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped creating workflow', icon: MinusCircle },
+ },
+ interrupt: {
+ accept: { text: 'Allow', icon: Check },
+ reject: { text: 'Skip', icon: MinusCircle },
+ },
+ },
+ edit_workflow: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
+ [ClientToolCallState.executing]: { text: 'Editing your workflow', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Edited your workflow', icon: Grid2x2 },
+ [ClientToolCallState.error]: { text: 'Failed to edit your workflow', icon: XCircle },
+ [ClientToolCallState.review]: { text: 'Review your workflow changes', icon: Grid2x2 },
+ [ClientToolCallState.rejected]: { text: 'Rejected workflow changes', icon: MinusCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: XCircle },
+ },
+ interrupt: {
+ accept: { text: 'Accept changes', icon: Check },
+ reject: { text: 'Reject changes', icon: MinusCircle },
+ },
+ },
+ edit_workflow_block: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Editing your workflow block', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Editing your workflow block', icon: Loader2 },
+ [ClientToolCallState.executing]: { text: 'Editing your workflow block', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Edited your workflow block', icon: Grid2x2 },
+ [ClientToolCallState.error]: { text: 'Failed to edit workflow block', icon: XCircle },
+ [ClientToolCallState.review]: { text: 'Review your workflow block changes', icon: Grid2x2 },
+ [ClientToolCallState.rejected]: { text: 'Rejected workflow block changes', icon: MinusCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted editing workflow block', icon: XCircle },
+ },
+ interrupt: {
+ accept: { text: 'Accept changes', icon: Check },
+ reject: { text: 'Reject changes', icon: MinusCircle },
+ },
+ },
+ rename_workflow: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Renaming workflow', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Rename workflow?', icon: Grid2x2 },
+ [ClientToolCallState.executing]: { text: 'Renaming workflow', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Renamed workflow', icon: Check },
+ [ClientToolCallState.error]: { text: 'Failed to rename workflow', icon: XCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted renaming workflow', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped renaming workflow', icon: MinusCircle },
+ },
+ interrupt: {
+ accept: { text: 'Allow', icon: Check },
+ reject: { text: 'Skip', icon: MinusCircle },
+ },
+ },
+ check_deployment_status: {
+ displayNames: {
+ [ClientToolCallState.generating]: {
+ text: 'Checking deployment status',
+ icon: Loader2,
+ },
+ [ClientToolCallState.pending]: { text: 'Checking deployment status', icon: Loader2 },
+ [ClientToolCallState.executing]: { text: 'Checking deployment status', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Checked deployment status', icon: Rocket },
+ [ClientToolCallState.error]: { text: 'Failed to check deployment status', icon: XCircle },
+ [ClientToolCallState.aborted]: {
+ text: 'Aborted checking deployment status',
+ icon: XCircle,
+ },
+ [ClientToolCallState.rejected]: {
+ text: 'Skipped checking deployment status',
+ icon: MinusCircle,
+ },
+ },
+ },
+ list_monitors: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Listing monitors', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Listing monitors', icon: Activity },
+ [ClientToolCallState.executing]: { text: 'Listing monitors', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Listed monitors', icon: Activity },
+ [ClientToolCallState.error]: { text: 'Failed to list monitors', icon: XCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted listing monitors', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped listing monitors', icon: MinusCircle },
+ },
+ },
+ [CopilotTool.read_monitor]: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Reading monitor document', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Reading monitor document', icon: FileJson },
+ [ClientToolCallState.executing]: { text: 'Reading monitor document', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Read monitor document', icon: FileJson },
+ [ClientToolCallState.error]: { text: 'Failed to read monitor document', icon: XCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted reading monitor document', icon: XCircle },
+ [ClientToolCallState.rejected]: {
+ text: 'Skipped reading monitor document',
+ icon: MinusCircle,
+ },
+ },
+ },
+ edit_monitor: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Editing monitor document', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Edit monitor document?', icon: Activity },
+ [ClientToolCallState.executing]: { text: 'Editing monitor document', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Edited monitor document', icon: Check },
+ [ClientToolCallState.error]: { text: 'Failed to edit monitor document', icon: XCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted editing monitor document', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped editing monitor document', icon: XCircle },
+ },
+ interrupt: {
+ accept: { text: 'Allow', icon: Check },
+ reject: { text: 'Skip', icon: XCircle },
+ },
+ },
+ [CopilotTool.read_block_outputs]: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Getting block outputs', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Getting block outputs', icon: Tag },
+ [ClientToolCallState.executing]: { text: 'Getting block outputs', icon: Loader2 },
+ [ClientToolCallState.aborted]: { text: 'Aborted getting outputs', icon: XCircle },
+ [ClientToolCallState.success]: { text: 'Retrieved block outputs', icon: Tag },
+ [ClientToolCallState.error]: { text: 'Failed to get outputs', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped getting outputs', icon: MinusCircle },
+ },
+ getDynamicText: (params, state) => {
+ const blockIds = params?.blockIds
+ if (!Array.isArray(blockIds) || blockIds.length === 0) return undefined
+ const count = blockIds.length
+ switch (state) {
+ case ClientToolCallState.success:
+ return `Retrieved outputs for ${count} block${count > 1 ? 's' : ''}`
+ case ClientToolCallState.executing:
+ case ClientToolCallState.generating:
+ case ClientToolCallState.pending:
+ return `Getting outputs for ${count} block${count > 1 ? 's' : ''}`
+ case ClientToolCallState.error:
+ return `Failed to get outputs for ${count} block${count > 1 ? 's' : ''}`
+ }
+ return undefined
+ },
+ },
+ [CopilotTool.read_block_upstream_references]: {
+ displayNames: {
+ [ClientToolCallState.generating]: { text: 'Getting upstream references', icon: Loader2 },
+ [ClientToolCallState.pending]: { text: 'Getting upstream references', icon: GitBranch },
+ [ClientToolCallState.executing]: { text: 'Getting upstream references', icon: Loader2 },
+ [ClientToolCallState.aborted]: { text: 'Aborted getting references', icon: XCircle },
+ [ClientToolCallState.success]: { text: 'Retrieved upstream references', icon: GitBranch },
+ [ClientToolCallState.error]: { text: 'Failed to get references', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped getting references', icon: MinusCircle },
+ },
+ getDynamicText: (params, state) => {
+ const blockIds = params?.blockIds
+ if (!Array.isArray(blockIds) || blockIds.length === 0) return undefined
+ const count = blockIds.length
+ switch (state) {
+ case ClientToolCallState.success:
+ return `Retrieved references for ${count} block${count > 1 ? 's' : ''}`
+ case ClientToolCallState.executing:
+ case ClientToolCallState.generating:
+ case ClientToolCallState.pending:
+ return `Getting references for ${count} block${count > 1 ? 's' : ''}`
+ case ClientToolCallState.error:
+ return `Failed to get references for ${count} block${count > 1 ? 's' : ''}`
+ }
+ return undefined
+ },
+ },
+ list_custom_tools: createEntityListMetadata('custom tools', Code2),
+ [CopilotTool.read_custom_tool]: createEntityReadMetadata('custom tool'),
+ create_custom_tool: createEntityMutationMetadata('custom tool', 'create', Code2),
+ edit_custom_tool: createEntityMutationMetadata('custom tool', 'edit', Code2),
+ rename_custom_tool: createEntityMutationMetadata('custom tool', 'rename', Code2),
+ [CopilotTool.list_indicators]: createEntityListMetadata('indicators', BarChart3),
+ [CopilotTool.read_indicator]: createEntityReadMetadata('indicator'),
+ create_indicator: createEntityMutationMetadata('indicator', 'create', BarChart3),
+ edit_indicator: createEntityMutationMetadata('indicator', 'edit', BarChart3),
+ rename_indicator: createEntityMutationMetadata('indicator', 'rename', BarChart3),
+ list_skills: createEntityListMetadata('skills', BookOpen),
+ [CopilotTool.read_skill]: createEntityReadMetadata('skill'),
+ create_skill: createEntityMutationMetadata('skill', 'create', BookOpen),
+ edit_skill: createEntityMutationMetadata('skill', 'edit', BookOpen),
+ rename_skill: createEntityMutationMetadata('skill', 'rename', BookOpen),
+ list_mcp_servers: createEntityListMetadata('MCP servers', Server),
+ [CopilotTool.read_mcp_server]: createEntityReadMetadata('MCP server'),
+ create_mcp_server: createEntityMutationMetadata('MCP server', 'create', Server),
+ edit_mcp_server: createEntityMutationMetadata('MCP server', 'edit', Server),
+ rename_mcp_server: createEntityMutationMetadata('MCP server', 'rename', Server),
list_gdrive_files: {
displayNames: {
[ClientToolCallState.generating]: { text: 'Listing GDrive files', icon: Loader2 },
diff --git a/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts b/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts
index 7f9610d3c..c7984bd66 100644
--- a/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts
+++ b/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts
@@ -76,6 +76,8 @@ export async function executeCopilotServerTool(input: {
}
signal?: AbortSignal
}): Promise {
+ const context =
+ input.context && Object.keys(input.context).length > 0 ? input.context : undefined
const response = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -83,7 +85,52 @@ export async function executeCopilotServerTool(input: {
body: JSON.stringify({
toolName: input.toolName,
payload: input.payload ?? {},
- ...(input.context ? { context: input.context } : {}),
+ ...(context ? { context } : {}),
+ }),
+ })
+
+ if (!response.ok) {
+ throw await buildCopilotServerToolError(response)
+ }
+
+ const json = await response.json()
+ const parsed = ExecuteResponseSuccessSchema.parse(json)
+ return parsed.result as TResult
+}
+
+export function isCopilotServerToolReviewResult(result: unknown): result is {
+ requiresReview: true
+ reviewToken: string
+} {
+ return (
+ !!result &&
+ typeof result === 'object' &&
+ (result as { requiresReview?: unknown }).requiresReview === true &&
+ typeof (result as { reviewToken?: unknown }).reviewToken === 'string'
+ )
+}
+
+export async function acceptCopilotServerToolReview(input: {
+ toolName: string
+ reviewToken: string
+ context?: {
+ contextEntityKind?: ReviewEntityKind
+ contextEntityId?: string
+ workspaceId?: string
+ }
+ signal?: AbortSignal
+}): Promise {
+ const context =
+ input.context && Object.keys(input.context).length > 0 ? input.context : undefined
+ const response = await fetch('/api/copilot/execute-copilot-server-tool', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ signal: input.signal,
+ body: JSON.stringify({
+ toolName: input.toolName,
+ reviewAction: 'accept',
+ reviewToken: input.reviewToken,
+ ...(context ? { context } : {}),
}),
})
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-tools.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-tools.test.ts
deleted file mode 100644
index da3e6aaba..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-tools.test.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { ToolResultSchemas } from '@/lib/copilot/registry'
-import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
-import { ReadBlockOutputsClientTool } from '@/lib/copilot/tools/client/workflow/read-block-outputs'
-import { ReadBlockUpstreamReferencesClientTool } from '@/lib/copilot/tools/client/workflow/read-block-upstream-references'
-
-const mockGetReadableWorkflowState = vi.fn()
-const originalFetch = globalThis.fetch
-const mockCopilotState = {
- toolCallsById: {} as Record; state?: string }>,
-}
-
-vi.mock('@/stores/copilot/store-access', () => ({
- getCopilotStoreForToolCall: () => ({
- getState: () => mockCopilotState,
- }),
-}))
-
-vi.mock('@/lib/copilot/tools/client/workflow/workflow-review-tool-utils', () => ({
- getReadableWorkflowState: (...args: unknown[]) => mockGetReadableWorkflowState(...args),
-}))
-
-vi.mock('@/blocks', () => ({
- getBlock: (blockType: string) => {
- const registry: Record = {
- agent: {
- outputs: {
- content: { type: 'string', description: 'Agent content' },
- meta: {
- sentiment: { type: 'string', description: 'Sentiment label' },
- },
- },
- },
- function: {
- outputs: {
- result: { type: 'json', description: 'Return value' },
- stdout: { type: 'string', description: 'Console output' },
- },
- },
- loop: {
- outputs: {},
- },
- }
-
- return registry[blockType]
- },
-}))
-
-describe('workflow output tools', () => {
- beforeEach(() => {
- vi.restoreAllMocks()
- vi.unstubAllGlobals?.()
- globalThis.fetch = originalFetch
- mockGetReadableWorkflowState.mockReset()
- mockCopilotState.toolCallsById = {}
- })
-
- it('read_block_outputs returns structured output entries with paths and types', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- mockGetReadableWorkflowState.mockResolvedValue({
- workflowId: 'wf-1',
- workflowState: {
- blocks: {
- 'agent-1': { id: 'agent-1', type: 'agent', name: 'agent', subBlocks: {} },
- 'loop-1': { id: 'loop-1', type: 'loop', name: 'loop', subBlocks: {} },
- },
- edges: [],
- loops: {
- 'loop-1': { id: 'loop-1', nodes: [], loopType: 'forEach' },
- },
- parallels: {},
- },
- workspaceId: 'ws-1',
- variables: {
- 'var-1': { id: 'var-1', name: 'riskLimit', type: 'number' },
- },
- })
-
- const toolCallId = 'read-block-outputs'
- const tool = new ReadBlockOutputsClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'read_block_outputs',
- contextEntityKind: 'workflow',
- contextEntityId: 'wf-1',
- log: vi.fn(),
- })
-
- await tool.execute({ entityId: 'wf-1', blockIds: ['agent-1', 'loop-1'] })
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body))
-
- expect(markCompleteBody.data.blocks).toEqual([
- {
- blockId: 'agent-1',
- blockName: 'agent',
- blockType: 'agent',
- outputs: [
- { path: 'agent.content', type: 'string' },
- { path: 'agent.meta.sentiment', type: 'string' },
- ],
- },
- {
- blockId: 'loop-1',
- blockName: 'loop',
- blockType: 'loop',
- outputs: [],
- insideSubflowOutputs: [
- { path: 'loop.index', type: 'number' },
- { path: 'loop.currentItem', type: 'any' },
- { path: 'loop.items', type: 'json' },
- ],
- outsideSubflowOutputs: [{ path: 'loop.results', type: 'json' }],
- },
- ])
- expect(
- ToolResultSchemas.read_block_outputs.parse({
- blocks: markCompleteBody.data.blocks,
- })
- ).toBeDefined()
- })
-
- it('read_block_upstream_references returns structured accessible output entries with paths and types', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
- const method = init?.method || 'GET'
-
- if (url === '/api/copilot/tools/mark-complete' && method === 'POST') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url} (${method})`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- mockGetReadableWorkflowState.mockResolvedValue({
- workflowId: 'wf-1',
- workflowState: {
- blocks: {
- 'agent-1': { id: 'agent-1', type: 'agent', name: 'agent', subBlocks: {} },
- 'fn-1': { id: 'fn-1', type: 'function', name: 'function', subBlocks: {} },
- },
- edges: [{ source: 'agent-1', target: 'fn-1' }],
- loops: {},
- parallels: {},
- },
- workspaceId: 'ws-1',
- variables: {
- 'var-1': { id: 'var-1', name: 'riskLimit', type: 'number' },
- },
- })
-
- const toolCallId = 'read-block-upstream-references'
- const tool = new ReadBlockUpstreamReferencesClientTool(toolCallId)
- tool.setExecutionContext({
- toolCallId,
- toolName: 'read_block_upstream_references',
- contextEntityKind: 'workflow',
- contextEntityId: 'wf-1',
- log: vi.fn(),
- })
-
- await tool.execute({ entityId: 'wf-1', blockIds: ['fn-1'] })
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
-
- const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST'
- })
- const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body))
-
- expect(markCompleteBody.data.results).toEqual([
- {
- blockId: 'fn-1',
- blockName: 'function',
- accessibleBlocks: [
- {
- blockId: 'agent-1',
- blockName: 'agent',
- blockType: 'agent',
- outputs: [
- { path: 'agent.content', type: 'string' },
- { path: 'agent.meta.sentiment', type: 'string' },
- ],
- },
- ],
- variables: [
- {
- id: 'var-1',
- name: 'riskLimit',
- type: 'number',
- tag: 'variable.risklimit',
- },
- ],
- },
- ])
- expect(
- ToolResultSchemas.read_block_upstream_references.parse(markCompleteBody.data)
- ).toBeDefined()
- })
-})
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/check-deployment-status.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/check-deployment-status.ts
deleted file mode 100644
index 30f2eb660..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/check-deployment-status.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { Loader2, Rocket, X, XCircle } from 'lucide-react'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target'
-import { createLogger } from '@/lib/logs/console/logger'
-
-interface CheckDeploymentStatusArgs {
- entityId: string
-}
-
-export class CheckDeploymentStatusClientTool extends BaseClientTool {
- static readonly id = 'check_deployment_status'
-
- constructor(toolCallId: string) {
- super(toolCallId, CheckDeploymentStatusClientTool.id, CheckDeploymentStatusClientTool.metadata)
- }
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: {
- text: 'Checking deployment status',
- icon: Loader2,
- },
- [ClientToolCallState.pending]: { text: 'Checking deployment status', icon: Loader2 },
- [ClientToolCallState.executing]: { text: 'Checking deployment status', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Checked deployment status', icon: Rocket },
- [ClientToolCallState.error]: { text: 'Failed to check deployment status', icon: X },
- [ClientToolCallState.aborted]: {
- text: 'Aborted checking deployment status',
- icon: XCircle,
- },
- [ClientToolCallState.rejected]: {
- text: 'Skipped checking deployment status',
- icon: XCircle,
- },
- },
- interrupt: undefined,
- }
-
- async execute(args?: CheckDeploymentStatusArgs): Promise {
- const logger = createLogger('CheckDeploymentStatusClientTool')
- try {
- this.setState(ClientToolCallState.executing)
- const workflowId = requireCopilotEntityId(args)
-
- // Fetch deployment status from API
- const [apiDeployRes, chatDeployRes] = await Promise.all([
- fetch(`/api/workflows/${workflowId}/deploy`),
- fetch(`/api/workflows/${workflowId}/chat/status`),
- ])
-
- const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null
- const chatDeploy = chatDeployRes.ok ? await chatDeployRes.json() : null
-
- const isApiDeployed = apiDeploy?.isDeployed || false
- const isChatDeployed = !!(chatDeploy?.isDeployed && chatDeploy?.deployment)
-
- const deploymentTypes: string[] = []
-
- if (isApiDeployed) {
- // Default to sync API, could be extended to detect streaming/async
- deploymentTypes.push('api')
- }
-
- if (isChatDeployed) {
- deploymentTypes.push('chat')
- }
-
- const isDeployed = isApiDeployed || isChatDeployed
-
- this.setState(ClientToolCallState.success)
- await this.markToolComplete(
- 200,
- isDeployed
- ? `Workflow is deployed as: ${deploymentTypes.join(', ')}`
- : 'Workflow is not deployed',
- {
- isDeployed,
- deploymentTypes,
- apiDeployed: isApiDeployed,
- chatDeployed: isChatDeployed,
- deployedAt: apiDeploy?.deployedAt || null,
- }
- )
- } catch (e: any) {
- logger.error('Check deployment status failed', { message: e?.message })
- this.setState(ClientToolCallState.error)
- await this.markToolComplete(500, e?.message || 'Failed to check deployment status')
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts
deleted file mode 100644
index c6a4e1eab..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { Check, Grid2x2, Loader2, X, XCircle } from 'lucide-react'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import { createLogger } from '@/lib/logs/console/logger'
-import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access'
-import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-
-type CreateWorkflowArgs = {
- name?: string
- description?: string
- folderId?: string | null
- workspaceId?: string
-}
-
-function readStoredToolArgs(toolCallId: string): TArgs | undefined {
- try {
- const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState()
- return toolCallsById[toolCallId]?.params as TArgs | undefined
- } catch {
- return undefined
- }
-}
-
-export class CreateWorkflowClientTool extends BaseClientTool {
- static readonly id = 'create_workflow'
- private currentArgs?: CreateWorkflowArgs
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Creating workflow', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Create workflow?', icon: Grid2x2 },
- [ClientToolCallState.executing]: { text: 'Creating workflow', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Created workflow', icon: Check },
- [ClientToolCallState.error]: { text: 'Failed to create workflow', icon: X },
- [ClientToolCallState.aborted]: { text: 'Aborted creating workflow', icon: XCircle },
- [ClientToolCallState.rejected]: { text: 'Skipped creating workflow', icon: XCircle },
- },
- interrupt: {
- accept: { text: 'Allow', icon: Check },
- reject: { text: 'Skip', icon: XCircle },
- },
- }
-
- constructor(toolCallId: string) {
- super(toolCallId, CreateWorkflowClientTool.id, CreateWorkflowClientTool.metadata)
- }
-
- async execute(args?: CreateWorkflowArgs): Promise {
- this.currentArgs = args
- }
-
- async handleAccept(args?: CreateWorkflowArgs): Promise {
- const logger = createLogger('CreateWorkflowClientTool')
-
- try {
- this.setState(ClientToolCallState.executing)
-
- const executionContext = this.requireExecutionContext()
- const resolvedArgs =
- args || this.currentArgs || readStoredToolArgs(this.toolCallId)
- const workspaceId =
- resolvedArgs?.workspaceId?.trim() || executionContext.workspaceId?.trim() || undefined
-
- if (!workspaceId) {
- throw new Error('workspaceId is required to create a workflow')
- }
-
- const workflowId = await useWorkflowRegistry.getState().createWorkflow({
- workspaceId,
- ...(resolvedArgs?.name?.trim() ? { name: resolvedArgs.name.trim() } : {}),
- ...(typeof resolvedArgs?.description === 'string'
- ? { description: resolvedArgs.description }
- : {}),
- ...(resolvedArgs?.folderId !== undefined ? { folderId: resolvedArgs.folderId } : {}),
- })
-
- const workflow = useWorkflowRegistry.getState().workflows[workflowId]
- const entityName = workflow?.name?.trim()
-
- await this.markToolComplete(200, 'Workflow created', {
- success: true,
- entityKind: 'workflow',
- entityId: workflowId,
- ...(entityName ? { entityName } : {}),
- workspaceId: workflow?.workspaceId ?? workspaceId,
- })
- this.setState(ClientToolCallState.success)
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- logger.error('Failed to create workflow', { toolCallId: this.toolCallId, message })
- await this.markToolComplete(500, message)
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts
deleted file mode 100644
index 1724c4d56..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
-import { EditWorkflowBlockClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow-block'
-
-const mockGetReadableWorkflowState = vi.fn()
-const mockResolveWorkflowTarget = vi.fn()
-const mockSetWorkflowState = vi.fn()
-const mockAcquireWritableWorkflowSessionLease = vi.fn()
-
-vi.mock('@/lib/copilot/tools/client/workflow/workflow-review-tool-utils', () => ({
- getReadableWorkflowState: (...args: any[]) => mockGetReadableWorkflowState(...args),
- resolveWorkflowTarget: (...args: any[]) => mockResolveWorkflowTarget(...args),
- buildWorkflowDocumentToolResult: ({
- workflowId,
- entityName,
- entityDocument,
- }: {
- workflowId: string
- entityName?: string
- entityDocument: string
- }) => ({
- entityKind: 'workflow',
- entityId: workflowId,
- ...(entityName ? { entityName } : {}),
- entityDocument,
- documentFormat: 'tg-mermaid-v1',
- }),
-}))
-
-vi.mock('@/lib/yjs/workflow-shared-session', () => ({
- acquireWritableWorkflowSessionLease: (...args: any[]) =>
- mockAcquireWritableWorkflowSessionLease(...args),
-}))
-
-vi.mock('@/lib/yjs/workflow-session', () => ({
- setWorkflowState: (...args: any[]) => mockSetWorkflowState(...args),
-}))
-
-vi.mock('@/stores/copilot/store-access', () => ({
- getCopilotStoreForToolCall: () => ({
- getState: () => ({
- toolCallsById: {},
- }),
- }),
-}))
-
-describe('EditWorkflowBlockClientTool', () => {
- beforeEach(() => {
- vi.restoreAllMocks()
- vi.unstubAllGlobals?.()
- mockGetReadableWorkflowState.mockReset()
- mockResolveWorkflowTarget.mockReset()
- mockSetWorkflowState.mockReset()
- mockAcquireWritableWorkflowSessionLease.mockReset()
-
- mockResolveWorkflowTarget.mockResolvedValue({
- workflowId: 'wf-1',
- workspaceId: 'workspace-1',
- })
-
- mockGetReadableWorkflowState.mockResolvedValue({
- workflowId: 'wf-1',
- entityName: 'Workflow 1',
- workspaceId: 'workspace-1',
- workflowState: {
- direction: 'TD',
- blocks: {
- fn1: {
- id: 'fn1',
- type: 'function',
- name: 'Compute Indicators',
- position: { x: 0, y: 0 },
- subBlocks: {
- code: { id: 'code', type: 'code', value: 'return { ok: true }' },
- },
- outputs: {},
- enabled: true,
- },
- },
- edges: [],
- loops: {},
- parallels: {},
- },
- })
-
- mockAcquireWritableWorkflowSessionLease.mockImplementation(async ({ workflowId }) => ({
- session: {
- workflowId,
- doc: { id: 'doc-1' },
- },
- release: vi.fn(),
- }))
- })
-
- it('stages block edits for review through the shared workflow review flow', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = typeof input === 'string' ? input : input.toString()
-
- if (url === '/api/copilot/execute-copilot-server-tool') {
- return {
- ok: true,
- status: 200,
- json: async () => ({
- success: true,
- result: {
- entityDocument:
- 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}',
- workflowState: {
- direction: 'TD',
- blocks: {
- fn1: {
- id: 'fn1',
- type: 'function',
- name: 'Compute Market Indicators',
- position: { x: 0, y: 0 },
- subBlocks: {
- code: { id: 'code', type: 'code', value: 'return { rsi: 50 }' },
- },
- outputs: {},
- enabled: true,
- },
- },
- edges: [],
- loops: {},
- parallels: {},
- },
- },
- }),
- }
- }
-
- if (url === '/api/copilot/tools/mark-complete') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url}`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const tool = new EditWorkflowBlockClientTool('tool-review')
- tool.setExecutionContext({
- toolCallId: 'tool-review',
- toolName: 'edit_workflow_block',
- contextEntityKind: 'workflow',
- contextEntityId: 'wf-1',
- log: vi.fn(),
- })
-
- await tool.handleUserAction({
- entityId: 'wf-1',
- blockId: 'fn1',
- blockType: 'function',
- subBlocks: {
- code: 'return { rsi: 50 }',
- },
- })
-
- expect(tool.getState()).toBe(ClientToolCallState.review)
- expect(tool.getInterruptDisplays()).toBeDefined()
- expect(mockSetWorkflowState).not.toHaveBeenCalled()
- })
-})
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.ts
deleted file mode 100644
index 17494d7fc..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Grid2x2, Grid2x2Check, Grid2x2X, Loader2, MinusCircle, XCircle } from 'lucide-react'
-import type { BaseClientToolMetadata } from '@/lib/copilot/tools/client/base-tool'
-import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
-import { EditWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow'
-
-export class EditWorkflowBlockClientTool extends EditWorkflowClientTool {
- static readonly id: string = 'edit_workflow_block'
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Editing your workflow block', icon: Loader2 },
- [ClientToolCallState.executing]: { text: 'Editing your workflow block', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Edited your workflow block', icon: Grid2x2Check },
- [ClientToolCallState.error]: { text: 'Failed to edit your workflow block', icon: XCircle },
- [ClientToolCallState.review]: { text: 'Review your workflow block changes', icon: Grid2x2 },
- [ClientToolCallState.rejected]: { text: 'Rejected workflow block changes', icon: Grid2x2X },
- [ClientToolCallState.aborted]: {
- text: 'Aborted editing your workflow block',
- icon: MinusCircle,
- },
- [ClientToolCallState.pending]: { text: 'Editing your workflow block', icon: Loader2 },
- },
- interrupt: {
- accept: { text: 'Accept changes', icon: Grid2x2Check },
- reject: { text: 'Reject changes', icon: Grid2x2X },
- },
- }
-
- constructor(
- toolCallId: string,
- toolName = EditWorkflowBlockClientTool.id,
- metadata: BaseClientToolMetadata = EditWorkflowBlockClientTool.metadata
- ) {
- super(toolCallId, toolName, metadata)
- }
-
- protected getServerToolName(): string {
- return EditWorkflowBlockClientTool.id
- }
-
- protected buildServerPayload(
- workflowId: string,
- args: Record | undefined,
- currentWorkflowState: string
- ): Record {
- const blockId = args?.blockId?.trim()
- if (!blockId) {
- throw new Error(`blockId is required for ${this.getServerToolName()}`)
- }
-
- return {
- entityId: workflowId,
- blockId,
- ...(args?.blockType?.trim() ? { blockType: args.blockType.trim() } : {}),
- ...(args?.name?.trim() ? { name: args.name.trim() } : {}),
- ...(typeof args?.enabled === 'boolean' ? { enabled: args.enabled } : {}),
- ...(args?.subBlocks ? { subBlocks: args.subBlocks } : {}),
- currentWorkflowState,
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.test.ts
deleted file mode 100644
index 246555447..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.test.ts
+++ /dev/null
@@ -1,497 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import {
- ClientToolCallState,
- REJECTED_TOOL_COMPLETION_STATUS,
-} from '@/lib/copilot/tools/client/base-tool'
-import { EditWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow'
-import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins'
-
-const mockGetReadableWorkflowState = vi.fn()
-const mockResolveWorkflowTarget = vi.fn()
-const mockSetWorkflowState = vi.fn()
-const mockAcquireWritableWorkflowSessionLease = vi.fn()
-
-const workflowDocument = [
- 'flowchart TD',
- '%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}',
- '%% TG_BLOCK {"id":"block-1","type":"trigger","name":"Trigger","position":{"x":0,"y":0},"subBlocks":{},"outputs":{},"enabled":true}',
-].join('\n')
-const editWorkflowDocument = [
- 'flowchart TD',
- ' n1["Trigger id: block-1 type: trigger"]',
-].join('\n')
-const workflowGraphDocumentFormat = 'tg-workflow-graph-mermaid-v1'
-
-let persistedToolCalls: Record = {}
-
-vi.mock('@/lib/copilot/tools/client/workflow/workflow-review-tool-utils', () => ({
- getReadableWorkflowState: (...args: any[]) => mockGetReadableWorkflowState(...args),
- resolveWorkflowTarget: (...args: any[]) => mockResolveWorkflowTarget(...args),
- buildWorkflowDocumentToolResult: ({
- workflowId,
- entityName,
- entityDocument,
- documentFormat,
- }: {
- workflowId: string
- entityName?: string
- entityDocument: string
- documentFormat?: string
- }) => ({
- entityKind: 'workflow',
- entityId: workflowId,
- ...(entityName ? { entityName } : {}),
- entityDocument,
- documentFormat: documentFormat ?? 'tg-mermaid-v1',
- }),
-}))
-
-vi.mock('@/lib/yjs/workflow-shared-session', () => ({
- acquireWritableWorkflowSessionLease: (...args: any[]) =>
- mockAcquireWritableWorkflowSessionLease(...args),
-}))
-
-vi.mock('@/lib/yjs/workflow-session', () => ({
- setWorkflowState: (...args: any[]) => mockSetWorkflowState(...args),
-}))
-
-vi.mock('@/stores/copilot/store-access', () => ({
- getCopilotStoreForToolCall: () => ({
- getState: () => ({
- toolCallsById: persistedToolCalls,
- }),
- }),
-}))
-
-describe('EditWorkflowClientTool approval gating', () => {
- beforeEach(() => {
- vi.restoreAllMocks()
- vi.unstubAllGlobals?.()
- persistedToolCalls = {}
- mockGetReadableWorkflowState.mockReset()
- mockResolveWorkflowTarget.mockReset()
- mockSetWorkflowState.mockReset()
- mockAcquireWritableWorkflowSessionLease.mockReset()
-
- mockResolveWorkflowTarget.mockResolvedValue({
- workflowId: 'wf-1',
- workspaceId: 'workspace-1',
- folderId: null,
- })
-
- mockGetReadableWorkflowState.mockResolvedValue({
- workflowId: 'wf-1',
- entityName: 'Workflow 1',
- workspaceId: 'workspace-1',
- workflowState: {
- blocks: {
- 'block-1': {
- id: 'block-1',
- type: 'trigger',
- name: 'Trigger',
- position: { x: 0, y: 0 },
- subBlocks: {},
- outputs: {},
- enabled: true,
- },
- },
- edges: [],
- loops: {},
- parallels: {},
- },
- })
-
- mockAcquireWritableWorkflowSessionLease.mockImplementation(async ({ workflowId }) => ({
- session: {
- workflowId,
- doc: { id: workflowId === 'wf-target' ? 'doc-target' : 'doc-1' },
- },
- release: vi.fn(),
- }))
- })
-
- it('stages workflow edits for review through the unified user-action handler', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
-
- if (url === '/api/copilot/execute-copilot-server-tool') {
- const body = JSON.parse(String(init?.body))
- expect(body.payload).toMatchObject({
- entityId: 'wf-1',
- entityDocument: editWorkflowDocument,
- removedBlockIds: ['removed-1'],
- })
- expect(body.payload).not.toHaveProperty('documentFormat')
- return {
- ok: true,
- status: 200,
- json: async () => ({
- success: true,
- result: {
- workflowState: {
- blocks: {
- 'block-1': {
- id: 'block-1',
- type: 'trigger',
- name: 'Renamed Trigger',
- position: { x: 0, y: 0 },
- subBlocks: {},
- outputs: {},
- enabled: true,
- },
- },
- edges: [],
- loops: {},
- parallels: {},
- },
- entityDocument: editWorkflowDocument,
- documentFormat: workflowGraphDocumentFormat,
- },
- }),
- }
- }
-
- if (url === '/api/copilot/tools/mark-complete') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url}`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const tool = new EditWorkflowClientTool('tool-review')
- tool.setExecutionContext({
- toolCallId: 'tool-review',
- toolName: 'edit_workflow',
- channelId: 'pair-1',
- contextEntityKind: 'workflow',
- contextEntityId: 'wf-1',
- log: vi.fn(),
- })
-
- await tool.handleUserAction({
- entityId: 'wf-1',
- entityDocument: editWorkflowDocument,
- removedBlockIds: ['removed-1'],
- })
-
- expect(tool.getState()).toBe(ClientToolCallState.review)
- expect(mockSetWorkflowState).not.toHaveBeenCalled()
- expect(fetchMock).toHaveBeenCalledTimes(1)
-
- await tool.handleReject()
-
- expect(tool.getState()).toBe(ClientToolCallState.rejected)
- expect(mockSetWorkflowState).not.toHaveBeenCalled()
- expect(fetchMock).toHaveBeenCalledTimes(2)
- const rejectRequest = fetchMock.mock.calls.find(([input]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete'
- })
- const rejectBody = JSON.parse(String(rejectRequest?.[1]?.body))
- expect(rejectBody.status).toBe(REJECTED_TOOL_COMPLETION_STATUS)
- expect(rejectBody.data).toEqual({ rejected: true })
- })
-
- it('stages workflow edits from a readable workflow snapshot when no live session is registered yet', async () => {
- mockGetReadableWorkflowState.mockResolvedValueOnce({
- workflowId: 'wf-1',
- workflowState: {
- blocks: {
- 'block-1': {
- id: 'block-1',
- type: 'trigger',
- name: 'Persisted Trigger',
- position: { x: 0, y: 0 },
- subBlocks: {},
- outputs: {},
- enabled: true,
- },
- },
- edges: [],
- loops: {},
- parallels: {},
- },
- })
-
- const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
-
- if (url === '/api/copilot/execute-copilot-server-tool') {
- expect(init?.body).toContain('"currentWorkflowState"')
- expect(init?.body).toContain('Persisted Trigger')
- return {
- ok: true,
- status: 200,
- json: async () => ({
- success: true,
- result: {
- workflowState: {
- blocks: {
- 'block-1': {
- id: 'block-1',
- type: 'trigger',
- name: 'Renamed Trigger',
- position: { x: 0, y: 0 },
- subBlocks: {},
- outputs: {},
- enabled: true,
- },
- },
- edges: [],
- loops: {},
- parallels: {},
- },
- entityDocument: editWorkflowDocument,
- documentFormat: workflowGraphDocumentFormat,
- },
- }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url}`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const tool = new EditWorkflowClientTool('tool-readable-state')
- tool.setExecutionContext({
- toolCallId: 'tool-readable-state',
- toolName: 'edit_workflow',
- channelId: 'pair-1',
- contextEntityKind: 'workflow',
- contextEntityId: 'wf-1',
- log: vi.fn(),
- })
-
- await tool.handleUserAction({
- entityId: 'wf-1',
- entityDocument: editWorkflowDocument,
- })
-
- expect(tool.getState()).toBe(ClientToolCallState.review)
- expect(mockSetWorkflowState).not.toHaveBeenCalled()
- expect(fetchMock).toHaveBeenCalledTimes(1)
- })
-
- it('applies staged workflow edits through Yjs on accept', async () => {
- const nextWorkflowState = {
- blocks: {
- 'block-1': {
- id: 'block-1',
- type: 'trigger',
- name: 'Accepted Trigger',
- position: { x: 0, y: 0 },
- subBlocks: {},
- outputs: {},
- enabled: true,
- },
- },
- edges: [],
- loops: {},
- parallels: {},
- }
-
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = typeof input === 'string' ? input : input.toString()
-
- if (url === '/api/copilot/execute-copilot-server-tool') {
- return {
- ok: true,
- status: 200,
- json: async () => ({
- success: true,
- result: {
- workflowState: nextWorkflowState,
- entityDocument: editWorkflowDocument,
- documentFormat: workflowGraphDocumentFormat,
- },
- }),
- }
- }
-
- if (url === '/api/copilot/tools/mark-complete') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url}`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const tool = new EditWorkflowClientTool('tool-accept')
- tool.setExecutionContext({
- toolCallId: 'tool-accept',
- toolName: 'edit_workflow',
- channelId: 'pair-1',
- contextEntityKind: 'workflow',
- contextEntityId: 'wf-1',
- log: vi.fn(),
- })
-
- await tool.execute({
- entityId: 'wf-1',
- entityDocument: editWorkflowDocument,
- })
- await tool.handleAccept()
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
- expect(mockSetWorkflowState).toHaveBeenCalledWith(
- { id: 'doc-1' },
- nextWorkflowState,
- YJS_ORIGINS.COPILOT_REVIEW_ACCEPT
- )
- expect(fetchMock).toHaveBeenCalledTimes(2)
-
- const markCompleteRequest = fetchMock.mock.calls.find(([input]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete'
- })
- const markCompleteInit = markCompleteRequest
- ? ((markCompleteRequest as unknown as Array)[1] as RequestInit | undefined)
- : undefined
- const markCompleteBody = JSON.parse(String(markCompleteInit?.body))
- expect(markCompleteBody.data).toMatchObject({
- entityKind: 'workflow',
- entityId: 'wf-1',
- entityDocument: editWorkflowDocument,
- documentFormat: workflowGraphDocumentFormat,
- })
- })
-
- it('rejects edit execution without explicit entityId even when current workflow context exists', async () => {
- const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
- const url = typeof input === 'string' ? input : input.toString()
-
- if (url === '/api/copilot/tools/mark-complete') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url}`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const tool = new EditWorkflowClientTool('tool-missing-workflow-id')
- tool.setExecutionContext({
- toolCallId: 'tool-missing-workflow-id',
- toolName: 'edit_workflow',
- channelId: 'pair-1',
- contextEntityKind: 'workflow',
- contextEntityId: 'wf-current',
- log: vi.fn(),
- })
-
- await tool.execute({
- entityDocument: editWorkflowDocument,
- })
-
- expect(tool.getState()).toBe(ClientToolCallState.error)
- expect(mockResolveWorkflowTarget).not.toHaveBeenCalled()
- expect(mockSetWorkflowState).not.toHaveBeenCalled()
-
- const markCompleteRequest = fetchMock.mock.calls.find(([input]) => {
- const url = typeof input === 'string' ? input : input.toString()
- return url === '/api/copilot/tools/mark-complete'
- })
- const markCompleteBody = JSON.parse(String(markCompleteRequest?.[1]?.body))
- expect(markCompleteBody.status).toBe(500)
- expect(markCompleteBody.message).toContain('entityId is required')
- })
-
- it('accepts persisted staged workflow edits after reload using the persisted workflow target', async () => {
- const stagedWorkflowState = {
- blocks: {
- 'block-1': {
- id: 'block-1',
- type: 'trigger',
- name: 'Persisted Trigger',
- position: { x: 0, y: 0 },
- subBlocks: {},
- outputs: {},
- enabled: true,
- },
- },
- edges: [],
- loops: {},
- parallels: {},
- }
-
- persistedToolCalls = {
- 'tool-persisted-review': {
- id: 'tool-persisted-review',
- name: 'edit_workflow',
- state: ClientToolCallState.review,
- params: {
- entityId: 'wf-target',
- entityDocument: editWorkflowDocument,
- },
- result: {
- entityId: 'wf-target',
- workflowState: stagedWorkflowState,
- },
- },
- }
-
- mockResolveWorkflowTarget.mockImplementation(async (_executionContext, options) => ({
- workflowId: options?.entityId ?? 'wf-current',
- }))
- mockAcquireWritableWorkflowSessionLease.mockImplementation(async ({ workflowId }) => ({
- session: {
- workflowId,
- doc: { id: workflowId === 'wf-target' ? 'doc-target' : 'doc-current' },
- },
- release: vi.fn(),
- }))
-
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
- const url = typeof input === 'string' ? input : input.toString()
-
- if (url === '/api/copilot/tools/mark-complete') {
- return {
- ok: true,
- status: 200,
- json: async () => ({ success: true }),
- }
- }
-
- throw new Error(`Unexpected fetch URL: ${url}`)
- })
- vi.stubGlobal('fetch', fetchMock)
-
- const tool = new EditWorkflowClientTool('tool-persisted-review')
- tool.setExecutionContext({
- toolCallId: 'tool-persisted-review',
- toolName: 'edit_workflow',
- channelId: 'pair-1',
- contextEntityKind: 'workflow',
- contextEntityId: 'wf-current',
- log: vi.fn(),
- })
- tool.hydratePersistedToolCall(persistedToolCalls['tool-persisted-review'])
-
- await tool.handleUserAction()
-
- expect(tool.getState()).toBe(ClientToolCallState.success)
- expect(mockSetWorkflowState).toHaveBeenCalledWith(
- { id: 'doc-target' },
- stagedWorkflowState,
- YJS_ORIGINS.COPILOT_REVIEW_ACCEPT
- )
- expect(mockSetWorkflowState).not.toHaveBeenCalledWith(
- { id: 'doc-current' },
- stagedWorkflowState,
- YJS_ORIGINS.COPILOT_REVIEW_ACCEPT
- )
- expect(fetchMock).toHaveBeenCalledTimes(1)
- })
-})
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts
deleted file mode 100644
index de6b9c0b3..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts
+++ /dev/null
@@ -1,220 +0,0 @@
-import { Grid2x2, Grid2x2Check, Grid2x2X, Loader2, MinusCircle, XCircle } from 'lucide-react'
-import {
- type BaseClientToolMetadata,
- ClientToolCallState,
- StagedReviewClientTool,
-} from '@/lib/copilot/tools/client/base-tool'
-import {
- executeCopilotServerTool,
- getCopilotServerToolErrorStatus,
-} from '@/lib/copilot/tools/client/server-tool-response'
-import {
- buildWorkflowDocumentToolResult,
- getReadableWorkflowState,
- resolveWorkflowTarget,
-} from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils'
-import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target'
-import { createLogger } from '@/lib/logs/console/logger'
-import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins'
-import { setWorkflowState } from '@/lib/yjs/workflow-session'
-import { acquireWritableWorkflowSessionLease } from '@/lib/yjs/workflow-shared-session'
-import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access'
-
-interface EditWorkflowArgs {
- entityDocument: string
- removedBlockIds?: string[]
- entityId?: string
-}
-
-function readStoredToolArgs(toolCallId: string): TArgs | undefined {
- try {
- const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState()
- return toolCallsById[toolCallId]?.params as TArgs | undefined
- } catch {
- return undefined
- }
-}
-
-export class EditWorkflowClientTool extends StagedReviewClientTool> {
- static readonly id: string = 'edit_workflow'
- private hasExecuted = false
- private hasAppliedState = false
-
- constructor(
- toolCallId: string,
- toolName = EditWorkflowClientTool.id,
- metadata: BaseClientToolMetadata = EditWorkflowClientTool.metadata
- ) {
- super(toolCallId, toolName, metadata)
- }
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 },
- [ClientToolCallState.executing]: { text: 'Editing your workflow', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Edited your workflow', icon: Grid2x2Check },
- [ClientToolCallState.error]: { text: 'Failed to edit your workflow', icon: XCircle },
- [ClientToolCallState.review]: { text: 'Review your workflow changes', icon: Grid2x2 },
- [ClientToolCallState.rejected]: { text: 'Rejected workflow changes', icon: Grid2x2X },
- [ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle },
- [ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
- },
- interrupt: {
- accept: { text: 'Accept changes', icon: Grid2x2Check },
- reject: { text: 'Reject changes', icon: Grid2x2X },
- },
- }
-
- async handleAccept(args?: EditWorkflowArgs): Promise {
- const logger = createLogger('EditWorkflowClientTool')
- try {
- const stagedResult = this.getStagedReviewResult()
- logger.info('handleAccept called', {
- toolCallId: this.toolCallId,
- state: this.getState(),
- hasResult: stagedResult !== undefined,
- })
-
- if (!stagedResult?.workflowState) {
- throw new Error('No staged workflow edits found to accept')
- }
-
- const executionContext = this.requireExecutionContext()
- const resolvedArgs = args || readStoredToolArgs(this.toolCallId)
- const requestedEntityId =
- resolvedArgs?.entityId?.trim() ??
- (typeof stagedResult?.entityId === 'string' ? stagedResult.entityId.trim() : undefined)
- if (!requestedEntityId) {
- throw new Error('entityId is required for edit_workflow')
- }
- const { workflowId } = await resolveWorkflowTarget(executionContext, {
- entityId: requestedEntityId,
- })
- const lease = await acquireWritableWorkflowSessionLease({
- workflowId,
- workspaceId:
- (typeof stagedResult.workspaceId === 'string' ? stagedResult.workspaceId : undefined) ??
- executionContext.workspaceId ??
- null,
- })
-
- try {
- if (!this.hasAppliedState) {
- setWorkflowState(
- lease.session.doc,
- stagedResult.workflowState,
- YJS_ORIGINS.COPILOT_REVIEW_ACCEPT
- )
- this.hasAppliedState = true
- }
- } finally {
- lease.release()
- }
-
- this.setState(ClientToolCallState.success)
- const completed = await this.markToolComplete(200, 'Workflow edits accepted', stagedResult)
- if (!completed) {
- logger.warn('markToolComplete failed during handleAccept', {
- toolCallId: this.toolCallId,
- })
- }
- } catch (error: any) {
- const message = error instanceof Error ? error.message : String(error)
- logger.error('handleAccept failed', { toolCallId: this.toolCallId, message })
- this.setState(ClientToolCallState.error)
- await this.markToolComplete(500, message || 'Failed to apply workflow edits')
- }
- }
-
- protected getRejectCompletionMessage(): string {
- return 'Workflow changes rejected'
- }
-
- protected getServerToolName(): string {
- return EditWorkflowClientTool.id
- }
-
- protected buildServerPayload(
- workflowId: string,
- args: Record | undefined,
- currentWorkflowState: string
- ): Record {
- const entityDocument = args?.entityDocument?.trim()
- if (!entityDocument) {
- throw new Error(`No entityDocument provided for ${this.getServerToolName()}`)
- }
-
- return {
- entityId: workflowId,
- entityDocument,
- ...(Array.isArray(args?.removedBlockIds) ? { removedBlockIds: args.removedBlockIds } : {}),
- currentWorkflowState,
- }
- }
-
- protected hasStagedReviewResult(result: Record | undefined): boolean {
- return !!result?.workflowState
- }
-
- async execute(args?: EditWorkflowArgs): Promise {
- const logger = createLogger('EditWorkflowClientTool')
- try {
- if (this.hasExecuted) {
- logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
- return
- }
- this.hasExecuted = true
- logger.info('execute called', { toolCallId: this.toolCallId, argsProvided: !!args })
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
- const requestedEntityId = requireCopilotEntityId(args, { toolName: 'edit_workflow' })
-
- const { workflowId, workspaceId } = await resolveWorkflowTarget(executionContext, {
- entityId: requestedEntityId,
- })
-
- const readableWorkflow = await getReadableWorkflowState(executionContext, workflowId)
-
- const result = (await executeCopilotServerTool({
- toolName: this.getServerToolName(),
- payload: this.buildServerPayload(
- workflowId,
- args,
- JSON.stringify(readableWorkflow.workflowState)
- ),
- signal: this.getAbortSignal(),
- })) as any
- if (!result.workflowState) {
- throw new Error('No workflow state returned from server')
- }
- if (typeof result.entityDocument !== 'string') {
- throw new Error('No workflow document returned from server')
- }
-
- const stagedResult = {
- ...result,
- ...buildWorkflowDocumentToolResult({
- workflowId,
- entityName: readableWorkflow.entityName,
- workspaceId: readableWorkflow.workspaceId ?? workspaceId,
- entityDocument: result.entityDocument,
- documentFormat: result.documentFormat,
- }),
- }
- this.hasAppliedState = false
- logger.info('server result parsed', {
- hasWorkflowState: !!result?.workflowState,
- blocksCount: result?.workflowState
- ? Object.keys(result.workflowState.blocks || {}).length
- : 0,
- })
-
- this.stageReviewResult(stagedResult)
- } catch (error: any) {
- const message = error instanceof Error ? error.message : String(error)
- logger.error('execute error', { message })
- await this.markToolComplete(getCopilotServerToolErrorStatus(error) ?? 500, message)
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/list-workflows.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/list-workflows.ts
deleted file mode 100644
index dc31eb959..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/list-workflows.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { ListChecks, Loader2, X, XCircle } from 'lucide-react'
-import { CopilotTool } from '@/lib/copilot/registry'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import { createLogger } from '@/lib/logs/console/logger'
-import { listWorkflowsForExecutionContext } from './workflow-review-tool-utils'
-
-const logger = createLogger('ListWorkflowsClientTool')
-
-export class ListWorkflowsClientTool extends BaseClientTool {
- static readonly id = CopilotTool.list_workflows
-
- constructor(toolCallId: string) {
- super(toolCallId, ListWorkflowsClientTool.id, ListWorkflowsClientTool.metadata)
- }
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Listing your workflows', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Listing your workflows', icon: ListChecks },
- [ClientToolCallState.executing]: { text: 'Listing your workflows', icon: Loader2 },
- [ClientToolCallState.aborted]: { text: 'Aborted listing workflows', icon: XCircle },
- [ClientToolCallState.success]: { text: 'Listed your workflows', icon: ListChecks },
- [ClientToolCallState.error]: { text: 'Failed to list workflows', icon: X },
- [ClientToolCallState.rejected]: { text: 'Skipped listing workflows', icon: XCircle },
- },
- }
-
- async execute(): Promise {
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
- const workflows = await listWorkflowsForExecutionContext(executionContext)
- const entities = workflows.map((workflow) => ({
- entityId: workflow.workflowId,
- ...(workflow.entityName ? { entityName: workflow.entityName } : {}),
- ...(workflow.workspaceId ? { workspaceId: workflow.workspaceId } : {}),
- }))
-
- logger.info('Found workflows', { count: workflows.length })
-
- await this.markToolComplete(200, `Found ${workflows.length} workflow(s)`, {
- entityKind: 'workflow',
- entities,
- count: workflows.length,
- })
- this.setState(ClientToolCallState.success)
- } catch (error: any) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message || 'Failed to list workflows')
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-outputs.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-outputs.ts
deleted file mode 100644
index 051b93a40..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-outputs.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { Loader2, Tag, X, XCircle } from 'lucide-react'
-import { CopilotTool } from '@/lib/copilot/registry'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import {
- computeBlockOutputReferences,
- getSubflowInsideOutputReferences,
- getSubflowOutsideOutputReferences,
- readWorkflowSubBlockValues,
- readWorkflowVariableOutputs,
-} from '@/lib/copilot/tools/client/workflow/block-output-utils'
-import { getReadableWorkflowState } from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils'
-import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target'
-import {
- ReadBlockOutputsResult,
- type ReadBlockOutputsResultType,
-} from '@/lib/copilot/tools/shared/schemas'
-import { createLogger } from '@/lib/logs/console/logger'
-
-const logger = createLogger('ReadBlockOutputsClientTool')
-
-interface ReadBlockOutputsArgs {
- blockIds?: string[]
- entityId: string
-}
-
-export class ReadBlockOutputsClientTool extends BaseClientTool {
- static readonly id = CopilotTool.read_block_outputs
-
- constructor(toolCallId: string) {
- super(toolCallId, ReadBlockOutputsClientTool.id, ReadBlockOutputsClientTool.metadata)
- }
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Getting block outputs', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Getting block outputs', icon: Tag },
- [ClientToolCallState.executing]: { text: 'Getting block outputs', icon: Loader2 },
- [ClientToolCallState.aborted]: { text: 'Aborted getting outputs', icon: XCircle },
- [ClientToolCallState.success]: { text: 'Retrieved block outputs', icon: Tag },
- [ClientToolCallState.error]: { text: 'Failed to get outputs', icon: X },
- [ClientToolCallState.rejected]: { text: 'Skipped getting outputs', icon: XCircle },
- },
- getDynamicText: (params, state) => {
- const blockIds = params?.blockIds
- if (blockIds && Array.isArray(blockIds) && blockIds.length > 0) {
- const count = blockIds.length
- switch (state) {
- case ClientToolCallState.success:
- return `Retrieved outputs for ${count} block${count > 1 ? 's' : ''}`
- case ClientToolCallState.executing:
- case ClientToolCallState.generating:
- case ClientToolCallState.pending:
- return `Getting outputs for ${count} block${count > 1 ? 's' : ''}`
- case ClientToolCallState.error:
- return `Failed to get outputs for ${count} block${count > 1 ? 's' : ''}`
- }
- }
- return undefined
- },
- }
-
- async execute(args?: ReadBlockOutputsArgs): Promise {
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
- const entityId = requireCopilotEntityId(args)
-
- const {
- workflowId: activeWorkflowId,
- workflowState: snapshot,
- variables,
- } = await getReadableWorkflowState(executionContext, entityId)
- const blocks = snapshot.blocks || {}
- const loops = snapshot.loops || {}
- const parallels = snapshot.parallels || {}
- const subBlockValues = readWorkflowSubBlockValues(activeWorkflowId, snapshot)
- const variableOutputs = readWorkflowVariableOutputs(variables)
-
- const ctx = { blocks, loops, parallels, subBlockValues }
- const targetBlockIds =
- args?.blockIds && args.blockIds.length > 0 ? args.blockIds : Object.keys(blocks)
-
- const blockOutputs: ReadBlockOutputsResultType['blocks'] = []
-
- for (const blockId of targetBlockIds) {
- const block = blocks[blockId]
- if (!block?.type) continue
-
- const blockName = block.name || block.type
-
- const blockOutput: ReadBlockOutputsResultType['blocks'][0] = {
- blockId,
- blockName,
- blockType: block.type,
- outputs: [],
- }
-
- if (block.type === 'loop' || block.type === 'parallel') {
- blockOutput.insideSubflowOutputs = getSubflowInsideOutputReferences(
- block.type,
- blockId,
- blockName,
- loops,
- parallels
- )
- blockOutput.outsideSubflowOutputs = getSubflowOutsideOutputReferences(blockName)
- } else {
- blockOutput.outputs = computeBlockOutputReferences(block, ctx, variableOutputs)
- }
-
- blockOutputs.push(blockOutput)
- }
-
- const includeVariables = !args?.blockIds || args.blockIds.length === 0
- const resultData: {
- blocks: typeof blockOutputs
- variables?: ReturnType
- } = {
- blocks: blockOutputs,
- }
- if (includeVariables) {
- resultData.variables = variableOutputs
- }
-
- const result = ReadBlockOutputsResult.parse(resultData)
-
- logger.info('Retrieved block outputs', {
- blockCount: blockOutputs.length,
- variableCount: resultData.variables?.length ?? 0,
- })
-
- await this.markToolComplete(200, 'Retrieved block outputs', result)
- this.setState(ClientToolCallState.success)
- } catch (error: any) {
- const message = error instanceof Error ? error.message : String(error)
- logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message })
- await this.markToolComplete(500, message || 'Failed to get block outputs')
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-upstream-references.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-upstream-references.ts
deleted file mode 100644
index 0808b8210..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-upstream-references.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-import { GitBranch, Loader2, X, XCircle } from 'lucide-react'
-import { BlockPathCalculator } from '@/lib/block-path-calculator'
-import { CopilotTool } from '@/lib/copilot/registry'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import {
- computeBlockOutputReferences,
- getSubflowInsideOutputReferences,
- getSubflowOutsideOutputReferences,
- readWorkflowSubBlockValues,
- readWorkflowVariableOutputs,
-} from '@/lib/copilot/tools/client/workflow/block-output-utils'
-import { getReadableWorkflowState } from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils'
-import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target'
-import {
- ReadBlockUpstreamReferencesResult,
- type ReadBlockUpstreamReferencesResultType,
-} from '@/lib/copilot/tools/shared/schemas'
-import { createLogger } from '@/lib/logs/console/logger'
-import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
-
-const logger = createLogger('ReadBlockUpstreamReferencesClientTool')
-
-interface ReadBlockUpstreamReferencesArgs {
- blockIds: string[]
- entityId: string
-}
-
-export class ReadBlockUpstreamReferencesClientTool extends BaseClientTool {
- static readonly id = CopilotTool.read_block_upstream_references
-
- constructor(toolCallId: string) {
- super(
- toolCallId,
- ReadBlockUpstreamReferencesClientTool.id,
- ReadBlockUpstreamReferencesClientTool.metadata
- )
- }
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Getting upstream references', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Getting upstream references', icon: GitBranch },
- [ClientToolCallState.executing]: { text: 'Getting upstream references', icon: Loader2 },
- [ClientToolCallState.aborted]: { text: 'Aborted getting references', icon: XCircle },
- [ClientToolCallState.success]: { text: 'Retrieved upstream references', icon: GitBranch },
- [ClientToolCallState.error]: { text: 'Failed to get references', icon: X },
- [ClientToolCallState.rejected]: { text: 'Skipped getting references', icon: XCircle },
- },
- getDynamicText: (params, state) => {
- const blockIds = params?.blockIds
- if (blockIds && Array.isArray(blockIds) && blockIds.length > 0) {
- const count = blockIds.length
- switch (state) {
- case ClientToolCallState.success:
- return `Retrieved references for ${count} block${count > 1 ? 's' : ''}`
- case ClientToolCallState.executing:
- case ClientToolCallState.generating:
- case ClientToolCallState.pending:
- return `Getting references for ${count} block${count > 1 ? 's' : ''}`
- case ClientToolCallState.error:
- return `Failed to get references for ${count} block${count > 1 ? 's' : ''}`
- }
- }
- return undefined
- },
- }
-
- async execute(args?: ReadBlockUpstreamReferencesArgs): Promise {
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
-
- if (!args?.blockIds || args.blockIds.length === 0) {
- await this.markToolComplete(400, 'blockIds array is required')
- this.setState(ClientToolCallState.error)
- return
- }
- const entityId = requireCopilotEntityId(args)
-
- const {
- workflowId: activeWorkflowId,
- workflowState: snapshot,
- variables,
- } = await getReadableWorkflowState(executionContext, entityId)
- const blocks = snapshot.blocks || {}
- const edges = snapshot.edges || []
- const loops = snapshot.loops || {}
- const parallels = snapshot.parallels || {}
- const subBlockValues = readWorkflowSubBlockValues(activeWorkflowId, snapshot)
-
- const ctx = { blocks, loops, parallels, subBlockValues }
- const variableOutputs = readWorkflowVariableOutputs(variables)
- const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
-
- const results: ReadBlockUpstreamReferencesResultType['results'] = []
-
- for (const blockId of args.blockIds) {
- const targetBlock = blocks[blockId]
- if (!targetBlock) {
- logger.warn(`Block ${blockId} not found`)
- continue
- }
-
- const insideSubflows: { blockId: string; blockName: string; blockType: string }[] = []
- const containingLoopIds = new Set()
- const containingParallelIds = new Set()
-
- Object.values(loops as Record).forEach((loop) => {
- if (loop?.nodes?.includes(blockId)) {
- containingLoopIds.add(loop.id)
- const loopBlock = blocks[loop.id]
- if (loopBlock) {
- insideSubflows.push({
- blockId: loop.id,
- blockName: loopBlock.name || loopBlock.type,
- blockType: 'loop',
- })
- }
- }
- })
-
- Object.values(parallels as Record).forEach((parallel) => {
- if (parallel?.nodes?.includes(blockId)) {
- containingParallelIds.add(parallel.id)
- const parallelBlock = blocks[parallel.id]
- if (parallelBlock) {
- insideSubflows.push({
- blockId: parallel.id,
- blockName: parallelBlock.name || parallelBlock.type,
- blockType: 'parallel',
- })
- }
- }
- })
-
- const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId)
- const accessibleIds = new Set(ancestorIds)
- accessibleIds.add(blockId)
-
- containingLoopIds.forEach((loopId) => {
- accessibleIds.add(loopId)
- loops[loopId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId))
- })
-
- containingParallelIds.forEach((parallelId) => {
- accessibleIds.add(parallelId)
- parallels[parallelId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId))
- })
-
- const accessibleBlocks: ReadBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'] =
- []
-
- for (const accessibleBlockId of accessibleIds) {
- const block = blocks[accessibleBlockId]
- if (!block?.type) continue
-
- const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop'
- if (accessibleBlockId === blockId && !canSelfReference) continue
-
- const blockName = block.name || block.type
- let accessContext: 'inside' | 'outside' | undefined
- let outputs: ReadBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'][0]['outputs']
-
- if (block.type === 'loop' || block.type === 'parallel') {
- const isInside =
- (block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) ||
- (block.type === 'parallel' && containingParallelIds.has(accessibleBlockId))
-
- accessContext = isInside ? 'inside' : 'outside'
- outputs = isInside
- ? getSubflowInsideOutputReferences(
- block.type,
- accessibleBlockId,
- blockName,
- loops,
- parallels
- )
- : getSubflowOutsideOutputReferences(blockName)
- } else {
- outputs = computeBlockOutputReferences(block, ctx, variableOutputs)
- }
-
- const entry: ReadBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'][0] =
- {
- blockId: accessibleBlockId,
- blockName,
- blockType: block.type,
- outputs,
- }
-
- if (accessContext) entry.accessContext = accessContext
- accessibleBlocks.push(entry)
- }
-
- const resultEntry: ReadBlockUpstreamReferencesResultType['results'][0] = {
- blockId,
- blockName: targetBlock.name || targetBlock.type,
- accessibleBlocks,
- variables: variableOutputs,
- }
-
- if (insideSubflows.length > 0) resultEntry.insideSubflows = insideSubflows
- results.push(resultEntry)
- }
-
- const result = ReadBlockUpstreamReferencesResult.parse({ results })
-
- logger.info('Retrieved upstream references', {
- blockIds: args.blockIds,
- resultCount: results.length,
- })
-
- await this.markToolComplete(200, 'Retrieved upstream references', result)
- this.setState(ClientToolCallState.success)
- } catch (error: any) {
- const message = error instanceof Error ? error.message : String(error)
- logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message })
- await this.markToolComplete(500, message || 'Failed to get upstream references')
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow-variables.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow-variables.ts
deleted file mode 100644
index 1f6bbf5c1..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow-variables.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { List, Loader2, X, XCircle } from 'lucide-react'
-import { CopilotTool } from '@/lib/copilot/registry'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import { getReadableWorkflowState } from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils'
-import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target'
-import { createLogger } from '@/lib/logs/console/logger'
-
-const logger = createLogger('ReadWorkflowVariablesClientTool')
-
-interface ReadWorkflowVariablesArgs {
- entityId: string
-}
-
-export class ReadWorkflowVariablesClientTool extends BaseClientTool {
- static readonly id = CopilotTool.read_workflow_variables
-
- constructor(toolCallId: string) {
- super(toolCallId, ReadWorkflowVariablesClientTool.id, ReadWorkflowVariablesClientTool.metadata)
- }
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Reading workflow variables', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Reading workflow variables', icon: List },
- [ClientToolCallState.executing]: { text: 'Reading workflow variables', icon: Loader2 },
- [ClientToolCallState.aborted]: { text: 'Aborted reading variables', icon: XCircle },
- [ClientToolCallState.success]: { text: 'Read workflow variables', icon: List },
- [ClientToolCallState.error]: { text: 'Failed to read variables', icon: X },
- [ClientToolCallState.rejected]: { text: 'Skipped reading variables', icon: XCircle },
- },
- }
-
- async execute(args?: ReadWorkflowVariablesArgs): Promise {
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
- const entityId = requireCopilotEntityId(args)
- const { workflowId, variables: varsRecord } = await getReadableWorkflowState(
- executionContext,
- entityId
- )
- const variables = Object.values(varsRecord).map((v: any) => ({
- name: String(v?.name || ''),
- value: (v as any)?.value,
- }))
-
- logger.info('Read workflow variables', { workflowId, count: variables.length })
- await this.markToolComplete(200, `Found ${variables.length} variable(s)`, { variables })
- this.setState(ClientToolCallState.success)
- } catch (error: any) {
- const message = error instanceof Error ? error.message : String(error)
- await this.markToolComplete(500, message || 'Failed to read workflow variables')
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow.ts
deleted file mode 100644
index 4d00e0dbb..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Loader2, Workflow as WorkflowIcon, X, XCircle } from 'lucide-react'
-import { CopilotTool } from '@/lib/copilot/registry'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import {
- buildWorkflowDocumentToolResult,
- buildWorkflowSummary,
- getReadableWorkflowState,
-} from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils'
-import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target'
-import { createLogger } from '@/lib/logs/console/logger'
-import { serializeWorkflowToTgMermaid } from '@/lib/workflows/studio-workflow-mermaid'
-
-interface ReadWorkflowArgs {
- entityId: string
-}
-
-const logger = createLogger('ReadWorkflowClientTool')
-
-export class ReadWorkflowClientTool extends BaseClientTool {
- static readonly id = CopilotTool.read_workflow
-
- constructor(toolCallId: string) {
- super(toolCallId, ReadWorkflowClientTool.id, ReadWorkflowClientTool.metadata)
- }
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Analyzing your workflow', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Analyzing your workflow', icon: WorkflowIcon },
- [ClientToolCallState.executing]: { text: 'Analyzing your workflow', icon: Loader2 },
- [ClientToolCallState.aborted]: { text: 'Aborted analyzing your workflow', icon: XCircle },
- [ClientToolCallState.success]: { text: 'Analyzed your workflow', icon: WorkflowIcon },
- [ClientToolCallState.error]: { text: 'Failed to analyze your workflow', icon: X },
- [ClientToolCallState.rejected]: { text: 'Skipped analyzing your workflow', icon: XCircle },
- },
- }
-
- async execute(args?: ReadWorkflowArgs): Promise {
- try {
- this.setState(ClientToolCallState.executing)
- const executionContext = this.requireExecutionContext()
- let requestedEntityId: string
- try {
- requestedEntityId = requireCopilotEntityId(args)
- } catch {
- await this.markToolComplete(400, 'entityId is required')
- this.setState(ClientToolCallState.error)
- return
- }
-
- logger.info('Reading workflow from readable workflow snapshot', {
- entityId: requestedEntityId,
- })
-
- const { workflowId, entityName, workflowState, workspaceId } = await getReadableWorkflowState(
- executionContext,
- requestedEntityId
- )
-
- let workflowDocument = ''
- try {
- workflowDocument = serializeWorkflowToTgMermaid(workflowState)
- logger.info('Successfully serialized workflow document', {
- workflowId,
- documentLength: workflowDocument.length,
- })
- } catch (stringifyError) {
- await this.markToolComplete(
- 500,
- `Failed to convert workflow to Mermaid: ${
- stringifyError instanceof Error ? stringifyError.message : 'Unknown error'
- }`
- )
- this.setState(ClientToolCallState.error)
- return
- }
-
- // Mark complete with data; keep state success for store render
- await this.markToolComplete(200, 'Workflow analyzed', {
- ...buildWorkflowDocumentToolResult({
- workflowId,
- entityName,
- workspaceId,
- entityDocument: workflowDocument,
- }),
- workflowSummary: buildWorkflowSummary(workflowState),
- })
- this.setState(ClientToolCallState.success)
- } catch (error: any) {
- const message = error instanceof Error ? error.message : String(error)
- logger.error('Error in tool execution', {
- toolCallId: this.toolCallId,
- error,
- message,
- })
- await this.markToolComplete(500, message || 'Failed to read workflow')
- this.setState(ClientToolCallState.error)
- }
- }
-}
diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/rename-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/rename-workflow.ts
deleted file mode 100644
index c19194442..000000000
--- a/apps/tradinggoose/lib/copilot/tools/client/workflow/rename-workflow.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { Check, Grid2x2, Loader2, X, XCircle } from 'lucide-react'
-import {
- BaseClientTool,
- type BaseClientToolMetadata,
- ClientToolCallState,
-} from '@/lib/copilot/tools/client/base-tool'
-import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target'
-import { createLogger } from '@/lib/logs/console/logger'
-import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access'
-import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-
-type RenameWorkflowArgs = {
- entityId: string
- name: string
-}
-
-function readStoredToolArgs(toolCallId: string): TArgs | undefined {
- try {
- const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState()
- return toolCallsById[toolCallId]?.params as TArgs | undefined
- } catch {
- return undefined
- }
-}
-
-export class RenameWorkflowClientTool extends BaseClientTool {
- static readonly id = 'rename_workflow'
- private currentArgs?: RenameWorkflowArgs
-
- static readonly metadata: BaseClientToolMetadata = {
- displayNames: {
- [ClientToolCallState.generating]: { text: 'Renaming workflow', icon: Loader2 },
- [ClientToolCallState.pending]: { text: 'Rename workflow?', icon: Grid2x2 },
- [ClientToolCallState.executing]: { text: 'Renaming workflow', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Renamed workflow', icon: Check },
- [ClientToolCallState.error]: { text: 'Failed to rename workflow', icon: X },
- [ClientToolCallState.aborted]: { text: 'Aborted renaming workflow', icon: XCircle },
- [ClientToolCallState.rejected]: { text: 'Skipped renaming workflow', icon: XCircle },
- },
- interrupt: {
- accept: { text: 'Allow', icon: Check },
- reject: { text: 'Skip', icon: XCircle },
- },
- }
-
- constructor(toolCallId: string) {
- super(toolCallId, RenameWorkflowClientTool.id, RenameWorkflowClientTool.metadata)
- }
-
- async execute(args?: RenameWorkflowArgs): Promise {
- this.currentArgs = args
- }
-
- async handleAccept(args?: RenameWorkflowArgs): Promise