Skip to content

Commit 7ef1150

Browse files
authored
fix(workflow-state, copilot): prevent copilot from setting undefined state, fix order of operations for copilot edit workflow, add sleep tool (#2440)
* Fix copilot ooo * Add copilot sleep tool * Fix lint
1 parent a337af9 commit 7ef1150

File tree

8 files changed

+418
-34
lines changed

8 files changed

+418
-34
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ const ACTION_VERBS = [
101101
'Generated',
102102
'Rendering',
103103
'Rendered',
104+
'Sleeping',
105+
'Slept',
106+
'Resumed',
104107
] as const
105108

106109
/**
@@ -580,6 +583,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
580583
(toolCall.state === (ClientToolCallState.executing as any) ||
581584
toolCall.state === ('executing' as any))
582585

586+
const showWake =
587+
toolCall.name === 'sleep' &&
588+
(toolCall.state === (ClientToolCallState.executing as any) ||
589+
toolCall.state === ('executing' as any))
590+
583591
const handleStateChange = (state: any) => {
584592
forceUpdate({})
585593
onStateChange?.(state)
@@ -1102,6 +1110,37 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
11021110
Move to Background
11031111
</Button>
11041112
</div>
1113+
) : showWake ? (
1114+
<div className='mt-[8px]'>
1115+
<Button
1116+
onClick={async () => {
1117+
try {
1118+
const instance = getClientTool(toolCall.id)
1119+
// Get elapsed seconds before waking
1120+
const elapsedSeconds = instance?.getElapsedSeconds?.() || 0
1121+
// Transition to background state locally so UI updates immediately
1122+
// Pass elapsed seconds in the result so dynamic text can use it
1123+
instance?.setState?.((ClientToolCallState as any).background, {
1124+
result: { _elapsedSeconds: elapsedSeconds },
1125+
})
1126+
// Update the tool call params in the store to include elapsed time for display
1127+
const { updateToolCallParams } = useCopilotStore.getState()
1128+
updateToolCallParams?.(toolCall.id, { _elapsedSeconds: Math.round(elapsedSeconds) })
1129+
await instance?.markToolComplete?.(
1130+
200,
1131+
`User woke you up after ${Math.round(elapsedSeconds)} seconds`
1132+
)
1133+
// Optionally force a re-render; store should sync state from server
1134+
forceUpdate({})
1135+
onStateChange?.('background')
1136+
} catch {}
1137+
}}
1138+
variant='primary'
1139+
title='Wake'
1140+
>
1141+
Wake
1142+
</Button>
1143+
</div>
11051144
) : null}
11061145
</div>
11071146
)

apps/sim/lib/copilot/registry.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const ToolIds = z.enum([
3333
'knowledge_base',
3434
'manage_custom_tool',
3535
'manage_mcp_tool',
36+
'sleep',
3637
])
3738
export type ToolId = z.infer<typeof ToolIds>
3839

@@ -252,6 +253,14 @@ export const ToolArgSchemas = {
252253
.optional()
253254
.describe('Required for add and edit operations. The MCP server configuration.'),
254255
}),
256+
257+
sleep: z.object({
258+
seconds: z
259+
.number()
260+
.min(0)
261+
.max(180)
262+
.describe('The number of seconds to sleep (0-180, max 3 minutes)'),
263+
}),
255264
} as const
256265
export type ToolArgSchemaMap = typeof ToolArgSchemas
257266

@@ -318,6 +327,7 @@ export const ToolSSESchemas = {
318327
knowledge_base: toolCallSSEFor('knowledge_base', ToolArgSchemas.knowledge_base),
319328
manage_custom_tool: toolCallSSEFor('manage_custom_tool', ToolArgSchemas.manage_custom_tool),
320329
manage_mcp_tool: toolCallSSEFor('manage_mcp_tool', ToolArgSchemas.manage_mcp_tool),
330+
sleep: toolCallSSEFor('sleep', ToolArgSchemas.sleep),
321331
} as const
322332
export type ToolSSESchemaMap = typeof ToolSSESchemas
323333

@@ -552,6 +562,11 @@ export const ToolResultSchemas = {
552562
serverName: z.string().optional(),
553563
message: z.string().optional(),
554564
}),
565+
sleep: z.object({
566+
success: z.boolean(),
567+
seconds: z.number(),
568+
message: z.string().optional(),
569+
}),
555570
} as const
556571
export type ToolResultSchemaMap = typeof ToolResultSchemas
557572

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { Loader2, MinusCircle, Moon, XCircle } from 'lucide-react'
2+
import {
3+
BaseClientTool,
4+
type BaseClientToolMetadata,
5+
ClientToolCallState,
6+
} from '@/lib/copilot/tools/client/base-tool'
7+
import { createLogger } from '@/lib/logs/console/logger'
8+
9+
/** Maximum sleep duration in seconds (3 minutes) */
10+
const MAX_SLEEP_SECONDS = 180
11+
12+
/** Track sleep start times for calculating elapsed time on wake */
13+
const sleepStartTimes: Record<string, number> = {}
14+
15+
interface SleepArgs {
16+
seconds?: number
17+
}
18+
19+
/**
20+
* Format seconds into a human-readable duration string
21+
*/
22+
function formatDuration(seconds: number): string {
23+
if (seconds >= 60) {
24+
return `${Math.round(seconds / 60)} minute${seconds >= 120 ? 's' : ''}`
25+
}
26+
return `${seconds} second${seconds !== 1 ? 's' : ''}`
27+
}
28+
29+
export class SleepClientTool extends BaseClientTool {
30+
static readonly id = 'sleep'
31+
32+
constructor(toolCallId: string) {
33+
super(toolCallId, SleepClientTool.id, SleepClientTool.metadata)
34+
}
35+
36+
static readonly metadata: BaseClientToolMetadata = {
37+
displayNames: {
38+
[ClientToolCallState.generating]: { text: 'Preparing to sleep', icon: Loader2 },
39+
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
40+
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
41+
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
42+
[ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle },
43+
[ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle },
44+
[ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle },
45+
[ClientToolCallState.background]: { text: 'Resumed', icon: Moon },
46+
},
47+
// No interrupt - auto-execute immediately
48+
getDynamicText: (params, state) => {
49+
const seconds = params?.seconds
50+
if (typeof seconds === 'number' && seconds > 0) {
51+
const displayTime = formatDuration(seconds)
52+
switch (state) {
53+
case ClientToolCallState.success:
54+
return `Slept for ${displayTime}`
55+
case ClientToolCallState.executing:
56+
case ClientToolCallState.pending:
57+
return `Sleeping for ${displayTime}`
58+
case ClientToolCallState.generating:
59+
return `Preparing to sleep for ${displayTime}`
60+
case ClientToolCallState.error:
61+
return `Failed to sleep for ${displayTime}`
62+
case ClientToolCallState.rejected:
63+
return `Skipped sleeping for ${displayTime}`
64+
case ClientToolCallState.aborted:
65+
return `Aborted sleeping for ${displayTime}`
66+
case ClientToolCallState.background: {
67+
// Calculate elapsed time from when sleep started
68+
const elapsedSeconds = params?._elapsedSeconds
69+
if (typeof elapsedSeconds === 'number' && elapsedSeconds > 0) {
70+
return `Resumed after ${formatDuration(Math.round(elapsedSeconds))}`
71+
}
72+
return 'Resumed early'
73+
}
74+
}
75+
}
76+
return undefined
77+
},
78+
}
79+
80+
/**
81+
* Get elapsed seconds since sleep started
82+
*/
83+
getElapsedSeconds(): number {
84+
const startTime = sleepStartTimes[this.toolCallId]
85+
if (!startTime) return 0
86+
return (Date.now() - startTime) / 1000
87+
}
88+
89+
async handleReject(): Promise<void> {
90+
await super.handleReject()
91+
this.setState(ClientToolCallState.rejected)
92+
}
93+
94+
async handleAccept(args?: SleepArgs): Promise<void> {
95+
const logger = createLogger('SleepClientTool')
96+
97+
// Use a timeout slightly longer than max sleep (3 minutes + buffer)
98+
const timeoutMs = (MAX_SLEEP_SECONDS + 30) * 1000
99+
100+
await this.executeWithTimeout(async () => {
101+
const params = args || {}
102+
logger.debug('handleAccept() called', {
103+
toolCallId: this.toolCallId,
104+
state: this.getState(),
105+
hasArgs: !!args,
106+
seconds: params.seconds,
107+
})
108+
109+
// Validate and clamp seconds
110+
let seconds = typeof params.seconds === 'number' ? params.seconds : 0
111+
if (seconds < 0) seconds = 0
112+
if (seconds > MAX_SLEEP_SECONDS) seconds = MAX_SLEEP_SECONDS
113+
114+
logger.debug('Starting sleep', { seconds })
115+
116+
// Track start time for elapsed calculation
117+
sleepStartTimes[this.toolCallId] = Date.now()
118+
119+
this.setState(ClientToolCallState.executing)
120+
121+
try {
122+
// Sleep for the specified duration
123+
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
124+
125+
logger.debug('Sleep completed successfully')
126+
this.setState(ClientToolCallState.success)
127+
await this.markToolComplete(200, `Slept for ${seconds} seconds`)
128+
} catch (error) {
129+
const message = error instanceof Error ? error.message : String(error)
130+
logger.error('Sleep failed', { error: message })
131+
this.setState(ClientToolCallState.error)
132+
await this.markToolComplete(500, message)
133+
} finally {
134+
// Clean up start time tracking
135+
delete sleepStartTimes[this.toolCallId]
136+
}
137+
}, timeoutMs)
138+
}
139+
140+
async execute(args?: SleepArgs): Promise<void> {
141+
// Auto-execute without confirmation - go straight to executing
142+
await this.handleAccept(args)
143+
}
144+
}

0 commit comments

Comments
 (0)