improvement(copilot): incremental edits (#891)

* v1

* Incremental edits

* Lint

* Remove dev env

* Fix tests

* Lint
This commit is contained in:
Siddharth Ganesan
2025-08-06 17:14:47 -07:00
committed by GitHub
parent 05e689bc60
commit f94258ef83
10 changed files with 579 additions and 121 deletions

View File

@@ -354,7 +354,14 @@ describe('Copilot Methods API Route', () => {
86400
)
expect(mockRedisGet).toHaveBeenCalledWith('tool_call:tool-call-123')
expect(mockToolRegistryExecute).toHaveBeenCalledWith('interrupt-tool', { key: 'value' })
expect(mockToolRegistryExecute).toHaveBeenCalledWith('interrupt-tool', {
key: 'value',
confirmationMessage: 'User approved',
fullData: {
message: 'User approved',
status: 'accepted',
},
})
})
it('should handle tool execution with interrupt - user rejection', async () => {
@@ -613,6 +620,10 @@ describe('Copilot Methods API Route', () => {
expect(mockToolRegistryExecute).toHaveBeenCalledWith('no_op', {
existing: 'param',
confirmationMessage: 'Confirmation message',
fullData: {
message: 'Confirmation message',
status: 'accepted',
},
})
})

View File

@@ -57,7 +57,7 @@ async function addToolToRedis(toolCallId: string): Promise<void> {
*/
async function pollRedisForTool(
toolCallId: string
): Promise<{ status: NotificationStatus; message?: string } | null> {
): Promise<{ status: NotificationStatus; message?: string; fullData?: any } | null> {
const redis = getRedisClient()
if (!redis) {
logger.warn('pollRedisForTool: Redis client not available')
@@ -86,12 +86,14 @@ async function pollRedisForTool(
let status: NotificationStatus | null = null
let message: string | undefined
let fullData: any = null
// Try to parse as JSON (new format), fallback to string (old format)
try {
const parsedData = JSON.parse(redisValue)
status = parsedData.status as NotificationStatus
message = parsedData.message || undefined
fullData = parsedData // Store the full parsed data
} catch {
// Fallback to old format (direct status string)
status = redisValue as NotificationStatus
@@ -138,7 +140,7 @@ async function pollRedisForTool(
})
}
return { status, message }
return { status, message, fullData }
}
// Wait before next poll
@@ -163,9 +165,13 @@ async function pollRedisForTool(
* Handle tool calls that require user interruption/approval
* Returns { approved: boolean, rejected: boolean, error?: boolean, message?: string } to distinguish between rejection, timeout, and error
*/
async function interruptHandler(
toolCallId: string
): Promise<{ approved: boolean; rejected: boolean; error?: boolean; message?: string }> {
async function interruptHandler(toolCallId: string): Promise<{
approved: boolean
rejected: boolean
error?: boolean
message?: string
fullData?: any
}> {
if (!toolCallId) {
logger.error('interruptHandler: No tool call ID provided')
return { approved: false, rejected: false, error: true, message: 'No tool call ID provided' }
@@ -185,31 +191,31 @@ async function interruptHandler(
return { approved: false, rejected: false }
}
const { status, message } = result
const { status, message, fullData } = result
if (status === 'rejected') {
logger.info('Tool execution rejected by user', { toolCallId, message })
return { approved: false, rejected: true, message }
return { approved: false, rejected: true, message, fullData }
}
if (status === 'accepted') {
logger.info('Tool execution approved by user', { toolCallId, message })
return { approved: true, rejected: false, message }
return { approved: true, rejected: false, message, fullData }
}
if (status === 'error') {
logger.error('Tool execution failed with error', { toolCallId, message })
return { approved: false, rejected: false, error: true, message }
return { approved: false, rejected: false, error: true, message, fullData }
}
if (status === 'background') {
logger.info('Tool execution moved to background', { toolCallId, message })
return { approved: true, rejected: false, message }
return { approved: true, rejected: false, message, fullData }
}
if (status === 'success') {
logger.info('Tool execution completed successfully', { toolCallId, message })
return { approved: true, rejected: false, message }
return { approved: true, rejected: false, message, fullData }
}
logger.warn('Unexpected tool call status', { toolCallId, status, message })
@@ -326,7 +332,7 @@ export async function POST(req: NextRequest) {
})
// Handle interrupt flow
const { approved, rejected, error, message } = await interruptHandler(toolCallId)
const { approved, rejected, error, message, fullData } = await interruptHandler(toolCallId)
if (rejected) {
logger.info(`[${requestId}] Tool execution rejected by user`, {
@@ -371,10 +377,13 @@ export async function POST(req: NextRequest) {
message,
})
// For noop tool, pass the confirmation message as a parameter
if (methodId === 'no_op' && message) {
// For tools that need confirmation data, pass the message and/or fullData as parameters
if (message) {
params.confirmationMessage = message
}
if (fullData) {
params.fullData = fullData
}
}
// Execute the tool directly via registry

View File

@@ -70,6 +70,16 @@ export async function POST(request: NextRequest) {
// Note: This endpoint is stateless, so we need to get this from the request
const currentWorkflowState = (body as any).currentWorkflowState
// Ensure currentWorkflowState has all required properties with proper defaults if provided
if (currentWorkflowState) {
if (!currentWorkflowState.loops) {
currentWorkflowState.loops = {}
}
if (!currentWorkflowState.parallels) {
currentWorkflowState.parallels = {}
}
}
logger.info(`[${requestId}] Creating diff from YAML`, {
contentLength: yamlContent.length,
hasDiffAnalysis: !!diffAnalysis,

View File

@@ -24,8 +24,8 @@ const MergeDiffRequestSchema = z.object({
proposedState: z.object({
blocks: z.record(z.any()),
edges: z.array(z.any()),
loops: z.record(z.any()),
parallels: z.record(z.any()),
loops: z.record(z.any()).optional(),
parallels: z.record(z.any()).optional(),
}),
diffAnalysis: z.any().optional(),
metadata: z.object({
@@ -50,6 +50,14 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const { existingDiff, yamlContent, diffAnalysis, options } = MergeDiffRequestSchema.parse(body)
// Ensure existingDiff.proposedState has all required properties with proper defaults
if (!existingDiff.proposedState.loops) {
existingDiff.proposedState.loops = {}
}
if (!existingDiff.proposedState.parallels) {
existingDiff.proposedState.parallels = {}
}
logger.info(`[${requestId}] Merging diff from YAML`, {
contentLength: yamlContent.length,
existingBlockCount: Object.keys(existingDiff.proposedState.blocks).length,

View File

@@ -0,0 +1,286 @@
/**
* Get User Workflow Tool - Client-side implementation
*/
import { BaseTool } from '@/lib/copilot/tools/base-tool'
import type {
CopilotToolCall,
ToolExecuteResult,
ToolExecutionOptions,
ToolMetadata,
} from '@/lib/copilot/tools/types'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface GetUserWorkflowParams {
workflowId?: string
includeMetadata?: boolean
}
export class GetUserWorkflowTool extends BaseTool {
static readonly id = 'get_user_workflow'
metadata: ToolMetadata = {
id: GetUserWorkflowTool.id,
displayConfig: {
states: {
executing: {
displayName: 'Analyzing your workflow',
icon: 'spinner',
},
accepted: {
displayName: 'Analyzing your workflow',
icon: 'spinner',
},
success: {
displayName: 'Workflow analyzed',
icon: 'workflow',
},
rejected: {
displayName: 'Skipped workflow analysis',
icon: 'skip',
},
errored: {
displayName: 'Failed to analyze workflow',
icon: 'error',
},
aborted: {
displayName: 'Aborted workflow analysis',
icon: 'abort',
},
},
},
schema: {
name: GetUserWorkflowTool.id,
description: 'Get the current workflow state as JSON',
parameters: {
type: 'object',
properties: {
workflowId: {
type: 'string',
description:
'The ID of the workflow to fetch (optional, uses active workflow if not provided)',
},
includeMetadata: {
type: 'boolean',
description: 'Whether to include workflow metadata',
},
},
required: [],
},
},
requiresInterrupt: false, // Client tools handle their own interrupts
stateMessages: {
success: 'Successfully retrieved workflow',
error: 'Failed to retrieve workflow',
rejected: 'User chose to skip workflow retrieval',
},
}
/**
* Execute the tool - fetch the workflow from stores and write to Redis
*/
async execute(
toolCall: CopilotToolCall,
options?: ToolExecutionOptions
): Promise<ToolExecuteResult> {
const logger = createLogger('GetUserWorkflowTool')
logger.info('Starting client tool execution', {
toolCallId: toolCall.id,
toolName: toolCall.name,
})
try {
// Parse parameters
const rawParams = toolCall.parameters || toolCall.input || {}
const params = rawParams as GetUserWorkflowParams
// Get workflow ID - use provided or active workflow
let workflowId = params.workflowId
if (!workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
options?.onStateChange?.('errored')
return {
success: false,
error: 'No active workflow found',
}
}
workflowId = activeWorkflowId
}
logger.info('Fetching user workflow from stores', {
workflowId,
includeMetadata: params.includeMetadata,
})
// Try to get workflow from diff/preview store first, then main store
let workflowState: any = null
// Check diff store first
const diffStore = useWorkflowDiffStore.getState()
if (diffStore.diffWorkflow && Object.keys(diffStore.diffWorkflow.blocks || {}).length > 0) {
workflowState = diffStore.diffWorkflow
logger.info('Using workflow from diff/preview store', { workflowId })
} else {
// Get the actual workflow state from the workflow store
const workflowStore = useWorkflowStore.getState()
const fullWorkflowState = workflowStore.getWorkflowState()
if (!fullWorkflowState || !fullWorkflowState.blocks) {
// Fallback to workflow registry metadata if no workflow state
const workflowRegistry = useWorkflowRegistry.getState()
const workflow = workflowRegistry.workflows[workflowId]
if (!workflow) {
options?.onStateChange?.('errored')
return {
success: false,
error: `Workflow ${workflowId} not found in any store`,
}
}
logger.warn('No workflow state found, using workflow metadata only', { workflowId })
workflowState = workflow
} else {
workflowState = fullWorkflowState
logger.info('Using workflow state from workflow store', {
workflowId,
blockCount: Object.keys(fullWorkflowState.blocks || {}).length,
})
}
}
// Ensure workflow state has all required properties with proper defaults
if (workflowState) {
if (!workflowState.loops) {
workflowState.loops = {}
}
if (!workflowState.parallels) {
workflowState.parallels = {}
}
if (!workflowState.edges) {
workflowState.edges = []
}
if (!workflowState.blocks) {
workflowState.blocks = {}
}
}
logger.info('Validating workflow state', {
workflowId,
hasWorkflowState: !!workflowState,
hasBlocks: !!workflowState?.blocks,
workflowStateType: typeof workflowState,
})
if (!workflowState || !workflowState.blocks) {
logger.error('Workflow state validation failed', {
workflowId,
workflowState: workflowState,
hasBlocks: !!workflowState?.blocks,
})
options?.onStateChange?.('errored')
return {
success: false,
error: 'Workflow state is empty or invalid',
}
}
// Include metadata if requested and available
if (params.includeMetadata && workflowState.metadata) {
// Metadata is already included in the workflow state
}
logger.info('Successfully fetched user workflow from stores', {
workflowId,
blockCount: Object.keys(workflowState.blocks || {}).length,
fromDiffStore:
!!diffStore.diffWorkflow && Object.keys(diffStore.diffWorkflow.blocks || {}).length > 0,
})
logger.info('About to stringify workflow state', {
workflowId,
workflowStateKeys: Object.keys(workflowState),
})
// Convert workflow state to JSON string
let workflowJson: string
try {
workflowJson = JSON.stringify(workflowState, null, 2)
logger.info('Successfully stringified workflow state', {
workflowId,
jsonLength: workflowJson.length,
})
} catch (stringifyError) {
logger.error('Error stringifying workflow state', {
workflowId,
error: stringifyError,
})
options?.onStateChange?.('errored')
return {
success: false,
error: `Failed to convert workflow to JSON: ${stringifyError instanceof Error ? stringifyError.message : 'Unknown error'}`,
}
}
logger.info('About to notify server with workflow data', {
workflowId,
toolCallId: toolCall.id,
dataLength: workflowJson.length,
})
// Notify server of success with structured data containing userWorkflow
const structuredData = JSON.stringify({
userWorkflow: workflowJson,
})
logger.info('Calling notify with structured data', {
toolCallId: toolCall.id,
structuredDataLength: structuredData.length,
})
await this.notify(toolCall.id, 'success', structuredData)
logger.info('Successfully notified server of success', {
toolCallId: toolCall.id,
})
options?.onStateChange?.('success')
return {
success: true,
data: workflowJson, // Return the same data that goes to Redis
}
} catch (error: any) {
logger.error('Error in client tool execution:', {
toolCallId: toolCall.id,
error: error,
stack: error instanceof Error ? error.stack : undefined,
message: error instanceof Error ? error.message : String(error),
})
try {
// Notify server of error
await this.notify(toolCall.id, 'errored', error.message || 'Failed to fetch workflow')
logger.info('Successfully notified server of error', {
toolCallId: toolCall.id,
})
} catch (notifyError) {
logger.error('Failed to notify server of error:', {
toolCallId: toolCall.id,
notifyError: notifyError,
})
}
options?.onStateChange?.('errored')
return {
success: false,
error: error.message || 'Failed to fetch workflow',
}
}
}
}

View File

@@ -8,6 +8,7 @@
* It also provides metadata for server-side tools for display purposes
*/
import { GetUserWorkflowTool } from '@/lib/copilot/tools/client-tools/get-user-workflow'
import { RunWorkflowTool } from '@/lib/copilot/tools/client-tools/run-workflow'
import { SERVER_TOOL_METADATA } from '@/lib/copilot/tools/server-tools/definitions'
import type { Tool, ToolMetadata } from '@/lib/copilot/tools/types'
@@ -112,6 +113,7 @@ export class ToolRegistry {
private registerDefaultTools(): void {
// Register actual client tool implementations
this.register(new RunWorkflowTool())
this.register(new GetUserWorkflowTool())
}
}

View File

@@ -273,11 +273,16 @@ async function applyOperationsToYaml(
return yamlDump(workflowData)
}
import { eq } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { db } from '@/db'
import { workflow as workflowTable } from '@/db/schema'
import { BaseCopilotTool } from '../base'
interface EditWorkflowParams {
operations: EditWorkflowOperation[]
workflowId: string
currentUserWorkflow?: string // Optional current workflow JSON - if not provided, will fetch from DB
}
interface EditWorkflowResult {
@@ -297,28 +302,110 @@ class EditWorkflowTool extends BaseCopilotTool<EditWorkflowParams, EditWorkflowR
// Export the tool instance
export const editWorkflowTool = new EditWorkflowTool()
/**
* Get user workflow from database - backend function for edit workflow
*/
async function getUserWorkflow(workflowId: string): Promise<string> {
logger.info('Fetching workflow from database', { workflowId })
// Fetch workflow from database
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) {
throw new Error(`Workflow ${workflowId} not found in database`)
}
// Try to load from normalized tables first, fallback to JSON blob
let workflowState: any = null
const subBlockValues: Record<string, Record<string, any>> = {}
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (normalizedData) {
workflowState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
}
// Extract subblock values from normalized data
Object.entries(normalizedData.blocks).forEach(([blockId, block]) => {
subBlockValues[blockId] = {}
Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => {
if ((subBlock as any).value !== undefined) {
subBlockValues[blockId][subBlockId] = (subBlock as any).value
}
})
})
} else if (workflowRecord.state) {
// Fallback to JSON blob
const jsonState = workflowRecord.state as any
workflowState = {
blocks: jsonState.blocks || {},
edges: jsonState.edges || [],
loops: jsonState.loops || {},
parallels: jsonState.parallels || {},
}
// For JSON blob, subblock values are embedded in the block state
Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => {
subBlockValues[blockId] = {}
Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => {
if ((subBlock as any).value !== undefined) {
subBlockValues[blockId][subBlockId] = (subBlock as any).value
}
})
})
}
if (!workflowState || !workflowState.blocks) {
throw new Error('Workflow state is empty or invalid')
}
logger.info('Successfully fetched workflow from database', {
workflowId,
blockCount: Object.keys(workflowState.blocks).length,
})
// Return the raw JSON workflow state
return JSON.stringify(workflowState, null, 2)
}
// Implementation function
async function editWorkflow(params: EditWorkflowParams): Promise<EditWorkflowResult> {
const { operations, workflowId } = params
const { operations, workflowId, currentUserWorkflow } = params
logger.info('Processing targeted update request', {
workflowId,
operationCount: operations.length,
hasCurrentUserWorkflow: !!currentUserWorkflow,
})
// Get current workflow state as JSON
const { getUserWorkflowTool } = await import('./get-user-workflow')
// Get current workflow state - use provided currentUserWorkflow or fetch from DB
let workflowStateJson: string
const getUserWorkflowResult = await getUserWorkflowTool.execute({
workflowId: workflowId,
includeMetadata: false,
})
if (!getUserWorkflowResult.success || !getUserWorkflowResult.data) {
throw new Error('Failed to get current workflow state')
if (currentUserWorkflow) {
logger.info('Using provided currentUserWorkflow for edits', {
workflowId,
jsonLength: currentUserWorkflow.length,
})
workflowStateJson = currentUserWorkflow
} else {
logger.info('No currentUserWorkflow provided, fetching from database', {
workflowId,
})
workflowStateJson = await getUserWorkflow(workflowId)
}
const workflowStateJson = getUserWorkflowResult.data
// Also get the DB version for diff calculation if we're using a different current workflow
let dbWorkflowStateJson: string = workflowStateJson
if (currentUserWorkflow) {
logger.info('Fetching DB workflow for diff calculation', { workflowId })
dbWorkflowStateJson = await getUserWorkflow(workflowId)
}
logger.info('Retrieved current workflow state', {
jsonLength: workflowStateJson.length,
@@ -328,6 +415,20 @@ async function editWorkflow(params: EditWorkflowParams): Promise<EditWorkflowRes
// Parse the JSON to get the workflow state object
const workflowState = JSON.parse(workflowStateJson)
// Ensure workflow state has all required properties with proper defaults
if (!workflowState.loops) {
workflowState.loops = {}
}
if (!workflowState.parallels) {
workflowState.parallels = {}
}
if (!workflowState.edges) {
workflowState.edges = []
}
if (!workflowState.blocks) {
workflowState.blocks = {}
}
// Extract subblock values from the workflow state (same logic as get-user-workflow.ts)
const subBlockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {

View File

@@ -1,90 +1,96 @@
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { db } from '@/db'
import { workflow as workflowTable } from '@/db/schema'
import { BaseCopilotTool } from '../base'
interface GetUserWorkflowParams {
workflowId: string
workflowId?: string
includeMetadata?: boolean
confirmationMessage?: string
fullData?: any
}
class GetUserWorkflowTool extends BaseCopilotTool<GetUserWorkflowParams, string> {
readonly id = 'get_user_workflow'
readonly displayName = 'Analyzing your workflow'
readonly requiresInterrupt = true // This triggers automatic Redis polling
protected async executeImpl(params: GetUserWorkflowParams): Promise<string> {
return getUserWorkflow(params)
const logger = createLogger('GetUserWorkflow')
logger.info('Server tool received params', {
hasFullData: !!params.fullData,
hasConfirmationMessage: !!params.confirmationMessage,
fullDataType: typeof params.fullData,
fullDataKeys: params.fullData ? Object.keys(params.fullData) : null,
confirmationMessageLength: params.confirmationMessage?.length || 0,
})
// Extract the workflow data from fullData or confirmationMessage
let workflowData: string | null = null
if (params.fullData?.userWorkflow) {
// New format: fullData contains structured data with userWorkflow field
workflowData = params.fullData.userWorkflow
logger.info('Using workflow data from fullData.userWorkflow', {
dataLength: workflowData?.length || 0,
})
} else if (params.confirmationMessage) {
// The confirmationMessage might contain the structured JSON data
logger.info('Attempting to parse confirmationMessage as structured data', {
messageLength: params.confirmationMessage.length,
messagePreview: params.confirmationMessage.substring(0, 100),
})
try {
// Try to parse the confirmation message as structured data
const parsedMessage = JSON.parse(params.confirmationMessage)
if (parsedMessage?.userWorkflow) {
workflowData = parsedMessage.userWorkflow
logger.info('Successfully extracted userWorkflow from confirmationMessage', {
dataLength: workflowData?.length || 0,
})
} else {
// Fallback: treat the entire message as workflow data
workflowData = params.confirmationMessage
logger.info('Using confirmationMessage directly as workflow data', {
dataLength: workflowData.length,
})
}
} catch (parseError) {
// If parsing fails, use the message directly
workflowData = params.confirmationMessage
logger.info('Failed to parse confirmationMessage, using directly', {
dataLength: workflowData.length,
parseError: parseError instanceof Error ? parseError.message : 'Unknown error',
})
}
} else {
throw new Error('No workflow data received from client tool')
}
if (!workflowData) {
throw new Error('No workflow data available')
}
try {
// Parse the workflow data to validate it's valid JSON
const workflowState = JSON.parse(workflowData)
if (!workflowState || !workflowState.blocks) {
throw new Error('Invalid workflow state received from client tool')
}
logger.info('Successfully parsed and validated workflow data', {
blockCount: Object.keys(workflowState.blocks).length,
})
// Return the workflow data as properly formatted JSON string
return JSON.stringify(workflowState, null, 2)
} catch (error) {
logger.error('Failed to parse workflow data from client tool', { error })
throw new Error('Invalid workflow data format received from client tool')
}
}
}
// Export the tool instance
export const getUserWorkflowTool = new GetUserWorkflowTool()
// Implementation function
async function getUserWorkflow(params: GetUserWorkflowParams): Promise<string> {
const logger = createLogger('GetUserWorkflow')
const { workflowId, includeMetadata = false } = params
logger.info('Fetching user workflow', { workflowId })
// Fetch workflow from database
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) {
throw new Error(`Workflow ${workflowId} not found`)
}
// Try to load from normalized tables first, fallback to JSON blob
let workflowState: any = null
const subBlockValues: Record<string, Record<string, any>> = {}
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (normalizedData) {
workflowState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
}
// Extract subblock values from normalized data
Object.entries(normalizedData.blocks).forEach(([blockId, block]) => {
subBlockValues[blockId] = {}
Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => {
if ((subBlock as any).value !== undefined) {
subBlockValues[blockId][subBlockId] = (subBlock as any).value
}
})
})
} else if (workflowRecord.state) {
// Fallback to JSON blob
workflowState = workflowRecord.state as any
// For JSON blob, subblock values are embedded in the block state
Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => {
subBlockValues[blockId] = {}
Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => {
if ((subBlock as any).value !== undefined) {
subBlockValues[blockId][subBlockId] = (subBlock as any).value
}
})
})
}
if (!workflowState || !workflowState.blocks) {
throw new Error('Workflow state is empty or invalid')
}
logger.info('Successfully fetched user workflow as JSON', {
workflowId,
blockCount: Object.keys(workflowState.blocks).length,
})
// Return the raw JSON workflow state
return JSON.stringify(workflowState, null, 2)
}

View File

@@ -531,6 +531,43 @@ function createToolCall(id: string, name: string, input: any = {}): any {
setToolCallState(toolCall, initialState, { preserveTerminalStates: false })
// Auto-execute client tools that don't require interrupt
if (!requiresInterrupt && toolRegistry.getTool(name)) {
logger.info('Auto-executing client tool:', name, toolCall.id)
// Execute client tool asynchronously
setTimeout(async () => {
try {
const tool = toolRegistry.getTool(name)
if (tool && toolCall.state === 'executing') {
await tool.execute(toolCall as any, {
onStateChange: (state: any) => {
// Update the tool call state in the store
const currentState = useCopilotStore.getState()
const updatedMessages = currentState.messages.map((msg) => ({
...msg,
toolCalls: msg.toolCalls?.map((tc) =>
tc.id === toolCall.id ? { ...tc, state } : tc
),
contentBlocks: msg.contentBlocks?.map((block) =>
block.type === 'tool_call' && block.toolCall?.id === toolCall.id
? { ...block, toolCall: { ...block.toolCall, state } }
: block
),
}))
useCopilotStore.setState({ messages: updatedMessages })
},
})
}
} catch (error) {
logger.error('Error auto-executing client tool:', name, toolCall.id, error)
setToolCallState(toolCall, 'errored', {
error: error instanceof Error ? error.message : 'Auto-execution failed',
})
}
}, 0)
}
return toolCall
}
@@ -2747,17 +2784,8 @@ export const useCopilotStore = create<CopilotStore>()(
hasDiffWorkflow: !!diffStoreBefore.diffWorkflow,
})
// Determine if we should clear or merge based on tool type and message context
const { messages } = get()
const currentMessage = messages[messages.length - 1]
const messageHasExistingEdits =
currentMessage?.toolCalls?.some(
(tc) =>
(tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW ||
tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) &&
tc.state !== 'executing'
) || false
// Determine diff merge strategy based on tool type and existing edits
const messageHasExistingEdits = !!diffStoreBefore.diffWorkflow
const shouldClearDiff =
toolName === COPILOT_TOOL_IDS.BUILD_WORKFLOW || // build_workflow always clears
(toolName === COPILOT_TOOL_IDS.EDIT_WORKFLOW && !messageHasExistingEdits) // first edit_workflow in message clears
@@ -2787,15 +2815,9 @@ export const useCopilotStore = create<CopilotStore>()(
hasDiffWorkflow: !!diffStoreBefore.diffWorkflow,
})
if (shouldClearDiff || !diffStoreBefore.diffWorkflow) {
// Use setProposedChanges which will create a new diff
// Pass undefined to let sim-agent generate the diff analysis
await diffStore.setProposedChanges(yamlContent, undefined)
} else {
// Use mergeProposedChanges which will merge into existing diff
// Pass undefined to let sim-agent generate the diff analysis
await diffStore.mergeProposedChanges(yamlContent, undefined)
}
// Always use setProposedChanges to ensure the diff view fully overwrites with new changes
// This provides better UX as users expect to see the latest changes, not merged/cumulative changes
await diffStore.setProposedChanges(yamlContent, undefined)
// Check diff store state after update
const diffStoreAfter = useWorkflowDiffStore.getState()

View File

@@ -114,6 +114,9 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
// PERFORMANCE OPTIMIZATION: Immediate state update to prevent UI flicker
batchedUpdate({ isDiffReady: false })
// Clear any existing diff state to ensure a fresh start
diffEngine.clearDiff()
const result = await diffEngine.createDiffFromYaml(yamlContent, diffAnalysis)
if (result.success && result.diff) {